Schema.js database agnostic (#1468)

* Schema.js database agnostic

* nits
This commit is contained in:
Florent Vilmart
2016-04-12 17:39:27 -04:00
parent c419106a38
commit c050a65d49
7 changed files with 177 additions and 163 deletions

View File

@@ -685,9 +685,10 @@ describe('Schema', () => {
.then(() => schema.reloadData())
.then(() => {
expect(schema['data']['NewClass']).toEqual({
objectId: 'string',
updatedAt: 'string',
createdAt: 'string'
objectId: { type: 'String' },
updatedAt: { type: 'Date' },
createdAt: { type: 'Date' },
ACL: { type: 'ACL' }
});
done();
});
@@ -747,7 +748,7 @@ describe('Schema', () => {
it('can merge schemas', done => {
expect(Schema.buildMergedSchemaObject({
_id: 'SomeClass',
someType: 'number'
someType: { type: 'Number' }
}, {
newType: {type: 'Number'}
})).toEqual({
@@ -760,8 +761,8 @@ describe('Schema', () => {
it('can merge deletions', done => {
expect(Schema.buildMergedSchemaObject({
_id: 'SomeClass',
someType: 'number',
outDatedType: 'string',
someType: { type: 'Number' },
outDatedType: { type: 'String' },
},{
newType: {type: 'GeoPoint'},
outDatedType: {__op: 'Delete'},
@@ -775,16 +776,16 @@ describe('Schema', () => {
it('ignore default field when merge with system class', done => {
expect(Schema.buildMergedSchemaObject({
_id: '_User',
username: 'string',
password: 'string',
authData: 'object',
email: 'string',
emailVerified: 'boolean'
username: { type: 'String' },
password: { type: 'String' },
authData: { type: 'Object' },
email: { type: 'String' },
emailVerified: { type: 'Boolean' },
},{
authData: {type: 'string'},
customField: {type: 'string'},
authData: { type: 'String' },
customField: { type: 'String' },
})).toEqual({
customField: {type: 'string'}
customField: { type: 'String' }
});
done();
});

View File

@@ -8,11 +8,11 @@ var dummySchema = {
data: {},
getExpectedType: function(className, key) {
if (key == 'userPointer') {
return '*_User';
return { type: 'Pointer', targetClass: '_User' };
} else if (key == 'picture') {
return 'file';
return { type: 'File' };
} else if (key == 'location') {
return 'geopoint';
return { type: 'GeoPoint' };
}
return;
},

View File

@@ -93,6 +93,33 @@ function parseFieldTypeToMongoFieldType({ type, targetClass }) {
}
}
// Returns { code, error } if invalid, or { result }, an object
// suitable for inserting into _SCHEMA collection, otherwise.
function mongoSchemaFromFieldsAndClassNameAndCLP(fields, className, classLevelPermissions) {
let mongoObject = {
_id: className,
objectId: 'string',
updatedAt: 'string',
createdAt: 'string'
};
for (let fieldName in fields) {
mongoObject[fieldName] = parseFieldTypeToMongoFieldType(fields[fieldName]);
}
if (typeof classLevelPermissions !== 'undefined') {
mongoObject._metadata = mongoObject._metadata || {};
if (!classLevelPermissions) {
delete mongoObject._metadata.class_permissions;
} else {
mongoObject._metadata.class_permissions = classLevelPermissions;
}
}
return mongoObject;
}
class MongoSchemaCollection {
_collection: MongoCollection;
@@ -136,8 +163,9 @@ class MongoSchemaCollection {
// later PR. Returns a promise that is expected to resolve with the newly created schema, in Parse format.
// If the class already exists, returns a promise that rejects with undefined as the reason. If the collection
// can't be added for a reason other than it already existing, requirements for rejection reason are TBD.
addSchema(name: string, fields) {
let mongoObject = _mongoSchemaObjectFromNameFields(name, fields);
addSchema(name: string, fields, classLevelPermissions) {
let mongoSchema = mongoSchemaFromFieldsAndClassNameAndCLP(fields, name, classLevelPermissions);
let mongoObject = _mongoSchemaObjectFromNameFields(name, mongoSchema);
return this._collection.insertOne(mongoObject)
.then(result => mongoSchemaToParseSchema(result.ops[0]))
.catch(error => {
@@ -155,18 +183,27 @@ class MongoSchemaCollection {
upsertSchema(name: string, query: string, update) {
return this._collection.upsertOne(_mongoSchemaQueryFromNameQuery(name, query), update);
}
updateField(className: string, fieldName: string, type: string) {
// We don't have this field. Update the schema.
// Note that we use the $exists guard and $set to avoid race
// conditions in the database. This is important!
let query = {};
query[fieldName] = { '$exists': false };
let update = {};
if (typeof type === 'string') {
type = {
type: type
}
}
update[fieldName] = parseFieldTypeToMongoFieldType(type);
update = {'$set': update};
return this.upsertSchema(className, query, update);
}
}
// Exported for testing reasons and because we haven't moved all mongo schema format
// related logic into the database adapter yet.
MongoSchemaCollection._TESTmongoSchemaToParseSchema = mongoSchemaToParseSchema
// Exported because we haven't moved all mongo schema format related logic
// into the database adapter yet. We will remove this before too long.
MongoSchemaCollection._DONOTUSEmongoFieldToParseSchemaField = mongoFieldToParseSchemaField
// Exported because we haven't moved all mongo schema format related logic
// into the database adapter yet. We will remove this before too long.
MongoSchemaCollection._DONOTUSEparseFieldTypeToMongoFieldType = parseFieldTypeToMongoFieldType;
export default MongoSchemaCollection

View File

@@ -90,9 +90,8 @@ DatabaseController.prototype.loadSchema = function(acceptor = returnsTrue) {
DatabaseController.prototype.redirectClassNameForKey = function(className, key) {
return this.loadSchema().then((schema) => {
var t = schema.getExpectedType(className, key);
var match = t ? t.match(/^relation<(.*)>$/) : false;
if (match) {
return match[1];
if (t.type == 'Relation') {
return t.targetClass;
} else {
return className;
}
@@ -446,11 +445,10 @@ DatabaseController.prototype.reduceInRelation = function(className, query, schem
let promises = Object.keys(query).map((key) => {
if (query[key] && (query[key]['$in'] || query[key]['$ne'] || query[key]['$nin'] || query[key].__type == 'Pointer')) {
let t = schema.getExpectedType(className, key);
let match = t ? t.match(/^relation<(.*)>$/) : false;
if (!match) {
if (!t || t.type !== 'Relation') {
return Promise.resolve(query);
}
let relatedClassName = match[1];
let relatedClassName = t.targetClass;
// Build the list of queries
let queries = Object.keys(query[key]).map((constraintKey) => {
let relatedIds;
@@ -599,7 +597,6 @@ DatabaseController.prototype.find = function(className, query, options = {}) {
if (options.limit) {
mongoOptions.limit = options.limit;
}
let isMaster = !('acl' in options);
let aclGroup = options.acl || [];
let acceptor = schema => schema.hasKeys(className, keysForQuery(query))

View File

@@ -213,7 +213,6 @@ class Schema {
this._collection = collection;
// this.data[className][fieldName] tells you the type of that field, in mongo format
// TODO: use Parse format
this.data = {};
// this.perms[className][operation] tells you the acl-style permissions
this.perms = {};
@@ -229,14 +228,7 @@ class Schema {
...(defaultColumns[schema.className] || {}),
...schema.fields,
}
// ACL doesn't show up in mongo, it's implicit
delete parseFormatSchema.ACL;
// createdAt and updatedAt are wacky and have legacy baggage
parseFormatSchema.createdAt = { type: 'String' };
parseFormatSchema.updatedAt = { type: 'String' };
//Necessary because we still use the mongo type internally here :(
this.data[schema.className] = _.mapValues(parseFormatSchema, MongoSchemaCollection._DONOTUSEparseFieldTypeToMongoFieldType);
this.data[schema.className] = parseFormatSchema;
this.perms[schema.className] = schema.classLevelPermissions;
});
});
@@ -249,17 +241,16 @@ class Schema {
// 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) {
if (this.data[className]) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
addClassIfNotExists(className, fields = {}, classLevelPermissions) {
var validationError = this.validateNewClass(className, fields, classLevelPermissions);
if (validationError) {
return Promise.reject(validationError);
}
let mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(fields, className, classLevelPermissions);
if (!mongoObject.result) {
return Promise.reject(mongoObject);
}
return this._collection.addSchema(className, mongoObject.result)
return this._collection.addSchema(className, fields, classLevelPermissions)
.then(res => {
return Promise.resolve(res);
})
.catch(error => {
if (error === undefined) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
@@ -285,9 +276,9 @@ class Schema {
});
let newSchema = buildMergedSchemaObject(existingFields, submittedFields);
let mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(newSchema, className, classLevelPermissions);
if (!mongoObject.result) {
throw new Parse.Error(mongoObject.code, mongoObject.error);
let validationError = this.validateSchemaData(className, newSchema, classLevelPermissions);
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.
@@ -302,12 +293,13 @@ class Schema {
insertedFields.push(fieldName);
}
});
return Promise.all(deletePromises) // Delete Everything
.then(() => this.reloadData()) // Reload our Schema, so we have all the new values
.then(() => {
let promises = insertedFields.map(fieldName => {
const mongoType = mongoObject.result[fieldName];
return this.validateField(className, fieldName, mongoType);
const type = submittedFields[fieldName];
return this.validateField(className, fieldName, type);
});
return Promise.all(promises);
})
@@ -315,7 +307,11 @@ class Schema {
return this.setPermissions(className, classLevelPermissions)
})
//TODO: Move this logic into the database adapter
.then(() => MongoSchemaCollection._TESTmongoSchemaToParseSchema(mongoObject.result));
.then(() => {
return { className: className,
fields: this.data[className],
classLevelPermissions: this.perms[className] }
});
}
@@ -363,6 +359,51 @@ class Schema {
});
}
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) {
for (let fieldName in fields) {
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);
}
// Sets the Class-level permissions for a given className, which must exist.
setPermissions(className, perms) {
if (typeof perms === 'undefined') {
@@ -393,13 +434,17 @@ class Schema {
if( fieldName.indexOf(".") > 0 ) {
// subdocument key (x.y) => ok if x is of type 'object'
fieldName = fieldName.split(".")[ 0 ];
type = 'object';
type = 'Object';
}
let expected = this.data[className][fieldName];
if (expected) {
expected = (expected === 'map' ? 'object' : expected);
if (expected === type) {
expected = (expected === 'map' ? 'Object' : expected);
if (expected.type && type.type
&& expected.type == type.type
&& expected.targetClass == type.targetClass) {
return Promise.resolve(this);
} else if (expected == type || expected.type == type) {
return Promise.resolve(this);
} else {
throw new Parse.Error(
@@ -419,10 +464,10 @@ class Schema {
return Promise.resolve(this);
}
if (type === 'geopoint') {
if (type === 'GeoPoint') {
// Make sure there are not other geopoint fields
for (let otherKey in this.data[className]) {
if (this.data[className][otherKey] === 'geopoint') {
if (this.data[className][otherKey].type === 'GeoPoint') {
throw new Parse.Error(
Parse.Error.INCORRECT_TYPE,
'there can only be one geopoint field in a class');
@@ -430,15 +475,7 @@ class Schema {
}
}
// We don't have this field. Update the schema.
// Note that we use the $exists guard and $set to avoid race
// conditions in the database. This is important!
let query = {};
query[fieldName] = { '$exists': false };
let update = {};
update[fieldName] = type;
update = {'$set': update};
return this._collection.upsertSchema(className, query, update).then(() => {
return this._collection.updateField(className, fieldName, type).then(() => {
// The update succeeded. Reload the schema
return this.reloadData();
}, () => {
@@ -487,7 +524,7 @@ class Schema {
throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`);
}
if (this.data[className][fieldName].startsWith('relation<')) {
if (this.data[className][fieldName].type == 'Relation') {
//For relations, drop the _Join table
return database.dropCollection(`_Join:${fieldName}:${className}`)
.then(() => {
@@ -504,7 +541,7 @@ class Schema {
// This is necessary to ensure that the data is still gone if they add the same field.
return database.adaptiveCollection(className)
.then(collection => {
let mongoFieldName = this.data[className][fieldName].startsWith('*') ? `_p_${fieldName}` : fieldName;
let mongoFieldName = this.data[className][fieldName].type === 'Pointer' ? `_p_${fieldName}` : fieldName;
return collection.updateMany({}, { "$unset": { [mongoFieldName]: null } });
});
})
@@ -524,7 +561,7 @@ class Schema {
continue;
}
let expected = getType(object[fieldName]);
if (expected === 'geopoint') {
if (expected === 'GeoPoint') {
geocount++;
}
if (geocount > 1) {
@@ -572,7 +609,6 @@ class Schema {
Parse.Error.INCORRECT_TYPE,
missingColumns[0]+' is required.');
}
return Promise.resolve(this);
}
@@ -629,13 +665,12 @@ class Schema {
if (this.data && this.data[className]) {
let classData = this.data[className];
return Object.keys(classData).filter((field) => {
return classData[field].startsWith('relation');
return classData[field].type === 'Relation';
}).reduce((memo, field) => {
let type = classData[field];
let className = type.slice('relation<'.length, type.length - 1);
memo[field] = {
__type: 'Relation',
className: className
className: type.targetClass
};
return memo;
}, {});
@@ -650,85 +685,22 @@ function load(collection) {
return schema.reloadData().then(() => schema);
}
// Returns { code, error } if invalid, or { result }, an object
// suitable for inserting into _SCHEMA collection, otherwise.
function mongoSchemaFromFieldsAndClassNameAndCLP(fields, className, classLevelPermissions) {
if (!classNameIsValid(className)) {
return {
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
};
}
for (let fieldName in fields) {
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 };
}
let mongoObject = {
_id: className,
objectId: 'string',
updatedAt: 'string',
createdAt: 'string'
};
for (let fieldName in defaultColumns[className]) {
mongoObject[fieldName] = MongoSchemaCollection._DONOTUSEparseFieldTypeToMongoFieldType(defaultColumns[className][fieldName]);
}
for (let fieldName in fields) {
mongoObject[fieldName] = MongoSchemaCollection._DONOTUSEparseFieldTypeToMongoFieldType(fields[fieldName]);
}
let geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === '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);
if (typeof classLevelPermissions !== 'undefined') {
mongoObject._metadata = mongoObject._metadata || {};
if (!classLevelPermissions) {
delete mongoObject._metadata.class_permissions;
} else {
mongoObject._metadata.class_permissions = classLevelPermissions;
}
}
return { result: mongoObject };
}
// 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(mongoObject, putRequest) {
function buildMergedSchemaObject(existingFields, putRequest) {
let newSchema = {};
let sysSchemaField = Object.keys(defaultColumns).indexOf(mongoObject._id) === -1 ? [] : Object.keys(defaultColumns[mongoObject._id]);
for (let oldField in mongoObject) {
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] = MongoSchemaCollection._DONOTUSEmongoFieldToParseSchemaField(mongoObject[oldField]);
newSchema[oldField] = existingFields[oldField];
}
}
}
@@ -768,9 +740,11 @@ function getType(obj) {
let type = typeof obj;
switch(type) {
case 'boolean':
return 'Boolean';
case 'string':
return 'String';
case 'number':
return type;
return 'Number';
case 'map':
case 'object':
if (!obj) {
@@ -790,25 +764,28 @@ function getType(obj) {
// Returns null if the type is unknown.
function getObjectType(obj) {
if (obj instanceof Array) {
return 'array';
return 'Array';
}
if (obj.__type){
switch(obj.__type) {
case 'Pointer' :
if(obj.className) {
return '*' + obj.className;
return {
type: 'Pointer',
targetClass: obj.className
}
}
case 'File' :
if(obj.name) {
return 'file';
return 'File';
}
case 'Date' :
if(obj.iso) {
return 'date';
return 'Date';
}
case 'GeoPoint' :
if(obj.latitude != null && obj.longitude != null) {
return 'geopoint';
return 'GeoPoint';
}
case 'Bytes' :
if(obj.base64) {
@@ -824,23 +801,26 @@ function getObjectType(obj) {
if (obj.__op) {
switch(obj.__op) {
case 'Increment':
return 'number';
return 'Number';
case 'Delete':
return null;
case 'Add':
case 'AddUnique':
case 'Remove':
return 'array';
return 'Array';
case 'AddRelation':
case 'RemoveRelation':
return 'relation<' + obj.objects[0].className + '>';
return {
type: 'Relation',
targetClass: obj.objects[0].className
}
case 'Batch':
return getObjectType(obj.ops[0]);
default:
throw 'unexpected op: ' + obj.__op;
}
}
return 'object';
return 'Object';
}
export {

View File

@@ -87,7 +87,6 @@ function del(config, auth, className, objectId) {
// Returns a promise for a {response, status, location} object.
function create(config, auth, className, restObject) {
enforceRoleSecurity('create', className, auth);
var write = new RestWrite(config, auth, className, null, restObject);
return write.execute();
}

View File

@@ -115,11 +115,11 @@ export function transformKeyValue(schema, className, restKey, restValue, options
if (schema && schema.getExpectedType) {
expected = schema.getExpectedType(className, key);
}
if ((expected && expected[0] == '*') ||
if ((expected && expected.type == 'Pointer') ||
(!expected && restValue && restValue.__type == 'Pointer')) {
key = '_p_' + key;
}
var inArray = (expected === 'array');
var inArray = (expected && expected.type === 'Array');
// Handle query constraints
if (options.query) {
@@ -713,7 +713,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals
className, newKey);
break;
}
if (expected && expected[0] != '*') {
if (expected && expected.type !== 'Pointer') {
log.info('transform.js', 'Found a pointer in a non-pointer column, dropping it.', className, key);
break;
}
@@ -721,7 +721,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals
break;
}
var objData = mongoObject[key].split('$');
var newClass = (expected ? expected.substring(1) : objData[0]);
var newClass = (expected ? expected.targetClass : objData[0]);
if (objData[0] !== newClass) {
throw 'pointer to incorrect className';
}
@@ -736,11 +736,11 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals
} else {
var expectedType = schema.getExpectedType(className, key);
var value = mongoObject[key];
if (expectedType === 'file' && FileCoder.isValidDatabaseObject(value)) {
if (expectedType && expectedType.type === 'File' && FileCoder.isValidDatabaseObject(value)) {
restObject[key] = FileCoder.databaseToJSON(value);
break;
}
if (expectedType === 'geopoint' && GeoPointCoder.isValidDatabaseObject(value)) {
if (expectedType && expectedType.type === 'GeoPoint' && GeoPointCoder.isValidDatabaseObject(value)) {
restObject[key] = GeoPointCoder.databaseToJSON(value);
break;
}