From 25d5c30be2111be332eb779eb0697774a17da7af Mon Sep 17 00:00:00 2001 From: Samuel Denis-D'Ortun Date: Mon, 1 Nov 2021 09:28:49 -0400 Subject: [PATCH] feat: add user-defined schema and migrations (#7418) --- spec/DefinedSchemas.spec.js | 644 ++++++++++++++++++ spec/schemas.spec.js | 4 +- .../Storage/Mongo/MongoSchemaCollection.js | 18 +- .../Storage/Mongo/MongoStorageAdapter.js | 5 + .../Storage/Postgres/PostgresClient.js | 2 +- .../Postgres/PostgresStorageAdapter.js | 10 + src/Adapters/Storage/StorageAdapter.js | 1 + src/Config.js | 45 ++ src/Controllers/SchemaController.js | 24 +- src/Options/Definitions.js | 39 ++ src/Options/index.js | 5 +- src/ParseServer.js | 7 +- src/Routers/SchemasRouter.js | 65 +- src/SchemaMigrations/DefinedSchemas.js | 434 ++++++++++++ src/SchemaMigrations/Migrations.js | 95 +++ src/index.js | 3 + 16 files changed, 1365 insertions(+), 36 deletions(-) create mode 100644 spec/DefinedSchemas.spec.js create mode 100644 src/SchemaMigrations/DefinedSchemas.js create mode 100644 src/SchemaMigrations/Migrations.js diff --git a/spec/DefinedSchemas.spec.js b/spec/DefinedSchemas.spec.js new file mode 100644 index 0000000000..9cc164640c --- /dev/null +++ b/spec/DefinedSchemas.spec.js @@ -0,0 +1,644 @@ +const { DefinedSchemas } = require('../lib/SchemaMigrations/DefinedSchemas'); +const Config = require('../lib/Config'); + +const cleanUpIndexes = schema => { + if (schema.indexes) { + delete schema.indexes._id_; + if (!Object.keys(schema.indexes).length) { + delete schema.indexes; + } + } +}; + +describe('DefinedSchemas', () => { + let config; + afterEach(async () => { + config = Config.get('test'); + if (config) { + await config.database.adapter.deleteAllClasses(); + } + }); + + describe('Fields', () => { + it('should keep default fields if not provided', async () => { + const server = await reconfigureServer(); + // Will perform create + await new DefinedSchemas({ definitions: [{ className: 'Test' }] }, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + const expectedFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }; + expect(schema.fields).toEqual(expectedFields); + + await server.config.schemaCache.clear(); + // Will perform update + await new DefinedSchemas({ definitions: [{ className: 'Test' }] }, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(expectedFields); + }); + it('should protect default fields', async () => { + const server = await reconfigureServer(); + + const schemas = { + definitions: [ + { + className: '_User', + fields: { + email: 'Object', + }, + }, + { + className: '_Role', + fields: { + users: 'Object', + }, + }, + { + className: '_Installation', + fields: { + installationId: 'Object', + }, + }, + { + className: 'Test', + fields: { + createdAt: { type: 'Object' }, + objectId: { type: 'Number' }, + updatedAt: { type: 'String' }, + ACL: { type: 'String' }, + }, + }, + ], + }; + + const expectedFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }; + + const expectedUserFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + }; + + const expectedRoleFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, + }; + + const expectedInstallationFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + installationId: { type: 'String' }, + deviceToken: { type: 'String' }, + channels: { type: 'Array' }, + deviceType: { type: 'String' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + localeIdentifier: { type: 'String' }, + badge: { type: 'Number' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + appIdentifier: { type: 'String' }, + parseVersion: { type: 'String' }, + }; + + // Perform create + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(expectedFields); + + let userSchema = await new Parse.Schema('_User').get(); + expect(userSchema.fields).toEqual(expectedUserFields); + + let roleSchema = await new Parse.Schema('_Role').get(); + expect(roleSchema.fields).toEqual(expectedRoleFields); + + let installationSchema = await new Parse.Schema('_Installation').get(); + expect(installationSchema.fields).toEqual(expectedInstallationFields); + + await server.config.schemaCache.clear(); + // Perform update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(expectedFields); + + userSchema = await new Parse.Schema('_User').get(); + expect(userSchema.fields).toEqual(expectedUserFields); + + roleSchema = await new Parse.Schema('_Role').get(); + expect(roleSchema.fields).toEqual(expectedRoleFields); + + installationSchema = await new Parse.Schema('_Installation').get(); + expect(installationSchema.fields).toEqual(expectedInstallationFields); + }); + it('should create new fields', async () => { + const server = await reconfigureServer(); + const fields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + aString: { type: 'String' }, + aStringWithDefault: { type: 'String', defaultValue: 'Test' }, + aStringWithRequired: { type: 'String', required: true }, + aStringWithRequiredAndDefault: { type: 'String', required: true, defaultValue: 'Test' }, + aBoolean: { type: 'Boolean' }, + aFile: { type: 'File' }, + aNumber: { type: 'Number' }, + aRelation: { type: 'Relation', targetClass: '_User' }, + aPointer: { type: 'Pointer', targetClass: '_Role' }, + aDate: { type: 'Date' }, + aGeoPoint: { type: 'GeoPoint' }, + aPolygon: { type: 'Polygon' }, + aArray: { type: 'Array' }, + aObject: { type: 'Object' }, + }; + const schemas = { + definitions: [ + { + className: 'Test', + fields, + }, + ], + }; + + // Create + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(fields); + + fields.anotherObject = { type: 'Object' }; + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(fields); + }); + it('should not delete removed fields when "deleteExtraFields" is false', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toBeDefined(); + + await new DefinedSchemas({ definitions: [{ className: 'Test' }] }, server.config).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual({ + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + aField: { type: 'String' }, + ACL: { type: 'ACL' }, + }); + }); + it('should delete removed fields when "deleteExtraFields" is true', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { + definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }], + }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toBeDefined(); + + await new DefinedSchemas( + { deleteExtraFields: true, definitions: [{ className: 'Test' }] }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual({ + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }); + }); + it('should re create fields with changed type when "recreateModifiedFields" is true', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + const object = new Parse.Object('Test'); + await object.save({ aField: 'Hello' }, { useMasterKey: true }); + + await new DefinedSchemas( + { + recreateModifiedFields: true, + definitions: [{ className: 'Test', fields: { aField: { type: 'Number' } } }], + }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'Number' }); + + await object.fetch({ useMasterKey: true }); + expect(object.get('aField')).toBeUndefined(); + }); + it('should not re create fields with changed type when "recreateModifiedFields" is not true', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + const object = new Parse.Object('Test'); + await object.save({ aField: 'Hello' }, { useMasterKey: true }); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'Number' } } }] }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + await object.fetch({ useMasterKey: true }); + expect(object.get('aField')).toBeDefined(); + }); + it('should just update classic fields with changed params', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + const object = new Parse.Object('Test'); + await object.save({ aField: 'Hello' }, { useMasterKey: true }); + + await new DefinedSchemas( + { + definitions: [ + { className: 'Test', fields: { aField: { type: 'String', required: true } } }, + ], + }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String', required: true }); + + await object.fetch({ useMasterKey: true }); + expect(object.get('aField')).toEqual('Hello'); + }); + }); + + describe('Indexes', () => { + it('should create new indexes', async () => { + const server = await reconfigureServer(); + + const indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + const schemas = { + definitions: [{ className: 'Test', fields: { aField: { type: 'String' } }, indexes }], + }; + await new DefinedSchemas(schemas, server.config).execute(); + + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + + indexes.complex2 = { createdAt: 1, aField: 1 }; + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + }); + it('should re create changed indexes', async () => { + const server = await reconfigureServer(); + + let indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }] }; + await new DefinedSchemas(schemas, server.config).execute(); + + indexes = { complex: { createdAt: 1 } }; + schemas = { definitions: [{ className: 'Test', indexes }] }; + + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + }); + + it('should delete removed indexes', async () => { + const server = await reconfigureServer(); + + let indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }] }; + await new DefinedSchemas(schemas, server.config).execute(); + + indexes = {}; + schemas = { definitions: [{ className: 'Test', indexes }] }; + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + }); + xit('should keep protected indexes', async () => { + const server = await reconfigureServer(); + + const expectedIndexes = { + username_1: { username: 1 }, + case_insensitive_username: { username: 1 }, + email_1: { email: 1 }, + case_insensitive_email: { email: 1 }, + }; + const schemas = { + definitions: [ + { + className: '_User', + indexes: { + case_insensitive_username: { password: true }, + case_insensitive_email: { password: true }, + }, + }, + { className: 'Test' }, + ], + }; + // Create + await new DefinedSchemas(schemas, server.config).execute(); + let userSchema = await new Parse.Schema('_User').get(); + let testSchema = await new Parse.Schema('Test').get(); + cleanUpIndexes(userSchema); + cleanUpIndexes(testSchema); + expect(testSchema.indexes).toBeUndefined(); + expect(userSchema.indexes).toEqual(expectedIndexes); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + userSchema = await new Parse.Schema('_User').get(); + testSchema = await new Parse.Schema('Test').get(); + cleanUpIndexes(userSchema); + cleanUpIndexes(testSchema); + expect(testSchema.indexes).toBeUndefined(); + expect(userSchema.indexes).toEqual(expectedIndexes); + }); + }); + + describe('ClassLevelPermissions', () => { + it('should use default CLP', async () => { + const server = await reconfigureServer(); + const schemas = { definitions: [{ className: 'Test' }] }; + await new DefinedSchemas(schemas, server.config).execute(); + + const expectedTestCLP = { + find: {}, + count: {}, + get: {}, + create: {}, + update: {}, + delete: {}, + addField: {}, + protectedFields: {}, + }; + let testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + + await new DefinedSchemas(schemas, server.config).execute(); + testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + }); + it('should save CLP', async () => { + const server = await reconfigureServer(); + + const expectedTestCLP = { + find: {}, + count: { requiresAuthentication: true }, + get: { 'role:Admin': true }, + create: { 'role:ARole': true, requiresAuthentication: true }, + update: { requiresAuthentication: true }, + delete: { requiresAuthentication: true }, + addField: {}, + protectedFields: { '*': ['aField'], 'role:Admin': ['anotherField'] }, + }; + const schemas = { + definitions: [ + { + className: 'Test', + fields: { aField: { type: 'String' }, anotherField: { type: 'Object' } }, + classLevelPermissions: expectedTestCLP, + }, + ], + }; + await new DefinedSchemas(schemas, server.config).execute(); + + let testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + + expectedTestCLP.update = {}; + expectedTestCLP.create = { requiresAuthentication: true }; + + await new DefinedSchemas(schemas, server.config).execute(); + testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + }); + it('should force addField to empty', async () => { + const server = await reconfigureServer(); + const schemas = { + definitions: [{ className: 'Test', classLevelPermissions: { addField: { '*': true } } }], + }; + await new DefinedSchemas(schemas, server.config).execute(); + + const expectedTestCLP = { + find: {}, + count: {}, + get: {}, + create: {}, + update: {}, + delete: {}, + addField: {}, + protectedFields: {}, + }; + + let testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + + await new DefinedSchemas(schemas, server.config).execute(); + testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + }); + }); + + it('should not delete automatically classes', async () => { + await reconfigureServer({ + schema: { definitions: [{ className: '_User' }, { className: 'Test' }] }, + }); + + await reconfigureServer({ schema: { definitions: [{ className: '_User' }] } }); + + const schema = await new Parse.Schema('Test').get(); + expect(schema.className).toEqual('Test'); + }); + + it('should disable class PUT/POST endpoint when lockSchemas provided to avoid dual source of truth', async () => { + await reconfigureServer({ + schema: { + lockSchemas: true, + definitions: [{ className: '_User' }, { className: 'Test' }], + }, + }); + + const schema = await new Parse.Schema('Test').get(); + expect(schema.className).toEqual('Test'); + + const schemas = await Parse.Schema.all(); + // Role could be flaky since all system classes are not ensured + // at start up by the DefinedSchema system + expect(schemas.filter(({ className }) => className !== '_Role').length).toEqual(3); + + await expectAsync(new Parse.Schema('TheNewTest').save()).toBeRejectedWithError( + 'Cannot perform this operation when schemas options is used.' + ); + + await expectAsync(new Parse.Schema('_User').update()).toBeRejectedWithError( + 'Cannot perform this operation when schemas options is used.' + ); + }); + it('should only enable delete class endpoint since', async () => { + await reconfigureServer({ + schema: { definitions: [{ className: '_User' }, { className: 'Test' }] }, + }); + await reconfigureServer({ schema: { definitions: [{ className: '_User' }] } }); + + let schemas = await Parse.Schema.all(); + expect(schemas.length).toEqual(4); + + await new Parse.Schema('_User').delete(); + schemas = await Parse.Schema.all(); + expect(schemas.length).toEqual(3); + }); + it('should run beforeMigration before execution of DefinedSchemas', async () => { + const config = { + schema: { + definitions: [{ className: '_User' }, { className: 'Test' }], + beforeMigration: async () => {}, + }, + }; + const spy = spyOn(config.schema, 'beforeMigration'); + await reconfigureServer(config); + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should run afterMigration after execution of DefinedSchemas', async () => { + const config = { + schema: { + definitions: [{ className: '_User' }, { className: 'Test' }], + afterMigration: async () => {}, + }, + }; + const spy = spyOn(config.schema, 'afterMigration'); + await reconfigureServer(config); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should use logger in case of error', async () => { + const server = await reconfigureServer({ schema: { definitions: [{ className: '_User' }] } }); + const error = new Error('A test error'); + const logger = require('../lib/logger').logger; + spyOn(DefinedSchemas.prototype, 'wait').and.resolveTo(); + spyOn(logger, 'error').and.callThrough(); + spyOn(Parse.Schema, 'all').and.callFake(() => { + throw error; + }); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + expect(logger.error).toHaveBeenCalledWith(`Failed to run migrations: ${error.toString()}`); + }); + it('should perform migration in parallel without failing', async () => { + const server = await reconfigureServer(); + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callThrough(); + const migrationOptions = { + definitions: [ + { + className: 'Test', + fields: { aField: { type: 'String' } }, + indexes: { aField: { aField: 1 } }, + classLevelPermissions: { + create: { requiresAuthentication: true }, + }, + }, + ], + }; + + // Simulate parallel deployment + await Promise.all([ + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + ]); + + const testSchema = (await Parse.Schema.all()).find( + ({ className }) => className === migrationOptions.definitions[0].className + ); + + expect(testSchema.indexes.aField).toEqual({ aField: 1 }); + expect(testSchema.fields.aField).toEqual({ type: 'String' }); + expect(testSchema.classLevelPermissions.create).toEqual({ requiresAuthentication: true }); + expect(logger.error).toHaveBeenCalledTimes(0); + }); +}); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 15dc9e111e..5180b36ca5 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -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 => { const obj = hasAllPODobject(); obj.save().then(() => { request({ @@ -769,7 +769,7 @@ describe('schemas', () => { json: true, body: { fields: { - aString: { type: 'String' }, + aString: { type: 'Number' }, }, }, }).then(fail, response => { diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index d2f89d75d4..78284503fc 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -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. @@ -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 diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index e134f0c06f..c1fe63ba9b 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -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 { return this._schemaCollection() .then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type)) diff --git a/src/Adapters/Storage/Postgres/PostgresClient.js b/src/Adapters/Storage/Postgres/PostgresClient.js index b436945249..16a9564c29 100644 --- a/src/Adapters/Storage/Postgres/PostgresClient.js +++ b/src/Adapters/Storage/Postgres/PostgresClient.js @@ -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()) { + if (monitor.isAttached()) { monitor.detach(); } monitor.attach(initOptions); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 065d5c93a3..e6c2bdbb1b 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -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 => { + const path = `{fields,${fieldName}}`; + await t.none( + 'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $, $) WHERE "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) { diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index d46265f64f..6e4573b748 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -35,6 +35,7 @@ export interface StorageAdapter { setClassLevelPermissions(className: string, clps: any): Promise; createClass(className: string, schema: SchemaType): Promise; addFieldIfNotExists(className: string, fieldName: string, type: any): Promise; + updateFieldOptions(className: string, fieldName: string, type: any): Promise; deleteClass(className: string): Promise; deleteAllClasses(fast: boolean): Promise; deleteFields(className: string, schema: SchemaType, fieldNames: Array): Promise; diff --git a/src/Config.js b/src/Config.js index 250880efbc..069b7e2a43 100644 --- a/src/Config.js +++ b/src/Config.js @@ -11,6 +11,7 @@ import { AccountLockoutOptions, PagesOptions, SecurityOptions, + SchemaOptions, } from './Options/Definitions'; import { isBoolean, isString } from 'lodash'; @@ -76,6 +77,7 @@ export class Config { pages, security, enforcePrivateUsers, + schema, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -112,6 +114,7 @@ export class Config { this.validateIdempotencyOptions(idempotencyOptions); this.validatePagesOptions(pages); this.validateSecurityOptions(security); + this.validateSchemaOptions(schema); this.validateEnforcePrivateUsers(enforcePrivateUsers); } @@ -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.'; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index c9c51e71ad..9ae6628088 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -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 && + field.__op !== 'Delete' + ) { throw new Parse.Error(255, `Field ${name} exists, cannot update.`); } if (!existingFields[name] && field.__op === 'Delete') { @@ -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]; @@ -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 @@ -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); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 88779376c2..e2cbd17920 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -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', diff --git a/src/Options/index.js b/src/Options/index.js index 34fa5198c2..31b9e10d41 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -1,3 +1,4 @@ +// @flow import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter'; @@ -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 = string | any | T; type NumberOrBoolean = number | boolean; type NumberOrString = number | string; @@ -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 */ + schema: ?SchemaOptions; /* Callback when server has closed */ serverCloseComplete: ?() => void; /* The security options to identify and report weak security settings. diff --git a/src/ParseServer.js b/src/ParseServer.js index a8b0ad38cf..dc2af9e7be 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -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(); @@ -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); @@ -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(); } diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index ae0a736eb5..54f73ceacc 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -35,7 +35,42 @@ function getOneSchema(req) { }); } -function createSchema(req) { +const checkIfDefinedSchemasIsUsed = req => { + if (req.config?.schema?.lockSchemas === true) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Cannot perform this operation when schemas options is used.' + ); + } +}; + +export const internalCreateSchema = async (className, body, config) => { + const controller = await config.database.loadSchema({ clearCache: true }); + const response = await controller.addClassIfNotExists( + className, + body.fields, + body.classLevelPermissions, + body.indexes + ); + return { + response, + }; +}; + +export const internalUpdateSchema = async (className, body, config) => { + const controller = await config.database.loadSchema({ clearCache: true }); + const response = await controller.updateClass( + className, + body.fields || {}, + body.classLevelPermissions, + body.indexes, + config.database + ); + return { response }; +}; + +async function createSchema(req) { + checkIfDefinedSchemasIsUsed(req); if (req.auth.isReadOnly) { throw new Parse.Error( Parse.Error.OPERATION_FORBIDDEN, @@ -53,20 +88,11 @@ function createSchema(req) { throw new Parse.Error(135, `POST ${req.path} needs a class name.`); } - return req.config.database - .loadSchema({ clearCache: true }) - .then(schema => - schema.addClassIfNotExists( - className, - req.body.fields, - req.body.classLevelPermissions, - req.body.indexes - ) - ) - .then(schema => ({ response: schema })); + return await internalCreateSchema(className, req.body, req.config); } function modifySchema(req) { + checkIfDefinedSchemasIsUsed(req); if (req.auth.isReadOnly) { throw new Parse.Error( Parse.Error.OPERATION_FORBIDDEN, @@ -76,22 +102,9 @@ function modifySchema(req) { if (req.body.className && req.body.className != req.params.className) { return classNameMismatchResponse(req.body.className, req.params.className); } - - const submittedFields = req.body.fields || {}; const className = req.params.className; - return req.config.database - .loadSchema({ clearCache: true }) - .then(schema => - schema.updateClass( - className, - submittedFields, - req.body.classLevelPermissions, - req.body.indexes, - req.config.database - ) - ) - .then(result => ({ response: result })); + return internalUpdateSchema(className, req.body, req.config); } const deleteSchema = req => { diff --git a/src/SchemaMigrations/DefinedSchemas.js b/src/SchemaMigrations/DefinedSchemas.js new file mode 100644 index 0000000000..0db3be6358 --- /dev/null +++ b/src/SchemaMigrations/DefinedSchemas.js @@ -0,0 +1,434 @@ +// @flow +// @flow-disable-next Cannot resolve module `parse/node`. +const Parse = require('parse/node'); +import { logger } from '../logger'; +import Config from '../Config'; +import { internalCreateSchema, internalUpdateSchema } from '../Routers/SchemasRouter'; +import { defaultColumns, systemClasses } from '../Controllers/SchemaController'; +import { ParseServerOptions } from '../Options'; +import * as Migrations from './Migrations'; + +export class DefinedSchemas { + config: ParseServerOptions; + schemaOptions: Migrations.SchemaOptions; + localSchemas: Migrations.JSONSchema[]; + retries: number; + maxRetries: number; + allCloudSchemas: Parse.Schema[]; + + constructor(schemaOptions: Migrations.SchemaOptions, config: ParseServerOptions) { + this.localSchemas = []; + this.config = Config.get(config.appId); + this.schemaOptions = schemaOptions; + if (schemaOptions && schemaOptions.definitions) { + if (!Array.isArray(schemaOptions.definitions)) { + throw `"schema.definitions" must be an array of schemas`; + } + + this.localSchemas = schemaOptions.definitions; + } + + this.retries = 0; + this.maxRetries = 3; + } + + async saveSchemaToDB(schema: Parse.Schema): Promise { + const payload = { + className: schema.className, + fields: schema._fields, + indexes: schema._indexes, + classLevelPermissions: schema._clp, + }; + await internalCreateSchema(schema.className, payload, this.config); + this.resetSchemaOps(schema); + } + + resetSchemaOps(schema: Parse.Schema) { + // Reset ops like SDK + schema._fields = {}; + schema._indexes = {}; + } + + // Simulate update like the SDK + // We cannot use SDK since routes are disabled + async updateSchemaToDB(schema: Parse.Schema) { + const payload = { + className: schema.className, + fields: schema._fields, + indexes: schema._indexes, + classLevelPermissions: schema._clp, + }; + await internalUpdateSchema(schema.className, payload, this.config); + this.resetSchemaOps(schema); + } + + async execute() { + try { + logger.info('Running Migrations'); + if (this.schemaOptions && this.schemaOptions.beforeMigration) { + await Promise.resolve(this.schemaOptions.beforeMigration()); + } + + await this.executeMigrations(); + + if (this.schemaOptions && this.schemaOptions.afterMigration) { + await Promise.resolve(this.schemaOptions.afterMigration()); + } + + logger.info('Running Migrations Completed'); + } catch (e) { + logger.error(`Failed to run migrations: ${e}`); + if (process.env.NODE_ENV === 'production') process.exit(1); + } + } + + async executeMigrations() { + let timeout = null; + try { + // Set up a time out in production + // if we fail to get schema + // pm2 or K8s and many other process managers will try to restart the process + // after the exit + if (process.env.NODE_ENV === 'production') { + timeout = setTimeout(() => { + logger.error('Timeout occurred during execution of migrations. Exiting...'); + process.exit(1); + }, 20000); + } + + // Hack to force session schema to be created + await this.createDeleteSession(); + this.allCloudSchemas = await Parse.Schema.all(); + clearTimeout(timeout); + await Promise.all(this.localSchemas.map(async localSchema => this.saveOrUpdate(localSchema))); + + this.checkForMissingSchemas(); + await this.enforceCLPForNonProvidedClass(); + } catch (e) { + if (timeout) clearTimeout(timeout); + if (this.retries < this.maxRetries) { + this.retries++; + // first retry 1sec, 2sec, 3sec total 6sec retry sequence + // retry will only happen in case of deploying multi parse server instance + // at the same time. Modern systems like k8 avoid this by doing rolling updates + await this.wait(1000 * this.retries); + await this.executeMigrations(); + } else { + logger.error(`Failed to run migrations: ${e}`); + if (process.env.NODE_ENV === 'production') process.exit(1); + } + } + } + + checkForMissingSchemas() { + if (this.schemaOptions.strict !== true) { + return; + } + + const cloudSchemas = this.allCloudSchemas.map(s => s.className); + const localSchemas = this.localSchemas.map(s => s.className); + const missingSchemas = cloudSchemas.filter( + c => !localSchemas.includes(c) && !systemClasses.includes(c) + ); + + if (new Set(localSchemas).size !== localSchemas.length) { + logger.error( + `The list of schemas provided contains duplicated "className" "${localSchemas.join( + '","' + )}"` + ); + process.exit(1); + } + + if (this.schemaOptions.strict && missingSchemas.length) { + logger.warn( + `The following schemas are currently present in the database, but not explicitly defined in a schema: "${missingSchemas.join( + '", "' + )}"` + ); + } + } + + // Required for testing purpose + wait(time: number) { + return new Promise(resolve => setTimeout(resolve, time)); + } + + async enforceCLPForNonProvidedClass(): Promise { + const nonProvidedClasses = this.allCloudSchemas.filter( + cloudSchema => + !this.localSchemas.some(localSchema => localSchema.className === cloudSchema.className) + ); + await Promise.all( + nonProvidedClasses.map(async schema => { + const parseSchema = new Parse.Schema(schema.className); + this.handleCLP(schema, parseSchema); + await this.updateSchemaToDB(parseSchema); + }) + ); + } + + // Create a fake session since Parse do not create the _Session until + // a session is created + async createDeleteSession() { + const session = new Parse.Session(); + await session.save(null, { useMasterKey: true }); + await session.destroy({ useMasterKey: true }); + } + + async saveOrUpdate(localSchema: Migrations.JSONSchema) { + const cloudSchema = this.allCloudSchemas.find(sc => sc.className === localSchema.className); + if (cloudSchema) { + try { + await this.updateSchema(localSchema, cloudSchema); + } catch (e) { + throw `Error during update of schema for type ${cloudSchema.className}: ${e}`; + } + } else { + try { + await this.saveSchema(localSchema); + } catch (e) { + throw `Error while saving Schema for type ${localSchema.className}: ${e}`; + } + } + } + + async saveSchema(localSchema: Migrations.JSONSchema) { + const newLocalSchema = new Parse.Schema(localSchema.className); + if (localSchema.fields) { + // Handle fields + Object.keys(localSchema.fields) + .filter(fieldName => !this.isProtectedFields(localSchema.className, fieldName)) + .forEach(fieldName => { + if (localSchema.fields) { + const field = localSchema.fields[fieldName]; + this.handleFields(newLocalSchema, fieldName, field); + } + }); + } + // Handle indexes + if (localSchema.indexes) { + Object.keys(localSchema.indexes).forEach(indexName => { + if (localSchema.indexes && !this.isProtectedIndex(localSchema.className, indexName)) { + newLocalSchema.addIndex(indexName, localSchema.indexes[indexName]); + } + }); + } + + this.handleCLP(localSchema, newLocalSchema); + + return await this.saveSchemaToDB(newLocalSchema); + } + + async updateSchema(localSchema: Migrations.JSONSchema, cloudSchema: Parse.Schema) { + const newLocalSchema = new Parse.Schema(localSchema.className); + + // Handle fields + // Check addition + if (localSchema.fields) { + Object.keys(localSchema.fields) + .filter(fieldName => !this.isProtectedFields(localSchema.className, fieldName)) + .forEach(fieldName => { + // @flow-disable-next + const field = localSchema.fields[fieldName]; + if (!cloudSchema.fields[fieldName]) { + this.handleFields(newLocalSchema, fieldName, field); + } + }); + } + + const fieldsToDelete: string[] = []; + const fieldsToRecreate: { + fieldName: string, + from: { type: string, targetClass?: string }, + to: { type: string, targetClass?: string }, + }[] = []; + const fieldsWithChangedParams: string[] = []; + + // Check deletion + Object.keys(cloudSchema.fields) + .filter(fieldName => !this.isProtectedFields(localSchema.className, fieldName)) + .forEach(fieldName => { + const field = cloudSchema.fields[fieldName]; + if (!localSchema.fields || !localSchema.fields[fieldName]) { + fieldsToDelete.push(fieldName); + return; + } + + const localField = localSchema.fields[fieldName]; + // Check if field has a changed type + if ( + !this.paramsAreEquals( + { type: field.type, targetClass: field.targetClass }, + { type: localField.type, targetClass: localField.targetClass } + ) + ) { + fieldsToRecreate.push({ + fieldName, + from: { type: field.type, targetClass: field.targetClass }, + to: { type: localField.type, targetClass: localField.targetClass }, + }); + return; + } + + // Check if something changed other than the type (like required, defaultValue) + if (!this.paramsAreEquals(field, localField)) { + fieldsWithChangedParams.push(fieldName); + } + }); + + if (this.schemaOptions.deleteExtraFields === true) { + fieldsToDelete.forEach(fieldName => { + newLocalSchema.deleteField(fieldName); + }); + + // Delete fields from the schema then apply changes + await this.updateSchemaToDB(newLocalSchema); + } else if (this.schemaOptions.strict === true && fieldsToDelete.length) { + logger.warn( + `The following fields exist in the database for "${ + localSchema.className + }", but are missing in the schema : "${fieldsToDelete.join('" ,"')}"` + ); + } + + if (this.schemaOptions.recreateModifiedFields === true) { + fieldsToRecreate.forEach(field => { + newLocalSchema.deleteField(field.fieldName); + }); + + // Delete fields from the schema then apply changes + await this.updateSchemaToDB(newLocalSchema); + + fieldsToRecreate.forEach(fieldInfo => { + if (localSchema.fields) { + const field = localSchema.fields[fieldInfo.fieldName]; + this.handleFields(newLocalSchema, fieldInfo.fieldName, field); + } + }); + } else if (this.schemaOptions.strict === true && fieldsToRecreate.length) { + fieldsToRecreate.forEach(field => { + const from = + field.from.type + (field.from.targetClass ? ` (${field.from.targetClass})` : ''); + const to = field.to.type + (field.to.targetClass ? ` (${field.to.targetClass})` : ''); + + logger.warn( + `The field "${field.fieldName}" type differ between the schema and the database for "${localSchema.className}"; Schema is defined as "${to}" and current database type is "${from}"` + ); + }); + } + + fieldsWithChangedParams.forEach(fieldName => { + if (localSchema.fields) { + const field = localSchema.fields[fieldName]; + this.handleFields(newLocalSchema, fieldName, field); + } + }); + + // Handle Indexes + // Check addition + if (localSchema.indexes) { + Object.keys(localSchema.indexes).forEach(indexName => { + if ( + (!cloudSchema.indexes || !cloudSchema.indexes[indexName]) && + !this.isProtectedIndex(localSchema.className, indexName) + ) { + if (localSchema.indexes) { + newLocalSchema.addIndex(indexName, localSchema.indexes[indexName]); + } + } + }); + } + + const indexesToAdd = []; + + // Check deletion + if (cloudSchema.indexes) { + Object.keys(cloudSchema.indexes).forEach(indexName => { + if (!this.isProtectedIndex(localSchema.className, indexName)) { + if (!localSchema.indexes || !localSchema.indexes[indexName]) { + newLocalSchema.deleteIndex(indexName); + } else if ( + !this.paramsAreEquals(localSchema.indexes[indexName], cloudSchema.indexes[indexName]) + ) { + newLocalSchema.deleteIndex(indexName); + if (localSchema.indexes) { + indexesToAdd.push({ + indexName, + index: localSchema.indexes[indexName], + }); + } + } + } + }); + } + + this.handleCLP(localSchema, newLocalSchema, cloudSchema); + // Apply changes + await this.updateSchemaToDB(newLocalSchema); + // Apply new/changed indexes + if (indexesToAdd.length) { + logger.debug( + `Updating indexes for "${newLocalSchema.className}" : ${indexesToAdd.join(' ,')}` + ); + indexesToAdd.forEach(o => newLocalSchema.addIndex(o.indexName, o.index)); + await this.updateSchemaToDB(newLocalSchema); + } + } + + handleCLP( + localSchema: Migrations.JSONSchema, + newLocalSchema: Parse.Schema, + cloudSchema: Parse.Schema + ) { + if (!localSchema.classLevelPermissions && !cloudSchema) { + logger.warn(`classLevelPermissions not provided for ${localSchema.className}.`); + } + // Use spread to avoid read only issue (encountered by Moumouls using directAccess) + const clp = ({ ...localSchema.classLevelPermissions } || {}: Parse.CLP.PermissionsMap); + // To avoid inconsistency we need to remove all rights on addField + clp.addField = {}; + newLocalSchema.setCLP(clp); + } + + isProtectedFields(className: string, fieldName: string) { + return ( + !!defaultColumns._Default[fieldName] || + !!(defaultColumns[className] && defaultColumns[className][fieldName]) + ); + } + + isProtectedIndex(className: string, indexName: string) { + let indexes = ['_id_']; + if (className === '_User') { + indexes = [ + ...indexes, + 'case_insensitive_username', + 'case_insensitive_email', + 'username_1', + 'email_1', + ]; + } + + return indexes.indexOf(indexName) !== -1; + } + + paramsAreEquals(objA: T, objB: T) { + const keysA: string[] = Object.keys(objA); + const keysB: string[] = Object.keys(objB); + + // Check key name + if (keysA.length !== keysB.length) return false; + return keysA.every(k => objA[k] === objB[k]); + } + + handleFields(newLocalSchema: Parse.Schema, fieldName: string, field: Migrations.FieldType) { + if (field.type === 'Relation') { + newLocalSchema.addRelation(fieldName, field.targetClass); + } else if (field.type === 'Pointer') { + newLocalSchema.addPointer(fieldName, field.targetClass, field); + } else { + newLocalSchema.addField(fieldName, field.type, field); + } + } +} diff --git a/src/SchemaMigrations/Migrations.js b/src/SchemaMigrations/Migrations.js new file mode 100644 index 0000000000..9142cdbcde --- /dev/null +++ b/src/SchemaMigrations/Migrations.js @@ -0,0 +1,95 @@ +// @flow + +export type FieldValueType = + | 'String' + | 'Boolean' + | 'File' + | 'Number' + | 'Relation' + | 'Pointer' + | 'Date' + | 'GeoPoint' + | 'Polygon' + | 'Array' + | 'Object' + | 'ACL'; + +export interface FieldType { + type: FieldValueType; + required?: boolean; + defaultValue?: mixed; + targetClass?: string; +} + +type ClassNameType = '_User' | '_Role' | string; + +export interface ProtectedFieldsInterface { + [key: string]: string[]; +} + +export interface IndexInterface { + [key: string]: number; +} + +export interface IndexesInterface { + [key: string]: IndexInterface; +} + +export interface SchemaOptions { + definitions: JSONSchema[]; + strict: ?boolean; + deleteExtraFields: ?boolean; + recreateModifiedFields: ?boolean; + lockSchemas: ?boolean; + /* Callback when server has started and before running schemas migration operations if schemas key provided */ + beforeMigration: ?() => void | Promise; + afterMigration: ?() => void | Promise; +} + +export type CLPOperation = 'find' | 'count' | 'get' | 'update' | 'create' | 'delete'; +// @Typescript 4.1+ // type CLPPermission = 'requiresAuthentication' | '*' | `user:${string}` | `role:${string}` + +type CLPValue = { [key: string]: boolean }; +type CLPData = { [key: string]: CLPOperation[] }; +type CLPInterface = { [key: string]: CLPValue }; + +export interface JSONSchema { + className: ClassNameType; + fields?: { [key: string]: FieldType }; + indexes?: IndexesInterface; + classLevelPermissions?: { + find?: CLPValue, + count?: CLPValue, + get?: CLPValue, + update?: CLPValue, + create?: CLPValue, + delete?: CLPValue, + addField?: CLPValue, + protectedFields?: ProtectedFieldsInterface, + }; +} + +export class CLP { + static allow(perms: { [key: string]: CLPData }): CLPInterface { + const out = {}; + + for (const [perm, ops] of Object.entries(perms)) { + // @flow-disable-next Property `@@iterator` is missing in mixed [1] but exists in `$Iterable` [2]. + for (const op of ops) { + out[op] = out[op] || {}; + out[op][perm] = true; + } + } + + return out; + } +} + +export function makeSchema(className: ClassNameType, schema: JSONSchema): JSONSchema { + // This function solve two things: + // 1. It provides auto-completion to the users who are implementing schemas + // 2. It allows forward-compatible point in order to allow future changes to the internal structure of JSONSchema without affecting all the users + schema.className = className; + + return schema; +} diff --git a/src/index.js b/src/index.js index 03cd15176f..bbbdaf545f 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,8 @@ import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter'; import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter'; import LRUCacheAdapter from './Adapters/Cache/LRUCache.js'; import * as TestUtils from './TestUtils'; +import * as SchemaMigrations from './SchemaMigrations/Migrations'; + import { useExternal } from './deprecated'; import { getLogger } from './logger'; import { PushWorker } from './Push/PushWorker'; @@ -40,4 +42,5 @@ export { PushWorker, ParseGraphQLServer, _ParseServer as ParseServer, + SchemaMigrations, };