diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index c800543eaf..1b84de1e38 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -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(); }); diff --git a/spec/transform.spec.js b/spec/transform.spec.js index 58252b229f..66ab2d3a1d 100644 --- a/spec/transform.spec.js +++ b/spec/transform.spec.js @@ -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; }, diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index cbfc3a870c..1366846fbd 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -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 diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 2e94a228c3..ad1347d9c0 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -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)) diff --git a/src/Schema.js b/src/Schema.js index fb4cd130d4..1aa33492da 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -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.`); - } - - let mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(fields, className, classLevelPermissions); - if (!mongoObject.result) { - return Promise.reject(mongoObject); + addClassIfNotExists(className, fields = {}, classLevelPermissions) { + var validationError = this.validateNewClass(className, fields, classLevelPermissions); + if (validationError) { + return Promise.reject(validationError); } - 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(); }, () => { @@ -451,7 +488,6 @@ class Schema { return this.validateField(className, fieldName, type, true); }, (error) => { // The schema still doesn't validate. Give up - console.log(error) throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema key will not revalidate'); }); @@ -488,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(() => { @@ -505,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 } }); }); }) @@ -525,7 +561,7 @@ class Schema { continue; } let expected = getType(object[fieldName]); - if (expected === 'geopoint') { + if (expected === 'GeoPoint') { geocount++; } if (geocount > 1) { @@ -573,7 +609,6 @@ class Schema { Parse.Error.INCORRECT_TYPE, missingColumns[0]+' is required.'); } - return Promise.resolve(this); } @@ -630,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; }, {}); @@ -651,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]; } } } @@ -769,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) { @@ -791,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) { @@ -825,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 { diff --git a/src/rest.js b/src/rest.js index 213056ab7c..f776fe09de 100644 --- a/src/rest.js +++ b/src/rest.js @@ -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(); } diff --git a/src/transform.js b/src/transform.js index d3c14a7c8f..57d65c499f 100644 --- a/src/transform.js +++ b/src/transform.js @@ -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; }