// An object that encapsulates everything we need to run a 'find' // operation, encoded in the REST API format. var Parse = require('parse/node').Parse; // restOptions can include: // skip // limit // order // count // include // keys // redirectClassNameForKey function RestQuery(config, auth, className, restWhere, restOptions) { restOptions = restOptions || {}; this.config = config; this.auth = auth; this.className = className; this.restWhere = restWhere || {}; this.response = null; this.findOptions = {}; if (!this.auth.isMaster) { this.findOptions.acl = this.auth.user ? [this.auth.user.id] : null; if (this.className == '_Session') { if (!this.findOptions.acl) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'This session token is invalid.'); } this.restWhere = { '$and': [this.restWhere, { 'user': { __type: 'Pointer', className: '_User', objectId: this.auth.user.id } }] }; } } this.doCount = false; // The format for this.include is not the same as the format for the // include option - it's the paths we should include, in order, // stored as arrays, taking into account that we need to include foo // before including foo.bar. Also it should dedupe. // For example, passing an arg of include=foo.bar,foo.baz could lead to // this.include = [['foo'], ['foo', 'baz'], ['foo', 'bar']] this.include = []; for (var option in restOptions) { switch(option) { case 'keys': this.keys = new Set(restOptions.keys.split(',')); this.keys.add('objectId'); this.keys.add('createdAt'); this.keys.add('updatedAt'); break; case 'count': this.doCount = true; break; case 'skip': case 'limit': this.findOptions[option] = restOptions[option]; break; case 'order': var fields = restOptions.order.split(','); var sortMap = {}; for (var field of fields) { if (field[0] == '-') { sortMap[field.slice(1)] = -1; } else { sortMap[field] = 1; } } this.findOptions.sort = sortMap; break; case 'include': var paths = restOptions.include.split(','); var pathSet = {}; for (var path of paths) { // Add all prefixes with a .-split to pathSet var parts = path.split('.'); for (var len = 1; len <= parts.length; len++) { pathSet[parts.slice(0, len).join('.')] = true; } } this.include = Object.keys(pathSet).sort((a, b) => { return a.length - b.length; }).map((s) => { return s.split('.'); }); break; case 'redirectClassNameForKey': this.redirectKey = restOptions.redirectClassNameForKey; this.redirectClassName = null; break; default: throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad option: ' + option); } } } // A convenient method to perform all the steps of processing a query // in order. // Returns a promise for the response - an object with optional keys // 'results' and 'count'. // TODO: consolidate the replaceX functions RestQuery.prototype.execute = function() { return Promise.resolve().then(() => { return this.getUserAndRoleACL(); }).then(() => { return this.redirectClassNameForKey(); }).then(() => { return this.replaceSelect(); }).then(() => { return this.replaceDontSelect(); }).then(() => { return this.replaceInQuery(); }).then(() => { return this.replaceNotInQuery(); }).then(() => { return this.runFind(); }).then(() => { return this.runCount(); }).then(() => { return this.handleInclude(); }).then(() => { return this.response; }); }; // Uses the Auth object to get the list of roles, adds the user id RestQuery.prototype.getUserAndRoleACL = function() { if (this.auth.isMaster || !this.auth.user) { return Promise.resolve(); } return this.auth.getUserRoles().then((roles) => { roles.push(this.auth.user.id); this.findOptions.acl = roles; return Promise.resolve(); }); }; // Changes the className if redirectClassNameForKey is set. // Returns a promise. RestQuery.prototype.redirectClassNameForKey = function() { if (!this.redirectKey) { return Promise.resolve(); } // We need to change the class name based on the schema return this.config.database.redirectClassNameForKey( this.className, this.redirectKey).then((newClassName) => { this.className = newClassName; this.redirectClassName = newClassName; }); }; // Replaces a $inQuery clause by running the subquery, if there is an // $inQuery clause. // The $inQuery clause turns into an $in with values that are just // pointers to the objects returned in the subquery. RestQuery.prototype.replaceInQuery = function() { var inQueryObject = findObjectWithKey(this.restWhere, '$inQuery'); if (!inQueryObject) { return; } // The inQuery value must have precisely two keys - where and className var inQueryValue = inQueryObject['$inQuery']; if (!inQueryValue.where || !inQueryValue.className) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $inQuery'); } var subquery = new RestQuery( this.config, this.auth, inQueryValue.className, inQueryValue.where); return subquery.execute().then((response) => { var values = []; for (var result of response.results) { values.push({ __type: 'Pointer', className: inQueryValue.className, objectId: result.objectId }); } delete inQueryObject['$inQuery']; inQueryObject['$in'] = values; // Recurse to repeat return this.replaceInQuery(); }); }; // Replaces a $notInQuery clause by running the subquery, if there is an // $notInQuery clause. // The $notInQuery clause turns into a $nin with values that are just // pointers to the objects returned in the subquery. RestQuery.prototype.replaceNotInQuery = function() { var notInQueryObject = findObjectWithKey(this.restWhere, '$notInQuery'); if (!notInQueryObject) { return; } // The notInQuery value must have precisely two keys - where and className var notInQueryValue = notInQueryObject['$notInQuery']; if (!notInQueryValue.where || !notInQueryValue.className) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $notInQuery'); } var subquery = new RestQuery( this.config, this.auth, notInQueryValue.className, notInQueryValue.where); return subquery.execute().then((response) => { var values = []; for (var result of response.results) { values.push({ __type: 'Pointer', className: notInQueryValue.className, objectId: result.objectId }); } delete notInQueryObject['$notInQuery']; notInQueryObject['$nin'] = values; // Recurse to repeat return this.replaceNotInQuery(); }); }; // Replaces a $select clause by running the subquery, if there is a // $select clause. // The $select clause turns into an $in with values selected out of // the subquery. // Returns a possible-promise. RestQuery.prototype.replaceSelect = function() { var selectObject = findObjectWithKey(this.restWhere, '$select'); if (!selectObject) { return; } // The select value must have precisely two keys - query and key var selectValue = selectObject['$select']; if (!selectValue.query || !selectValue.key || typeof selectValue.query !== 'object' || !selectValue.query.className || !selectValue.query.where || Object.keys(selectValue).length !== 2) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $select'); } var subquery = new RestQuery( this.config, this.auth, selectValue.query.className, selectValue.query.where); return subquery.execute().then((response) => { var values = []; for (var result of response.results) { values.push(result[selectValue.key]); } delete selectObject['$select']; selectObject['$in'] = values; // Keep replacing $select clauses return this.replaceSelect(); }) }; // Replaces a $dontSelect clause by running the subquery, if there is a // $dontSelect clause. // The $dontSelect clause turns into an $nin with values selected out of // the subquery. // Returns a possible-promise. RestQuery.prototype.replaceDontSelect = function() { var dontSelectObject = findObjectWithKey(this.restWhere, '$dontSelect'); if (!dontSelectObject) { return; } // The dontSelect value must have precisely two keys - query and key var dontSelectValue = dontSelectObject['$dontSelect']; if (!dontSelectValue.query || !dontSelectValue.key || typeof dontSelectValue.query !== 'object' || !dontSelectValue.query.className || !dontSelectValue.query.where || Object.keys(dontSelectValue).length !== 2) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $dontSelect'); } var subquery = new RestQuery( this.config, this.auth, dontSelectValue.query.className, dontSelectValue.query.where); return subquery.execute().then((response) => { var values = []; for (var result of response.results) { values.push(result[dontSelectValue.key]); } delete dontSelectObject['$dontSelect']; dontSelectObject['$nin'] = values; // Keep replacing $dontSelect clauses return this.replaceDontSelect(); }) }; // Returns a promise for whether it was successful. // Populates this.response with an object that only has 'results'. RestQuery.prototype.runFind = function() { return this.config.database.find( this.className, this.restWhere, this.findOptions).then((results) => { if (this.className == '_User') { for (var result of results) { delete result.password; } } updateParseFiles(this.config, results); if (this.keys) { var keySet = this.keys; results = results.map((object) => { var newObject = {}; for (var key in object) { if (keySet.has(key)) { newObject[key] = object[key]; } } return newObject; }); } if (this.redirectClassName) { for (var r of results) { r.className = this.redirectClassName; } } this.response = {results: results}; }); }; // Returns a promise for whether it was successful. // Populates this.response.count with the count RestQuery.prototype.runCount = function() { if (!this.doCount) { return; } this.findOptions.count = true; delete this.findOptions.skip; return this.config.database.find( this.className, this.restWhere, this.findOptions).then((c) => { this.response.count = c; }); }; // Augments this.response with data at the paths provided in this.include. RestQuery.prototype.handleInclude = function() { if (this.include.length == 0) { return; } var pathResponse = includePath(this.config, this.auth, this.response, this.include[0]); if (pathResponse.then) { return pathResponse.then((newResponse) => { this.response = newResponse; this.include = this.include.slice(1); return this.handleInclude(); }); } return pathResponse; }; // Adds included values to the response. // Path is a list of field names. // Returns a promise for an augmented response. function includePath(config, auth, response, path) { var pointers = findPointers(response.results, path); if (pointers.length == 0) { return response; } var className = null; var objectIds = {}; for (var pointer of pointers) { if (className === null) { className = pointer.className; } else { if (className != pointer.className) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'inconsistent type data for include'); } } objectIds[pointer.objectId] = true; } if (!className) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad pointers'); } // Get the objects for all these object ids var where = {'objectId': {'$in': Object.keys(objectIds)}}; var query = new RestQuery(config, auth, className, where); return query.execute().then((includeResponse) => { var replace = {}; for (var obj of includeResponse.results) { obj.__type = 'Object'; obj.className = className; replace[obj.objectId] = obj; } var resp = { results: replacePointers(response.results, path, replace) }; if (response.count) { resp.count = response.count; } return resp; }); } // Object may be a list of REST-format object to find pointers in, or // it may be a single object. // If the path yields things that aren't pointers, this throws an error. // Path is a list of fields to search into. // Returns a list of pointers in REST format. function findPointers(object, path) { if (object instanceof Array) { var answer = []; for (x of object) { answer = answer.concat(findPointers(x, path)); } return answer; } if (typeof object !== 'object') { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'can only include pointer fields'); } if (path.length == 0) { if (object.__type == 'Pointer') { return [object]; } throw new Parse.Error(Parse.Error.INVALID_QUERY, 'can only include pointer fields'); } var subobject = object[path[0]]; if (!subobject) { return []; } return findPointers(subobject, path.slice(1)); } // Object may be a list of REST-format objects to replace pointers // in, or it may be a single object. // Path is a list of fields to search into. // replace is a map from object id -> object. // Returns something analogous to object, but with the appropriate // pointers inflated. function replacePointers(object, path, replace) { if (object instanceof Array) { return object.map((obj) => replacePointers(obj, path, replace)); } if (typeof object !== 'object') { return object; } if (path.length == 0) { if (object.__type == 'Pointer' && replace[object.objectId]) { return replace[object.objectId]; } return object; } var subobject = object[path[0]]; if (!subobject) { return object; } var newsub = replacePointers(subobject, path.slice(1), replace); var answer = {}; for (var key in object) { if (key == path[0]) { answer[key] = newsub; } else { answer[key] = object[key]; } } return answer; } // Find file references in REST-format object and adds the url key // with the current mount point and app id // Object may be a single object or list of REST-format objects function updateParseFiles(config, object) { if (object instanceof Array) { object.map((obj) => updateParseFiles(config, obj)); return; } if (typeof object !== 'object') { return; } for (var key in object) { if (object[key] && object[key]['__type'] && object[key]['__type'] == 'File') { var filename = object[key]['name']; var encoded = encodeURIComponent(filename); encoded = encoded.replace('%40', '@'); if (filename.indexOf('tfss-') === 0) { object[key]['url'] = 'http://files.parsetfss.com/' + config.fileKey + '/' + encoded; } else { object[key]['url'] = config.mount + '/files/' + config.applicationId + '/' + encoded; } } } } // Finds a subobject that has the given key, if there is one. // Returns undefined otherwise. function findObjectWithKey(root, key) { if (typeof root !== 'object') { return; } if (root instanceof Array) { for (var item of root) { var answer = findObjectWithKey(item, key); if (answer) { return answer; } } } if (root && root[key]) { return root; } for (var subkey in root) { var answer = findObjectWithKey(root[subkey], key); if (answer) { return answer; } } } module.exports = RestQuery;