Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add user-defined schema and migrations #7418

Merged
merged 57 commits into from
Nov 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
bd1498f
wip: base implementation of defined schema
Moumouls Dec 22, 2020
06ee331
wip: block routes when defined schemas used
Moumouls Dec 22, 2020
6cf6355
wip: tests
Moumouls Dec 22, 2020
2907315
wip: testing
Moumouls Dec 23, 2020
0aaf531
wip: tested indexes
Moumouls Dec 23, 2020
cb07388
wip: tested CLP
Moumouls Dec 23, 2020
acba945
fix flow
Moumouls Dec 23, 2020
86b5125
remove fit
Moumouls Dec 23, 2020
69f19dc
coverage
Moumouls Dec 24, 2020
ae1cc7a
fix
Moumouls Dec 24, 2020
22e6815
add changelog
Moumouls Dec 24, 2020
410bebe
wip: add retry, missing test
Moumouls Jan 4, 2021
3e0145e
tested retry
Moumouls Jan 5, 2021
bf52c3d
test parallel
Moumouls Jan 6, 2021
0549d02
spread clp to avoid read only issue
Moumouls Apr 14, 2021
5f6628b
Merge branch 'upstream/master' into moumouls/defined-schema
Moumouls May 14, 2021
c79a32d
restored lock
Moumouls May 14, 2021
46269ff
update workflow
Moumouls May 14, 2021
d610bb9
update node version
Moumouls May 14, 2021
5188e69
Merge branch 'master' into moumouls/defined-schema
sadortun Jun 6, 2021
014120d
Updated SchemaMigrations, added validations and Types
sadortun Jun 15, 2021
555a038
Fix typo
sadortun Jun 15, 2021
97b57de
Clean code
sadortun Jun 15, 2021
99a8205
Clean up CLP, add logging
sadortun Jul 14, 2021
f369f63
Merge remote-tracking branch 'remotes/upstream/master' into pr-migrat…
sadortun Aug 11, 2021
9d7fe89
Fixed tests
sadortun Aug 11, 2021
aa9b15e
Fix formating issues
sadortun Aug 11, 2021
42696e9
Fix code review issues
sadortun Aug 11, 2021
b2c4efc
Merge branch 'master' into pr-migrations
sadortun Aug 11, 2021
089bcfc
Re-enable all tests
sadortun Aug 11, 2021
72e3bf3
Merge branch 'pr-migrations' of https://github.com/GoPlan-Finance/par…
sadortun Aug 11, 2021
9db55b6
Replace the auto-locking of schemas by an explicit "lockSchemas" para…
sadortun Aug 12, 2021
9c915ee
Added ACL type
sadortun Aug 12, 2021
265420c
Rename Migration/Schemas to Schema/Definitions
sadortun Aug 16, 2021
2a5fd93
Re-added Migration retries to handle concurrent server startups
sadortun Aug 16, 2021
02caf69
Ensure before/after migration only run once.
sadortun Aug 16, 2021
f34153f
Added ParseServer startup options
sadortun Aug 16, 2021
d244d56
Merge branch 'master' into pr-migrations
sadortun Aug 16, 2021
fa2d3d0
Merge branch 'master' into pr-migrations
sadortun Oct 5, 2021
a10c574
Revert unwanted changes
sadortun Oct 5, 2021
b6806e9
Implement code review changes
sadortun Oct 5, 2021
a1cd981
Implement code review changes
sadortun Oct 5, 2021
d3ba3bb
Updated CHANGELOG
sadortun Oct 5, 2021
0e37d39
fix: test & review
Moumouls Oct 5, 2021
426857f
Merge pull request #1 from Moumouls/pr-migrations-moumouls
sadortun Oct 5, 2021
d6bf3e1
Merge branch 'upstream/master' into pr-migrations-moumouls
Moumouls Oct 11, 2021
8cc1cf2
test: clean on after each
Moumouls Oct 12, 2021
040b695
Merge branch 'upstream/master' into pr-migrations-moumouls
Moumouls Oct 13, 2021
cba3179
Merge pull request #2 from Moumouls/pr-migrations-moumouls
sadortun Oct 13, 2021
8bf9231
Merge branch 'master' into pr-migrations
sadortun Oct 21, 2021
19c4160
Implement code review changes
sadortun Oct 28, 2021
d7d2a9a
Merge remote-tracking branch 'remotes/upstream/alpha' into pr-migrations
sadortun Oct 28, 2021
49645ed
Merge branch 'alpha' into pr-migrations
sadortun Oct 30, 2021
af80d29
Merge branch 'alpha' into pr-migrations
sadortun Oct 31, 2021
41d906b
Disable failing Postgres test
sadortun Oct 31, 2021
cd6fb50
Merge remote-tracking branch 'remotes/upstream/alpha' into pr-migrations
sadortun Nov 1, 2021
63422fc
Fix flow errors
sadortun Nov 1, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
644 changes: 644 additions & 0 deletions spec/DefinedSchemas.spec.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions spec/schemas.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ describe('schemas', () => {
});
});

