Files
kami-parse-server/src/Controllers/SchemaController.js
Florent Vilmart ddb0fb8a27 Adds redis cache for distributed environments (#2691)
* Makes schemaCache clearning promise-based

* Adds redis cache adapter for distributed systems

* Adds redis service to travis

* allow pg to fail
2016-09-17 13:52:02 -07:00

958 lines
33 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This class handles schema validation, persistence, and modification.
//
// Each individual Schema object should be immutable. The helpers to
// do things with the Schema just return a new schema when the schema
// is changed.
//
// The canonical place to store this Schema is in the database itself,
// in a _SCHEMA collection. This is not the right way to do it for an
// open source framework, but it's backward compatible, so we're
// keeping it this way for now.
//
// In API-handling code, you should only use the Schema class via the
// DatabaseController. This will let us replace the schema logic for
// different databases.
// TODO: hide all schema logic inside the database adapter.
const Parse = require('parse/node').Parse;
import _ from 'lodash';
const defaultColumns = Object.freeze({
// Contain the default columns for every parse object type (except _Join collection)
_Default: {
"objectId": {type:'String'},
"createdAt": {type:'Date'},
"updatedAt": {type:'Date'},
"ACL": {type:'ACL'},
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_User: {
"username": {type:'String'},
"password": {type:'String'},
"email": {type:'String'},
"emailVerified": {type:'Boolean'},
"authData": {type:'Object'}
},
// The additional default columns for the _Installation collection (in addition to DefaultCols)
_Installation: {
"installationId": {type:'String'},
"deviceToken": {type:'String'},
"channels": {type:'Array'},
"deviceType": {type:'String'},
"pushType": {type:'String'},
"GCMSenderId": {type:'String'},
"timeZone": {type:'String'},
"localeIdentifier": {type:'String'},
"badge": {type:'Number'},
"appVersion": {type:'String'},
"appName": {type:'String'},
"appIdentifier": {type:'String'},
"parseVersion": {type:'String'},
},
// The additional default columns for the _Role collection (in addition to DefaultCols)
_Role: {
"name": {type:'String'},
"users": {type:'Relation', targetClass:'_User'},
"roles": {type:'Relation', targetClass:'_Role'}
},
// The additional default columns for the _Session collection (in addition to DefaultCols)
_Session: {
"restricted": {type:'Boolean'},
"user": {type:'Pointer', targetClass:'_User'},
"installationId": {type:'String'},
"sessionToken": {type:'String'},
"expiresAt": {type:'Date'},
"createdWith": {type:'Object'}
},
_Product: {
"productIdentifier": {type:'String'},
"download": {type:'File'},
"downloadName": {type:'String'},
"icon": {type:'File'},
"order": {type:'Number'},
"title": {type:'String'},
"subtitle": {type:'String'},
},
_PushStatus: {
"pushTime": {type:'String'},
"source": {type:'String'}, // rest or webui
"query": {type:'String'}, // the stringified JSON query
"payload": {type:'String'}, // the stringified JSON payload,
"title": {type:'String'},
"expiry": {type:'Number'},
"status": {type:'String'},
"numSent": {type:'Number'},
"numFailed": {type:'Number'},
"pushHash": {type:'String'},
"errorMessage": {type:'Object'},
"sentPerType": {type:'Object'},
"failedPerType":{type:'Object'},
},
_JobStatus: {
"jobName": {type: 'String'},
"source": {type: 'String'},
"status": {type: 'String'},
"message": {type: 'String'},
"params": {type: 'Object'}, // params received when calling the job
"finishedAt": {type: 'Date'}
},
_Hooks: {
"functionName": {type:'String'},
"className": {type:'String'},
"triggerName": {type:'String'},
"url": {type:'String'}
},
_GlobalConfig: {
"objectId": {type: 'String'},
"params": {type: 'Object'}
}
});
const requiredColumns = Object.freeze({
_Product: ["productIdentifier", "icon", "order", "title", "subtitle"],
_Role: ["name", "ACL"]
});
const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus']);
const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig']);
// 10 alpha numberic chars + uppercase
const userIdRegex = /^[a-zA-Z0-9]{10}$/;
// Anything that start with role
const roleRegex = /^role:.*/;
// * permission
const publicRegex = /^\*$/
const permissionKeyRegex = Object.freeze([userIdRegex, roleRegex, publicRegex]);
function verifyPermissionKey(key) {
let result = permissionKeyRegex.reduce((isGood, regEx) => {
isGood = isGood || key.match(regEx) != null;
return isGood;
}, false);
if (!result) {
throw new Parse.Error(Parse.Error.INVALID_JSON, `'${key}' is not a valid key for class level permissions`);
}
}
const CLPValidKeys = Object.freeze(['find', 'get', 'create', 'update', 'delete', 'addField', 'readUserFields', 'writeUserFields']);
function validateCLP(perms, fields) {
if (!perms) {
return;
}
Object.keys(perms).forEach((operation) => {
if (CLPValidKeys.indexOf(operation) == -1) {
throw new Parse.Error(Parse.Error.INVALID_JSON, `${operation} is not a valid operation for class level permissions`);
}
if (operation === 'readUserFields' || operation === 'writeUserFields') {
if (!Array.isArray(perms[operation])) {
throw new Parse.Error(Parse.Error.INVALID_JSON, `'${perms[operation]}' is not a valid value for class level permissions ${operation}`);
} else {
perms[operation].forEach((key) => {
if (!fields[key] || fields[key].type != 'Pointer' || fields[key].targetClass != '_User') {
throw new Parse.Error(Parse.Error.INVALID_JSON, `'${key}' is not a valid column for class level pointer permissions ${operation}`);
}
});
}
return;
}
Object.keys(perms[operation]).forEach((key) => {
verifyPermissionKey(key);
let perm = perms[operation][key];
if (perm !== true) {
throw new Parse.Error(Parse.Error.INVALID_JSON, `'${perm}' is not a valid value for class level permissions ${operation}:${key}:${perm}`);
}
});
});
}
const joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/;
const classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/;
function classNameIsValid(className) {
// Valid classes must:
return (
// Be one of _User, _Installation, _Role, _Session OR
systemClasses.indexOf(className) > -1 ||
// Be a join table OR
joinClassRegex.test(className) ||
// Include only alpha-numeric and underscores, and not start with an underscore or number
fieldNameIsValid(className)
);
}
// Valid fields must be alpha-numeric, and not start with an underscore or number
function fieldNameIsValid(fieldName) {
return classAndFieldRegex.test(fieldName);
}
// Checks that it's not trying to clobber one of the default fields of the class.
function fieldNameIsValidForClass(fieldName, className) {
if (!fieldNameIsValid(fieldName)) {
return false;
}
if (defaultColumns._Default[fieldName]) {
return false;
}
if (defaultColumns[className] && defaultColumns[className][fieldName]) {
return false;
}
return true;
}
function invalidClassNameMessage(className) {
return 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character ';
}
const invalidJsonError = new Parse.Error(Parse.Error.INVALID_JSON, "invalid JSON");
const validNonRelationOrPointerTypes = [
'Number',
'String',
'Boolean',
'Date',
'Object',
'Array',
'GeoPoint',
'File',
];
// Returns an error suitable for throwing if the type is invalid
const fieldTypeIsInvalid = ({ type, targetClass }) => {
if (['Pointer', 'Relation'].includes(type)) {
if (!targetClass) {
return new Parse.Error(135, `type ${type} needs a class name`);
} else if (typeof targetClass !== 'string') {
return invalidJsonError;
} else if (!classNameIsValid(targetClass)) {
return new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(targetClass));
} else {
return undefined;
}
}
if (typeof type !== 'string') {
return invalidJsonError;
}
if (!validNonRelationOrPointerTypes.includes(type)) {
return new Parse.Error(Parse.Error.INCORRECT_TYPE, `invalid field type: ${type}`);
}
return undefined;
}
const convertSchemaToAdapterSchema = schema => {
schema = injectDefaultSchema(schema);
delete schema.fields.ACL;
schema.fields._rperm = { type: 'Array' };
schema.fields._wperm = { type: 'Array' };
if (schema.className === '_User') {
delete schema.fields.password;
schema.fields._hashed_password = { type: 'String' };
}
return schema;
}
const convertAdapterSchemaToParseSchema = ({...schema}) => {
delete schema.fields._rperm;
delete schema.fields._wperm;
schema.fields.ACL = { type: 'ACL' };
if (schema.className === '_User') {
delete schema.fields.authData; //Auth data is implicit
delete schema.fields._hashed_password;
schema.fields.password = { type: 'String' };
}
return schema;
}
const injectDefaultSchema = ({className, fields, classLevelPermissions}) => ({
className,
fields: {
...defaultColumns._Default,
...(defaultColumns[className] || {}),
...fields,
},
classLevelPermissions,
});
const _HooksSchema = {className: "_Hooks", fields: defaultColumns._Hooks};
const _GlobalConfigSchema = { className: "_GlobalConfig", fields: defaultColumns._GlobalConfig }
const _PushStatusSchema = convertSchemaToAdapterSchema(injectDefaultSchema({
className: "_PushStatus",
fields: {},
classLevelPermissions: {}
}));
const _JobStatusSchema = convertSchemaToAdapterSchema(injectDefaultSchema({
className: "_JobStatus",
fields: {},
classLevelPermissions: {}
}));
const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _PushStatusSchema, _GlobalConfigSchema];
const dbTypeMatchesObjectType = (dbType, objectType) => {
if (dbType.type !== objectType.type) return false;
if (dbType.targetClass !== objectType.targetClass) return false;
if (dbType === objectType.type) return true;
if (dbType.type === objectType.type) return true;
return false;
}
const typeToString = (type) => {
if (type.targetClass) {
return `${type.type}<${type.targetClass}>`;
}
return `${type.type || type}`;
}
// Stores the entire schema of the app in a weird hybrid format somewhere between
// the mongo format and the Parse format. Soon, this will all be Parse format.
export default class SchemaController {
_dbAdapter;
data;
perms;
constructor(databaseAdapter, schemaCache) {
this._dbAdapter = databaseAdapter;
this._cache = schemaCache;
// this.data[className][fieldName] tells you the type of that field, in mongo format
this.data = {};
// this.perms[className][operation] tells you the acl-style permissions
this.perms = {};
}
reloadData(options = {clearCache: false}) {
let promise = Promise.resolve();
if (options.clearCache) {
promise = promise.then(() => {
return this._cache.clear();
});
}
if (this.reloadDataPromise && !options.clearCache) {
return this.reloadDataPromise;
}
this.data = {};
this.perms = {};
this.reloadDataPromise = promise.then(() => {
return this.getAllClasses(options);
})
.then(allSchemas => {
allSchemas.forEach(schema => {
this.data[schema.className] = injectDefaultSchema(schema).fields;
this.perms[schema.className] = schema.classLevelPermissions;
});
// Inject the in-memory classes
volatileClasses.forEach(className => {
this.data[className] = injectDefaultSchema({
className,
fields: {},
classLevelPermissions: {}
});
});
delete this.reloadDataPromise;
}, (err) => {
delete this.reloadDataPromise;
throw err;
});
return this.reloadDataPromise;
}
getAllClasses(options = {clearCache: false}) {
let promise = Promise.resolve();
if (options.clearCache) {
promise = this._cache.clear();
}
return promise.then(() => {
return this._cache.getAllClasses()
}).then((allClasses) => {
if (allClasses && allClasses.length && !options.clearCache) {
return Promise.resolve(allClasses);
}
return this._dbAdapter.getAllClasses()
.then(allSchemas => allSchemas.map(injectDefaultSchema))
.then(allSchemas => {
return this._cache.setAllClasses(allSchemas).then(() => {
return allSchemas;
});
})
});
}
getOneSchema(className, allowVolatileClasses = false, options = {clearCache: false}) {
let promise = Promise.resolve();
if (options.clearCache) {
promise = this._cache.clear();
}
return promise.then(() => {
if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) {
return Promise.resolve(this.data[className]);
}
return this._cache.getOneSchema(className).then((cached) => {
if (cached && !options.clearCache) {
return Promise.resolve(cached);
}
return this._dbAdapter.getClass(className)
.then(injectDefaultSchema)
.then((result) => {
return this._cache.setOneSchema(className, result).then(() => {
return result;
})
});
});
});
}
// Create a new class that includes the three default fields.
// ACL is an implicit column that does not get an entry in the
// _SCHEMAS database. Returns a promise that resolves with the
// created schema, in mongo format.
// on success, and rejects with an error on fail. Ensure you
// have authorization (master key, or client class creation
// enabled) before calling this function.
addClassIfNotExists(className, fields = {}, classLevelPermissions) {
var validationError = this.validateNewClass(className, fields, classLevelPermissions);
if (validationError) {
return Promise.reject(validationError);
}
return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, className }))
.then(convertAdapterSchemaToParseSchema)
.then((res) => {
return this._cache.clear().then(() => {
return Promise.resolve(res);
});
})
.catch(error => {
if (error && error.code === Parse.Error.DUPLICATE_VALUE) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
} else {
throw error;
}
});
}
updateClass(className, submittedFields, classLevelPermissions, database) {
return this.getOneSchema(className)
.then(schema => {
let existingFields = schema.fields;
Object.keys(submittedFields).forEach(name => {
let field = submittedFields[name];
if (existingFields[name] && field.__op !== 'Delete') {
throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
}
if (!existingFields[name] && field.__op === 'Delete') {
throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`);
}
});
delete existingFields._rperm;
delete existingFields._wperm;
let newSchema = buildMergedSchemaObject(existingFields, submittedFields);
let validationError = this.validateSchemaData(className, newSchema, classLevelPermissions, Object.keys(existingFields));
if (validationError) {
throw new Parse.Error(validationError.code, validationError.error);
}
// Finally we have checked to make sure the request is valid and we can start deleting fields.
// Do all deletions first, then a single save to _SCHEMA collection to handle all additions.
let deletePromises = [];
let insertedFields = [];
Object.keys(submittedFields).forEach(fieldName => {
if (submittedFields[fieldName].__op === 'Delete') {
const promise = this.deleteField(fieldName, className, database);
deletePromises.push(promise);
} else {
insertedFields.push(fieldName);
}
});
return Promise.all(deletePromises) // Delete Everything
.then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values
.then(() => {
let promises = insertedFields.map(fieldName => {
const type = submittedFields[fieldName];
return this.enforceFieldExists(className, fieldName, type);
});
return Promise.all(promises);
})
.then(() => this.setPermissions(className, classLevelPermissions, newSchema))
//TODO: Move this logic into the database adapter
.then(() => ({
className: className,
fields: this.data[className],
classLevelPermissions: this.perms[className]
}));
})
.catch(error => {
if (error === undefined) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`);
} else {
throw error;
}
})
}
// Returns a promise that resolves successfully to the new schema
// object or fails with a reason.
enforceClassExists(className) {
if (this.data[className]) {
return Promise.resolve(this);
}
// We don't have this class. Update the schema
return this.addClassIfNotExists(className)
// The schema update succeeded. Reload the schema
.then(() => this.reloadData({ clearCache: true }))
.catch(error => {
// The schema update failed. This can be okay - it might
// have failed because there's a race condition and a different
// client is making the exact same schema update that we want.
// So just reload the schema.
return this.reloadData({ clearCache: true });
})
.then(() => {
// Ensure that the schema now validates
if (this.data[className]) {
return this;
} else {
throw new Parse.Error(Parse.Error.INVALID_JSON, `Failed to add ${className}`);
}
})
.catch(error => {
console.error(error);
// The schema still doesn't validate. Give up
throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema class name does not revalidate');
});
}
validateNewClass(className, fields = {}, classLevelPermissions) {
if (this.data[className]) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
}
if (!classNameIsValid(className)) {
return {
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
};
}
return this.validateSchemaData(className, fields, classLevelPermissions, []);
}
validateSchemaData(className, fields, classLevelPermissions, existingFieldNames) {
for (let fieldName in fields) {
if (!existingFieldNames.includes(fieldName)) {
if (!fieldNameIsValid(fieldName)) {
return {
code: Parse.Error.INVALID_KEY_NAME,
error: 'invalid field name: ' + fieldName,
};
}
if (!fieldNameIsValidForClass(fieldName, className)) {
return {
code: 136,
error: 'field ' + fieldName + ' cannot be added',
};
}
const error = fieldTypeIsInvalid(fields[fieldName]);
if (error) return { code: error.code, error: error.message };
}
}
for (let fieldName in defaultColumns[className]) {
fields[fieldName] = defaultColumns[className][fieldName];
}
let geoPoints = Object.keys(fields).filter(key => fields[key] && fields[key].type === 'GeoPoint');
if (geoPoints.length > 1) {
return {
code: Parse.Error.INCORRECT_TYPE,
error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.',
};
}
validateCLP(classLevelPermissions, fields);
}
// Sets the Class-level permissions for a given className, which must exist.
setPermissions(className, perms, newSchema) {
if (typeof perms === 'undefined') {
return Promise.resolve();
}
validateCLP(perms, newSchema);
return this._dbAdapter.setClassLevelPermissions(className, perms)
.then(() => this.reloadData({ clearCache: true }));
}
// Returns a promise that resolves successfully to the new schema
// object if the provided className-fieldName-type tuple is valid.
// The className must already be validated.
// If 'freeze' is true, refuse to update the schema for this field.
enforceFieldExists(className, fieldName, type, freeze) {
if (fieldName.indexOf(".") > 0) {
// subdocument key (x.y) => ok if x is of type 'object'
fieldName = fieldName.split(".")[ 0 ];
type = 'Object';
}
if (!fieldNameIsValid(fieldName)) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`);
}
// If someone tries to create a new field with null/undefined as the value, return;
if (!type) {
return Promise.resolve(this);
}
return this.reloadData().then(() => {
let expectedType = this.getExpectedType(className, fieldName);
if (typeof type === 'string') {
type = { type };
}
if (expectedType) {
if (!dbTypeMatchesObjectType(expectedType, type)) {
throw new Parse.Error(
Parse.Error.INCORRECT_TYPE,
`schema mismatch for ${className}.${fieldName}; expected ${typeToString(expectedType)} but got ${typeToString(type)}`
);
}
return this;
}
return this._dbAdapter.addFieldIfNotExists(className, fieldName, type).then(() => {
// The update succeeded. Reload the schema
return this.reloadData({ clearCache: true });
}, error => {
//TODO: introspect the error and only reload if the error is one for which is makes sense to reload
// The update failed. This can be okay - it might have been a race
// condition where another client updated the schema in the same
// way that we wanted to. So, just reload the schema
return this.reloadData({ clearCache: true });
}).then(error => {
// Ensure that the schema now validates
if (!dbTypeMatchesObjectType(this.getExpectedType(className, fieldName), type)) {
throw new Parse.Error(Parse.Error.INVALID_JSON, `Could not add field ${fieldName}`);
}
// Remove the cached schema
this._cache.clear();
return this;
});
});
}
// Delete a field, and remove that data from all objects. This is intended
// to remove unused fields, if other writers are writing objects that include
// this field, the field may reappear. Returns a Promise that resolves with
// no object on success, or rejects with { code, error } on failure.
// Passing the database and prefix is necessary in order to drop relation collections
// and remove fields from objects. Ideally the database would belong to
// a database adapter and this function would close over it or access it via member.
deleteField(fieldName, className, database) {
if (!classNameIsValid(className)) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className));
}
if (!fieldNameIsValid(fieldName)) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`);
}
//Don't allow deleting the default fields.
if (!fieldNameIsValidForClass(fieldName, className)) {
throw new Parse.Error(136, `field ${fieldName} cannot be changed`);
}
return this.getOneSchema(className, false, {clearCache: true})
.catch(error => {
if (error === undefined) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`);
} else {
throw error;
}
})
.then(schema => {
if (!schema.fields[fieldName]) {
throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`);
}
if (schema.fields[fieldName].type == 'Relation') {
//For relations, drop the _Join table
return database.adapter.deleteFields(className, schema, [fieldName])
.then(() => database.adapter.deleteClass(`_Join:${fieldName}:${className}`));
}
return database.adapter.deleteFields(className, schema, [fieldName]);
}).then(() => {
this._cache.clear();
});
}
// Validates an object provided in REST format.
// Returns a promise that resolves to the new schema if this object is
// valid.
validateObject(className, object, query) {
let geocount = 0;
let promise = this.enforceClassExists(className);
for (let fieldName in object) {
if (object[fieldName] === undefined) {
continue;
}
let expected = getType(object[fieldName]);
if (expected === 'GeoPoint') {
geocount++;
}
if (geocount > 1) {
// Make sure all field validation operations run before we return.
// If not - we are continuing to run logic, but already provided response from the server.
return promise.then(() => {
return Promise.reject(new Parse.Error(Parse.Error.INCORRECT_TYPE,
'there can only be one geopoint field in a class'));
});
}
if (!expected) {
continue;
}
if (fieldName === 'ACL') {
// Every object has ACL implicitly.
continue;
}
promise = promise.then(schema => schema.enforceFieldExists(className, fieldName, expected));
}
promise = thenValidateRequiredColumns(promise, className, object, query);
return promise;
}
// Validates that all the properties are set for the object
validateRequiredColumns(className, object, query) {
let columns = requiredColumns[className];
if (!columns || columns.length == 0) {
return Promise.resolve(this);
}
let missingColumns = columns.filter(function(column){
if (query && query.objectId) {
if (object[column] && typeof object[column] === "object") {
// Trying to delete a required column
return object[column].__op == 'Delete';
}
// Not trying to do anything there
return false;
}
return !object[column]
});
if (missingColumns.length > 0) {
throw new Parse.Error(
Parse.Error.INCORRECT_TYPE,
missingColumns[0]+' is required.');
}
return Promise.resolve(this);
}
// Validates the base CLP for an operation
testBaseCLP(className, aclGroup, operation) {
if (!this.perms[className] || !this.perms[className][operation]) {
return true;
}
let classPerms = this.perms[className];
let perms = classPerms[operation];
// Handle the public scenario quickly
if (perms['*']) {
return true;
}
// Check permissions against the aclGroup provided (array of userId/roles)
if (aclGroup.some(acl => { return perms[acl] === true })) {
return true;
}
return false;
}
// Validates an operation passes class-level-permissions set in the schema
validatePermission(className, aclGroup, operation) {
if (this.testBaseCLP(className, aclGroup, operation)) {
return Promise.resolve();
}
if (!this.perms[className] || !this.perms[className][operation]) {
return true;
}
let classPerms = this.perms[className];
let perms = classPerms[operation];
// No matching CLP, let's check the Pointer permissions
// And handle those later
let permissionField = ['get', 'find'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields';
// Reject create when write lockdown
if (permissionField == 'writeUserFields' && operation == 'create') {
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN,
`Permission denied for action ${operation} on class ${className}.`);
}
// Process the readUserFields later
if (Array.isArray(classPerms[permissionField]) && classPerms[permissionField].length > 0) {
return Promise.resolve();
}
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN,
`Permission denied for action ${operation} on class ${className}.`);
};
// Returns the expected type for a className+key combination
// or undefined if the schema is not set
getExpectedType(className, fieldName) {
if (this.data && this.data[className]) {
const expectedType = this.data[className][fieldName]
return expectedType === 'map' ? 'Object' : expectedType;
}
return undefined;
};
// Checks if a given class is in the schema.
hasClass(className) {
return this.reloadData().then(() => !!(this.data[className]));
}
}
// Returns a promise for a new Schema.
const load = (dbAdapter, schemaCache, options) => {
let schema = new SchemaController(dbAdapter, schemaCache);
return schema.reloadData(options).then(() => schema);
}
// Builds a new schema (in schema API response format) out of an
// existing mongo schema + a schemas API put request. This response
// does not include the default fields, as it is intended to be passed
// to mongoSchemaFromFieldsAndClassName. No validation is done here, it
// is done in mongoSchemaFromFieldsAndClassName.
function buildMergedSchemaObject(existingFields, putRequest) {
let newSchema = {};
let sysSchemaField = Object.keys(defaultColumns).indexOf(existingFields._id) === -1 ? [] : Object.keys(defaultColumns[existingFields._id]);
for (let oldField in existingFields) {
if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') {
if (sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1) {
continue;
}
let fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete'
if (!fieldIsDeleted) {
newSchema[oldField] = existingFields[oldField];
}
}
}
for (let newField in putRequest) {
if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') {
if (sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1) {
continue;
}
newSchema[newField] = putRequest[newField];
}
}
return newSchema;
}
// Given a schema promise, construct another schema promise that
// validates this field once the schema loads.
function thenValidateRequiredColumns(schemaPromise, className, object, query) {
return schemaPromise.then((schema) => {
return schema.validateRequiredColumns(className, object, query);
});
}
// Gets the type from a REST API formatted object, where 'type' is
// extended past javascript types to include the rest of the Parse
// type system.
// The output should be a valid schema value.
// TODO: ensure that this is compatible with the format used in Open DB
function getType(obj) {
let type = typeof obj;
switch(type) {
case 'boolean':
return 'Boolean';
case 'string':
return 'String';
case 'number':
return 'Number';
case 'map':
case 'object':
if (!obj) {
return undefined;
}
return getObjectType(obj);
case 'function':
case 'symbol':
case 'undefined':
default:
throw 'bad obj: ' + obj;
}
}
// This gets the type for non-JSON types like pointers and files, but
// also gets the appropriate type for $ operators.
// Returns null if the type is unknown.
function getObjectType(obj) {
if (obj instanceof Array) {
return 'Array';
}
if (obj.__type){
switch(obj.__type) {
case 'Pointer' :
if(obj.className) {
return {
type: 'Pointer',
targetClass: obj.className
}
}
case 'File' :
if(obj.name) {
return 'File';
}
case 'Date' :
if(obj.iso) {
return 'Date';
}
case 'GeoPoint' :
if(obj.latitude != null && obj.longitude != null) {
return 'GeoPoint';
}
case 'Bytes' :
if(obj.base64) {
return;
}
default:
throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type);
}
}
if (obj['$ne']) {
return getObjectType(obj['$ne']);
}
if (obj.__op) {
switch(obj.__op) {
case 'Increment':
return 'Number';
case 'Delete':
return null;
case 'Add':
case 'AddUnique':
case 'Remove':
return 'Array';
case 'AddRelation':
case 'RemoveRelation':
return {
type: 'Relation',
targetClass: obj.objects[0].className
}
case 'Batch':
return getObjectType(obj.ops[0]);
default:
throw 'unexpected op: ' + obj.__op;
}
}
return 'Object';
}
export {
load,
classNameIsValid,
fieldNameIsValid,
invalidClassNameMessage,
buildMergedSchemaObject,
systemClasses,
defaultColumns,
convertSchemaToAdapterSchema,
VolatileClassesSchemas,
};