Files
kami-parse-server/src/Controllers/DatabaseController.js

647 lines
21 KiB
JavaScript
Raw Normal View History

2016-01-28 10:58:12 -08:00
// A database adapter that works with data exported from the hosted
// Parse database.
2016-03-30 19:35:54 -07:00
import intersect from 'intersect';
2016-01-28 10:58:12 -08:00
var mongodb = require('mongodb');
var Parse = require('parse/node').Parse;
var Schema = require('./../Schema');
var transform = require('./../transform');
const deepcopy = require('deepcopy');
2016-01-28 10:58:12 -08:00
function DatabaseController(adapter) {
this.adapter = adapter;
2016-01-28 10:58:12 -08:00
// We don't want a mutable this.schema, because then you could have
// one request that uses different schemas for different parts of
// it. Instead, use loadSchema to get a schema.
this.schemaPromise = null;
this.connect();
}
// Connects to the database. Returns a promise that resolves when the
// connection is successful.
DatabaseController.prototype.connect = function() {
return this.adapter.connect();
2016-01-28 10:58:12 -08:00
};
DatabaseController.prototype.adaptiveCollection = function(className) {
return this.adapter.adaptiveCollection(className);
};
DatabaseController.prototype.schemaCollection = function() {
return this.adapter.schemaCollection();
};
DatabaseController.prototype.collectionExists = function(className) {
return this.adapter.collectionExists(className);
};
DatabaseController.prototype.dropCollection = function(className) {
return this.adapter.dropCollection(className);
};
DatabaseController.prototype.validateClassName = function(className) {
if (!Schema.classNameIsValid(className)) {
const error = new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className);
return Promise.reject(error);
}
return Promise.resolve();
};
2016-01-28 10:58:12 -08:00
// Returns a promise for a schema object.
// If we are provided a acceptor, then we run it on the schema.
// If the schema isn't accepted, we reload it at most once.
DatabaseController.prototype.loadSchema = function(acceptor = () => true) {
2016-01-28 10:58:12 -08:00
if (!this.schemaPromise) {
this.schemaPromise = this.schemaCollection().then(collection => {
2016-01-28 10:58:12 -08:00
delete this.schemaPromise;
return Schema.load(collection);
2016-01-28 10:58:12 -08:00
});
return this.schemaPromise;
}
return this.schemaPromise.then((schema) => {
if (acceptor(schema)) {
return schema;
}
this.schemaPromise = this.schemaCollection().then(collection => {
2016-01-28 10:58:12 -08:00
delete this.schemaPromise;
return Schema.load(collection);
2016-01-28 10:58:12 -08:00
});
return this.schemaPromise;
});
};
// Returns a promise for the classname that is related to the given
// classname through the key.
// TODO: make this not in the DatabaseController interface
DatabaseController.prototype.redirectClassNameForKey = function(className, key) {
2016-01-28 10:58:12 -08:00
return this.loadSchema().then((schema) => {
var t = schema.getExpectedType(className, key);
if (t.type == 'Relation') {
return t.targetClass;
2016-01-28 10:58:12 -08:00
} else {
return className;
}
});
};
// Uses the schema to validate the object (REST API format).
// Returns a promise that resolves to the new schema.
// This does not update this.schema, because in a situation like a
// batch request, that could confuse other users of the schema.
2016-03-07 23:07:24 -05:00
DatabaseController.prototype.validateObject = function(className, object, query, options) {
let schema;
2016-03-30 20:27:12 -04:00
let isMaster = !('acl' in options);
var aclGroup = options.acl || [];
2016-03-07 23:07:24 -05:00
return this.loadSchema().then(s => {
schema = s;
2016-03-30 20:27:12 -04:00
if (isMaster) {
return Promise.resolve();
}
return this.canAddField(schema, className, object, aclGroup);
2016-03-07 23:07:24 -05:00
}).then(() => {
2016-02-19 13:06:02 -05:00
return schema.validateObject(className, object, query);
2016-01-28 10:58:12 -08:00
});
};
// Like transform.untransformObject but you need to provide a className.
// Filters out any data that shouldn't be on this REST-formatted object.
DatabaseController.prototype.untransformObject = function(
2016-01-28 10:58:12 -08:00
schema, isMaster, aclGroup, className, mongoObject) {
var object = transform.untransformObject(schema, className, mongoObject);
if (className !== '_User') {
return object;
}
if (isMaster || (aclGroup.indexOf(object.objectId) > -1)) {
return object;
}
delete object.authData;
delete object.sessionToken;
return object;
};
// Runs an update on the database.
// Returns a promise for an object with the new values for field
// modifications that don't know their results ahead of time, like
// 'increment'.
// Options:
// acl: a list of strings. If the object to be updated has an ACL,
// one of the provided strings must provide the caller with
// write permissions.
DatabaseController.prototype.update = function(className, query, update, options) {
2016-03-16 19:33:04 -04:00
const originalUpdate = update;
// Make a copy of the object, so we don't mutate the incoming data.
update = deepcopy(update);
2016-01-28 10:58:12 -08:00
var acceptor = function(schema) {
return schema.hasKeys(className, Object.keys(query));
};
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
var mongoUpdate, schema;
return this.loadSchema(acceptor)
.then(s => {
schema = s;
if (!isMaster) {
return schema.validatePermission(className, aclGroup, 'update');
}
return Promise.resolve();
})
.then(() => this.handleRelationUpdates(className, query.objectId, update))
.then(() => this.adaptiveCollection(className))
.then(collection => {
var mongoWhere = transform.transformWhere(schema, className, query);
if (options.acl) {
var writePerms = [
{_wperm: {'$exists': false}}
];
for (var entry of options.acl) {
writePerms.push({_wperm: {'$in': [entry]}});
}
mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]};
}
mongoUpdate = transform.transformUpdate(schema, className, update);
return collection.findOneAndUpdate(mongoWhere, mongoUpdate);
})
.then(result => {
if (!result) {
return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.'));
2016-01-28 10:58:12 -08:00
}
return sanitizeDatabaseResult(originalUpdate, result);
});
2016-01-28 10:58:12 -08:00
};
function sanitizeDatabaseResult(originalObject, result) {
let response = {};
if (!result) {
return Promise.resolve(response);
}
Object.keys(originalObject).forEach(key => {
let keyUpdate = originalObject[key];
// determine if that was an op
if (keyUpdate && typeof keyUpdate === 'object' && keyUpdate.__op
&& ['Add', 'AddUnique', 'Remove', 'Increment'].indexOf(keyUpdate.__op) > -1) {
// only valid ops that produce an actionable result
response[key] = result[key];
}
});
return Promise.resolve(response);
}
2016-01-28 10:58:12 -08:00
// Processes relation-updating operations from a REST-format update.
// Returns a promise that resolves successfully when these are
// processed.
// This mutates update.
DatabaseController.prototype.handleRelationUpdates = function(className,
2016-01-28 10:58:12 -08:00
objectId,
update) {
var pending = [];
var deleteMe = [];
objectId = update.objectId || objectId;
var process = (op, key) => {
if (!op) {
return;
}
if (op.__op == 'AddRelation') {
for (var object of op.objects) {
pending.push(this.addRelation(key, className,
objectId,
object.objectId));
}
deleteMe.push(key);
}
if (op.__op == 'RemoveRelation') {
for (var object of op.objects) {
pending.push(this.removeRelation(key, className,
objectId,
object.objectId));
}
deleteMe.push(key);
}
if (op.__op == 'Batch') {
for (var x of op.ops) {
2016-01-28 10:58:12 -08:00
process(x, key);
}
}
};
for (var key in update) {
process(update[key], key);
}
for (var key of deleteMe) {
delete update[key];
}
return Promise.all(pending);
};
// Adds a relation.
// Returns a promise that resolves successfully iff the add was successful.
DatabaseController.prototype.addRelation = function(key, fromClassName, fromId, toId) {
let doc = {
2016-01-28 10:58:12 -08:00
relatedId: toId,
owningId : fromId
2016-01-28 10:58:12 -08:00
};
let className = `_Join:${key}:${fromClassName}`;
return this.adaptiveCollection(className).then((coll) => {
return coll.upsertOne(doc, doc);
2016-01-28 10:58:12 -08:00
});
};
// Removes a relation.
// Returns a promise that resolves successfully iff the remove was
// successful.
DatabaseController.prototype.removeRelation = function(key, fromClassName, fromId, toId) {
2016-01-28 10:58:12 -08:00
var doc = {
relatedId: toId,
owningId: fromId
};
let className = `_Join:${key}:${fromClassName}`;
return this.adaptiveCollection(className).then(coll => {
return coll.deleteOne(doc);
2016-01-28 10:58:12 -08:00
});
};
// Removes objects matches this query from the database.
// Returns a promise that resolves successfully iff the object was
// deleted.
// Options:
// acl: a list of strings. If the object to be updated has an ACL,
// one of the provided strings must provide the caller with
// write permissions.
DatabaseController.prototype.destroy = function(className, query, options = {}) {
2016-01-28 10:58:12 -08:00
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
var schema;
return this.loadSchema()
.then(s => {
schema = s;
if (!isMaster) {
return schema.validatePermission(className, aclGroup, 'delete');
2016-01-28 10:58:12 -08:00
}
return Promise.resolve();
})
.then(() => this.adaptiveCollection(className))
.then(collection => {
let mongoWhere = transform.transformWhere(schema, className, query);
2016-01-28 10:58:12 -08:00
if (options.acl) {
var writePerms = [
{ _wperm: { '$exists': false } }
];
for (var entry of options.acl) {
writePerms.push({ _wperm: { '$in': [entry] } });
}
mongoWhere = { '$and': [mongoWhere, { '$or': writePerms }] };
}
return collection.deleteMany(mongoWhere);
})
.then(resp => {
//Check _Session to avoid changing password failed without any session.
// TODO: @nlutsenko Stop relying on `result.n`
if (resp.result.n === 0 && className !== "_Session") {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.');
}
});
2016-01-28 10:58:12 -08:00
};
// Inserts an object into the database.
// Returns a promise that resolves successfully iff the object saved.
DatabaseController.prototype.create = function(className, object, options) {
// Make a copy of the object, so we don't mutate the incoming data.
let originalObject = object;
object = deepcopy(object);
2016-01-28 10:58:12 -08:00
var schema;
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
return this.validateClassName(className)
.then(() => this.loadSchema())
.then(s => {
schema = s;
if (!isMaster) {
return schema.validatePermission(className, aclGroup, 'create');
}
return Promise.resolve();
})
.then(() => this.handleRelationUpdates(className, null, object))
.then(() => this.adaptiveCollection(className))
.then(coll => {
var mongoObject = transform.transformCreate(schema, className, object);
return coll.insertOne(mongoObject);
})
.then(result => {
return sanitizeDatabaseResult(originalObject, result.ops[0]);
});
2016-01-28 10:58:12 -08:00
};
2016-03-07 23:07:24 -05:00
DatabaseController.prototype.canAddField = function(schema, className, object, aclGroup) {
let classSchema = schema.data[className];
if (!classSchema) {
return Promise.resolve();
}
let fields = Object.keys(object);
let schemaFields = Object.keys(classSchema);
let newKeys = fields.filter((field) => {
return schemaFields.indexOf(field) < 0;
})
if (newKeys.length > 0) {
return schema.validatePermission(className, aclGroup, 'addField');
}
return Promise.resolve();
}
2016-01-28 10:58:12 -08:00
// Runs a mongo query on the database.
// This should only be used for testing - use 'find' for normal code
// to avoid Mongo-format dependencies.
// Returns a promise that resolves to a list of items.
DatabaseController.prototype.mongoFind = function(className, query, options = {}) {
return this.adaptiveCollection(className)
.then(collection => collection.find(query, options));
2016-01-28 10:58:12 -08:00
};
// Deletes everything in the database matching the current collectionPrefix
// Won't delete collections in the system namespace
// Returns a promise.
DatabaseController.prototype.deleteEverything = function() {
2016-01-28 10:58:12 -08:00
this.schemaPromise = null;
return this.adapter.allCollections().then(collections => {
let promises = collections.map(collection => collection.drop());
2016-01-28 10:58:12 -08:00
return Promise.all(promises);
});
};
// Finds the keys in a query. Returns a Set. REST format only
function keysForQuery(query) {
var sublist = query['$and'] || query['$or'];
if (sublist) {
let answer = sublist.reduce((memo, subquery) => {
return memo.concat(keysForQuery(subquery));
}, []);
return new Set(answer);
2016-01-28 10:58:12 -08:00
}
return new Set(Object.keys(query));
}
// Returns a promise for a list of related ids given an owning id.
// className here is the owning className.
DatabaseController.prototype.relatedIds = function(className, key, owningId) {
return this.adaptiveCollection(joinTableName(className, key))
.then(coll => coll.find({owningId : owningId}))
.then(results => results.map(r => r.relatedId));
2016-01-28 10:58:12 -08:00
};
// Returns a promise for a list of owning ids given some related ids.
// className here is the owning className.
DatabaseController.prototype.owningIds = function(className, key, relatedIds) {
return this.adaptiveCollection(joinTableName(className, key))
.then(coll => coll.find({ relatedId: { '$in': relatedIds } }))
.then(results => results.map(r => r.owningId));
2016-01-28 10:58:12 -08:00
};
// Modifies query so that it no longer has $in on relation fields, or
// equal-to-pointer constraints on relation fields.
// Returns a promise that resolves when query is mutated
DatabaseController.prototype.reduceInRelation = function(className, query, schema) {
2016-01-28 10:58:12 -08:00
// Search for an in-relation or equal-to-relation
2016-03-02 12:01:49 -05:00
// Make it sequential for now, not sure of paralleization side effects
if (query['$or']) {
let ors = query['$or'];
return Promise.all(ors.map((aQuery, index) => {
return this.reduceInRelation(className, aQuery, schema).then((aQuery) => {
query['$or'][index] = aQuery;
})
}));
}
2016-03-02 20:28:00 -05:00
let promises = Object.keys(query).map((key) => {
if (query[key] && (query[key]['$in'] || query[key]['$ne'] || query[key]['$nin'] || query[key].__type == 'Pointer')) {
2016-03-02 20:28:00 -05:00
let t = schema.getExpectedType(className, key);
if (!t || t.type !== 'Relation') {
2016-03-02 20:28:00 -05:00
return Promise.resolve(query);
2016-01-28 10:58:12 -08:00
}
let relatedClassName = t.targetClass;
// Build the list of queries
let queries = Object.keys(query[key]).map((constraintKey) => {
let relatedIds;
let isNegation = false;
if (constraintKey === 'objectId') {
relatedIds = [query[key].objectId];
} else if (constraintKey == '$in') {
relatedIds = query[key]['$in'].map(r => r.objectId);
} else if (constraintKey == '$nin') {
isNegation = true;
relatedIds = query[key]['$nin'].map(r => r.objectId);
} else if (constraintKey == '$ne') {
isNegation = true;
relatedIds = [query[key]['$ne'].objectId];
} else {
return;
}
return {
isNegation,
relatedIds
}
});
// remove the current queryKey as we don,t need it anymore
delete query[key];
// execute each query independnently to build the list of
// $in / $nin
let promises = queries.map((q) => {
if (!q) {
return Promise.resolve();
}
return this.owningIds(className, key, q.relatedIds).then((ids) => {
if (q.isNegation) {
this.addNotInObjectIdsIds(ids, query);
} else {
this.addInObjectIdsIds(ids, query);
}
return Promise.resolve();
});
2016-01-28 10:58:12 -08:00
});
return Promise.all(promises).then(() => {
return Promise.resolve();
})
2016-01-28 10:58:12 -08:00
}
return Promise.resolve();
2016-03-02 20:28:00 -05:00
})
2016-03-02 20:28:00 -05:00
return Promise.all(promises).then(() => {
return Promise.resolve(query);
})
2016-01-28 10:58:12 -08:00
};
// Modifies query so that it no longer has $relatedTo
// Returns a promise that resolves when query is mutated
DatabaseController.prototype.reduceRelationKeys = function(className, query) {
if (query['$or']) {
return Promise.all(query['$or'].map((aQuery) => {
return this.reduceRelationKeys(className, aQuery);
}));
}
2016-01-28 10:58:12 -08:00
var relatedTo = query['$relatedTo'];
if (relatedTo) {
return this.relatedIds(
relatedTo.object.className,
relatedTo.key,
relatedTo.object.objectId).then((ids) => {
delete query['$relatedTo'];
this.addInObjectIdsIds(ids, query);
2016-01-28 10:58:12 -08:00
return this.reduceRelationKeys(className, query);
});
}
};
2016-03-30 19:35:54 -07:00
DatabaseController.prototype.addInObjectIdsIds = function(ids = null, query) {
let idsFromString = typeof query.objectId === 'string' ? [query.objectId] : null;
let idsFromEq = query.objectId && query.objectId['$eq'] ? [query.objectId['$eq']] : null;
let idsFromIn = query.objectId && query.objectId['$in'] ? query.objectId['$in'] : null;
let allIds = [idsFromString, idsFromEq, idsFromIn, ids].filter(list => list !== null);
let totalLength = allIds.reduce((memo, list) => memo + list.length, 0);
let idsIntersection = [];
if (totalLength > 125) {
idsIntersection = intersect.big(allIds);
} else {
idsIntersection = intersect(allIds);
}
2016-03-30 19:35:54 -07:00
2016-03-30 19:48:33 -07:00
// Need to make sure we don't clobber existing $lt or other constraints on objectId.
// Clobbering $eq, $in and shorthand $eq (query.objectId === 'string') constraints
// is expected though.
2016-03-30 19:35:54 -07:00
if (!('objectId' in query) || typeof query.objectId === 'string') {
query.objectId = {};
}
query.objectId['$in'] = idsIntersection;
return query;
}
DatabaseController.prototype.addNotInObjectIdsIds = function(ids = null, query) {
let idsFromNin = query.objectId && query.objectId['$nin'] ? query.objectId['$nin'] : null;
let allIds = [idsFromNin, ids].filter(list => list !== null);
let totalLength = allIds.reduce((memo, list) => memo + list.length, 0);
let idsIntersection = [];
if (totalLength > 125) {
idsIntersection = intersect.big(allIds);
} else {
idsIntersection = intersect(allIds);
}
// Need to make sure we don't clobber existing $lt or other constraints on objectId.
// Clobbering $eq, $in and shorthand $eq (query.objectId === 'string') constraints
// is expected though.
if (!('objectId' in query) || typeof query.objectId === 'string') {
query.objectId = {};
}
query.objectId['$nin'] = idsIntersection;
return query;
}
2016-01-28 10:58:12 -08:00
// Runs a query on the database.
// Returns a promise that resolves to a list of items.
// Options:
// skip number of results to skip.
// limit limit to this number of results.
// sort an object where keys are the fields to sort by.
// the value is +1 for ascending, -1 for descending.
// count run a count instead of returning results.
// acl restrict this operation with an ACL for the provided array
// of user objectIds and roles. acl: null means no user.
// when this field is not present, don't do anything regarding ACLs.
// TODO: make userIds not needed here. The db adapter shouldn't know
// anything about users, ideally. Then, improve the format of the ACL
// arg to work like the others.
DatabaseController.prototype.find = function(className, query, options = {}) {
2016-03-30 19:35:54 -07:00
let mongoOptions = {};
2016-01-28 10:58:12 -08:00
if (options.skip) {
mongoOptions.skip = options.skip;
}
if (options.limit) {
mongoOptions.limit = options.limit;
}
2016-03-30 19:35:54 -07:00
let isMaster = !('acl' in options);
let aclGroup = options.acl || [];
let acceptor = schema => schema.hasKeys(className, keysForQuery(query))
let schema = null;
return this.loadSchema(acceptor).then(s => {
2016-01-28 10:58:12 -08:00
schema = s;
if (options.sort) {
mongoOptions.sort = {};
2016-03-30 19:35:54 -07:00
for (let key in options.sort) {
let mongoKey = transform.transformKey(schema, className, key);
2016-01-28 10:58:12 -08:00
mongoOptions.sort[mongoKey] = options.sort[key];
}
}
if (!isMaster) {
2016-03-30 19:35:54 -07:00
let op = typeof query.objectId == 'string' && Object.keys(query).length === 1 ?
'get' :
'find';
2016-01-28 10:58:12 -08:00
return schema.validatePermission(className, aclGroup, op);
}
return Promise.resolve();
2016-03-30 19:35:54 -07:00
})
.then(() => this.reduceRelationKeys(className, query))
.then(() => this.reduceInRelation(className, query, schema))
.then(() => this.adaptiveCollection(className))
.then(collection => {
let mongoWhere = transform.transformWhere(schema, className, query);
2016-01-28 10:58:12 -08:00
if (!isMaster) {
2016-03-30 19:35:54 -07:00
let orParts = [
2016-01-28 10:58:12 -08:00
{"_rperm" : { "$exists": false }},
{"_rperm" : { "$in" : ["*"]}}
];
2016-03-30 19:35:54 -07:00
for (let acl of aclGroup) {
2016-01-28 10:58:12 -08:00
orParts.push({"_rperm" : { "$in" : [acl]}});
}
mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]};
}
if (options.count) {
delete mongoOptions.limit;
return collection.count(mongoWhere, mongoOptions);
2016-01-28 10:58:12 -08:00
} else {
return collection.find(mongoWhere, mongoOptions)
2016-01-28 10:58:12 -08:00
.then((mongoResults) => {
return mongoResults.map((r) => {
return this.untransformObject(
schema, isMaster, aclGroup, className, r);
});
});
}
});
};
function joinTableName(className, key) {
return `_Join:${key}:${className}`;
}
module.exports = DatabaseController;