feat: add user-defined schema and migrations (#7418)

This commit is contained in:
Samuel Denis-D'Ortun
2021-11-01 09:28:49 -04:00
committed by GitHub
parent 653d25731f
commit 25d5c30be2
16 changed files with 1365 additions and 36 deletions

View File

@@ -212,7 +212,7 @@ class MongoSchemaCollection {
.then(
schema => {
// If a field with this name already exists, it will be handled elsewhere.
if (schema.fields[fieldName] != undefined) {
if (schema.fields[fieldName] !== undefined) {
return;
}
// The schema exists. Check for existing GeoPoints.
@@ -274,6 +274,22 @@ class MongoSchemaCollection {
}
});
}
async updateFieldOptions(className: string, fieldName: string, fieldType: any) {
const { ...fieldOptions } = fieldType;
delete fieldOptions.type;
delete fieldOptions.targetClass;
await this.upsertSchema(
className,
{ [fieldName]: { $exists: true } },
{
$set: {
[`_metadata.fields_options.${fieldName}`]: fieldOptions,
},
}
);
}
}
// Exported for testing reasons and because we haven't moved all mongo schema format

View File

@@ -362,6 +362,11 @@ export class MongoStorageAdapter implements StorageAdapter {
.catch(err => this.handleError(err));
}
async updateFieldOptions(className: string, fieldName: string, type: any) {
const schemaCollection = await this._schemaCollection();
await schemaCollection.updateFieldOptions(className, fieldName, type);
}
addFieldIfNotExists(className: string, fieldName: string, type: any): Promise<void> {
return this._schemaCollection()
.then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type))

View File

