Merge pull request #729 from ParsePlatform/nlutsenko.decouple.schema

Decouple and remove direct mongo access from Schema/SchemaRouter.
This commit is contained in:
Nikita Lutsenko
2016-02-29 23:53:01 -08:00
6 changed files with 200 additions and 250 deletions

View File

@@ -1,3 +1,5 @@
'use strict';
var Config = require('../src/Config'); var Config = require('../src/Config');
var Schema = require('../src/Schema'); var Schema = require('../src/Schema');
var dd = require('deep-diff'); var dd = require('deep-diff');
@@ -483,7 +485,7 @@ describe('Schema', () => {
.then(schema => schema.deleteField('installationId', '_Installation')) .then(schema => schema.deleteField('installationId', '_Installation'))
.catch(error => { .catch(error => {
expect(error.code).toEqual(136); expect(error.code).toEqual(136);
expect(error.error).toEqual('field installationId cannot be changed'); expect(error.message).toEqual('field installationId cannot be changed');
done(); done();
}); });
}); });
@@ -493,7 +495,7 @@ describe('Schema', () => {
.then(schema => schema.deleteField('field', 'NoClass')) .then(schema => schema.deleteField('field', 'NoClass'))
.catch(error => { .catch(error => {
expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME);
expect(error.error).toEqual('class NoClass does not exist'); expect(error.message).toEqual('Class NoClass does not exist.');
done(); done();
}); });
}); });
@@ -504,7 +506,7 @@ describe('Schema', () => {
.then(schema => schema.deleteField('missingField', 'HasAllPOD')) .then(schema => schema.deleteField('missingField', 'HasAllPOD'))
.fail(error => { .fail(error => {
expect(error.code).toEqual(255); expect(error.code).toEqual(255);
expect(error.error).toEqual('field missingField does not exist, cannot delete'); expect(error.message).toEqual('Field missingField does not exist, cannot delete.');
done(); done();
}); });
}); });
@@ -519,17 +521,24 @@ describe('Schema', () => {
relation.add(obj1); relation.add(obj1);
return obj2.save(); return obj2.save();
}) })
.then(() => { .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations'))
config.database.adapter.database.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { .then(exists => {
expect(err).toEqual(null); if (!exists) {
config.database.loadSchema() fail('Relation collection should exist after save.');
.then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database.adapter.database, 'test_')) }
.then(() => config.database.adapter.database.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => {
expect(err).not.toEqual(null);
done();
}))
});
}) })
.then(() => config.database.loadSchema())
.then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database))
.then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations'))
.then(exists => {
if (exists) {
fail('Relation collection should not exist after deleting relation field.');
}
done();
}, error => {
fail(error);
done();
});
}); });
it('can delete string fields and resave as number field', done => { it('can delete string fields and resave as number field', done => {
@@ -538,7 +547,7 @@ describe('Schema', () => {
var obj2 = hasAllPODobject(); var obj2 = hasAllPODobject();
var p = Parse.Object.saveAll([obj1, obj2]) var p = Parse.Object.saveAll([obj1, obj2])
.then(() => config.database.loadSchema()) .then(() => config.database.loadSchema())
.then(schema => schema.deleteField('aString', 'HasAllPOD', config.database.adapter.database, 'test_')) .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database))
.then(() => new Parse.Query('HasAllPOD').get(obj1.id)) .then(() => new Parse.Query('HasAllPOD').get(obj1.id))
.then(obj1Reloaded => { .then(obj1Reloaded => {
expect(obj1Reloaded.get('aString')).toEqual(undefined); expect(obj1Reloaded.get('aString')).toEqual(undefined);
@@ -568,7 +577,7 @@ describe('Schema', () => {
expect(obj1.get('aPointer').id).toEqual(obj1.id); expect(obj1.get('aPointer').id).toEqual(obj1.id);
}) })
.then(() => config.database.loadSchema()) .then(() => config.database.loadSchema())
.then(schema => schema.deleteField('aPointer', 'NewClass', config.database.adapter.database, 'test_')) .then(schema => schema.deleteField('aPointer', 'NewClass', config.database))
.then(() => new Parse.Query('NewClass').get(obj1.id)) .then(() => new Parse.Query('NewClass').get(obj1.id))
.then(obj1 => { .then(obj1 => {
expect(obj1.get('aPointer')).toEqual(undefined); expect(obj1.get('aPointer')).toEqual(undefined);

View File

@@ -1,3 +1,5 @@
'use strict';
var Parse = require('parse/node').Parse; var Parse = require('parse/node').Parse;
var request = require('request'); var request = require('request');
var dd = require('deep-diff'); var dd = require('deep-diff');
@@ -369,7 +371,7 @@ describe('schemas', () => {
}, (error, response, body) => { }, (error, response, body) => {
expect(response.statusCode).toEqual(400); expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME);
expect(body.error).toEqual('class NoClass does not exist'); expect(body.error).toEqual('Class NoClass does not exist.');
done(); done();
}); });
}); });
@@ -390,13 +392,13 @@ describe('schemas', () => {
}, (error, response, body) => { }, (error, response, body) => {
expect(response.statusCode).toEqual(400); expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(255); expect(body.code).toEqual(255);
expect(body.error).toEqual('field aString exists, cannot update'); expect(body.error).toEqual('Field aString exists, cannot update.');
done(); done();
}); });
}) })
}); });
it('refuses to delete non-existant fields', done => { it('refuses to delete non-existent fields', done => {
var obj = hasAllPODobject(); var obj = hasAllPODobject();
obj.save() obj.save()
.then(() => { .then(() => {
@@ -406,13 +408,13 @@ describe('schemas', () => {
json: true, json: true,
body: { body: {
fields: { fields: {
nonExistantKey: {__op: "Delete"}, nonExistentKey: {__op: "Delete"},
} }
} }
}, (error, response, body) => { }, (error, response, body) => {
expect(response.statusCode).toEqual(400); expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(255); expect(body.code).toEqual(255);
expect(body.error).toEqual('field nonExistantKey does not exist, cannot delete'); expect(body.error).toEqual('Field nonExistentKey does not exist, cannot delete.');
done(); done();
}); });
}); });
@@ -660,7 +662,8 @@ describe('schemas', () => {
}, (error, response, body) => { }, (error, response, body) => {
expect(response.statusCode).toEqual(400); expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(255); expect(body.code).toEqual(255);
expect(body.error).toEqual('class HasAllPOD not empty, contains 1 objects, cannot drop schema'); expect(body.error).toMatch(/HasAllPOD/);
expect(body.error).toMatch(/contains 1/);
done(); done();
}); });
}); });
@@ -710,12 +713,18 @@ describe('schemas', () => {
}, (error, response, body) => { }, (error, response, body) => {
expect(response.statusCode).toEqual(200); expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({}); expect(response.body).toEqual({});
config.database.adapter.database.collection('test__Join:aRelation:MyOtherClass', { strict: true }, (err, coll) => { config.database.collectionExists('_Join:aRelation:MyOtherClass').then(exists => {
//Expect Join table to be gone if (exists) {
expect(err).not.toEqual(null); fail('Relation collection should be deleted.');
config.database.adapter.database.collection('test_MyOtherClass', { strict: true }, (err, coll) => { done();
// Expect data table to be gone }
expect(err).not.toEqual(null); return config.database.collectionExists('MyOtherClass');
}).then(exists => {
if (exists) {
fail('Class collection should be deleted.');
done();
}
}).then(() => {
request.get({ request.get({
url: 'http://localhost:8378/1/schemas/MyOtherClass', url: 'http://localhost:8378/1/schemas/MyOtherClass',
headers: masterKeyHeaders, headers: masterKeyHeaders,
@@ -729,9 +738,10 @@ describe('schemas', () => {
}); });
}); });
}); });
}); }).then(() => {
}, error => { }, error => {
fail(error); fail(error);
done();
}); });
}); });
}); });

