Finish implementation of PUT /schemas/:className

This commit is contained in:
Drew Gross
2016-02-16 12:30:30 -08:00
parent cef5a5fabf
commit a455e1b23f
4 changed files with 436 additions and 90 deletions

View File

@@ -162,6 +162,9 @@ describe('Schema', () => {
foo: 'string',
})
done();
})
.catch(error => {
fail('Error creating class: ' + JSON.stringify(error));
});
});
@@ -570,4 +573,32 @@ describe('Schema', () => {
Parse.Object.enableSingleInstance();
});
});
it('can merge schemas', done => {
expect(Schema.buildMergedSchemaObject({
_id: 'SomeClass',
someType: 'number'
}, {
newType: {type: 'Number'}
})).toEqual({
someType: {type: 'Number'},
newType: {type: 'Number'},
});
done();
});
it('can merge deletions', done => {
expect(Schema.buildMergedSchemaObject({
_id: 'SomeClass',
someType: 'number',
outDatedType: 'string',
},{
newType: {type: 'GeoPoint'},
outDatedType: {__op: 'Delete'},
})).toEqual({
someType: {type: 'Number'},
newType: {type: 'GeoPoint'},
});
done();
});
});

View File

@@ -339,7 +339,7 @@ describe('schemas', () => {
});
});
it('rejects class name mis-matches', done => {
it('rejects class name mis-matches in put', done => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
@@ -349,6 +349,7 @@ describe('schemas', () => {
expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME);
expect(body.error).toEqual('class name mismatch between WrongClassName and NewClass');
done();
});
});
@@ -370,12 +371,133 @@ describe('schemas', () => {
});
});
it('refuses to put to existing fields, even if it would not be a change', done => {
var obj = hasAllPODobject();
obj.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/HasAllPOD',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'}
}
}
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(255);
expect(body.error).toEqual('field aString exists, cannot update');
done();
});
})
});
it('refuses to delete non-existant fields', done => {
var obj = hasAllPODobject();
obj.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/HasAllPOD',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
nonExistantKey: {__op: "Delete"},
}
}
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(255);
expect(body.error).toEqual('field nonExistantKey does not exist, cannot delete');
done();
});
});
});
it('refuses to add a geopoint to a class that already has one', done => {
var obj = hasAllPODobject();
obj.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/HasAllPOD',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
newGeo: {type: 'GeoPoint'}
}
}
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE);
expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo when aGeoPoint already exists.');
done();
});
});
});
it('refuses to add two geopoints', done => {
var obj = new Parse.Object('NewClass');
obj.set('aString', 'aString');
obj.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
newGeo1: {type: 'GeoPoint'},
newGeo2: {type: 'GeoPoint'},
}
}
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE);
expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo2 when newGeo1 already exists.');
done();
});
});
});
it('allows you to delete and add a geopoint in the same request', done => {
var obj = new Parse.Object('NewClass');
obj.set('geo1', new Parse.GeoPoint({latitude: 0, longitude: 0}));
obj.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
geo2: {type: 'GeoPoint'},
geo1: {__op: 'Delete'}
}
}
}, (error, response, body) => {
expect(dd(body, {
"className": "NewClass",
"fields": {
"ACL": {"type": "ACL"},
"createdAt": {"type": "Date"},
"objectId": {"type": "String"},
"updatedAt": {"type": "Date"},
"geo2": {"type": "GeoPoint"},
}
})).toEqual(undefined);
done();
});
})
});
it('put with no modifications returns all fields', done => {
var obj = hasAllPODobject();
obj.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/HasAllPOD'
url: 'http://localhost:8378/1/schemas/HasAllPOD',
headers: masterKeyHeaders,
json: true,
body: {},
@@ -383,7 +505,7 @@ describe('schemas', () => {
expect(body).toEqual(plainOldDataSchema);
done();
});
});
})
});
it('lets you add fields', done => {
@@ -403,16 +525,112 @@ describe('schemas', () => {
}
}
}, (error, response, body) => {
expect(body).toEqual('blah');
expect(dd(body, {
className: 'NewClass',
fields: {
"ACL": {"type": "ACL"},
"createdAt": {"type": "Date"},
"objectId": {"type": "String"},
"updatedAt": {"type": "Date"},
"newField": {"type": "String"},
},
})).toEqual(undefined);
request.get({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
}, (error, response, body) => {
expect(body).toEqual('blah');
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
newField: {type: 'String'},
}
});
done();
});
});
})
});
it('lets you delete multiple fields and add fields', done => {
var obj1 = hasAllPODobject();
obj1.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/HasAllPOD',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {__op: 'Delete'},
aNumber: {__op: 'Delete'},
aNewString: {type: 'String'},
aNewNumber: {type: 'Number'},
aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'},
aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'},
}
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'HasAllPOD',
fields: {
//Default fields
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
//Custom fields
aBool: {type: 'Boolean'},
aDate: {type: 'Date'},
aObject: {type: 'Object'},
aArray: {type: 'Array'},
aGeoPoint: {type: 'GeoPoint'},
aFile: {type: 'File'},
aNewNumber: {type: 'Number'},
aNewString: {type: 'String'},
aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'},
aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'},
}
});
var obj2 = new Parse.Object('HasAllPOD');
obj2.set('aNewPointer', obj1);
var relation = obj2.relation('aNewRelation');
relation.add(obj1);
obj2.save().then(done); //Just need to make sure saving works on the new object.
});
});
});
it('will not delete any fields if the additions are invalid', done => {
var obj = hasAllPODobject();
obj.save()
.then(() => {
request.put({
url: 'http://localhost:8378/1/schemas/HasAllPOD',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
fakeNewField: {type: 'fake type'},
aString: {__op: 'Delete'}
}
}
}, (error, response, body) => {
expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE);
expect(body.error).toEqual('invalid field type: fake type');
request.get({
url: 'http://localhost:8378/1/schemas/HasAllPOD',
headers: masterKeyHeaders,
json: true,
}, (error, response, body) => {
expect(response.body).toEqual(plainOldDataSchema);
done();
});
});
});
});
});

