feat: add user-defined schema and migrations (#7418)
This commit is contained in:
committed by
GitHub
parent
653d25731f
commit
25d5c30be2
644
spec/DefinedSchemas.spec.js
Normal file
644
spec/DefinedSchemas.spec.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
const obj = hasAllPODobject();
|
||||||
obj.save().then(() => {
|
obj.save().then(() => {
|
||||||
request({
|
request({
|
||||||
@@ -769,7 +769,7 @@ describe('schemas', () => {
|
|||||||
json: true,
|
json: true,
|
||||||
body: {
|
body: {
|
||||||
fields: {
|
fields: {
|
||||||
aString: { type: 'String' },
|
aString: { type: 'Number' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).then(fail, response => {
|
}).then(fail, response => {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class MongoSchemaCollection {
|
|||||||
.then(
|
.then(
|
||||||
schema => {
|
schema => {
|
||||||
// If a field with this name already exists, it will be handled elsewhere.
|
// If a field with this name already exists, it will be handled elsewhere.
|
||||||
if (schema.fields[fieldName] != undefined) {
|
if (schema.fields[fieldName] !== undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The schema exists. Check for existing GeoPoints.
|
// 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
|
// Exported for testing reasons and because we haven't moved all mongo schema format
|
||||||
|
|||||||
@@ -362,6 +362,11 @@ export class MongoStorageAdapter implements StorageAdapter {
|
|||||||
.catch(err => this.handleError(err));
|
.catch(err => this.handleError(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFieldOptions(className: string, fieldName: string, type: any) {
|
||||||
|
const schemaCollection = await this._schemaCollection();
|
||||||
|
await schemaCollection.updateFieldOptions(className, fieldName, type);
|
||||||
|
}
|
||||||
|
|
||||||
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void> {
|
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void> {
|
||||||
return this._schemaCollection()
|
return this._schemaCollection()
|
||||||
.then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type))
|
.then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type))
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function createClient(uri, databaseOptions) {
|
|||||||
|
|
||||||
if (process.env.PARSE_SERVER_LOG_LEVEL === 'debug') {
|
if (process.env.PARSE_SERVER_LOG_LEVEL === 'debug') {
|
||||||
const monitor = require('pg-monitor');
|
const monitor = require('pg-monitor');
|
||||||
if(monitor.isAttached()) {
|
if (monitor.isAttached()) {
|
||||||
monitor.detach();
|
monitor.detach();
|
||||||
}
|
}
|
||||||
monitor.attach(initOptions);
|
monitor.attach(initOptions);
|
||||||
|
|||||||
@@ -1119,6 +1119,16 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
|||||||
this._notifySchemaChange();
|
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", $<path>, $<type>) WHERE "className"=$<className>',
|
||||||
|
{ path, type, className }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.)
|
// 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.
|
// and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible.
|
||||||
async deleteClass(className: string) {
|
async deleteClass(className: string) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface StorageAdapter {
|
|||||||
setClassLevelPermissions(className: string, clps: any): Promise<void>;
|
setClassLevelPermissions(className: string, clps: any): Promise<void>;
|
||||||
createClass(className: string, schema: SchemaType): Promise<void>;
|
createClass(className: string, schema: SchemaType): Promise<void>;
|
||||||
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void>;
|
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void>;
|
||||||
|
updateFieldOptions(className: string, fieldName: string, type: any): Promise<void>;
|
||||||
deleteClass(className: string): Promise<void>;
|
deleteClass(className: string): Promise<void>;
|
||||||
deleteAllClasses(fast: boolean): Promise<void>;
|
deleteAllClasses(fast: boolean): Promise<void>;
|
||||||
deleteFields(className: string, schema: SchemaType, fieldNames: Array<string>): Promise<void>;
|
deleteFields(className: string, schema: SchemaType, fieldNames: Array<string>): Promise<void>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
AccountLockoutOptions,
|
AccountLockoutOptions,
|
||||||
PagesOptions,
|
PagesOptions,
|
||||||
SecurityOptions,
|
SecurityOptions,
|
||||||
|
SchemaOptions,
|
||||||
} from './Options/Definitions';
|
} from './Options/Definitions';
|
||||||
import { isBoolean, isString } from 'lodash';
|
import { isBoolean, isString } from 'lodash';
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export class Config {
|
|||||||
pages,
|
pages,
|
||||||
security,
|
security,
|
||||||
enforcePrivateUsers,
|
enforcePrivateUsers,
|
||||||
|
schema,
|
||||||
}) {
|
}) {
|
||||||
if (masterKey === readOnlyMasterKey) {
|
if (masterKey === readOnlyMasterKey) {
|
||||||
throw new Error('masterKey and readOnlyMasterKey should be different');
|
throw new Error('masterKey and readOnlyMasterKey should be different');
|
||||||
@@ -112,6 +114,7 @@ export class Config {
|
|||||||
this.validateIdempotencyOptions(idempotencyOptions);
|
this.validateIdempotencyOptions(idempotencyOptions);
|
||||||
this.validatePagesOptions(pages);
|
this.validatePagesOptions(pages);
|
||||||
this.validateSecurityOptions(security);
|
this.validateSecurityOptions(security);
|
||||||
|
this.validateSchemaOptions(schema);
|
||||||
this.validateEnforcePrivateUsers(enforcePrivateUsers);
|
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) {
|
static validatePagesOptions(pages) {
|
||||||
if (Object.prototype.toString.call(pages) !== '[object Object]') {
|
if (Object.prototype.toString.call(pages) !== '[object Object]') {
|
||||||
throw 'Parse Server option pages must be an object.';
|
throw 'Parse Server option pages must be an object.';
|
||||||
|
|||||||
@@ -831,7 +831,11 @@ export default class SchemaController {
|
|||||||
const existingFields = schema.fields;
|
const existingFields = schema.fields;
|
||||||
Object.keys(submittedFields).forEach(name => {
|
Object.keys(submittedFields).forEach(name => {
|
||||||
const field = submittedFields[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.`);
|
throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
|
||||||
}
|
}
|
||||||
if (!existingFields[name] && field.__op === 'Delete') {
|
if (!existingFields[name] && field.__op === 'Delete') {
|
||||||
@@ -1057,7 +1061,12 @@ export default class SchemaController {
|
|||||||
// object if the provided className-fieldName-type tuple is valid.
|
// object if the provided className-fieldName-type tuple is valid.
|
||||||
// The className must already be validated.
|
// The className must already be validated.
|
||||||
// If 'freeze' is true, refuse to update the schema for this field.
|
// 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) {
|
if (fieldName.indexOf('.') > 0) {
|
||||||
// subdocument key (x.y) => ok if x is of type 'object'
|
// subdocument key (x.y) => ok if x is of type 'object'
|
||||||
fieldName = fieldName.split('.')[0];
|
fieldName = fieldName.split('.')[0];
|
||||||
@@ -1101,7 +1110,14 @@ export default class SchemaController {
|
|||||||
)} but got ${typeToString(type)}`
|
)} 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
|
return this._dbAdapter
|
||||||
@@ -1236,7 +1252,7 @@ export default class SchemaController {
|
|||||||
// Every object has ACL implicitly.
|
// Every object has ACL implicitly.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
promises.push(schema.enforceFieldExists(className, fieldName, expected));
|
promises.push(schema.enforceFieldExists(className, fieldName, expected, true));
|
||||||
}
|
}
|
||||||
const results = await Promise.all(promises);
|
const results = await Promise.all(promises);
|
||||||
const enforceFields = results.filter(result => !!result);
|
const enforceFields = results.filter(result => !!result);
|
||||||
|
|||||||
@@ -446,6 +446,45 @@ module.exports.SecurityOptions = {
|
|||||||
default: false,
|
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 = {
|
module.exports.PagesOptions = {
|
||||||
customRoutes: {
|
customRoutes: {
|
||||||
env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES',
|
env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @flow
|
||||||
import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter';
|
import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter';
|
||||||
import { FilesAdapter } from '../Adapters/Files/FilesAdapter';
|
import { FilesAdapter } from '../Adapters/Files/FilesAdapter';
|
||||||
import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter';
|
import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter';
|
||||||
@@ -7,8 +8,8 @@ import { MailAdapter } from '../Adapters/Email/MailAdapter';
|
|||||||
import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter';
|
import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter';
|
||||||
import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter';
|
import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter';
|
||||||
import { CheckGroup } from '../Security/CheckGroup';
|
import { CheckGroup } from '../Security/CheckGroup';
|
||||||
|
import type { SchemaOptions } from '../SchemaMigrations/Migrations';
|
||||||
|
|
||||||
// @flow
|
|
||||||
type Adapter<T> = string | any | T;
|
type Adapter<T> = string | any | T;
|
||||||
type NumberOrBoolean = number | boolean;
|
type NumberOrBoolean = number | boolean;
|
||||||
type NumberOrString = number | string;
|
type NumberOrString = number | string;
|
||||||
@@ -241,6 +242,8 @@ export interface ParseServerOptions {
|
|||||||
playgroundPath: ?string;
|
playgroundPath: ?string;
|
||||||
/* Callback when server has started */
|
/* Callback when server has started */
|
||||||
serverStartComplete: ?(error: ?Error) => void;
|
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 */
|
/* Callback when server has closed */
|
||||||
serverCloseComplete: ?() => void;
|
serverCloseComplete: ?() => void;
|
||||||
/* The security options to identify and report weak security settings.
|
/* The security options to identify and report weak security settings.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer';
|
|||||||
import { SecurityRouter } from './Routers/SecurityRouter';
|
import { SecurityRouter } from './Routers/SecurityRouter';
|
||||||
import CheckRunner from './Security/CheckRunner';
|
import CheckRunner from './Security/CheckRunner';
|
||||||
import Deprecator from './Deprecator/Deprecator';
|
import Deprecator from './Deprecator/Deprecator';
|
||||||
|
import { DefinedSchemas } from './SchemaMigrations/DefinedSchemas';
|
||||||
|
|
||||||
// Mutate the Parse object to add the Cloud Code handlers
|
// Mutate the Parse object to add the Cloud Code handlers
|
||||||
addParseCloud();
|
addParseCloud();
|
||||||
@@ -68,6 +69,7 @@ class ParseServer {
|
|||||||
javascriptKey,
|
javascriptKey,
|
||||||
serverURL = requiredParameter('You must provide a serverURL!'),
|
serverURL = requiredParameter('You must provide a serverURL!'),
|
||||||
serverStartComplete,
|
serverStartComplete,
|
||||||
|
schema,
|
||||||
} = options;
|
} = options;
|
||||||
// Initialize the node client SDK automatically
|
// Initialize the node client SDK automatically
|
||||||
Parse.initialize(appId, javascriptKey || 'unused', masterKey);
|
Parse.initialize(appId, javascriptKey || 'unused', masterKey);
|
||||||
@@ -84,7 +86,10 @@ class ParseServer {
|
|||||||
databaseController
|
databaseController
|
||||||
.performInitialization()
|
.performInitialization()
|
||||||
.then(() => hooksController.load())
|
.then(() => hooksController.load())
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
|
if (schema) {
|
||||||
|
await new DefinedSchemas(schema, this.config).execute();
|
||||||
|
}
|
||||||
if (serverStartComplete) {
|
if (serverStartComplete) {
|
||||||
serverStartComplete();
|
serverStartComplete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
if (req.auth.isReadOnly) {
|
||||||
throw new Parse.Error(
|
throw new Parse.Error(
|
||||||
Parse.Error.OPERATION_FORBIDDEN,
|
Parse.Error.OPERATION_FORBIDDEN,
|
||||||
@@ -53,20 +88,11 @@ function createSchema(req) {
|
|||||||
throw new Parse.Error(135, `POST ${req.path} needs a class name.`);
|
throw new Parse.Error(135, `POST ${req.path} needs a class name.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return req.config.database
|
return await internalCreateSchema(className, req.body, req.config);
|
||||||
.loadSchema({ clearCache: true })
|
|
||||||
.then(schema =>
|
|
||||||
schema.addClassIfNotExists(
|
|
||||||
className,
|
|
||||||
req.body.fields,
|
|
||||||
req.body.classLevelPermissions,
|
|
||||||
req.body.indexes
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(schema => ({ response: schema }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function modifySchema(req) {
|
function modifySchema(req) {
|
||||||
|
checkIfDefinedSchemasIsUsed(req);
|
||||||
if (req.auth.isReadOnly) {
|
if (req.auth.isReadOnly) {
|
||||||
throw new Parse.Error(
|
throw new Parse.Error(
|
||||||
Parse.Error.OPERATION_FORBIDDEN,
|
Parse.Error.OPERATION_FORBIDDEN,
|
||||||
@@ -76,22 +102,9 @@ function modifySchema(req) {
|
|||||||
if (req.body.className && req.body.className != req.params.className) {
|
if (req.body.className && req.body.className != req.params.className) {
|
||||||
return classNameMismatchResponse(req.body.className, req.params.className);
|
return classNameMismatchResponse(req.body.className, req.params.className);
|
||||||
}
|
}
|
||||||
|
|
||||||
const submittedFields = req.body.fields || {};
|
|
||||||
const className = req.params.className;
|
const className = req.params.className;
|
||||||
|
|
||||||
return req.config.database
|
return internalUpdateSchema(className, req.body, req.config);
|
||||||
.loadSchema({ clearCache: true })
|
|
||||||
.then(schema =>
|
|
||||||
schema.updateClass(
|
|
||||||
className,
|
|
||||||
submittedFields,
|
|
||||||
req.body.classLevelPermissions,
|
|
||||||
req.body.indexes,
|
|
||||||
req.config.database
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(result => ({ response: result }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSchema = req => {
|
const deleteSchema = req => {
|
||||||
|
|||||||
434
src/SchemaMigrations/DefinedSchemas.js
Normal file
434
src/SchemaMigrations/DefinedSchemas.js
Normal file
@@ -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<void> {
|
||||||
|
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<void>(resolve => setTimeout(resolve, time));
|
||||||
|
}
|
||||||
|
|
||||||
|
async enforceCLPForNonProvidedClass(): Promise<void> {
|
||||||
|
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<T: { [key: string]: any }>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/SchemaMigrations/Migrations.js
Normal file
95
src/SchemaMigrations/Migrations.js
Normal file
@@ -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<void>;
|
||||||
|
afterMigration: ?() => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter';
|
|||||||
import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter';
|
import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter';
|
||||||
import LRUCacheAdapter from './Adapters/Cache/LRUCache.js';
|
import LRUCacheAdapter from './Adapters/Cache/LRUCache.js';
|
||||||
import * as TestUtils from './TestUtils';
|
import * as TestUtils from './TestUtils';
|
||||||
|
import * as SchemaMigrations from './SchemaMigrations/Migrations';
|
||||||
|
|
||||||
import { useExternal } from './deprecated';
|
import { useExternal } from './deprecated';
|
||||||
import { getLogger } from './logger';
|
import { getLogger } from './logger';
|
||||||
import { PushWorker } from './Push/PushWorker';
|
import { PushWorker } from './Push/PushWorker';
|
||||||
@@ -40,4 +42,5 @@ export {
|
|||||||
PushWorker,
|
PushWorker,
|
||||||
ParseGraphQLServer,
|
ParseGraphQLServer,
|
||||||
_ParseServer as ParseServer,
|
_ParseServer as ParseServer,
|
||||||
|
SchemaMigrations,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user