diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index 98103ce6e4..52d33d51c9 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -69,16 +69,16 @@ describe('DatabaseController', function () { 'getExpectedType', ]); - it('should not decorate query if no pointer CLPs are present', done => { + it('should not decorate query if no pointer CLPs are present', async done => { const clp = buildCLP(); const query = { a: 'b' }; schemaController.testPermissionsForClassName .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) .and.returnValue(true); - schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + await schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -91,7 +91,7 @@ describe('DatabaseController', function () { done(); }); - it('should decorate query if a pointer CLP entry is present', done => { + it('should decorate query if a pointer CLP entry is present', async done => { const clp = buildCLP(['user']); const query = { a: 'b' }; @@ -103,7 +103,7 @@ describe('DatabaseController', function () { .withArgs(CLASS_NAME, 'user') .and.returnValue({ type: 'Pointer' }); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -116,7 +116,7 @@ describe('DatabaseController', function () { done(); }); - it('should decorate query if an array CLP entry is present', done => { + it('should decorate query if an array CLP entry is present', async done => { const clp = buildCLP(['users']); const query = { a: 'b' }; @@ -128,7 +128,7 @@ describe('DatabaseController', function () { .withArgs(CLASS_NAME, 'users') .and.returnValue({ type: 'Array' }); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -144,7 +144,7 @@ describe('DatabaseController', function () { done(); }); - it('should decorate query if an object CLP entry is present', done => { + it('should decorate query if an object CLP entry is present', async done => { const clp = buildCLP(['user']); const query = { a: 'b' }; @@ -156,7 +156,7 @@ describe('DatabaseController', function () { .withArgs(CLASS_NAME, 'user') .and.returnValue({ type: 'Object' }); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -172,7 +172,7 @@ describe('DatabaseController', function () { done(); }); - it('should decorate query if a pointer CLP is present and the same field is part of the query', done => { + it('should decorate query if a pointer CLP is present and the same field is part of the query', async done => { const clp = buildCLP(['user']); const query = { a: 'b', user: 'a' }; @@ -184,7 +184,7 @@ describe('DatabaseController', function () { .withArgs(CLASS_NAME, 'user') .and.returnValue({ type: 'Pointer' }); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -199,7 +199,7 @@ describe('DatabaseController', function () { done(); }); - it('should transform the query to an $or query if multiple array/pointer CLPs are present', done => { + it('should transform the query to an $or query if multiple array/pointer CLPs are present', async done => { const clp = buildCLP(['user', 'users', 'userObject']); const query = { a: 'b' }; @@ -217,7 +217,7 @@ describe('DatabaseController', function () { .withArgs(CLASS_NAME, 'userObject') .and.returnValue({ type: 'Object' }); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -236,7 +236,7 @@ describe('DatabaseController', function () { done(); }); - it('should not return a $or operation if the query involves one of the two fields also used as array/pointer permissions', done => { + it('should not return a $or operation if the query involves one of the two fields also used as array/pointer permissions', async done => { const clp = buildCLP(['users', 'user']); const query = { a: 'b', user: createUserPointer(USER_ID) }; schemaController.testPermissionsForClassName @@ -249,7 +249,7 @@ describe('DatabaseController', function () { schemaController.getExpectedType .withArgs(CLASS_NAME, 'users') .and.returnValue({ type: 'Array' }); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -260,7 +260,7 @@ describe('DatabaseController', function () { done(); }); - it('should not return a $or operation if the query involves one of the fields also used as array/pointer permissions', done => { + it('should not return a $or operation if the query involves one of the fields also used as array/pointer permissions', async done => { const clp = buildCLP(['user', 'users', 'userObject']); const query = { a: 'b', user: createUserPointer(USER_ID) }; schemaController.testPermissionsForClassName @@ -276,7 +276,7 @@ describe('DatabaseController', function () { schemaController.getExpectedType .withArgs(CLASS_NAME, 'userObject') .and.returnValue({ type: 'Object' }); - const output = databaseController.addPointerPermissions( + const output = await databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, @@ -287,7 +287,7 @@ describe('DatabaseController', function () { done(); }); - it('should throw an error if for some unexpected reason the property specified in the CLP is neither a pointer nor an array', done => { + it('should throw an error if for some unexpected reason the property specified in the CLP is neither a pointer nor an array', async done => { const clp = buildCLP(['user']); const query = { a: 'b' }; @@ -299,15 +299,15 @@ describe('DatabaseController', function () { .withArgs(CLASS_NAME, 'user') .and.returnValue({ type: 'Number' }); - expect(() => { + await expectAsync( databaseController.addPointerPermissions( schemaController, CLASS_NAME, OPERATION, query, ACL_GROUP - ); - }).toThrow( + ) + ).toBeRejectedWith( Error( `An unexpected condition occurred when resolving pointer permissions: ${CLASS_NAME} user` ) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 7f5b5650fc..1531e054c2 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -875,6 +875,14 @@ describe('SchemaController', () => { addField: { '*': true }, protectedFields: { '*': [] }, }, + indexes: { + _id_: { + _id: 1, + }, + name_1: { + name: 1, + }, + }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); done(); @@ -1097,14 +1105,15 @@ describe('SchemaController', () => { }) .then(() => schema.deleteField('relationField', 'NewClass', config.database)) .then(() => schema.reloadData()) - .then(() => { + .then(async () => { const expectedSchema = { objectId: { type: 'String' }, updatedAt: { type: 'Date' }, createdAt: { type: 'Date' }, ACL: { type: 'ACL' }, }; - expect(dd(schema.schemaData.NewClass.fields, expectedSchema)).toEqual(undefined); + const schemaData = await schema.getSchemaData(); + expect(dd(schemaData.NewClass.fields, expectedSchema)).toEqual(undefined); }) .then(done) .catch(done.fail); @@ -1331,14 +1340,16 @@ describe('SchemaController', () => { schema = s; return schema.getOneSchema('_User', false); }) - .then(userSchema => { + .then(async userSchema => { validateSchemaStructure(userSchema); - validateSchemaDataStructure(schema.schemaData); + const schemaData = await schema.getSchemaData(); + validateSchemaDataStructure(schemaData); return schema.getOneSchema('_PushStatus', true); }) - .then(pushStatusSchema => { + .then(async pushStatusSchema => { + const schemaData = await schema.getSchemaData(); validateSchemaStructure(pushStatusSchema); - validateSchemaDataStructure(schema.schemaData); + validateSchemaDataStructure(schemaData); }) .then(done) .catch(done.fail); @@ -1353,7 +1364,7 @@ describe('SchemaController', () => { it('ensureFields should throw when schema is not set', async () => { const schema = await config.database.loadSchema(); try { - schema.ensureFields([ + await schema.ensureFields([ { className: 'NewClass', fieldName: 'fieldName', diff --git a/spec/SchemaPerformance.spec.js b/spec/SchemaPerformance.spec.js index 0471871c54..3257f2f860 100644 --- a/spec/SchemaPerformance.spec.js +++ b/spec/SchemaPerformance.spec.js @@ -202,6 +202,6 @@ describe('Schema Performance', function () { {}, config.database ); - expect(getAllSpy.calls.count()).toBe(2); + expect(getAllSpy.calls.count()).toBe(4); }); }); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 9557dd7924..dc75327a7f 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1556,7 +1556,7 @@ describe('schemas', () => { it('ensure refresh cache after deleting a class', async done => { config = Config.get('test'); - spyOn(config.schemaCache, 'del').and.callFake(() => {}); + spyOn(config.schemaCache, 'clear').and.callFake(() => {}); spyOn(SchemaController.prototype, 'reloadData').and.callFake(() => Promise.resolve()); await request({ url: 'http://localhost:8378/1/schemas', diff --git a/src/Adapters/Cache/SchemaCache.js b/src/Adapters/Cache/SchemaCache.js deleted file mode 100644 index f55edf0635..0000000000 --- a/src/Adapters/Cache/SchemaCache.js +++ /dev/null @@ -1,23 +0,0 @@ -const SchemaCache = {}; - -export default { - all() { - return [...(SchemaCache.allClasses || [])]; - }, - - get(className) { - return this.all().find(cached => cached.className === className); - }, - - put(allSchema) { - SchemaCache.allClasses = allSchema; - }, - - del(className) { - this.put(this.all().filter(cached => cached.className !== className)); - }, - - clear() { - delete SchemaCache.allClasses; - }, -}; diff --git a/src/Adapters/Schema/InMemorySchemaCache.js b/src/Adapters/Schema/InMemorySchemaCache.js new file mode 100644 index 0000000000..7799b2007d --- /dev/null +++ b/src/Adapters/Schema/InMemorySchemaCache.js @@ -0,0 +1,34 @@ +import { SchemaAndData } from './types'; + +export default class InMemorySchemaCache { + fetchingSchemaPromise: any; + cache = {}; + + async fetchSchema(getDataFromDb: () => Promise<SchemaAndData>): Promise<SchemaAndData> { + if (this.cache.isCached) { + return { + allClasses: this.cache.allClasses, + schemaData: this.cache.schemaData, + }; + } + if (!this.fetchingSchemaPromise) { + this.fetchingSchemaPromise = await getDataFromDb(); + } + const result = await this.fetchingSchemaPromise; + this.cache.isCached = true; + this.cache.allClasses = result ? result.allClasses : undefined; + this.cache.schemaData = result ? result.schemaData : undefined; + + return { + allClasses: this.cache.allClasses, + schemaData: this.cache.schemaData, + }; + } + + clear(): Promise<void> { + this.cache.isCached = false; + this.cache.allClasses = undefined; + this.cache.schemaData = undefined; + this.fetchingSchemaPromise = undefined; + } +} diff --git a/src/Adapters/Schema/SchemaCacheAccess.js b/src/Adapters/Schema/SchemaCacheAccess.js new file mode 100644 index 0000000000..f4d75ec438 --- /dev/null +++ b/src/Adapters/Schema/SchemaCacheAccess.js @@ -0,0 +1,62 @@ +/*eslint no-unused-vars: "off"*/ +/** + * @module Adapters + */ +import type { Schema } from '../../Controllers/types'; +import SchemaCacheAdapter from './SchemaCacheAdapter'; +import { injectDefaultSchema, SchemaData } from '../../Schema/SchemaData'; +import { StorageAdapter } from '../Storage/StorageAdapter'; +import type { ParseServerOptions } from '../../Options'; +import { SchemaAndData } from './types'; + +/** + * @interface SchemaCacheAccess + */ +export class SchemaCacheAccess { + schemaCacheAdapter: SchemaCacheAdapter; + dbAdapter: StorageAdapter; + protectedFields: any; + + constructor(schemaCacheAdapter: SchemaCacheAdapter, dbAdapter, options: ParseServerOptions) { + this.schemaCacheAdapter = schemaCacheAdapter; + this.dbAdapter = dbAdapter; + this.protectedFields = options ? options.protectedFields : undefined; + } + + async getSchemaAndData(): Promise<SchemaAndData> { + const that = this; + return this.schemaCacheAdapter.fetchSchema(async () => { + const rawAllSchemas = await that.dbAdapter.getAllClasses(); + const allSchemas = rawAllSchemas.map(injectDefaultSchema); + + const schemaData = new SchemaData(allSchemas, that.protectedFields); + + return { + schemaData, + allClasses: allSchemas, + }; + }); + } + + async all(): Promise<Array<Schema>> { + const data = await this.getSchemaAndData(); + + return data.allClasses; + } + + async get(className): Promise<Schema> { + const allSchemas = await this.all(); + + return allSchemas.find(cached => cached.className === className); + } + + clear(): Promise<void> { + return this.schemaCacheAdapter.clear(); + } + + async getSchemaData(): Promise<SchemaData> { + const data = await this.getSchemaAndData(); + + return data.schemaData; + } +} diff --git a/src/Adapters/Schema/SchemaCacheAdapter.js b/src/Adapters/Schema/SchemaCacheAdapter.js new file mode 100644 index 0000000000..dcb5158f63 --- /dev/null +++ b/src/Adapters/Schema/SchemaCacheAdapter.js @@ -0,0 +1,20 @@ +/*eslint no-unused-vars: "off"*/ +/** + * @module Adapters + */ +import { SchemaAndData } from './types'; + +/** + * @interface SchemaCacheAdapter + */ +export default class SchemaCacheAdapter { + /** + * Get all schema entries and its corresponding intermediate format + */ + async fetchSchema(getDataFromDb: () => Promise<SchemaAndData>): Promise<SchemaAndData> {} + + /** + * Clear cache + */ + clear(): Promise<void> {} +} diff --git a/src/Adapters/Schema/types.js b/src/Adapters/Schema/types.js new file mode 100644 index 0000000000..77201fc4f4 --- /dev/null +++ b/src/Adapters/Schema/types.js @@ -0,0 +1,7 @@ +import type { Schema } from '../../Controllers/types'; +import { SchemaData } from '../../Schema/SchemaData'; + +export type SchemaAndData = { + allClasses: Array<Schema>, + schemaData: SchemaData, +}; diff --git a/src/Config.js b/src/Config.js index 04834d3291..123ea81eae 100644 --- a/src/Config.js +++ b/src/Config.js @@ -35,7 +35,11 @@ export class Config { config.applicationId = applicationId; Object.keys(cacheInfo).forEach(key => { if (key == 'databaseController') { - config.database = new DatabaseController(cacheInfo.databaseController.adapter, config); + config.database = new DatabaseController( + cacheInfo.databaseController.adapter, + cacheInfo.databaseController.schemaCache, + config + ); } else { config[key] = cacheInfo[key]; } diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 3e69b1f5eb..191613f2fb 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -16,10 +16,11 @@ import * as SchemaController from './SchemaController'; import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter'; -import SchemaCache from '../Adapters/Cache/SchemaCache'; import type { LoadSchemaOptions } from './types'; import type { ParseServerOptions } from '../Options'; import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter'; +import { SchemaCacheAccess } from '../Adapters/Schema/SchemaCacheAccess'; +import InMemorySchemaCache from '../Adapters/Schema/InMemorySchemaCache'; function addWriteACL(query, acl) { const newQuery = _.cloneDeep(query); @@ -122,7 +123,7 @@ const validateQuery = (query: any): void => { }; // Filters out any data that shouldn't be on this REST-formatted object. -const filterSensitiveData = ( +const filterSensitiveData = async ( isMaster: boolean, aclGroup: any[], auth: any, @@ -136,7 +137,7 @@ const filterSensitiveData = ( if (auth && auth.user) userId = auth.user.id; // replace protectedFields when using pointer-permissions - const perms = schema.getClassLevelPermissions(className); + const perms = await schema.getClassLevelPermissions(className); if (perms) { const isReadOperation = ['get', 'find'].indexOf(operation) > -1; @@ -362,21 +363,30 @@ const relationSchema = { class DatabaseController { adapter: StorageAdapter; - schemaCache: any; - schemaPromise: ?Promise<SchemaController.SchemaController>; + schemaCache: SchemaCacheAccess; + schemaController: any; _transactionalSession: ?any; options: ParseServerOptions; idempotencyOptions: any; - constructor(adapter: StorageAdapter, options: ParseServerOptions) { + constructor( + adapter: StorageAdapter, + schemaCache: SchemaCacheAccess, + options: ParseServerOptions + ) { this.adapter = adapter; + this.schemaCache = + schemaCache || new SchemaCacheAccess(new InMemorySchemaCache(), adapter, options); this.options = options || {}; this.idempotencyOptions = this.options.idempotencyOptions || {}; // Prevent mutable this.schema, otherwise one request could use // multiple schemas, so instead use loadSchema to get a schema. - this.schemaPromise = null; this._transactionalSession = null; - this.options = options; + this.schemaController = new SchemaController.SchemaController( + adapter, + this.schemaCache, + this.options + ); } collectionExists(className: string): Promise<boolean> { @@ -402,15 +412,10 @@ class DatabaseController { loadSchema( options: LoadSchemaOptions = { clearCache: false } ): Promise<SchemaController.SchemaController> { - if (this.schemaPromise != null) { - return this.schemaPromise; + if (options.clearCache) { + this.schemaCache.clear(); } - this.schemaPromise = SchemaController.load(this.adapter, options); - this.schemaPromise.then( - () => delete this.schemaPromise, - () => delete this.schemaPromise - ); - return this.loadSchema(options); + return Promise.resolve(this.schemaController); } loadSchemaIfNeeded( @@ -424,8 +429,8 @@ class DatabaseController { // classname through the key. // TODO: make this not in the DatabaseController interface redirectClassNameForKey(className: string, key: string): Promise<?string> { - return this.loadSchema().then(schema => { - var t = schema.getExpectedType(className, key); + return this.loadSchema().then(async schema => { + var t = await schema.getExpectedType(className, key); if (t != null && typeof t !== 'string' && t.type === 'Relation') { return t.targetClass; } @@ -477,15 +482,15 @@ class DatabaseController { var isMaster = acl === undefined; var aclGroup = acl || []; - return this.loadSchemaIfNeeded(validSchemaController).then(schemaController => { + return this.loadSchemaIfNeeded(validSchemaController).then(async schemaController => { return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'update') ) - .then(() => { + .then(async () => { relationUpdates = this.collectRelationUpdates(className, originalQuery.objectId, update); if (!isMaster) { - query = this.addPointerPermissions( + query = await this.addPointerPermissions( schemaController, className, 'update', @@ -497,7 +502,7 @@ class DatabaseController { query = { $and: [ query, - this.addPointerPermissions( + await this.addPointerPermissions( schemaController, className, 'addField', @@ -739,13 +744,13 @@ class DatabaseController { const isMaster = acl === undefined; const aclGroup = acl || []; - return this.loadSchemaIfNeeded(validSchemaController).then(schemaController => { + return this.loadSchemaIfNeeded(validSchemaController).then(async schemaController => { return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'delete') - ).then(() => { + ).then(async () => { if (!isMaster) { - query = this.addPointerPermissions( + query = await this.addPointerPermissions( schemaController, className, 'delete', @@ -812,7 +817,7 @@ class DatabaseController { return this.validateClassName(className) .then(() => this.loadSchemaIfNeeded(validSchemaController)) - .then(schemaController => { + .then(async schemaController => { return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'create') @@ -848,14 +853,15 @@ class DatabaseController { }); } - canAddField( + async canAddField( schema: SchemaController.SchemaController, className: string, object: any, aclGroup: string[], runOptions: QueryOptions ): Promise<void> { - const classSchema = schema.schemaData[className]; + const schemaData = await this.schemaCache.getSchemaData(); + const classSchema = schemaData[className]; if (!classSchema) { return Promise.resolve(); } @@ -885,9 +891,8 @@ class DatabaseController { * @param {boolean} fast set to true if it's ok to just delete rows and not indexes * @returns {Promise<void>} when the deletions completes */ - deleteEverything(fast: boolean = false): Promise<any> { - this.schemaPromise = null; - SchemaCache.clear(); + async deleteEverything(fast: boolean = false): Promise<any> { + await this.schemaCache.clear(); return this.adapter.deleteAllClasses(fast); } @@ -956,8 +961,8 @@ class DatabaseController { }); } - const promises = Object.keys(query).map(key => { - const t = schema.getExpectedType(className, key); + const promises = Object.keys(query).map(async key => { + const t = await schema.getExpectedType(className, key); if (!t || t.type !== 'Relation') { return Promise.resolve(query); } @@ -1176,7 +1181,7 @@ class DatabaseController { } throw error; }) - .then(schema => { + .then(async schema => { // Parse.com treats queries on _created_at and _updated_at as if they were queries on createdAt and updatedAt, // so duplicate that behavior here. If both are specified, the correct behavior to match Parse.com is to // use the one that appears first in the sort list. @@ -1216,10 +1221,10 @@ class DatabaseController { ) .then(() => this.reduceRelationKeys(className, query, queryOptions)) .then(() => this.reduceInRelation(className, query, schemaController)) - .then(() => { + .then(async () => { let protectedFields; if (!isMaster) { - query = this.addPointerPermissions( + query = await this.addPointerPermissions( schemaController, className, op, @@ -1229,7 +1234,7 @@ class DatabaseController { /* Don't use projections to optimize the protectedFields since the protectedFields based on pointer-permissions are determined after querying. The filtering can overwrite the protected fields. */ - protectedFields = this.addProtectedFields( + protectedFields = await this.addProtectedFields( schemaController, className, query, @@ -1290,20 +1295,22 @@ class DatabaseController { } else { return this.adapter .find(className, schema, query, queryOptions) - .then(objects => - objects.map(object => { - object = untransformObjectACL(object); - return filterSensitiveData( - isMaster, - aclGroup, - auth, - op, - schemaController, - className, - protectedFields, - object - ); - }) + .then(async objects => + Promise.all( + objects.map(object => { + object = untransformObjectACL(object); + return filterSensitiveData( + isMaster, + aclGroup, + auth, + op, + schemaController, + className, + protectedFields, + object + ); + }) + ) ) .catch(error => { throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, error); @@ -1350,8 +1357,7 @@ class DatabaseController { this.adapter.deleteClass(joinTableName(className, name)) ) ).then(() => { - SchemaCache.del(className); - return schemaController.reloadData(); + this.schemaCache.clear(); }); } else { return Promise.resolve(); @@ -1442,19 +1448,19 @@ class DatabaseController { // 2. Exctract a list of field names that are PP for target collection and operation; // 3. Constraint the original query so that each PP field must // point to caller's id (or contain it in case of PP field being an array) - addPointerPermissions( + async addPointerPermissions( schema: SchemaController.SchemaController, className: string, operation: string, query: any, aclGroup: any[] = [] - ): any { + ): Promise<any> { // Check if class has public permission for operation // If the BaseCLP pass, let go through - if (schema.testPermissionsForClassName(className, aclGroup, operation)) { + if (await schema.testPermissionsForClassName(className, aclGroup, operation)) { return query; } - const perms = schema.getClassLevelPermissions(className); + const perms = await schema.getClassLevelPermissions(className); const userACL = aclGroup.filter(acl => { return acl.indexOf('role:') != 0 && acl != '*'; @@ -1491,40 +1497,42 @@ class DatabaseController { objectId: userId, }; - const queries = permFields.map(key => { - const fieldDescriptor = schema.getExpectedType(className, key); - const fieldType = - fieldDescriptor && - typeof fieldDescriptor === 'object' && - Object.prototype.hasOwnProperty.call(fieldDescriptor, 'type') - ? fieldDescriptor.type - : null; - - let queryClause; - - if (fieldType === 'Pointer') { - // constraint for single pointer setup - queryClause = { [key]: userPointer }; - } else if (fieldType === 'Array') { - // constraint for users-array setup - queryClause = { [key]: { $all: [userPointer] } }; - } else if (fieldType === 'Object') { - // constraint for object setup - queryClause = { [key]: userPointer }; - } else { - // This means that there is a CLP field of an unexpected type. This condition should not happen, which is - // why is being treated as an error. - throw Error( - `An unexpected condition occurred when resolving pointer permissions: ${className} ${key}` - ); - } - // if we already have a constraint on the key, use the $and - if (Object.prototype.hasOwnProperty.call(query, key)) { - return this.reduceAndOperation({ $and: [queryClause, query] }); - } - // otherwise just add the constaint - return Object.assign({}, query, queryClause); - }); + const queries = await Promise.all( + permFields.map(async key => { + const fieldDescriptor = await schema.getExpectedType(className, key); + const fieldType = + fieldDescriptor && + typeof fieldDescriptor === 'object' && + Object.prototype.hasOwnProperty.call(fieldDescriptor, 'type') + ? fieldDescriptor.type + : null; + + let queryClause; + + if (fieldType === 'Pointer') { + // constraint for single pointer setup + queryClause = { [key]: userPointer }; + } else if (fieldType === 'Array') { + // constraint for users-array setup + queryClause = { [key]: { $all: [userPointer] } }; + } else if (fieldType === 'Object') { + // constraint for object setup + queryClause = { [key]: userPointer }; + } else { + // This means that there is a CLP field of an unexpected type. This condition should not happen, which is + // why is being treated as an error. + throw Error( + `An unexpected condition occurred when resolving pointer permissions: ${className} ${key}` + ); + } + // if we already have a constraint on the key, use the $and + if (Object.prototype.hasOwnProperty.call(query, key)) { + return this.reduceAndOperation({ $and: [queryClause, query] }); + } + // otherwise just add the constaint + return Object.assign({}, query, queryClause); + }) + ); return queries.length === 1 ? queries[0] : this.reduceOrOperation({ $or: queries }); } else { @@ -1532,15 +1540,15 @@ class DatabaseController { } } - addProtectedFields( + async addProtectedFields( schema: SchemaController.SchemaController, className: string, query: any = {}, aclGroup: any[] = [], auth: any = {}, queryOptions: FullQueryOptions = {} - ): null | string[] { - const perms = schema.getClassLevelPermissions(className); + ): Promise<null | string[]> { + const perms = await schema.getClassLevelPermissions(className); if (!perms) return null; const protectedFields = perms.protectedFields; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 9ae6628088..864bc3790f 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -16,12 +16,11 @@ // TODO: hide all schema logic inside the database adapter. // @flow-disable-next const Parse = require('parse/node').Parse; +// @flow-disable-next +import { SchemaCacheAccess } from '../Adapters/Schema/SchemaCacheAccess'; import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; -import SchemaCache from '../Adapters/Cache/SchemaCache'; import DatabaseController from './DatabaseController'; -import Config from '../Config'; -// @flow-disable-next -import deepcopy from 'deepcopy'; + import type { Schema, SchemaFields, @@ -29,126 +28,13 @@ import type { SchemaField, LoadSchemaOptions, } from './types'; - -const defaultColumns: { [string]: SchemaFields } = Object.freeze({ - // Contain the default columns for every parse object type (except _Join collection) - _Default: { - objectId: { type: 'String' }, - createdAt: { type: 'Date' }, - updatedAt: { type: 'Date' }, - ACL: { type: 'ACL' }, - }, - // The additional default columns for the _User collection (in addition to DefaultCols) - _User: { - username: { type: 'String' }, - password: { type: 'String' }, - email: { type: 'String' }, - emailVerified: { type: 'Boolean' }, - authData: { type: 'Object' }, - }, - // The additional default columns for the _Installation collection (in addition to DefaultCols) - _Installation: { - 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' }, - }, - // The additional default columns for the _Role collection (in addition to DefaultCols) - _Role: { - name: { type: 'String' }, - users: { type: 'Relation', targetClass: '_User' }, - roles: { type: 'Relation', targetClass: '_Role' }, - }, - // The additional default columns for the _Session collection (in addition to DefaultCols) - _Session: { - user: { type: 'Pointer', targetClass: '_User' }, - installationId: { type: 'String' }, - sessionToken: { type: 'String' }, - expiresAt: { type: 'Date' }, - createdWith: { type: 'Object' }, - }, - _Product: { - productIdentifier: { type: 'String' }, - download: { type: 'File' }, - downloadName: { type: 'String' }, - icon: { type: 'File' }, - order: { type: 'Number' }, - title: { type: 'String' }, - subtitle: { type: 'String' }, - }, - _PushStatus: { - pushTime: { type: 'String' }, - source: { type: 'String' }, // rest or webui - query: { type: 'String' }, // the stringified JSON query - payload: { type: 'String' }, // the stringified JSON payload, - title: { type: 'String' }, - expiry: { type: 'Number' }, - expiration_interval: { type: 'Number' }, - status: { type: 'String' }, - numSent: { type: 'Number' }, - numFailed: { type: 'Number' }, - pushHash: { type: 'String' }, - errorMessage: { type: 'Object' }, - sentPerType: { type: 'Object' }, - failedPerType: { type: 'Object' }, - sentPerUTCOffset: { type: 'Object' }, - failedPerUTCOffset: { type: 'Object' }, - count: { type: 'Number' }, // tracks # of batches queued and pending - }, - _JobStatus: { - jobName: { type: 'String' }, - source: { type: 'String' }, - status: { type: 'String' }, - message: { type: 'String' }, - params: { type: 'Object' }, // params received when calling the job - finishedAt: { type: 'Date' }, - }, - _JobSchedule: { - jobName: { type: 'String' }, - description: { type: 'String' }, - params: { type: 'String' }, - startAfter: { type: 'String' }, - daysOfWeek: { type: 'Array' }, - timeOfDay: { type: 'String' }, - lastRun: { type: 'Number' }, - repeatMinutes: { type: 'Number' }, - }, - _Hooks: { - functionName: { type: 'String' }, - className: { type: 'String' }, - triggerName: { type: 'String' }, - url: { type: 'String' }, - }, - _GlobalConfig: { - objectId: { type: 'String' }, - params: { type: 'Object' }, - masterKeyOnly: { type: 'Object' }, - }, - _GraphQLConfig: { - objectId: { type: 'String' }, - config: { type: 'Object' }, - }, - _Audience: { - objectId: { type: 'String' }, - name: { type: 'String' }, - query: { type: 'String' }, //storing query as JSON string to prevent "Nested keys should not contain the '$' or '.' characters" error - lastUsed: { type: 'Date' }, - timesUsed: { type: 'Number' }, - }, - _Idempotency: { - reqId: { type: 'String' }, - expire: { type: 'Date' }, - }, -}); +import type { ParseServerOptions } from '../Options'; +import { + injectDefaultSchema, + volatileClasses, + defaultColumns, + SchemaData, +} from '../Schema/SchemaData'; const requiredColumns = Object.freeze({ _Product: ['productIdentifier', 'icon', 'order', 'title', 'subtitle'], @@ -170,17 +56,6 @@ const systemClasses = Object.freeze([ '_Idempotency', ]); -const volatileClasses = Object.freeze([ - '_JobStatus', - '_PushStatus', - '_Hooks', - '_GlobalConfig', - '_GraphQLConfig', - '_JobSchedule', - '_Audience', - '_Idempotency', -]); - // Anything that start with role const roleRegex = /^role:.*/; // Anything that starts with userField (allowed for protected fields only) @@ -529,81 +404,6 @@ const convertAdapterSchemaToParseSchema = ({ ...schema }) => { return schema; }; -class SchemaData { - __data: any; - __protectedFields: any; - constructor(allSchemas = [], protectedFields = {}) { - this.__data = {}; - this.__protectedFields = protectedFields; - allSchemas.forEach(schema => { - if (volatileClasses.includes(schema.className)) { - return; - } - Object.defineProperty(this, schema.className, { - get: () => { - if (!this.__data[schema.className]) { - const data = {}; - data.fields = injectDefaultSchema(schema).fields; - data.classLevelPermissions = deepcopy(schema.classLevelPermissions); - data.indexes = schema.indexes; - - const classProtectedFields = this.__protectedFields[schema.className]; - if (classProtectedFields) { - for (const key in classProtectedFields) { - const unq = new Set([ - ...(data.classLevelPermissions.protectedFields[key] || []), - ...classProtectedFields[key], - ]); - data.classLevelPermissions.protectedFields[key] = Array.from(unq); - } - } - - this.__data[schema.className] = data; - } - return this.__data[schema.className]; - }, - }); - }); - - // Inject the in-memory classes - volatileClasses.forEach(className => { - Object.defineProperty(this, className, { - get: () => { - if (!this.__data[className]) { - const schema = injectDefaultSchema({ - className, - fields: {}, - classLevelPermissions: {}, - }); - const data = {}; - data.fields = schema.fields; - data.classLevelPermissions = schema.classLevelPermissions; - data.indexes = schema.indexes; - this.__data[className] = data; - } - return this.__data[className]; - }, - }); - }); - } -} - -const injectDefaultSchema = ({ className, fields, classLevelPermissions, indexes }: Schema) => { - const defaultSchema: Schema = { - className, - fields: { - ...defaultColumns._Default, - ...(defaultColumns[className] || {}), - ...fields, - }, - classLevelPermissions, - }; - if (indexes && Object.keys(indexes).length !== 0) { - defaultSchema.indexes = indexes; - } - return defaultSchema; -}; - const _HooksSchema = { className: '_Hooks', fields: defaultColumns._Hooks }; const _GlobalConfigSchema = { className: '_GlobalConfig', @@ -681,79 +481,58 @@ const typeToString = (type: SchemaField | string): string => { // the mongo format and the Parse format. Soon, this will all be Parse format. export default class SchemaController { _dbAdapter: StorageAdapter; - schemaData: { [string]: Schema }; - reloadDataPromise: ?Promise<any>; - protectedFields: any; userIdRegEx: RegExp; + schemaCache: SchemaCacheAccess; - constructor(databaseAdapter: StorageAdapter) { + constructor( + databaseAdapter: StorageAdapter, + schemaCache: SchemaCacheAccess, + options: ParseServerOptions + ) { this._dbAdapter = databaseAdapter; - this.schemaData = new SchemaData(SchemaCache.all(), this.protectedFields); - this.protectedFields = Config.get(Parse.applicationId).protectedFields; + this.schemaCache = schemaCache; - const customIds = Config.get(Parse.applicationId).allowCustomObjectId; + const customIds = options.allowCustomObjectId; const customIdRegEx = /^.{1,}$/u; // 1+ chars const autoIdRegEx = /^[a-zA-Z0-9]{1,}$/; this.userIdRegEx = customIds ? customIdRegEx : autoIdRegEx; - - this._dbAdapter.watch(() => { - this.reloadData({ clearCache: true }); - }); + if (this._dbAdapter) { + this._dbAdapter.watch(() => { + this.reloadData({ clearCache: true }); + }); + } } - reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise<any> { - if (this.reloadDataPromise && !options.clearCache) { - return this.reloadDataPromise; - } - this.reloadDataPromise = this.getAllClasses(options) - .then( - allSchemas => { - this.schemaData = new SchemaData(allSchemas, this.protectedFields); - delete this.reloadDataPromise; - }, - err => { - this.schemaData = new SchemaData(); - delete this.reloadDataPromise; - throw err; - } - ) - .then(() => {}); - return this.reloadDataPromise; + async getSchemaData() { + return this.schemaCache.getSchemaData(); } - getAllClasses(options: LoadSchemaOptions = { clearCache: false }): Promise<Array<Schema>> { + async reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise<any> { if (options.clearCache) { - return this.setAllClasses(); - } - const cached = SchemaCache.all(); - if (cached && cached.length) { - return Promise.resolve(cached); + await this.schemaCache.clear(); } - return this.setAllClasses(); } - setAllClasses(): Promise<Array<Schema>> { - return this._dbAdapter - .getAllClasses() - .then(allSchemas => allSchemas.map(injectDefaultSchema)) - .then(allSchemas => { - SchemaCache.put(allSchemas); - return allSchemas; - }); + async getAllClasses(options: LoadSchemaOptions = { clearCache: false }): Promise<Array<Schema>> { + if (options.clearCache) { + await this.schemaCache.clear(); + } + return this.schemaCache.all(); } - getOneSchema( + async getOneSchema( className: string, allowVolatileClasses: boolean = false, options: LoadSchemaOptions = { clearCache: false } ): Promise<Schema> { if (options.clearCache) { - SchemaCache.clear(); + await this.schemaCache.clear(); } if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) { - const data = this.schemaData[className]; + const schemaData = await this.schemaCache.getSchemaData(); + const data = schemaData[className]; return Promise.resolve({ className, fields: data.fields, @@ -761,11 +540,11 @@ export default class SchemaController { indexes: data.indexes, }); } - const cached = SchemaCache.get(className); + const cached = await this.schemaCache.get(className); if (cached && !options.clearCache) { return Promise.resolve(cached); } - return this.setAllClasses().then(allSchemas => { + return this.getAllClasses({ clearCache: true }).then(allSchemas => { const oneSchema = allSchemas.find(schema => schema.className === className); if (!oneSchema) { return Promise.reject(undefined); @@ -787,7 +566,7 @@ export default class SchemaController { classLevelPermissions: any, indexes: any = {} ): Promise<void | Schema> { - var validationError = this.validateNewClass(className, fields, classLevelPermissions); + var validationError = await this.validateNewClass(className, fields, classLevelPermissions); if (validationError) { if (validationError instanceof Parse.Error) { return Promise.reject(validationError); @@ -807,7 +586,7 @@ export default class SchemaController { }) ); // TODO: Remove by updating schema cache directly - await this.reloadData({ clearCache: true }); + await this.schemaCache.clear(); const parseSchema = convertAdapterSchemaToParseSchema(adapterSchema); return parseSchema; } catch (error) { @@ -877,11 +656,12 @@ export default class SchemaController { let enforceFields = []; return ( deletePromise // Delete Everything - .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values - .then(() => { + .then(() => this.schemaCache.clear()) // Reload our Schema, so we have all the new values + .then(async () => { + const schemaData = await this.getSchemaData(); const promises = insertedFields.map(fieldName => { const type = submittedFields[fieldName]; - return this.enforceFieldExists(className, fieldName, type); + return this.enforceFieldExistsSync(className, fieldName, type, false, schemaData); }); return Promise.all(promises); }) @@ -897,11 +677,12 @@ export default class SchemaController { fullNewSchema ) ) - .then(() => this.reloadData({ clearCache: true })) + .then(() => this.schemaCache.clear()) + .then(() => this.ensureFields(enforceFields)) + .then(() => this.schemaCache.getSchemaData()) //TODO: Move this logic into the database adapter - .then(() => { - this.ensureFields(enforceFields); - const schema = this.schemaData[className]; + .then(schemaData => { + const schema = schemaData[className]; const reloadedSchema: Schema = { className: className, fields: schema.fields, @@ -928,8 +709,9 @@ export default class SchemaController { // Returns a promise that resolves successfully to the new schema // object or fails with a reason. - enforceClassExists(className: string): Promise<SchemaController> { - if (this.schemaData[className]) { + async enforceClassExists(className: string): Promise<SchemaController> { + const schemaData = await this.schemaCache.getSchemaData(); + if (schemaData[className]) { return Promise.resolve(this); } // We don't have this class. Update the schema @@ -941,11 +723,12 @@ export default class SchemaController { // have failed because there's a race condition and a different // client is making the exact same schema update that we want. // So just reload the schema. - return this.reloadData({ clearCache: true }); + return this.schemaCache.clear(); }) - .then(() => { + .then(() => this.schemaCache.getSchemaData()) + .then(schemaData => { // Ensure that the schema now validates - if (this.schemaData[className]) { + if (schemaData[className]) { return this; } else { throw new Parse.Error(Parse.Error.INVALID_JSON, `Failed to add ${className}`); @@ -958,8 +741,13 @@ export default class SchemaController { ); } - validateNewClass(className: string, fields: SchemaFields = {}, classLevelPermissions: any): any { - if (this.schemaData[className]) { + async validateNewClass( + className: string, + fields: SchemaFields = {}, + classLevelPermissions: any + ): any { + const schemaData = await this.schemaCache.getSchemaData(); + if (schemaData[className]) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); } if (!classNameIsValid(className)) { @@ -1051,22 +839,35 @@ export default class SchemaController { } validateCLP(perms, newSchema, this.userIdRegEx); await this._dbAdapter.setClassLevelPermissions(className, perms); - const cached = SchemaCache.get(className); + await this.schemaCache.clear(); + const cached = await this.schemaCache.get(className); if (cached) { cached.classLevelPermissions = perms; } } + async enforceFieldExists( + className: string, + fieldName: string, + type: string | SchemaField, + isValidation?: boolean + ): Promise<any> { + const schemaData = await this.getSchemaData(); + + return this.enforceFieldExistsSync(className, fieldName, type, isValidation, schemaData); + } + // Returns a promise that resolves successfully to the new schema // 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( + enforceFieldExistsSync( className: string, fieldName: string, type: string | SchemaField, - isValidation?: boolean - ) { + isValidation: boolean, + schemaData: SchemaData + ): Promise<any> { if (fieldName.indexOf('.') > 0) { // subdocument key (x.y) => ok if x is of type 'object' fieldName = fieldName.split('.')[0]; @@ -1081,7 +882,7 @@ export default class SchemaController { return undefined; } - const expectedType = this.getExpectedType(className, fieldName); + const expectedType = this.getExpectedTypeSync(className, fieldName, schemaData); if (typeof type === 'string') { type = ({ type }: SchemaField); } @@ -1141,11 +942,11 @@ export default class SchemaController { }); } - ensureFields(fields: any) { + async ensureFields(fields: any): Promise<any> { for (let i = 0; i < fields.length; i += 1) { const { className, fieldName } = fields[i]; let { type } = fields[i]; - const expectedType = this.getExpectedType(className, fieldName); + const expectedType = await this.getExpectedType(className, fieldName); if (typeof type === 'string') { type = { type: type }; } @@ -1215,7 +1016,7 @@ export default class SchemaController { }); }) .then(() => { - SchemaCache.clear(); + return this.schemaCache.clear(); }); } @@ -1259,9 +1060,9 @@ export default class SchemaController { if (enforceFields.length !== 0) { // TODO: Remove by updating schema cache directly - await this.reloadData({ clearCache: true }); + await this.schemaCache.clear(); } - this.ensureFields(enforceFields); + await this.ensureFields(enforceFields); const promise = Promise.resolve(schema); return thenValidateRequiredColumns(promise, className, object, query); @@ -1292,9 +1093,9 @@ export default class SchemaController { return Promise.resolve(this); } - testPermissionsForClassName(className: string, aclGroup: string[], operation: string) { + async testPermissionsForClassName(className: string, aclGroup: string[], operation: string) { return SchemaController.testPermissions( - this.getClassLevelPermissions(className), + await this.getClassLevelPermissions(className), aclGroup, operation ); @@ -1393,9 +1194,14 @@ export default class SchemaController { } // Validates an operation passes class-level-permissions set in the schema - validatePermission(className: string, aclGroup: string[], operation: string, action?: string) { + async validatePermission( + className: string, + aclGroup: string[], + operation: string, + action?: string + ) { return SchemaController.validatePermission( - this.getClassLevelPermissions(className), + await this.getClassLevelPermissions(className), className, aclGroup, operation, @@ -1403,35 +1209,36 @@ export default class SchemaController { ); } - getClassLevelPermissions(className: string): any { - return this.schemaData[className] && this.schemaData[className].classLevelPermissions; + async getClassLevelPermissions(className: string): Promise<any> { + const schemaData = await this.schemaCache.getSchemaData(); + return schemaData[className] && schemaData[className].classLevelPermissions; } // Returns the expected type for a className+key combination // or undefined if the schema is not set - getExpectedType(className: string, fieldName: string): ?(SchemaField | string) { - if (this.schemaData[className]) { - const expectedType = this.schemaData[className].fields[fieldName]; + async getExpectedType(className: string, fieldName: string): Promise<?(SchemaField | string)> { + const schemaData = await this.schemaCache.getSchemaData(); + return this.getExpectedTypeSync(className, fieldName, schemaData); + } + + getExpectedTypeSync( + className: string, + fieldName: string, + schemaData: SchemaData + ): Promise<?(SchemaField | string)> { + if (schemaData[className]) { + const expectedType = schemaData[className].fields[fieldName]; return expectedType === 'map' ? 'Object' : expectedType; } return undefined; } // Checks if a given class is in the schema. - hasClass(className: string) { - if (this.schemaData[className]) { - return Promise.resolve(true); - } - return this.reloadData().then(() => !!this.schemaData[className]); + hasClass(className: string): Promise<boolean> { + return this.schemaCache.getSchemaData().then(schemaData => !!schemaData[className]); } } -// Returns a promise for a new Schema. -const load = (dbAdapter: StorageAdapter, options: any): Promise<SchemaController> => { - const schema = new SchemaController(dbAdapter); - return schema.reloadData(options).then(() => schema); -}; - // Builds a new schema (in schema API response format) out of an // existing mongo schema + a schemas API put request. This response // does not include the default fields, as it is intended to be passed @@ -1590,7 +1397,6 @@ function getObjectType(obj): ?(SchemaField | string) { } export { - load, classNameIsValid, fieldNameIsValid, invalidClassNameMessage, diff --git a/src/Controllers/index.js b/src/Controllers/index.js index 0a9b3db57d..86ae877cfe 100644 --- a/src/Controllers/index.js +++ b/src/Controllers/index.js @@ -24,7 +24,8 @@ import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter'; import ParsePushAdapter from '@parse/push-adapter'; import ParseGraphQLController from './ParseGraphQLController'; -import SchemaCache from '../Adapters/Cache/SchemaCache'; +import InMemorySchemaCache from '../Adapters/Schema/InMemorySchemaCache'; +import { SchemaCacheAccess } from '../Adapters/Schema/SchemaCacheAccess'; export function getControllers(options: ParseServerOptions) { const loggerController = getLoggerController(options); @@ -63,7 +64,7 @@ export function getControllers(options: ParseServerOptions) { databaseController, hooksController, authDataManager, - schemaCache: SchemaCache, + schemaCache: databaseController.schemaCache, }; } @@ -151,7 +152,7 @@ export function getLiveQueryController(options: ParseServerOptions): LiveQueryCo export function getDatabaseController(options: ParseServerOptions): DatabaseController { const { databaseURI, collectionPrefix, databaseOptions } = options; - let { databaseAdapter } = options; + let { databaseAdapter, schemaCacheAdapter } = options; if ( (databaseOptions || (databaseURI && databaseURI !== defaults.databaseURI) || @@ -164,7 +165,13 @@ export function getDatabaseController(options: ParseServerOptions): DatabaseCont } else { databaseAdapter = loadAdapter(databaseAdapter); } - return new DatabaseController(databaseAdapter, options); + + schemaCacheAdapter = loadAdapter(schemaCacheAdapter, InMemorySchemaCache); + return new DatabaseController( + databaseAdapter, + new SchemaCacheAccess(schemaCacheAdapter, databaseAdapter, options), + options + ); } export function getHooksController( diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js index 011905ca2d..525ac262f9 100644 --- a/src/GraphQL/ParseGraphQLSchema.js +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -12,7 +12,6 @@ import * as defaultGraphQLQueries from './loaders/defaultGraphQLQueries'; import * as defaultGraphQLMutations from './loaders/defaultGraphQLMutations'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; import DatabaseController from '../Controllers/DatabaseController'; -import SchemaCache from '../Adapters/Cache/SchemaCache'; import { toGraphQLError } from './parseGraphQLUtils'; import * as schemaDirectives from './loaders/schemaDirectives'; import * as schemaTypes from './loaders/schemaTypes'; @@ -88,7 +87,7 @@ class ParseGraphQLSchema { this.log = params.log || requiredParameter('You must provide a log instance!'); this.graphQLCustomTypeDefs = params.graphQLCustomTypeDefs; this.appId = params.appId || requiredParameter('You must provide the appId!'); - this.schemaCache = SchemaCache; + this.schemaCache = this.databaseController.schemaCache; } async load() { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index f8b8eab633..2a41d47941 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -435,6 +435,11 @@ module.exports.ParseServerOptions = { help: 'Defined schema', action: parsers.objectParser, }, + schemaCacheAdapter: { + env: 'PARSE_SERVER_SCHEMA_CACHE_ADAPTER', + help: 'Adapter module for the schema cache', + action: parsers.moduleOrObjectParser, + }, security: { env: 'PARSE_SERVER_SECURITY', help: 'The security options to identify and report weak security settings.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 24b60c46a9..299680ba99 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -80,6 +80,7 @@ * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. * @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false. * @property {SchemaOptions} schema Defined schema + * @property {Adapter<SchemaCacheAdapter>} schemaCacheAdapter Adapter module for the schema cache * @property {SecurityOptions} security The security options to identify and report weak security settings. * @property {Function} serverCloseComplete Callback when server has closed * @property {Function} serverStartComplete Callback when server has started diff --git a/src/Options/index.js b/src/Options/index.js index 8124446f99..74145fa185 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -8,6 +8,7 @@ import { MailAdapter } from '../Adapters/Email/MailAdapter'; import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter'; import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter'; import { CheckGroup } from '../Security/CheckGroup'; +import SchemaCacheAdapter from '../Adapters/Schema/SchemaCacheAdapter'; export interface SchemaOptions { /* Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema @@ -178,6 +179,8 @@ export interface ParseServerOptions { passwordPolicy: ?PasswordPolicyOptions; /* Adapter module for the cache */ cacheAdapter: ?Adapter<CacheAdapter>; + /* Adapter module for the schema cache */ + schemaCacheAdapter: ?Adapter<SchemaCacheAdapter>; /* Adapter module for email sending */ emailAdapter: ?Adapter<MailAdapter>; /* Public URL to your parse server with http:// or https://. diff --git a/src/RestWrite.js b/src/RestWrite.js index 3e20328a9a..d05cf10708 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1528,9 +1528,9 @@ RestWrite.prototype.runAfterSaveTrigger = function () { const { originalObject, updatedObject } = this.buildParseObjects(); updatedObject._handleSaveResponse(this.response.response, this.response.status || 200); - this.config.database.loadSchema().then(schemaController => { + this.config.database.loadSchema().then(async schemaController => { // Notifiy LiveQueryServer if possible - const perms = schemaController.getClassLevelPermissions(updatedObject.className); + const perms = await schemaController.getClassLevelPermissions(updatedObject.className); this.config.liveQueryController.onAfterSave( updatedObject.className, updatedObject, diff --git a/src/Schema/SchemaData.js b/src/Schema/SchemaData.js new file mode 100644 index 0000000000..b6ede99dd9 --- /dev/null +++ b/src/Schema/SchemaData.js @@ -0,0 +1,211 @@ +// @flow-disable-next +import deepcopy from 'deepcopy'; +import type { Schema, SchemaFields } from '../Controllers/types'; + +const defaultColumns: { [string]: SchemaFields } = Object.freeze({ + // Contain the default columns for every parse object type (except _Join collection) + _Default: { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }, + // The additional default columns for the _User collection (in addition to DefaultCols) + _User: { + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + }, + // The additional default columns for the _Installation collection (in addition to DefaultCols) + _Installation: { + 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' }, + }, + // The additional default columns for the _Role collection (in addition to DefaultCols) + _Role: { + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, + }, + // The additional default columns for the _Session collection (in addition to DefaultCols) + _Session: { + user: { type: 'Pointer', targetClass: '_User' }, + installationId: { type: 'String' }, + sessionToken: { type: 'String' }, + expiresAt: { type: 'Date' }, + createdWith: { type: 'Object' }, + }, + _Product: { + productIdentifier: { type: 'String' }, + download: { type: 'File' }, + downloadName: { type: 'String' }, + icon: { type: 'File' }, + order: { type: 'Number' }, + title: { type: 'String' }, + subtitle: { type: 'String' }, + }, + _PushStatus: { + pushTime: { type: 'String' }, + source: { type: 'String' }, // rest or webui + query: { type: 'String' }, // the stringified JSON query + payload: { type: 'String' }, // the stringified JSON payload, + title: { type: 'String' }, + expiry: { type: 'Number' }, + expiration_interval: { type: 'Number' }, + status: { type: 'String' }, + numSent: { type: 'Number' }, + numFailed: { type: 'Number' }, + pushHash: { type: 'String' }, + errorMessage: { type: 'Object' }, + sentPerType: { type: 'Object' }, + failedPerType: { type: 'Object' }, + sentPerUTCOffset: { type: 'Object' }, + failedPerUTCOffset: { type: 'Object' }, + count: { type: 'Number' }, // tracks # of batches queued and pending + }, + _JobStatus: { + jobName: { type: 'String' }, + source: { type: 'String' }, + status: { type: 'String' }, + message: { type: 'String' }, + params: { type: 'Object' }, // params received when calling the job + finishedAt: { type: 'Date' }, + }, + _JobSchedule: { + jobName: { type: 'String' }, + description: { type: 'String' }, + params: { type: 'String' }, + startAfter: { type: 'String' }, + daysOfWeek: { type: 'Array' }, + timeOfDay: { type: 'String' }, + lastRun: { type: 'Number' }, + repeatMinutes: { type: 'Number' }, + }, + _Hooks: { + functionName: { type: 'String' }, + className: { type: 'String' }, + triggerName: { type: 'String' }, + url: { type: 'String' }, + }, + _GlobalConfig: { + objectId: { type: 'String' }, + params: { type: 'Object' }, + masterKeyOnly: { type: 'Object' }, + }, + _GraphQLConfig: { + objectId: { type: 'String' }, + config: { type: 'Object' }, + }, + _Audience: { + objectId: { type: 'String' }, + name: { type: 'String' }, + query: { type: 'String' }, //storing query as JSON string to prevent "Nested keys should not contain the '$' or '.' characters" error + lastUsed: { type: 'Date' }, + timesUsed: { type: 'Number' }, + }, + _Idempotency: { + reqId: { type: 'String' }, + expire: { type: 'Date' }, + }, +}); + +const volatileClasses = Object.freeze([ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_JobSchedule', + '_Audience', + '_Idempotency', +]); + +const injectDefaultSchema = ({ className, fields, classLevelPermissions, indexes }: Schema) => { + const defaultSchema: Schema = { + className, + fields: { + ...defaultColumns._Default, + ...(defaultColumns[className] || {}), + ...fields, + }, + classLevelPermissions, + }; + if (indexes && Object.keys(indexes).length !== 0) { + defaultSchema.indexes = indexes; + } + return defaultSchema; +}; + +class SchemaData { + __data: any; + __protectedFields: any; + constructor(allSchemas = [], protectedFields = {}) { + this.__data = {}; + this.__protectedFields = protectedFields; + allSchemas.forEach(schema => { + if (volatileClasses.includes(schema.className)) { + return; + } + Object.defineProperty(this, schema.className, { + get: () => { + if (!this.__data[schema.className]) { + const data = {}; + data.fields = injectDefaultSchema(schema).fields; + data.classLevelPermissions = deepcopy(schema.classLevelPermissions); + data.indexes = schema.indexes; + + const classProtectedFields = this.__protectedFields[schema.className]; + if (classProtectedFields) { + for (const key in classProtectedFields) { + const unq = new Set([ + ...(data.classLevelPermissions.protectedFields[key] || []), + ...classProtectedFields[key], + ]); + data.classLevelPermissions.protectedFields[key] = Array.from(unq); + } + } + + this.__data[schema.className] = data; + } + return this.__data[schema.className]; + }, + }); + }); + + // Inject the in-memory classes + volatileClasses.forEach(className => { + Object.defineProperty(this, className, { + get: () => { + if (!this.__data[className]) { + const schema = injectDefaultSchema({ + className, + fields: {}, + classLevelPermissions: {}, + }); + const data = {}; + data.fields = schema.fields; + data.classLevelPermissions = schema.classLevelPermissions; + data.indexes = schema.indexes; + this.__data[className] = data; + } + return this.__data[className]; + }, + }); + }); + } +} + +export { defaultColumns, volatileClasses, injectDefaultSchema, SchemaData }; diff --git a/src/index.js b/src/index.js index bbbdaf545f..faf0a5e6c4 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import InMemoryCacheAdapter from './Adapters/Cache/InMemoryCacheAdapter'; import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter'; import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter'; import LRUCacheAdapter from './Adapters/Cache/LRUCache.js'; +import SchemaCacheAdapter from './Adapters/Schema/SchemaCacheAdapter'; import * as TestUtils from './TestUtils'; import * as SchemaMigrations from './SchemaMigrations/Migrations'; @@ -38,6 +39,7 @@ export { NullCacheAdapter, RedisCacheAdapter, LRUCacheAdapter, + SchemaCacheAdapter, TestUtils, PushWorker, ParseGraphQLServer, diff --git a/src/rest.js b/src/rest.js index fca3497a5d..a9bc66981b 100644 --- a/src/rest.js +++ b/src/rest.js @@ -161,9 +161,9 @@ function del(config, auth, className, objectId, context) { schemaController ); }) - .then(() => { + .then(async () => { // Notify LiveQuery server if possible - const perms = schemaController.getClassLevelPermissions(className); + const perms = await schemaController.getClassLevelPermissions(className); config.liveQueryController.onAfterDelete(className, inflatedObject, null, perms); return triggers.maybeRunTrigger( triggers.Types.afterDelete,