View File

@@ -116,7 +116,7 @@ function schemaAPITypeToMongoFieldType(type) {
return invalidJsonError;
} else if (!classNameIsValid(type.targetClass)) {
return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME };
} else {
} else {
return { result: '*' + type.targetClass };
}
}
@@ -200,6 +200,114 @@ Schema.prototype.reload = function() {
return load(this.collection);
};
// Returns { code, error } if invalid, or { result }, an object
// suitable for inserting into _SCHEMA collection, otherwise
function mongoSchemaFromFieldsAndClassName(fields, className) {
if (!classNameIsValid(className)) {
return {
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
};
}
for (var 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',
};
}
}
var mongoObject = {
_id: className,
objectId: 'string',
updatedAt: 'string',
createdAt: 'string'
};
for (var fieldName in defaultColumns[className]) {
var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]);
if (!validatedField.result) {
return validatedField;
}
mongoObject[fieldName] = validatedField.result;
}
for (var fieldName in fields) {
var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]);
if (!validatedField.result) {
return validatedField;
}
mongoObject[fieldName] = validatedField.result;
}
var 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.',
};
}
return { result: mongoObject };
}
function mongoFieldTypeToSchemaAPIType(type) {
if (type[0] === '*') {
return {
type: 'Pointer',
targetClass: type.slice(1),
};
}
if (type.startsWith('relation<')) {
return {
type: 'Relation',
targetClass: type.slice('relation<'.length, type.length - 1),
};
}
switch (type) {
case 'number': return {type: 'Number'};
case 'string': return {type: 'String'};
case 'boolean': return {type: 'Boolean'};
case 'date': return {type: 'Date'};
case 'map':
case 'object': return {type: 'Object'};
case 'array': return {type: 'Array'};
case 'geopoint': return {type: 'GeoPoint'};
case 'file': return {type: 'File'};
}
}
// 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) {
var newSchema = {};
for (var oldField in mongoObject) {
if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') {
var fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete'
if (!fieldIsDeleted) {
newSchema[oldField] = mongoFieldTypeToSchemaAPIType(mongoObject[oldField]);
}
}
}
for (var newField in putRequest) {
if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') {
newSchema[newField] = putRequest[newField];
}
}
return newSchema;
}
// 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
@@ -215,58 +323,13 @@ Schema.prototype.addClassIfNotExists = function(className, fields) {
});
}
if (!classNameIsValid(className)) {
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
});
}
for (var fieldName in fields) {
if (!fieldNameIsValid(fieldName)) {
return Promise.reject({
code: Parse.Error.INVALID_KEY_NAME,
error: 'invalid field name: ' + fieldName,
});
}
if (!fieldNameIsValidForClass(fieldName, className)) {
return Promise.reject({
code: 136,
error: 'field ' + fieldName + ' cannot be added',
});
}
var mongoObject = mongoSchemaFromFieldsAndClassName(fields, className);
if (!mongoObject.result) {
return Promise.reject(mongoObject);
}
var mongoObject = {
_id: className,
objectId: 'string',
updatedAt: 'string',
createdAt: 'string'
};
for (var fieldName in defaultColumns[className]) {
var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]);
if (validatedField.code) {
return Promise.reject(validatedField);
}
mongoObject[fieldName] = validatedField.result;
}
for (var fieldName in fields) {
var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]);
if (validatedField.code) {
return Promise.reject(validatedField);
}
mongoObject[fieldName] = validatedField.result;
}
var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint');
if (geoPoints.length > 1) {
return Promise.reject({
code: Parse.Error.INCORRECT_TYPE,
error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.',
});
}
return this.collection.insertOne(mongoObject)
return this.collection.insertOne(mongoObject.result)
.then(result => result.ops[0])
.catch(error => {
if (error.code === 11000) { //Mongo's duplicate key error
@@ -651,4 +714,8 @@ function getObjectType(obj) {
module.exports = {
load: load,
classNameIsValid: classNameIsValid,
mongoSchemaFromFieldsAndClassName: mongoSchemaFromFieldsAndClassName,
schemaAPITypeToMongoFieldType: schemaAPITypeToMongoFieldType,
buildMergedSchemaObject: buildMergedSchemaObject,
mongoFieldTypeToSchemaAPIType: mongoFieldTypeToSchemaAPIType,
};

View File

@@ -24,36 +24,10 @@ function classNameMismatchResponse(bodyClass, pathClass) {
});
}
function mongoFieldTypeToSchemaAPIType(type) {
if (type[0] === '*') {
return {
type: 'Pointer',
targetClass: type.slice(1),
};
}
if (type.startsWith('relation<')) {
return {
type: 'Relation',
targetClass: type.slice('relation<'.length, type.length - 1),
};
}
switch (type) {
case 'number': return {type: 'Number'};
case 'string': return {type: 'String'};
case 'boolean': return {type: 'Boolean'};
case 'date': return {type: 'Date'};
case 'map':
case 'object': return {type: 'Object'};
case 'array': return {type: 'Array'};
case 'geopoint': return {type: 'GeoPoint'};
case 'file': return {type: 'File'};
}
}
function mongoSchemaAPIResponseFields(schema) {
var fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata');
var response = fieldNames.reduce((obj, fieldName) => {
obj[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName])
obj[fieldName] = Schema.mongoFieldTypeToSchemaAPIType(schema[fieldName])
return obj;
}, {});
response.ACL = {type: 'ACL'};
@@ -131,17 +105,15 @@ function modifySchema(req) {
}
if (req.body.className && req.body.className != req.params.className) {
return classNameMismatchResponse(req.body.className, req.path.className);
return classNameMismatchResponse(req.body.className, req.params.className);
}
if (!req.body.fields) {
req.body.fields = {};
}
var submittedFields = req.body.fields || {};
var className = req.params.className;
return req.config.database.loadSchema()
.then(schema => schema.hasClass(req.params.className))
.then(hasClass => {
if (!hasClass) {
.then(schema => {
if (!schema.data[className]) {
return Promise.resolve({
status: 400,
response: {
@@ -150,6 +122,64 @@ function modifySchema(req) {
}
});
}
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') {
return Promise.resolve({
status: 400,
response: {
code: 255,
error: 'field ' + submittedFieldName + ' does not exist, cannot delete',
}
});
}
}
var newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields);
var mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className);
if (!mongoObject.result) {
return Promise.resolve({
status: 400,
response: mongoObject,
});
}
// 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.
var deletionPromises = []
Object.keys(submittedFields).forEach(submittedFieldName => {
if (submittedFields[submittedFieldName].__op === 'Delete') {
var promise = req.config.database.connect()
.then(() => schema.deleteField(
submittedFieldName,
className,
req.config.database.db,
req.config.database.collectionPrefix
));
deletionPromises.push(promise);
}
});
return Promise.all(deletionPromises)
.then(() => new Promise((resolve, reject) => {
schema.collection.update({_id: className}, mongoObject.result, {w: 1}, (err, docs) => {
if (err) {
reject(err);
}
resolve({ response: mongoSchemaToSchemaAPIResponse(mongoObject.result)});
})
}));
});
}