Skip to content

WIP: Locked Pre defined Schemas / auto migration #6379

New issue

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

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

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/Controllers/DatabaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -1693,7 +1693,7 @@ class DatabaseController {

// TODO: create indexes on first creation of a _User object. Otherwise it's impossible to
// have a Parse app without it having a _User collection.
performInitialization() {
async performInitialization(options) {
const requiredUserFields = {
fields: {
...SchemaController.defaultColumns._Default,
Expand Down Expand Up @@ -1750,13 +1750,17 @@ class DatabaseController {
const adapterInit = this.adapter.performInitialization({
VolatileClassesSchemas: SchemaController.VolatileClassesSchemas,
});
return Promise.all([
const promises = await Promise.all([
usernameUniqueness,
emailUniqueness,
roleUniqueness,
adapterInit,
indexPromise,
]);
if (options.schemas) {
await (await this.loadSchema()).loadSchemas(options.schemas);
}
return promises;
}

static _validateQuery: (any, boolean) => void;
Expand Down
209 changes: 181 additions & 28 deletions src/Controllers/SchemaController.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ import type {
SchemaField,
LoadSchemaOptions,
} from './types';
import logger from '../logger';

const defaultIndexes = Object.freeze({
_Default: {
objectId: true,
},
_User: {
username: true,
email: true,
},
_Role: {
name: true,
},
});

const defaultColumns: { [string]: SchemaFields } = Object.freeze({
// Contain the default columns for every parse object type (except _Join collection)
Expand Down Expand Up @@ -592,34 +606,26 @@ const _GraphQLConfigSchema = {
className: '_GraphQLConfig',
fields: defaultColumns._GraphQLConfig,
};
const _PushStatusSchema = convertSchemaToAdapterSchema(
injectDefaultSchema({
className: '_PushStatus',
fields: {},
classLevelPermissions: {},
})
);
const _JobStatusSchema = convertSchemaToAdapterSchema(
injectDefaultSchema({
className: '_JobStatus',
fields: {},
classLevelPermissions: {},
})
);
const _JobScheduleSchema = convertSchemaToAdapterSchema(
injectDefaultSchema({
className: '_JobSchedule',
fields: {},
classLevelPermissions: {},
})
);
const _AudienceSchema = convertSchemaToAdapterSchema(
injectDefaultSchema({
className: '_Audience',
fields: defaultColumns._Audience,
classLevelPermissions: {},
})
);
const _PushStatusSchema = convertSchemaToAdapterSchema({
className: '_PushStatus',
fields: {},
classLevelPermissions: {},
});
const _JobStatusSchema = convertSchemaToAdapterSchema({
className: '_JobStatus',
fields: {},
classLevelPermissions: {},
});
const _JobScheduleSchema = convertSchemaToAdapterSchema({
className: '_JobSchedule',
fields: {},
classLevelPermissions: {},
});
const _AudienceSchema = convertSchemaToAdapterSchema({
className: '_Audience',
fields: defaultColumns._Audience,
classLevelPermissions: {},
});
const VolatileClassesSchemas = [
_HooksSchema,
_JobStatusSchema,
Expand Down Expand Up @@ -651,6 +657,13 @@ const typeToString = (type: SchemaField | string): string => {
return `${type.type}`;
};

const paramsAreEquals = (indexA, indexB) => {
const keysIndexA = Object.keys(indexA);
const keysIndexB = Object.keys(indexB);
if (keysIndexA.length !== keysIndexB.length) return false;
return keysIndexA.every(k => indexA[k] === indexB[k]);
};

// Stores the entire schema of the app in a weird hybrid format somewhere between
// the mongo format and the Parse format. Soon, this will all be Parse format.
export default class SchemaController {
Expand Down Expand Up @@ -813,6 +826,146 @@ export default class SchemaController {
});
}

async loadSchemas(localSchemas, database: DatabaseController) {
const cloudSchemas = await this.getAllClasses({ clearCache: true });
// We do not check classes to delete, developer need to delete manually classes
// to avoid data loss during auto migration
await Promise.all(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should return this and lose the async.

i.e. return Promise.all(...

localSchemas.map(async localSchema => {
const cloudSchema = cloudSchemas.find(
({ className }) => className === localSchema.className
);
if (cloudSchema) {
await this.migrateClass(localSchema, cloudSchema, database);
} else {
if (!localSchema.className)
throw new Parse.Error(
500,
`className is undefined on a schema. Check your schemas objects.`
);
await this.addClassIfNotExists(
localSchema.className,
localSchema.fields,
localSchema.classLevelPermissions,
localSchema.indexes
);
}
})
);
}

async migrateClass(localSchema, cloudSchema, database: DatabaseController) {
localSchema.fields = localSchema.fields || {};
localSchema.indexes = localSchema.indexes || {};
localSchema.classLevelPermissions = localSchema.classLevelPermissions || {};

const fieldsToAdd = {};
const fieldsToDelete = {};
const indexesToAdd = {};
const indexesToDelete = {};

const isNotDefaultField = fieldName =>
!defaultColumns['_Default'][fieldName] ||
(defaultColumns[localSchema.className] &&
!defaultColumns[localSchema.className][fieldName]);

Object.keys(localSchema.fields)
.filter(fieldName => isNotDefaultField(fieldName))
.forEach(fieldName => {
if (!cloudSchema.fields[fieldName])
fieldsToAdd[fieldName] = localSchema.fields[fieldName];
});

Object.keys(cloudSchema.fields)
.filter(fieldName => isNotDefaultField(fieldName))
.map(async fieldName => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming accidental use of map and async > .forEach()

if (!localSchema.fields[fieldName]) {
fieldsToDelete[fieldName] = { __op: 'Delete' };
return;
}
if (
!paramsAreEquals(
cloudSchema.fields[fieldName],
localSchema.fields[fieldName]
)
) {
fieldsToDelete[fieldName] = { __op: 'Delete' };
fieldsToAdd[fieldName] = localSchema.fields[fieldName];
}
});

const isNotDefaultIndex = indexName =>
!defaultIndexes['_Default'][indexName] ||
(defaultIndexes[localSchema.className] &&
!defaultIndexes[localSchema.className][indexName]);

Object.keys(localSchema.indexes)
.filter(indexName => isNotDefaultIndex(indexName))
.forEach(indexName => {
if (!cloudSchema.indexes[indexName])
indexesToAdd[indexName] = localSchema.indexes[indexName];
});

Object.keys(cloudSchema.indexes)
.filter(indexName => isNotDefaultIndex(indexName))
.map(async indexName => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as previous comment on async

if (!localSchema.indexes[indexName]) {
indexesToDelete[indexName] = { __op: 'Delete' };
return;
}
if (
!paramsAreEquals(
cloudSchema.indexes[indexName],
localSchema.indexes[indexName]
)
) {
indexesToDelete[indexName] = { __op: 'Delete' };
indexesToAdd[indexName] = localSchema.indexes[indexName];
}
});
const CLPs = {
...localSchema.classLevelPermissions,
// Block add field feature when developers use parse with schemas
// to avoid auto migration issues across SDKs
addField: {},
};
// Concurrent migrations can lead to errors
try {
await this.updateClass(
localSchema.className,
fieldsToDelete,
CLPs,
indexesToDelete,
database
);
} catch (e) {
setTimeout(async () => {
try {
await this.migrateClass(localSchema, cloudSchema, database);
} catch (e) {
logger.error('Error occured during migration deletion time.');
}
}, 15000);
}
try {
await this.updateClass(
localSchema.className,
fieldsToAdd,
CLPs,
indexesToAdd,
database
);
} catch (e) {
setTimeout(async () => {
try {
await this.migrateClass(localSchema, cloudSchema, database);
} catch (e) {
logger.error('Error occured during migration addition.');
}
}, 15000);
}
}

updateClass(
className: string,
submittedFields: SchemaFields,
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -528,4 +528,10 @@ module.exports.LiveQueryServerOptions = {
help: 'Adapter module for the WebSocketServer',
action: parsers.moduleOrObjectParser,
},
schemas: {
env: 'PARSE_SERVER_SCHEMAS',
help:
'Schemas options allow to lock and pre define schemas of the parse server.',
action: parsers.objectParser,
},
};
1 change: 1 addition & 0 deletions src/Options/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
* @property {Boolean} verbose Set the logging to verbose
* @property {Boolean} verifyUserEmails Enable (or disable) user email validation, defaults to false
* @property {String} webhookKey Key sent with outgoing webhook calls
* @property {Parse.Schema[]} schemas lock and pre define schemas of the parse server.
*/

/**
Expand Down
2 changes: 1 addition & 1 deletion src/ParseServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class ParseServer {
this.config = Config.put(Object.assign({}, options, allControllers));

logging.setLogger(loggerController);
const dbInitPromise = databaseController.performInitialization();
const dbInitPromise = databaseController.performInitialization(options);
const hooksLoadPromise = hooksController.load();

// Note: Tests will start to fail if any validation happens after this is called.
Expand Down
Loading