View File

@@ -30,6 +30,17 @@ export class MongoStorageAdapter {
}); });
} }
collectionExists(name: string) {
return this.connect().then(() => {
return this.database.listCollections({ name: name }).toArray();
}).then(collections => {
return collections.length > 0;
});
}
dropCollection(name: string) {
return this.collection(name).then(collection => collection.drop());
}
// Used for testing only right now. // Used for testing only right now.
collectionsContaining(match: string) { collectionsContaining(match: string) {
return this.connect().then(() => { return this.connect().then(() => {

View File

@@ -38,10 +38,18 @@ DatabaseController.prototype.collection = function(className) {
return this.rawCollection(className); return this.rawCollection(className);
}; };
DatabaseController.prototype.collectionExists = function(className) {
return this.adapter.collectionExists(this.collectionPrefix + className);
};
DatabaseController.prototype.rawCollection = function(className) { DatabaseController.prototype.rawCollection = function(className) {
return this.adapter.collection(this.collectionPrefix + className); return this.adapter.collection(this.collectionPrefix + className);
}; };
DatabaseController.prototype.dropCollection = function(className) {
return this.adapter.dropCollection(this.collectionPrefix + className);
};
function returnsTrue() { function returnsTrue() {
return true; return true;
} }

View File

@@ -114,59 +114,32 @@ function modifySchema(req) {
return req.config.database.loadSchema() return req.config.database.loadSchema()
.then(schema => { .then(schema => {
if (!schema.data[className]) { if (!schema.data[className]) {
return Promise.resolve({ throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${req.params.className} does not exist.`);
status: 400,
response: {
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + req.params.className + ' does not exist',
}
});
}
var existingFields = schema.data[className];
for (var submittedFieldName in submittedFields) {
if (existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op !== 'Delete') {
return Promise.resolve({
status: 400,
response: {
code: 255,
error: 'field ' + submittedFieldName + ' exists, cannot update',
}
});
} }
if (!existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op === 'Delete') { let existingFields = schema.data[className];
return Promise.resolve({ Object.keys(submittedFields).forEach(name => {
status: 400, let field = submittedFields[name];
response: { if (existingFields[name] && field.__op !== 'Delete') {
code: 255, throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
error: 'field ' + submittedFieldName + ' does not exist, cannot delete', }
if (!existingFields[name] && field.__op === 'Delete') {
throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`);
} }
}); });
}
}
var newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields); let newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields);
var mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className); let mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className);
if (!mongoObject.result) { if (!mongoObject.result) {
return Promise.resolve({ throw new Parse.Error(mongoObject.code, mongoObject.error);
status: 400,
response: mongoObject,
});
} }
// Finally we have checked to make sure the request is valid and we can start deleting fields. // 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. // Do all deletions first, then a single save to _SCHEMA collection to handle all additions.
var deletionPromises = [] let deletionPromises = [];
Object.keys(submittedFields).forEach(submittedFieldName => { Object.keys(submittedFields).forEach(submittedFieldName => {
if (submittedFields[submittedFieldName].__op === 'Delete') { if (submittedFields[submittedFieldName].__op === 'Delete') {
var promise = req.config.database.connect() let promise = schema.deleteField(submittedFieldName, className, req.config.database);
.then(() => schema.deleteField(
submittedFieldName,
className,
req.config.database.adapter.database,
req.config.database.collectionPrefix
));
deletionPromises.push(promise); deletionPromises.push(promise);
} }
}); });
@@ -184,20 +157,12 @@ function modifySchema(req) {
} }
// A helper function that removes all join tables for a schema. Returns a promise. // A helper function that removes all join tables for a schema. Returns a promise.
var removeJoinTables = (database, prefix, mongoSchema) => { var removeJoinTables = (database, mongoSchema) => {
return Promise.all(Object.keys(mongoSchema) return Promise.all(Object.keys(mongoSchema)
.filter(field => mongoSchema[field].startsWith('relation<')) .filter(field => mongoSchema[field].startsWith('relation<'))
.map(field => { .map(field => {
var joinCollectionName = prefix + '_Join:' + field + ':' + mongoSchema._id; let collectionName = `_Join:${field}:${mongoSchema._id}`;
return new Promise((resolve, reject) => { return database.dropCollection(collectionName);
database.dropCollection(joinCollectionName, (err, results) => {
if (err) {
reject(err);
} else {
resolve();
}
})
});
}) })
); );
}; };
@@ -208,59 +173,39 @@ function deleteSchema(req) {
} }
if (!Schema.classNameIsValid(req.params.className)) { if (!Schema.classNameIsValid(req.params.className)) {
return Promise.resolve({ throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, Schema.invalidClassNameMessage(req.params.className));
status: 400,
response: {
code: Parse.Error.INVALID_CLASS_NAME,
error: Schema.invalidClassNameMessage(req.params.className),
}
});
} }
return req.config.database.collection(req.params.className) return req.config.database.collection(req.params.className)
.then(coll => new Promise((resolve, reject) => { .then(collection => {
coll.count((err, count) => { return collection.count()
if (err) { .then(count => {
reject(err); if (count > 0) {
} else if (count > 0) { throw new Parse.Error(255, `Class ${req.params.className} is not empty, contains ${count} objects, cannot drop schema.`);
resolve({
status: 400,
response: {
code: 255,
error: 'class ' + req.params.className + ' not empty, contains ' + count + ' objects, cannot drop schema',
} }
}); return collection.drop();
} else { })
coll.drop((err, reply) => { .then(() => {
if (err) {
reject(err);
} else {
// We've dropped the collection now, so delete the item from _SCHEMA // We've dropped the collection now, so delete the item from _SCHEMA
// and clear the _Join collections // and clear the _Join collections
req.config.database.collection('_SCHEMA') return req.config.database.collection('_SCHEMA')
.then(coll => new Promise((resolve, reject) => { .then(coll => coll.findAndRemove({_id: req.params.className}, []))
coll.findAndRemove({ _id: req.params.className }, [], (err, doc) => { .then(doc => {
if (err) { if (doc.value === null) {
reject(err); //tried to delete non-existent class
} else if (doc.value === null) { return Promise.resolve();
//tried to delete non-existant class
resolve({ response: {}});
} else {
removeJoinTables(req.config.database.adapter.database, req.config.database.collectionPrefix, doc.value)
.then(resolve, reject);
} }
return removeJoinTables(req.config.database, doc.value);
}); });
})) })
.then(resolve.bind(undefined, {response: {}}), reject); })
} .then(() => {
}); // Success
} return { response: {} };
}); }, error => {
}))
.catch( (error) => {
if (error.message == 'ns not found') { if (error.message == 'ns not found') {
// If they try to delete a non-existant class, thats fine, just let them. // If they try to delete a non-existent class, that's fine, just let them.
return Promise.resolve({ response: {} }); return { response: {} };
} }
return Promise.reject(error); return Promise.reject(error);

View File

@@ -500,28 +500,17 @@ Schema.prototype.validateField = function(className, key, type, freeze) {
// Passing the database and prefix is necessary in order to drop relation collections // Passing the database and prefix is necessary in order to drop relation collections
// and remove fields from objects. Ideally the database would belong to // 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. // a database adapter and this function would close over it or access it via member.
Schema.prototype.deleteField = function(fieldName, className, database, prefix) { Schema.prototype.deleteField = function(fieldName, className, database) {
if (!classNameIsValid(className)) { if (!classNameIsValid(className)) {
return Promise.reject({ throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className));
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
});
} }
if (!fieldNameIsValid(fieldName)) { if (!fieldNameIsValid(fieldName)) {
return Promise.reject({ throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`);
code: Parse.Error.INVALID_KEY_NAME,
error: 'invalid field name: ' + fieldName,
});
} }
//Don't allow deleting the default fields. //Don't allow deleting the default fields.
if (!fieldNameIsValidForClass(fieldName, className)) { if (!fieldNameIsValidForClass(fieldName, className)) {
return Promise.reject({ throw new Parse.Error(136, `field ${fieldName} cannot be changed`);
code: 136,
error: 'field ' + fieldName + ' cannot be changed',
});
} }
return this.reload() return this.reload()
@@ -529,51 +518,29 @@ Schema.prototype.deleteField = function(fieldName, className, database, prefix)
return schema.hasClass(className) return schema.hasClass(className)
.then(hasClass => { .then(hasClass => {
if (!hasClass) { if (!hasClass) {
return Promise.reject({ throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`);
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + className + ' does not exist',
});
} }
if (!schema.data[className][fieldName]) { if (!schema.data[className][fieldName]) {
return Promise.reject({ throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`);
code: 255,
error: 'field ' + fieldName + ' does not exist, cannot delete',
});
} }
if (schema.data[className][fieldName].startsWith('relation<')) { if (schema.data[className][fieldName].startsWith('relation<')) {
//For relations, drop the _Join table //For relations, drop the _Join table
return database.dropCollection(prefix + '_Join:' + fieldName + ':' + className) return database.dropCollection(`_Join:${fieldName}:${className}`);
//Save the _SCHEMA object }
.then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }}));
} else { // for non-relations, remove all the data.
//for non-relations, remove all the data. This is necessary to ensure that the data is still gone // This is necessary to ensure that the data is still gone if they add the same field.
//if they add the same field. return database.collection(className)
return new Promise((resolve, reject) => { .then(collection => {
database.collection(prefix + className, (err, coll) => { var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? '_p_' + fieldName : fieldName;
if (err) { return collection.update({}, { "$unset": { [mongoFieldName] : null } }, { multi: true });
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 // Save the _SCHEMA object
.then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})) .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }}));
.then(resolve)
.catch(reject);
}
}); });
}); };
}
});
});
}
// Given a schema promise, construct another schema promise that // Given a schema promise, construct another schema promise that
// validates this field once the schema loads. // validates this field once the schema loads.