@@ -20,7 +20,7 @@ export function createClient(uri, databaseOptions) {
if (process.env.PARSE_SERVER_LOG_LEVEL === 'debug') {
const monitor = require('pg-monitor');
if(monitor.isAttached()) {
if (monitor.isAttached()) {
monitor.detach();
}
monitor.attach(initOptions);

View File

@@ -1119,6 +1119,16 @@ export class PostgresStorageAdapter implements StorageAdapter {
this._notifySchemaChange();
}
async updateFieldOptions(className: string, fieldName: string, type: any) {
await this._client.tx('update-schema-field-options', async t => {
const path = `{fields,${fieldName}}`;
await t.none(
'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $<path>, $<type>) WHERE "className"=$<className>',
{ path, type, className }
);
});
}
// Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.)
// and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible.
async deleteClass(className: string) {

View File

@@ -35,6 +35,7 @@ export interface StorageAdapter {
setClassLevelPermissions(className: string, clps: any): Promise<void>;
createClass(className: string, schema: SchemaType): 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>;
deleteAllClasses(fast: boolean): Promise<void>;
deleteFields(className: string, schema: SchemaType, fieldNames: Array<string>): Promise<void>;

View File

@@ -11,6 +11,7 @@ import {
AccountLockoutOptions,
PagesOptions,
SecurityOptions,
SchemaOptions,
} from './Options/Definitions';
import { isBoolean, isString } from 'lodash';
@@ -76,6 +77,7 @@ export class Config {
pages,
security,
enforcePrivateUsers,
schema,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -112,6 +114,7 @@ export class Config {
this.validateIdempotencyOptions(idempotencyOptions);
this.validatePagesOptions(pages);
this.validateSecurityOptions(security);
this.validateSchemaOptions(schema);
this.validateEnforcePrivateUsers(enforcePrivateUsers);
}
@@ -137,6 +140,48 @@ export class Config {
}
}
static validateSchemaOptions(schema: SchemaOptions) {
if (!schema) return;
if (Object.prototype.toString.call(schema) !== '[object Object]') {
throw 'Parse Server option schema must be an object.';
}
if (schema.definitions === undefined) {
schema.definitions = SchemaOptions.definitions.default;
} else if (!Array.isArray(schema.definitions)) {
throw 'Parse Server option schema.definitions must be an array.';
}
if (schema.strict === undefined) {
schema.strict = SchemaOptions.strict.default;
} else if (!isBoolean(schema.strict)) {
throw 'Parse Server option schema.strict must be a boolean.';
}
if (schema.deleteExtraFields === undefined) {
schema.deleteExtraFields = SchemaOptions.deleteExtraFields.default;
} else if (!isBoolean(schema.deleteExtraFields)) {
throw 'Parse Server option schema.deleteExtraFields must be a boolean.';
}
if (schema.recreateModifiedFields === undefined) {
schema.recreateModifiedFields = SchemaOptions.recreateModifiedFields.default;
} else if (!isBoolean(schema.recreateModifiedFields)) {
throw 'Parse Server option schema.recreateModifiedFields must be a boolean.';
}
if (schema.lockSchemas === undefined) {
schema.lockSchemas = SchemaOptions.lockSchemas.default;
} else if (!isBoolean(schema.lockSchemas)) {
throw 'Parse Server option schema.lockSchemas must be a boolean.';
}
if (schema.beforeMigration === undefined) {
schema.beforeMigration = null;
} else if (schema.beforeMigration !== null && typeof schema.beforeMigration !== 'function') {
throw 'Parse Server option schema.beforeMigration must be a function.';
}
if (schema.afterMigration === undefined) {
schema.afterMigration = null;
} else if (schema.afterMigration !== null && typeof schema.afterMigration !== 'function') {
throw 'Parse Server option schema.afterMigration must be a function.';
}
}
static validatePagesOptions(pages) {
if (Object.prototype.toString.call(pages) !== '[object Object]') {
throw 'Parse Server option pages must be an object.';

View File

@@ -831,7 +831,11 @@ export default class SchemaController {
const existingFields = schema.fields;
Object.keys(submittedFields).forEach(name => {
const field = submittedFields[name];
if (existingFields[name] && field.__op !== 'Delete') {
if (
existingFields[name] &&
existingFields[name].type !== field.type &&
field.__op !== 'Delete'
) {
throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
}
if (!existingFields[name] && field.__op === 'Delete') {
@@ -1057,7 +1061,12 @@ export default class SchemaController {
// object if the provided className-fieldName-type tuple is valid.
// The className must already be validated.
// If 'freeze' is true, refuse to update the schema for this field.
enforceFieldExists(className: string, fieldName: string, type: string | SchemaField) {
enforceFieldExists(
className: string,
fieldName: string,
type: string | SchemaField,
isValidation?: boolean
) {
if (fieldName.indexOf('.') > 0) {
// subdocument key (x.y) => ok if x is of type 'object'
fieldName = fieldName.split('.')[0];
@@ -1101,7 +1110,14 @@ export default class SchemaController {
)} but got ${typeToString(type)}`
);
}
return undefined;
// If type options do not change
// we can safely return
if (isValidation || JSON.stringify(expectedType) === JSON.stringify(type)) {
return undefined;
}
// Field options are may be changed
// ensure to have an update to date schema field
return this._dbAdapter.updateFieldOptions(className, fieldName, type);
}
return this._dbAdapter
@@ -1236,7 +1252,7 @@ export default class SchemaController {
// Every object has ACL implicitly.
continue;
}
promises.push(schema.enforceFieldExists(className, fieldName, expected));
promises.push(schema.enforceFieldExists(className, fieldName, expected, true));
}
const results = await Promise.all(promises);
const enforceFields = results.filter(result => !!result);

View File

@@ -446,6 +446,45 @@ module.exports.SecurityOptions = {
default: false,
},
};
module.exports.SchemaOptions = {
definitions: {
help: 'The schema definitions.',
default: [],
},
strict: {
env: 'PARSE_SERVER_SCHEMA_STRICT',
help: 'Is true if Parse Server should exit if schema update fail.',
action: parsers.booleanParser,
default: true,
},
deleteExtraFields: {
env: 'PARSE_SERVER_SCHEMA_DELETE_EXTRA_FIELDS',
help:
'Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development.',
action: parsers.booleanParser,
default: false,
},
recreateModifiedFields: {
env: 'PARSE_SERVER_SCHEMA_RECREATE_MODIFIED_FIELDS',
help:
'Is true if Parse Server should recreate any fields that are different between the current database schema and theschema definition. This should only be used during development.',
action: parsers.booleanParser,
default: false,
},
lockSchemas: {
env: 'PARSE_SERVER_SCHEMA_LOCK',
help:
'Is true if Parse Server will reject any attempts to modify the schema while the server is running.',
action: parsers.booleanParser,
default: false,
},
beforeMigration: {
help: 'Execute a callback before running schema migrations.',
},
afterMigration: {
help: 'Execute a callback after running schema migrations.',
},
};
module.exports.PagesOptions = {
customRoutes: {
env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES',

View File

@@ -1,3 +1,4 @@
// @flow
import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter';
import { FilesAdapter } from '../Adapters/Files/FilesAdapter';
import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter';
@@ -7,8 +8,8 @@ import { MailAdapter } from '../Adapters/Email/MailAdapter';
import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter';
import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter';
import { CheckGroup } from '../Security/CheckGroup';
import type { SchemaOptions } from '../SchemaMigrations/Migrations';
// @flow
type Adapter<T> = string | any | T;
type NumberOrBoolean = number | boolean;
type NumberOrString = number | string;
@@ -241,6 +242,8 @@ export interface ParseServerOptions {
playgroundPath: ?string;
/* Callback when server has started */
serverStartComplete: ?(error: ?Error) => void;
/* Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema */
schema: ?SchemaOptions;
/* Callback when server has closed */
serverCloseComplete: ?() => void;
/* The security options to identify and report weak security settings.

View File

@@ -44,6 +44,7 @@ import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer';
import { SecurityRouter } from './Routers/SecurityRouter';
import CheckRunner from './Security/CheckRunner';
import Deprecator from './Deprecator/Deprecator';
import { DefinedSchemas } from './SchemaMigrations/DefinedSchemas';
// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();
@@ -68,6 +69,7 @@ class ParseServer {
javascriptKey,
serverURL = requiredParameter('You must provide a serverURL!'),
serverStartComplete,
schema,
} = options;
// Initialize the node client SDK automatically
Parse.initialize(appId, javascriptKey || 'unused', masterKey);
@@ -84,7 +86,10 @@ class ParseServer {
databaseController
.performInitialization()
.then(() => hooksController.load())
.then(() => {
.then(async () => {
if (schema) {
await new DefinedSchemas(schema, this.config).execute();
}
if (serverStartComplete) {
serverStartComplete();
}

View File

@@ -35,7 +35,42 @@ function getOneSchema(req) {
});
}
function createSchema(req) {
const checkIfDefinedSchemasIsUsed = req => {
if (req.config?.schema?.lockSchemas === true) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'Cannot perform this operation when schemas options is used.'
);
}
};
export const internalCreateSchema = async (className, body, config) => {
const controller = await config.database.loadSchema({ clearCache: true });
const response = await controller.addClassIfNotExists(
className,
body.fields,
body.classLevelPermissions,
body.indexes
);
return {
response,
};
};
export const internalUpdateSchema = async (className, body, config) => {
const controller = await config.database.loadSchema({ clearCache: true });
const response = await controller.updateClass(
className,
body.fields || {},
body.classLevelPermissions,
body.indexes,
config.database
);
return { response };
};
async function createSchema(req) {
checkIfDefinedSchemasIsUsed(req);
if (req.auth.isReadOnly) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
@@ -53,20 +88,11 @@ function createSchema(req) {
throw new Parse.Error(135, `POST ${req.path} needs a class name.`);
}
return req.config.database
.loadSchema({ clearCache: true })
.then(schema =>
schema.addClassIfNotExists(
className,
req.body.fields,
req.body.classLevelPermissions,
req.body.indexes
)
)
.then(schema => ({ response: schema }));
return await internalCreateSchema(className, req.body, req.config);
}
function modifySchema(req) {
checkIfDefinedSchemasIsUsed(req);
if (req.auth.isReadOnly) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
@@ -76,22 +102,9 @@ function modifySchema(req) {
if (req.body.className && req.body.className != req.params.className) {
return classNameMismatchResponse(req.body.className, req.params.className);
}
const submittedFields = req.body.fields || {};
const className = req.params.className;
return req.config.database
.loadSchema({ clearCache: true })
.then(schema =>
schema.updateClass(
className,
submittedFields,
req.body.classLevelPermissions,
req.body.indexes,
req.config.database
)
)
.then(result => ({ response: result }));
return internalUpdateSchema(className, req.body, req.config);
}
const deleteSchema = req => {

View 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);
}
}
}

View 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;
}

View File

@@ -5,6 +5,8 @@ import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter';
import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter';
import LRUCacheAdapter from './Adapters/Cache/LRUCache.js';
import * as TestUtils from './TestUtils';
import * as SchemaMigrations from './SchemaMigrations/Migrations';
import { useExternal } from './deprecated';
import { getLogger } from './logger';
import { PushWorker } from './Push/PushWorker';
@@ -40,4 +42,5 @@ export {
PushWorker,
ParseGraphQLServer,
_ParseServer as ParseServer,
SchemaMigrations,
};