Skip to content

Commit 25d5c30

Browse files
authored
feat: add user-defined schema and migrations (#7418)
1 parent 653d257 commit 25d5c30

16 files changed

+1365
-36
lines changed

spec/DefinedSchemas.spec.js

+644
Large diffs are not rendered by default.

spec/schemas.spec.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ describe('schemas', () => {
759759
});
760760
});
761761

762-
it('refuses to put to existing fields, even if it would not be a change', done => {
762+
it('refuses to put to existing fields with different type, even if it would not be a change', done => {
763763
const obj = hasAllPODobject();
764764
obj.save().then(() => {
765765
request({
@@ -769,7 +769,7 @@ describe('schemas', () => {
769769
json: true,
770770
body: {
771771
fields: {
772-
aString: { type: 'String' },
772+
aString: { type: 'Number' },
773773
},
774774
},
775775
}).then(fail, response => {

src/Adapters/Storage/Mongo/MongoSchemaCollection.js

+17-1
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ class MongoSchemaCollection {
212212
.then(
213213
schema => {
214214
// If a field with this name already exists, it will be handled elsewhere.
215-
if (schema.fields[fieldName] != undefined) {
215+
if (schema.fields[fieldName] !== undefined) {
216216
return;
217217
}
218218
// The schema exists. Check for existing GeoPoints.
@@ -274,6 +274,22 @@ class MongoSchemaCollection {
274274
}
275275
});
276276
}
277+
278+
async updateFieldOptions(className: string, fieldName: string, fieldType: any) {
279+
const { ...fieldOptions } = fieldType;
280+
delete fieldOptions.type;
281+
delete fieldOptions.targetClass;
282+
283+
await this.upsertSchema(
284+
className,
285+
{ [fieldName]: { $exists: true } },
286+
{
287+
$set: {
288+
[`_metadata.fields_options.${fieldName}`]: fieldOptions,
289+
},
290+
}
291+
);
292+
}
277293
}
278294

279295
// Exported for testing reasons and because we haven't moved all mongo schema format

src/Adapters/Storage/Mongo/MongoStorageAdapter.js

+5
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,11 @@ export class MongoStorageAdapter implements StorageAdapter {
362362
.catch(err => this.handleError(err));
363363
}
364364

365+
async updateFieldOptions(className: string, fieldName: string, type: any) {
366+
const schemaCollection = await this._schemaCollection();
367+
await schemaCollection.updateFieldOptions(className, fieldName, type);
368+
}
369+
365370
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void> {
366371
return this._schemaCollection()
367372
.then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type))

src/Adapters/Storage/Postgres/PostgresClient.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function createClient(uri, databaseOptions) {
2020

2121
if (process.env.PARSE_SERVER_LOG_LEVEL === 'debug') {
2222
const monitor = require('pg-monitor');
23-
if(monitor.isAttached()) {
23+
if (monitor.isAttached()) {
2424
monitor.detach();
2525
}
2626
monitor.attach(initOptions);

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

+10
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,16 @@ export class PostgresStorageAdapter implements StorageAdapter {
11191119
this._notifySchemaChange();
11201120
}
11211121

1122+
async updateFieldOptions(className: string, fieldName: string, type: any) {
1123+
await this._client.tx('update-schema-field-options', async t => {
1124+
const path = `{fields,${fieldName}}`;
1125+
await t.none(
1126+
'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $<path>, $<type>) WHERE "className"=$<className>',
1127+
{ path, type, className }
1128+
);
1129+
});
1130+
}
1131+
11221132
// Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.)
11231133
// and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible.
11241134
async deleteClass(className: string) {

src/Adapters/Storage/StorageAdapter.js

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface StorageAdapter {
3535
setClassLevelPermissions(className: string, clps: any): Promise<void>;
3636
createClass(className: string, schema: SchemaType): Promise<void>;
3737
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void>;
38+
updateFieldOptions(className: string, fieldName: string, type: any): Promise<void>;
3839
deleteClass(className: string): Promise<void>;
3940
deleteAllClasses(fast: boolean): Promise<void>;
4041
deleteFields(className: string, schema: SchemaType, fieldNames: Array<string>): Promise<void>;

src/Config.js

+45
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
AccountLockoutOptions,
1212
PagesOptions,
1313
SecurityOptions,
14+
SchemaOptions,
1415
} from './Options/Definitions';
1516
import { isBoolean, isString } from 'lodash';
1617

@@ -76,6 +77,7 @@ export class Config {
7677
pages,
7778
security,
7879
enforcePrivateUsers,
80+
schema,
7981
}) {
8082
if (masterKey === readOnlyMasterKey) {
8183
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -112,6 +114,7 @@ export class Config {
112114
this.validateIdempotencyOptions(idempotencyOptions);
113115
this.validatePagesOptions(pages);
114116
this.validateSecurityOptions(security);
117+
this.validateSchemaOptions(schema);
115118
this.validateEnforcePrivateUsers(enforcePrivateUsers);
116119
}
117120

@@ -137,6 +140,48 @@ export class Config {
137140
}
138141
}
139142

143+
static validateSchemaOptions(schema: SchemaOptions) {
144+
if (!schema) return;
145+
if (Object.prototype.toString.call(schema) !== '[object Object]') {
146+
throw 'Parse Server option schema must be an object.';
147+
}
148+
if (schema.definitions === undefined) {
149+
schema.definitions = SchemaOptions.definitions.default;
150+
} else if (!Array.isArray(schema.definitions)) {
151+
throw 'Parse Server option schema.definitions must be an array.';
152+
}
153+
if (schema.strict === undefined) {
154+
schema.strict = SchemaOptions.strict.default;
155+
} else if (!isBoolean(schema.strict)) {
156+
throw 'Parse Server option schema.strict must be a boolean.';
157+
}
158+
if (schema.deleteExtraFields === undefined) {
159+
schema.deleteExtraFields = SchemaOptions.deleteExtraFields.default;
160+
} else if (!isBoolean(schema.deleteExtraFields)) {
161+
throw 'Parse Server option schema.deleteExtraFields must be a boolean.';
162+
}
163+
if (schema.recreateModifiedFields === undefined) {
164+
schema.recreateModifiedFields = SchemaOptions.recreateModifiedFields.default;
165+
} else if (!isBoolean(schema.recreateModifiedFields)) {
166+
throw 'Parse Server option schema.recreateModifiedFields must be a boolean.';
167+
}
168+
if (schema.lockSchemas === undefined) {
169+
schema.lockSchemas = SchemaOptions.lockSchemas.default;
170+
} else if (!isBoolean(schema.lockSchemas)) {
171+
throw 'Parse Server option schema.lockSchemas must be a boolean.';
172+
}
173+
if (schema.beforeMigration === undefined) {
174+
schema.beforeMigration = null;
175+
} else if (schema.beforeMigration !== null && typeof schema.beforeMigration !== 'function') {
176+
throw 'Parse Server option schema.beforeMigration must be a function.';
177+
}
178+
if (schema.afterMigration === undefined) {
179+
schema.afterMigration = null;
180+
} else if (schema.afterMigration !== null && typeof schema.afterMigration !== 'function') {
181+
throw 'Parse Server option schema.afterMigration must be a function.';
182+
}
183+
}
184+
140185
static validatePagesOptions(pages) {
141186
if (Object.prototype.toString.call(pages) !== '[object Object]') {
142187
throw 'Parse Server option pages must be an object.';

src/Controllers/SchemaController.js

+20-4
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,11 @@ export default class SchemaController {
831831
const existingFields = schema.fields;
832832
Object.keys(submittedFields).forEach(name => {
833833
const field = submittedFields[name];
834-
if (existingFields[name] && field.__op !== 'Delete') {
834+
if (
835+
existingFields[name] &&
836+
existingFields[name].type !== field.type &&
837+
field.__op !== 'Delete'
838+
) {
835839
throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
836840
}
837841
if (!existingFields[name] && field.__op === 'Delete') {
@@ -1057,7 +1061,12 @@ export default class SchemaController {
10571061
// object if the provided className-fieldName-type tuple is valid.
10581062
// The className must already be validated.
10591063
// If 'freeze' is true, refuse to update the schema for this field.
1060-
enforceFieldExists(className: string, fieldName: string, type: string | SchemaField) {
1064+
enforceFieldExists(
1065+
className: string,
1066+
fieldName: string,
1067+
type: string | SchemaField,
1068+
isValidation?: boolean
1069+
) {
10611070
if (fieldName.indexOf('.') > 0) {
10621071
// subdocument key (x.y) => ok if x is of type 'object'
10631072
fieldName = fieldName.split('.')[0];
@@ -1101,7 +1110,14 @@ export default class SchemaController {
11011110
)} but got ${typeToString(type)}`
11021111
);
11031112
}
1104-
return undefined;
1113+
// If type options do not change
1114+
// we can safely return
1115+
if (isValidation || JSON.stringify(expectedType) === JSON.stringify(type)) {
1116+
return undefined;
1117+
}
1118+
// Field options are may be changed
1119+
// ensure to have an update to date schema field
1120+
return this._dbAdapter.updateFieldOptions(className, fieldName, type);
11051121
}
11061122

11071123
return this._dbAdapter
@@ -1236,7 +1252,7 @@ export default class SchemaController {
12361252
// Every object has ACL implicitly.
12371253
continue;
12381254
}
1239-
promises.push(schema.enforceFieldExists(className, fieldName, expected));
1255+
promises.push(schema.enforceFieldExists(className, fieldName, expected, true));
12401256
}
12411257
const results = await Promise.all(promises);
12421258
const enforceFields = results.filter(result => !!result);

src/Options/Definitions.js

+39
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,45 @@ module.exports.SecurityOptions = {
446446
default: false,
447447
},
448448
};
449+
module.exports.SchemaOptions = {
450+
definitions: {
451+
help: 'The schema definitions.',
452+
default: [],
453+
},
454+
strict: {
455+
env: 'PARSE_SERVER_SCHEMA_STRICT',
456+
help: 'Is true if Parse Server should exit if schema update fail.',
457+
action: parsers.booleanParser,
458+
default: true,
459+
},
460+
deleteExtraFields: {
461+
env: 'PARSE_SERVER_SCHEMA_DELETE_EXTRA_FIELDS',
462+
help:
463+
'Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development.',
464+
action: parsers.booleanParser,
465+
default: false,
466+
},
467+
recreateModifiedFields: {
468+
env: 'PARSE_SERVER_SCHEMA_RECREATE_MODIFIED_FIELDS',
469+
help:
470+
'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.',
471+
action: parsers.booleanParser,
472+
default: false,
473+
},
474+
lockSchemas: {
475+
env: 'PARSE_SERVER_SCHEMA_LOCK',
476+
help:
477+
'Is true if Parse Server will reject any attempts to modify the schema while the server is running.',
478+
action: parsers.booleanParser,
479+
default: false,
480+
},
481+
beforeMigration: {
482+
help: 'Execute a callback before running schema migrations.',
483+
},
484+
afterMigration: {
485+
help: 'Execute a callback after running schema migrations.',
486+
},
487+
};
449488
module.exports.PagesOptions = {
450489
customRoutes: {
451490
env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES',

src/Options/index.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @flow
12
import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter';
23
import { FilesAdapter } from '../Adapters/Files/FilesAdapter';
34
import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter';
@@ -7,8 +8,8 @@ import { MailAdapter } from '../Adapters/Email/MailAdapter';
78
import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter';
89
import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter';
910
import { CheckGroup } from '../Security/CheckGroup';
11+
import type { SchemaOptions } from '../SchemaMigrations/Migrations';
1012

11-
// @flow
1213
type Adapter<T> = string | any | T;
1314
type NumberOrBoolean = number | boolean;
1415
type NumberOrString = number | string;
@@ -241,6 +242,8 @@ export interface ParseServerOptions {
241242
playgroundPath: ?string;
242243
/* Callback when server has started */
243244
serverStartComplete: ?(error: ?Error) => void;
245+
/* Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema */
246+
schema: ?SchemaOptions;
244247
/* Callback when server has closed */
245248
serverCloseComplete: ?() => void;
246249
/* The security options to identify and report weak security settings.

src/ParseServer.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer';
4444
import { SecurityRouter } from './Routers/SecurityRouter';
4545
import CheckRunner from './Security/CheckRunner';
4646
import Deprecator from './Deprecator/Deprecator';
47+
import { DefinedSchemas } from './SchemaMigrations/DefinedSchemas';
4748

4849
// Mutate the Parse object to add the Cloud Code handlers
4950
addParseCloud();
@@ -68,6 +69,7 @@ class ParseServer {
6869
javascriptKey,
6970
serverURL = requiredParameter('You must provide a serverURL!'),
7071
serverStartComplete,
72+
schema,
7173
} = options;
7274
// Initialize the node client SDK automatically
7375
Parse.initialize(appId, javascriptKey || 'unused', masterKey);
@@ -84,7 +86,10 @@ class ParseServer {
8486
databaseController
8587
.performInitialization()
8688
.then(() => hooksController.load())
87-
.then(() => {
89+
.then(async () => {
90+
if (schema) {
91+
await new DefinedSchemas(schema, this.config).execute();
92+
}
8893
if (serverStartComplete) {
8994
serverStartComplete();
9095
}

0 commit comments

Comments
 (0)