diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 636311a640..7f59fec2a2 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,10 +1,25 @@ -// These tests check that the Schema operates correctly. var Config = require('../src/Config'); var Schema = require('../src/Schema'); var dd = require('deep-diff'); var config = new Config('test'); +var hasAllPODobject = () => { + var obj = new Parse.Object('HasAllPOD'); + obj.set('aNumber', 5); + obj.set('aString', 'string'); + obj.set('aBool', true); + obj.set('aDate', new Date()); + obj.set('aObject', {k1: 'value', k2: true, k3: 5}); + obj.set('aArray', ['contents', true, 5]); + obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); + obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); + var objACL = new Parse.ACL(); + objACL.setPublicWriteAccess(false); + obj.setACL(objACL); + return obj; +}; + describe('Schema', () => { it('can validate one object', (done) => { config.database.loadSchema().then((schema) => { @@ -406,4 +421,153 @@ describe('Schema', () => { done(); }); }); + + it('can check if a class exists', done => { + config.database.loadSchema() + .then(schema => { + return schema.addClassIfNotExists('NewClass', {}) + .then(() => { + schema.hasClass('NewClass') + .then(hasClass => { + expect(hasClass).toEqual(true); + done(); + }) + .catch(fail); + + schema.hasClass('NonexistantClass') + .then(hasClass => { + expect(hasClass).toEqual(false); + done(); + }) + .catch(fail); + }) + .catch(error => { + fail('Couldn\'t create class'); + fail(error); + }); + }) + .catch(error => fail('Couldn\'t load schema')); + }); + + it('refuses to delete fields from invalid class names', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('fieldName', 'invalid class name')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + done(); + }); + }); + + it('refuses to delete invalid fields', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('invalid field name', 'ValidClassName')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); + done(); + }); + }); + + it('refuses to delete the default fields', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('installationId', '_Installation')) + .catch(error => { + expect(error.code).toEqual(136); + expect(error.error).toEqual('field installationId cannot be changed'); + done(); + }); + }); + + it('refuses to delete fields from nonexistant classes', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('field', 'NoClass')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.error).toEqual('class NoClass does not exist'); + done(); + }); + }); + + it('refuses to delete fields that dont exist', done => { + hasAllPODobject().save() + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('missingField', 'HasAllPOD')) + .fail(error => { + expect(error.code).toEqual(255); + expect(error.error).toEqual('field missingField does not exist, cannot delete'); + done(); + }); + }); + + it('drops related collection when deleting relation field', done => { + var obj1 = hasAllPODobject(); + obj1.save() + .then(savedObj1 => { + var obj2 = new Parse.Object('HasPointersAndRelations'); + obj2.set('aPointer', savedObj1); + var relation = obj2.relation('aRelation'); + relation.add(obj1); + return obj2.save(); + }) + .then(() => { + config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { + expect(err).toEqual(null); + config.database.loadSchema() + .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database.db, 'test_')) + .then(() => config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { + expect(err).not.toEqual(null); + done(); + })) + }); + }) + }); + + it('can delete string fields and resave as number field', done => { + Parse.Object.disableSingleInstance(); + var obj1 = hasAllPODobject(); + var obj2 = hasAllPODobject(); + var p = Parse.Object.saveAll([obj1, obj2]) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database.db, 'test_')) + .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) + .then(obj1Reloaded => { + expect(obj1Reloaded.get('aString')).toEqual(undefined); + obj1Reloaded.set('aString', ['not a string', 'this time']); + obj1Reloaded.save() + .then(obj1reloadedAgain => { + expect(obj1reloadedAgain.get('aString')).toEqual(['not a string', 'this time']); + return new Parse.Query('HasAllPOD').get(obj2.id); + }) + .then(obj2reloaded => { + expect(obj2reloaded.get('aString')).toEqual(undefined); + done(); + Parse.Object.enableSingleInstance(); + }); + }) + }); + + it('can delete pointer fields and resave as string', done => { + Parse.Object.disableSingleInstance(); + var obj1 = new Parse.Object('NewClass'); + obj1.save() + .then(() => { + obj1.set('aPointer', obj1); + return obj1.save(); + }) + .then(obj1 => { + expect(obj1.get('aPointer').id).toEqual(obj1.id); + }) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aPointer', 'NewClass', config.database.db, 'test_')) + .then(() => new Parse.Query('NewClass').get(obj1.id)) + .then(obj1 => { + expect(obj1.get('aPointer')).toEqual(undefined); + obj1.set('aPointer', 'Now a string'); + return obj1.save(); + }) + .then(obj1 => { + expect(obj1.get('aPointer')).toEqual('Now a string'); + done(); + Parse.Object.enableSingleInstance(); + }); + }); }); diff --git a/src/ExportAdapter.js b/src/ExportAdapter.js index 1676ccfb42..7c2bd67437 100644 --- a/src/ExportAdapter.js +++ b/src/ExportAdapter.js @@ -43,8 +43,6 @@ ExportAdapter.prototype.connect = function() { // Returns a promise for a Mongo collection. // Generally just for internal use. -var joinRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; -var otherRegex = /^[A-Za-z][A-Za-z0-9_]*$/; ExportAdapter.prototype.collection = function(className) { if (!Schema.classNameIsValid(className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, diff --git a/src/Schema.js b/src/Schema.js index 3656507a7c..d8f38499f6 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -409,6 +409,88 @@ Schema.prototype.validateField = function(className, key, type, freeze) { }); }; +// 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 fuction would close over it or access it via member. +Schema.prototype.deleteField = function(fieldName, className, database, prefix) { + if (!classNameIsValid(className)) { + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + error: invalidClassNameMessage(className), + }); + } + + if (!fieldNameIsValid(fieldName)) { + return Promise.reject({ + code: Parse.Error.INVALID_KEY_NAME, + error: 'invalid field name: ' + fieldName, + }); + } + + //Don't allow deleting the default fields. + if (!fieldNameIsValidForClass(fieldName, className)) { + return Promise.reject({ + code: 136, + error: 'field ' + fieldName + ' cannot be changed', + }); + } + + return this.reload() + .then(schema => { + return schema.hasClass(className) + .then(hasClass => { + if (!hasClass) { + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class ' + className + ' does not exist', + }); + } + + if (!schema.data[className][fieldName]) { + return Promise.reject({ + code: 255, + error: 'field ' + fieldName + ' does not exist, cannot delete', + }); + } + + if (schema.data[className][fieldName].startsWith('relation')) { + //For relations, drop the _Join table + return database.dropCollection(prefix + '_Join:' + fieldName + ':' + className) + //Save the _SCHEMA object + .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})); + } else { + //for non-relations, remove all the data. This is necessary to ensure that the data is still gone + //if they add the same field. + return new Promise((resolve, reject) => { + database.collection(prefix + className, (err, coll) => { + if (err) { + reject(err); + } else { + var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? + '_p_' + fieldName : + fieldName; + return coll.update({}, { + "$unset": { [mongoFieldName] : null }, + }, { + multi: true, + }) + //Save the _SCHEMA object + .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})) + .then(resolve) + .catch(reject); + } + }); + }); + } + }); + }); +} + // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateField(schemaPromise, className, key, type) { @@ -477,6 +559,13 @@ Schema.prototype.getExpectedType = function(className, key) { return undefined; }; +// Checks if a given class is in the schema. Needs to load the +// schema first, which is kinda janky. Hopefully we can refactor +// and make this be a regular value. +Schema.prototype.hasClass = function(className) { + return this.reload().then(newSchema => !!newSchema.data[className]); +} + // Helper function to check if a field is a pointer, returns true or false. Schema.prototype.isPointer = function(className, key) { var expected = this.getExpectedType(className, key);