it('refuses to put to existing fields, even if it would not be a change', done => {
it('refuses to put to existing fields with different type, even if it would not be a change', done => {
sadortun marked this conversation as resolved.
Show resolved Hide resolved
const obj = hasAllPODobject();
obj.save().then(() => {
request({
Expand All @@ -769,7 +769,7 @@ describe('schemas', () => {
json: true,
body: {
fields: {
aString: { type: 'String' },
aString: { type: 'Number' },
},
},
}).then(fail, response => {
Expand Down
18 changes: 17 additions & 1 deletion src/Adapters/Storage/Mongo/MongoSchemaCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ class MongoSchemaCollection {
.then(
schema => {
// If a field with this name already exists, it will be handled elsewhere.
if (schema.fields[fieldName] != undefined) {
if (schema.fields[fieldName] !== undefined) {
return;
}
// The schema exists. Check for existing GeoPoints.
Expand Down Expand Up @@ -274,6 +274,22 @@ class MongoSchemaCollection {
}
});
}

async updateFieldOptions(className: string, fieldName: string, fieldType: any) {
const { ...fieldOptions } = fieldType;
delete fieldOptions.type;
delete fieldOptions.targetClass;

await this.upsertSchema(
className,
{ [fieldName]: { $exists: true } },
{
$set: {
[`_metadata.fields_options.${fieldName}`]: fieldOptions,
},
}
);
}
}

// Exported for testing reasons and because we haven't moved all mongo schema format
Expand Down
5 changes: 5 additions & 0 deletions src/Adapters/Storage/Mongo/MongoStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,11 @@ export class MongoStorageAdapter implements StorageAdapter {
.catch(err => this.handleError(err));
}

async updateFieldOptions(className: string, fieldName: string, type: any) {
const schemaCollection = await this._schemaCollection();
await schemaCollection.updateFieldOptions(className, fieldName, type);
}

addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void> {
return this._schemaCollection()
.then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type))
Expand Down
2 changes: 1 addition & 1 deletion src/Adapters/Storage/Postgres/PostgresClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function createClient(uri, databaseOptions) {

if (process.env.PARSE_SERVER_LOG_LEVEL === 'debug') {
const monitor = require('pg-monitor');
if(monitor.isAttached()) {
sadortun marked this conversation as resolved.
Show resolved Hide resolved
if (monitor.isAttached()) {
monitor.detach();
}
monitor.attach(initOptions);
Expand Down
10 changes: 10 additions & 0 deletions src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,16 @@ export class PostgresStorageAdapter implements StorageAdapter {
this._notifySchemaChange();
}

async updateFieldOptions(className: string, fieldName: string, type: any) {
await this._client.tx('update-schema-field-options', async t => {
sadortun marked this conversation as resolved.
Show resolved Hide resolved
const path = `{fields,${fieldName}}`;
await t.none(
'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $<path>, $<type>) WHERE "className"=$<className>',
{ path, type, className }
);
});
}

// Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.)
// and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible.
async deleteClass(className: string) {
Expand Down
1 change: 1 addition & 0 deletions src/Adapters/Storage/StorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface StorageAdapter {
setClassLevelPermissions(className: string, clps: any): Promise<void>;
createClass(className: string, schema: SchemaType): Promise<void>;
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void>;
updateFieldOptions(className: string, fieldName: string, type: any): Promise<void>;
deleteClass(className: string): Promise<void>;
deleteAllClasses(fast: boolean): Promise<void>;
deleteFields(className: string, schema: SchemaType, fieldNames: Array<string>): Promise<void>;
Expand Down
45 changes: 45 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
AccountLockoutOptions,
PagesOptions,
SecurityOptions,
SchemaOptions,
} from './Options/Definitions';
import { isBoolean, isString } from 'lodash';

Expand Down Expand Up @@ -76,6 +77,7 @@ export class Config {
pages,
security,
enforcePrivateUsers,
schema,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
Expand Down Expand Up @@ -112,6 +114,7 @@ export class Config {
this.validateIdempotencyOptions(idempotencyOptions);
this.validatePagesOptions(pages);
this.validateSecurityOptions(security);
this.validateSchemaOptions(schema);
this.validateEnforcePrivateUsers(enforcePrivateUsers);
}

Expand All @@ -137,6 +140,48 @@ export class Config {
}
}

static validateSchemaOptions(schema: SchemaOptions) {
if (!schema) return;
if (Object.prototype.toString.call(schema) !== '[object Object]') {
throw 'Parse Server option schema must be an object.';
}
if (schema.definitions === undefined) {
schema.definitions = SchemaOptions.definitions.default;
} else if (!Array.isArray(schema.definitions)) {
throw 'Parse Server option schema.definitions must be an array.';
}
if (schema.strict === undefined) {
schema.strict = SchemaOptions.strict.default;
} else if (!isBoolean(schema.strict)) {
throw 'Parse Server option schema.strict must be a boolean.';
}
if (schema.deleteExtraFields === undefined) {
schema.deleteExtraFields = SchemaOptions.deleteExtraFields.default;
} else if (!isBoolean(schema.deleteExtraFields)) {
throw 'Parse Server option schema.deleteExtraFields must be a boolean.';
}
if (schema.recreateModifiedFields === undefined) {
schema.recreateModifiedFields = SchemaOptions.recreateModifiedFields.default;
} else if (!isBoolean(schema.recreateModifiedFields)) {
throw 'Parse Server option schema.recreateModifiedFields must be a boolean.';
}
if (schema.lockSchemas === undefined) {
schema.lockSchemas = SchemaOptions.lockSchemas.default;
} else if (!isBoolean(schema.lockSchemas)) {
throw 'Parse Server option schema.lockSchemas must be a boolean.';
}
if (schema.beforeMigration === undefined) {
schema.beforeMigration = null;
} else if (schema.beforeMigration !== null && typeof schema.beforeMigration !== 'function') {
throw 'Parse Server option schema.beforeMigration must be a function.';
}
if (schema.afterMigration === undefined) {
schema.afterMigration = null;
} else if (schema.afterMigration !== null && typeof schema.afterMigration !== 'function') {
throw 'Parse Server option schema.afterMigration must be a function.';
}
}

static validatePagesOptions(pages) {
if (Object.prototype.toString.call(pages) !== '[object Object]') {
throw 'Parse Server option pages must be an object.';
Expand Down
24 changes: 20 additions & 4 deletions src/Controllers/SchemaController.js
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,11 @@ export default class SchemaController {
const existingFields = schema.fields;
Object.keys(submittedFields).forEach(name => {
const field = submittedFields[name];
if (existingFields[name] && field.__op !== 'Delete') {
if (
existingFields[name] &&
existingFields[name].type !== field.type &&
sadortun marked this conversation as resolved.
Show resolved Hide resolved
field.__op !== 'Delete'
) {
throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
}
if (!existingFields[name] && field.__op === 'Delete') {
Expand Down Expand Up @@ -1057,7 +1061,12 @@ export default class SchemaController {
// object if the provided className-fieldName-type tuple is valid.
// The className must already be validated.
// If 'freeze' is true, refuse to update the schema for this field.
enforceFieldExists(className: string, fieldName: string, type: string | SchemaField) {
enforceFieldExists(
className: string,
fieldName: string,
type: string | SchemaField,
isValidation?: boolean
) {
if (fieldName.indexOf('.') > 0) {
// subdocument key (x.y) => ok if x is of type 'object'
fieldName = fieldName.split('.')[0];
Expand Down Expand Up @@ -1101,7 +1110,14 @@ export default class SchemaController {
)} but got ${typeToString(type)}`
);
}
return undefined;
// If type options do not change
// we can safely return
if (isValidation || JSON.stringify(expectedType) === JSON.stringify(type)) {
return undefined;
}
// Field options are may be changed
// ensure to have an update to date schema field
return this._dbAdapter.updateFieldOptions(className, fieldName, type);
}

return this._dbAdapter
Expand Down Expand Up @@ -1236,7 +1252,7 @@ export default class SchemaController {
// Every object has ACL implicitly.
continue;
}
promises.push(schema.enforceFieldExists(className, fieldName, expected));
promises.push(schema.enforceFieldExists(className, fieldName, expected, true));
}
const results = await Promise.all(promises);
const enforceFields = results.filter(result => !!result);
Expand Down
39 changes: 39 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,45 @@ module.exports.SecurityOptions = {
default: false,
},
};
module.exports.SchemaOptions = {
definitions: {
help: 'The schema definitions.',
default: [],
},
strict: {
env: 'PARSE_SERVER_SCHEMA_STRICT',
help: 'Is true if Parse Server should exit if schema update fail.',
action: parsers.booleanParser,
default: true,
},
deleteExtraFields: {
env: 'PARSE_SERVER_SCHEMA_DELETE_EXTRA_FIELDS',
help:
'Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development.',
action: parsers.booleanParser,
default: false,
},
recreateModifiedFields: {
env: 'PARSE_SERVER_SCHEMA_RECREATE_MODIFIED_FIELDS',
help:
'Is true if Parse Server should recreate any fields that are different between the current database schema and theschema definition. This should only be used during development.',
action: parsers.booleanParser,
default: false,
},
lockSchemas: {
env: 'PARSE_SERVER_SCHEMA_LOCK',
help:
'Is true if Parse Server will reject any attempts to modify the schema while the server is running.',
action: parsers.booleanParser,
default: false,
},
beforeMigration: {
help: 'Execute a callback before running schema migrations.',
},
afterMigration: {
help: 'Execute a callback after running schema migrations.',
},
};
module.exports.PagesOptions = {
customRoutes: {
env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES',
Expand Down
5 changes: 4 additions & 1 deletion src/Options/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @flow
import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter';
import { FilesAdapter } from '../Adapters/Files/FilesAdapter';
import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter';
Expand All @@ -7,8 +8,8 @@ import { MailAdapter } from '../Adapters/Email/MailAdapter';
import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter';
import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter';
import { CheckGroup } from '../Security/CheckGroup';
import type { SchemaOptions } from '../SchemaMigrations/Migrations';

// @flow
type Adapter<T> = string | any | T;
type NumberOrBoolean = number | boolean;
type NumberOrString = number | string;
Expand Down Expand Up @@ -241,6 +242,8 @@ export interface ParseServerOptions {
playgroundPath: ?string;
/* Callback when server has started */
serverStartComplete: ?(error: ?Error) => void;
/* Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema */
sadortun marked this conversation as resolved.
Show resolved Hide resolved
schema: ?SchemaOptions;
/* Callback when server has closed */
serverCloseComplete: ?() => void;
/* The security options to identify and report weak security settings.
Expand Down
7 changes: 6 additions & 1 deletion src/ParseServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer';
import { SecurityRouter } from './Routers/SecurityRouter';
import CheckRunner from './Security/CheckRunner';
import Deprecator from './Deprecator/Deprecator';
import { DefinedSchemas } from './SchemaMigrations/DefinedSchemas';

// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();
Expand All @@ -68,6 +69,7 @@ class ParseServer {
javascriptKey,
serverURL = requiredParameter('You must provide a serverURL!'),
serverStartComplete,
schema,
} = options;
// Initialize the node client SDK automatically
Parse.initialize(appId, javascriptKey || 'unused', masterKey);
Expand All @@ -84,7 +86,10 @@ class ParseServer {
databaseController
.performInitialization()
.then(() => hooksController.load())
.then(() => {
.then(async () => {
if (schema) {
await new DefinedSchemas(schema, this.config).execute();
}
if (serverStartComplete) {
serverStartComplete();
}
Expand Down
Loading