UNPKG

parse-mockdb

Version:
882 lines (765 loc) 24.8 kB
'use strict'; const _ = require('lodash'); const crypto = require('./crypto'); const DEFAULT_LIMIT = 100; const QUOTE_REGEXP = /(\\Q|\\E)/g; const CONFIG = { DEBUG: process.env.DEBUG_DB, }; let Parse; let db = {}; let hooks = {}; const masks = {}; let indirect = null; let outOfBandResults = null; let defaultController = null; let mocked = false; let user = null; function debugPrint(prefix, object) { if (CONFIG.DEBUG) { console.log(['[', ']'].join(prefix), JSON.stringify(object, null, 4)); } } function isOp(object) { return object && typeof object === 'object' && '__op' in object; } function isPointer(object) { return object && object.__type === 'Pointer'; } function isDate(object) { return object && object.__type === 'Date'; } /** * Deserialize an encoded query parameter if necessary */ function deserializeQueryParam(param) { if (!!param && (typeof param === 'object')) { if (param.__type === 'Date') { return new Date(param.iso); } } return param; } /** * Evaluates whether 2 objects are the same, independent of their representation * (e.g. Pointer, Object) */ function objectsAreEqual(obj1, obj2) { // Always search through array on array columns if (Array.isArray(obj1)) { if (Array.isArray(obj2)) { throw new Parse.Error(107, `You cannot use ${obj2} as a query parameter`); } else { return _.some(obj1, obj => objectsAreEqual(obj, obj2)); } } // scalar values (including null/undefined) // Note: undefined equals null. // For all other objects, strict equality is applied if (obj1 === obj2 || (_.isNil(obj1) && _.isNil(obj2))) { return true; } // if any of those is null or undefined the other is not because // of above --> abort if (_.isNil(obj1) || _.isNil(obj2)) { return false; } // objects if (_.isEqual(obj1, obj2)) { return true; } // both pointers if (obj1.objectId !== undefined && obj1.objectId === obj2.objectId) { return true; } // both dates if (isDate(obj1) && isDate(obj2)) { return deserializeQueryParam(obj1) === deserializeQueryParam(obj2); } return false; } // Ensures `object` has an array at `key`. Creates array if `key` doesn't exist. // Will throw if value for `key` exists and is not Array. function ensureArray(object, key) { if (!object[key]) { object[key] = []; } if (!Array.isArray(object[key])) { throw new Error("Can't perform array operation on non-array field"); } } const MASKED_UPDATE_OPS = new Set(['AddRelation', 'RemoveRelation']); /** * Update Operators. * * Params: * object - object on which to operate * key - value to be modified in bound object. * value - operator value, i.e. `{__op: "Increment", amount: 1}` */ const UPDATE_OPERATORS = { Increment: (object, key, value) => { if (object[key] === undefined) { object[key] = 0; } object[key] += value.amount; }, Add: (object, key, value) => { ensureArray(object, key); value.objects.forEach(el => { object[key].push(el); }); }, AddUnique: (object, key, value) => { ensureArray(object, key); const array = object[key]; value.objects.forEach(el => { if (!_.some(array, e => objectsAreEqual(e, el))) { array.push(el); } }); }, Remove: (object, key, value) => { ensureArray(object, key); const array = object[key]; value.objects.forEach(el => { _.remove(array, item => objectsAreEqual(item, el)); }); }, Delete: (object, key) => { delete object[key]; }, AddRelation: (object, key, value) => { ensureArray(object, key); const relation = object[key]; value.objects.forEach(pointer => { if (!_.some(relation, e => objectsAreEqual(e, pointer))) { relation.push(pointer); } }); }, RemoveRelation: (object, key, value) => { ensureArray(object, key); const relation = object[key]; value.objects.forEach(item => { _.remove(relation, pointer => objectsAreEqual(pointer, item)); }); }, }; function getCollection(collection) { if (!db[collection]) { db[collection] = {}; } return db[collection]; } function getMask(collection) { if (!masks[collection]) { masks[collection] = new Set(); } return masks[collection]; } /** * Clears the MockDB and any registered hooks. */ function cleanUp() { db = {}; hooks = {}; } /** * Registers a hook on a class denoted by className. * * @param {string} className The name of the class to register hook on. * @param {string} hookType One of 'beforeSave', 'afterSave', 'beforeDelete', 'afterDelete' * @param {function} hookFn Function that will be called with `this` bound to hydrated model. * Must return a promise. * * @note Only supports beforeSave, beforeDelete, and afterSave at the moment. */ function registerHook(className, hookType, hookFn) { if (!hooks[className]) { hooks[className] = {}; } hooks[className][hookType] = hookFn; } /** * Retrieves a previously registered hook. * * @param {string} className The name of the class to get the hook on. * @param {string} hookType One of 'beforeSave', 'afterSave', 'beforeDelete', 'afterDelete' */ function getHook(className, hookType) { if (hooks[className] && hooks[className][hookType]) { return hooks[className][hookType]; } return undefined; } function mockUser(_user) { user = _user; } function makeRequestObject(original, model, useMasterKey) { return { installationId: 'parse-mockdb', master: useMasterKey, object: model, original, user, }; } // Destructive. Takes data for update operation and removes all atomic operations. // Returns the extracted ops. function extractOps(data) { const ops = {}; _.forIn(data, (attribute, key) => { if (isOp(attribute)) { ops[key] = attribute; delete data[key]; } }); return ops; } // Destructive. Applies all the update `ops` to `data`. // Throws on unknown update operator. function applyOps(data, ops, className) { debugPrint('OPS', ops); _.forIn(ops, (value, key) => { const operator = value.__op; if (operator in UPDATE_OPERATORS) { UPDATE_OPERATORS[operator](data, key, value, className); } else { throw new Error(`Unknown update operator: ${key}`); } if (MASKED_UPDATE_OPS.has(operator)) { getMask(className).add(key); } }); } // Batch requests have the API version included in path function normalizePath(path) { return path.replace('/1/', ''); } const SPECIAL_CLASS_NAMES = { roles: '_Role', users: '_User', push: '_Push', }; /** * Given a class name and a where clause, returns DB matches by applying * the where clause (recursively if nested) */ function recursivelyMatch(className, where) { debugPrint('MATCH', { className, where }); const collection = getCollection(className); // eslint-disable-next-line no-use-before-define const matches = _.filter(_.values(collection), queryFilter(where)); debugPrint('MATCHES', { matches }); return _.cloneDeep(matches); // return copies instead of originals } // according to the js sdk api documentation parse uses the following radius of the earth const RADIUS_OF_EARTH_KM = 6371.0; const RADIUS_OF_EARTH_MILES = 3958.8; // the parse rest guide says that the maximum distance is 100 miles if no explicit maximum // is provided; here we already convert this distance into radians const DEFAULT_MAX_DISTANCE = 100 / RADIUS_OF_EARTH_MILES; /** * Operators for queries * * Params: * operand - the value on which the query operator is applied * value - operator value, i.e. the number 30 in `age: {$lt: 30}` */ const QUERY_OPERATORS = { $exists: (operand, value) => !!operand === value, $in: (operand, values) => _.some(values, value => objectsAreEqual(operand, value)), $nin: (operand, values) => _.every(values, value => !objectsAreEqual(operand, value)), $eq: (operand, value) => objectsAreEqual(operand, value), $ne: (operand, value) => !objectsAreEqual(operand, value), $lt: (operand, value) => operand < value, $lte: (operand, value) => operand <= value, $gt: (operand, value) => operand > value, $gte: (operand, value) => operand >= value, $regex: (operand, value) => { const regex = _.clone(value.$regex).replace(QUOTE_REGEXP, ''); return (new RegExp(regex, value.$options).test(operand)); }, $select: (operand, value) => { const foreignKey = value.key; const query = value.query; const matches = recursivelyMatch(query.className, query.where); const objectMatches = _.filter(matches, match => match[foreignKey] === operand); return objectMatches.length; }, $inQuery: (operand, query) => { const matches = recursivelyMatch(query.className, query.where); return _.find(matches, match => operand && match.objectId === operand.objectId); }, $all: (operand, value) => _.every(value, obj1 => _.some(operand, obj2 => objectsAreEqual(obj1, obj2))), $relatedTo: (operand, value) => { const object = value.object; const className = object.className; const id = object.objectId; const relatedKey = value.key; const relations = getCollection(className)[id][relatedKey] || []; // What is going on here? nothing is returned here? // TODO: could use a unit test to help document what's supposed to happen here if (indirect) { // Grab the className from the first relation item in order to set the class // correctly on the way out outOfBandResults = {}; if (relations && relations.length > 0) { outOfBandResults.className = relations[0].className; } outOfBandResults.matches = relations.reduce((results, relation) => { // eslint-disable-next-line no-use-before-define const matches = recursivelyMatch(relations[0].className, { objectId: relation.objectId, }); return results.concat(matches); }, []); } else { return objectsAreEqual(relations, operand); } return undefined; }, $nearSphere: (operand, value, additionalArgs) => { let maxDistance = additionalArgs.maxDistanceInRadians; if (_.isNil(maxDistance)) { maxDistance = DEFAULT_MAX_DISTANCE; } return new Parse.GeoPoint(operand).radiansTo(new Parse.GeoPoint(value)) <= maxDistance; }, // ignore these additional parameters for the $nearSphere op $maxDistance: () => true, $maxDistanceInRadians: () => true, $maxDistanceInKilometers: () => true, $maxDistanceInMiles: () => true, }; function evaluateObject(object, whereParams, key) { const nestedKeys = key.split('.'); if (nestedKeys.length > 1) { for (let i = 0; i < nestedKeys.length - 1; i++) { if (!object[nestedKeys[i]]) { // key not found return false; } object = object[nestedKeys[i]]; key = nestedKeys[i + 1]; } } if (typeof whereParams === 'object' && !Array.isArray(whereParams) && whereParams) { // Handle objects that actually represent scalar values if (isPointer(whereParams) || isDate(whereParams)) { return QUERY_OPERATORS.$eq.apply(null, [object[key], whereParams]); } if (key in QUERY_OPERATORS) { return QUERY_OPERATORS[key].apply(null, [object, whereParams]); } if ('$regex' in whereParams) { return QUERY_OPERATORS.$regex.apply(null, [object[key], whereParams]); } // $maxDistance... is not an operator for itself but just an additional parameter // for the $nearSphere operator, so we have to fetch this value in advance. const args = {}; if (whereParams) { args.maxDistanceInRadians = whereParams.$maxDistance || whereParams.$maxDistanceInRadians; if ('$maxDistanceInKilometers' in whereParams) { args.maxDistanceInRadians = whereParams.$maxDistanceInKilometers / RADIUS_OF_EARTH_KM; } else if ('$maxDistanceInMiles' in whereParams) { args.maxDistanceInRadians = whereParams.$maxDistanceInMiles / RADIUS_OF_EARTH_MILES; } } // Process each key in where clause to determine if we have a match return _.reduce(whereParams, (matches, value, constraint) => { const keyValue = deserializeQueryParam(object[key]); const param = deserializeQueryParam(value); // Constraint can take the form form of a query operator OR an equality match if (constraint in QUERY_OPERATORS) { // { age: {$lt: 30} } return matches && QUERY_OPERATORS[constraint].apply( null, [keyValue, param, args] ); } // { age: 30 } return matches && QUERY_OPERATORS.$eq.apply(null, [keyValue[constraint], param]); }, true); } return QUERY_OPERATORS.$eq.apply(null, [object[key], whereParams]); } /** * Returns a function that filters query matches on a where clause */ function queryFilter(where) { if (where.$or) { return object => _.reduce(where.$or, (result, subclause) => result || queryFilter(subclause)(object), false); } // Go through each key in where clause return object => _.reduce(where, (result, whereParams, key) => { const match = evaluateObject(object, whereParams, key); return result && match; }, true); } function handleRequest(method, path, body) { const explodedPath = normalizePath(path).split('/'); const start = explodedPath.shift(); const className = start === 'classes' ? explodedPath.shift() : SPECIAL_CLASS_NAMES[start]; const request = { method, className, data: body, objectId: explodedPath.shift(), }; try { // eslint-disable-next-line no-use-before-define return HANDLERS[method](request); } catch (e) { return Promise.reject(e); } } function respond(status, response) { return { status, response, }; } /** * Batch requests have the following form: { * requests: [ * { method, path, body }, * ] * } */ function handleBatchRequest(unused1, unused2, data) { const requests = data.requests; const getResults = requests.map(request => { const method = request.method; const path = request.path; const body = request.body; return handleRequest(method, path, body) .then(result => Promise.resolve({ success: result.response })); }); return Promise.all(getResults).then(results => respond(200, results)); } /** * Given an object, a pointer, or a JSON representation of a Parse Object, * return a fully fetched version of the Object. */ function fetchObjectByPointer(pointer) { const collection = getCollection(pointer.className); const storedItem = collection[pointer.objectId]; if (storedItem === undefined) { return undefined; } return Object.assign( { __type: 'Object', className: pointer.className }, _.cloneDeep(storedItem) ); } /** * Recursive function that traverses an include path and replaces pointers * with fully fetched objects */ function includePaths(object, pathsRemaining) { debugPrint('INCLUDE', { object, pathsRemaining }); const path = pathsRemaining.shift(); const target = object && object[path]; if (target) { if (Array.isArray(target)) { object[path] = target.map(item => { if (item.className) { // This is a pointer or an object const fetched = fetchObjectByPointer(item); includePaths(fetched, _.cloneDeep(pathsRemaining)); return fetched; } return item; }); } else { if (object[path].__type === 'Pointer') { object[path] = fetchObjectByPointer(target); } includePaths(object[path], pathsRemaining); } } return object; } /** * Given a set of matches of a GET query (e.g. find()), returns fully * fetched Parse Objects that include the nested objects requested by * Parse.Query.include() */ function queryMatchesAfterIncluding(matches, includeClause) { if (!includeClause) { return matches; } const includeClauses = includeClause.split(','); matches = _.map(matches, match => { for (let i = 0; i < includeClauses.length; i++) { const paths = includeClauses[i].split('.'); match = includePaths(match, paths); } return match; }); return matches; } /** * Sort query results if necessary */ function sortQueryresults(matches, order) { const orderArray = order.split(',').map(k => { let dir = 'asc'; let key = k; if (k.charAt(0) === '-') { key = k.substring(1); dir = 'desc'; } return [item => deserializeQueryParam(item[key]), dir]; }); const keys = orderArray.map(_.first); const orders = orderArray.map(_.last); return _.orderBy(matches, keys, orders); } /** * Handles a GET request (Parse.Query.find(), get(), first(), Parse.Object.fetch()) */ function handleGetRequest(request) { const objId = request.objectId; const className = request.className; if (objId) { // Object.fetch() query const collection = getCollection(className); const currentObject = collection[objId]; if (!currentObject) { return Promise.resolve(respond(404, { code: 101, error: 'object not found for update', })); } let match = _.cloneDeep(currentObject); if (match) { const toOmit = Array.from(getMask(className)); match = _.omit(match, toOmit); } return Promise.resolve(respond(200, match)); } const data = request.data; indirect = data.redirectClassNameForKey; let matches = recursivelyMatch(className, data.where); let matchesClassName = ''; if (indirect) { matches = outOfBandResults.matches; if (outOfBandResults.className) { matchesClassName = outOfBandResults.className; } } if (request.data.count) { return Promise.resolve(respond(200, { count: matches.length })); } matches = queryMatchesAfterIncluding(matches, data.include); const toOmit = Array.from(getMask(className)); matches = matches.map((match) => _.omit(match, toOmit)); // TODO: Can we just call toJSON() in order to avoid this? matches.forEach(match => { if (match.createdAt) { match.createdAt = match.createdAt.toJSON(); } if (match.updatedAt) { match.updatedAt = match.updatedAt.toJSON(); } }); // sort results if necessary if (data.order && data.order.length > 0 && matches.length > 0) { matches = sortQueryresults(matches, data.order); } const limit = data.limit || DEFAULT_LIMIT; const startIndex = data.skip || 0; const endIndex = startIndex + limit; const response = { results: matches.slice(startIndex, endIndex) }; // Add the class name for the outgoing objects to the response if sepcified if (matchesClassName.length > 0) { response.className = matchesClassName; } return Promise.resolve(respond(200, response)); } /** * Executes a registered hook with data provided. * * Hydrates the data into an instance of the class named by `className` param and binds it to the * function to be run. * * @param {string} className The name of the class to get the hook on. * @param {string} hookType One of 'beforeSave', 'afterSave', 'beforeDelete', 'afterDelete' * @param {Object} data The Data that is to be hydrated into an instance of className class. */ function runHook(className, hookType, data) { let hook = getHook(className, hookType); if (hook) { const hydrate = (rawData) => { const modelData = Object.assign({}, rawData, { className }); const modelJSON = _.mapValues(modelData, // Convert dates into JSON loadable representations value => ((value instanceof Date) ? value.toJSON() : value) ); return Parse.Object.fromJSON(modelJSON); }; const model = hydrate(data, className); hook = hook.bind(model); const collection = getCollection(className); let original; if (collection[model.id]) { original = hydrate(collection[model.id]); } // TODO Stub out Parse.Cloud.useMasterKey() so that we can report the correct 'master' // value here. return hook(makeRequestObject(original, model, false)).then((beforeSaveOverrideValue) => { debugPrint('HOOK', { beforeSaveOverrideValue }); // Unlike BeforeDeleteResponse, BeforeSaveResponse might specify let objectToProceedWith = model; if (hookType === 'beforeSave' && beforeSaveOverrideValue) { objectToProceedWith = beforeSaveOverrideValue.toJSON(); } return Promise.resolve(objectToProceedWith); }); } return Promise.resolve(data); } function getChangedKeys(originalObject, updatedObject) { if (originalObject === updatedObject) { return []; } return _.reduce(updatedObject, (result, value, key) => { if (!_.isEqual(originalObject[key], value)) { result.push(key); } return result; }, []); } /** * Handles a POST request (Parse.Object.save()) */ function handlePostRequest(request) { const className = request.className; const collection = getCollection(className); let newObject; return runHook(className, 'beforeSave', request.data).then(result => { const changedKeys = getChangedKeys(request.data, result); const newId = crypto.newObjectId(); const now = new Date(); const ops = extractOps(result); newObject = Object.assign( result, { objectId: newId, createdAt: now, updatedAt: now } ); applyOps(newObject, ops, className); const toOmit = ['updatedAt'].concat(Array.from(getMask(className))); const toPick = Object.keys(ops).concat(changedKeys); collection[newId] = newObject; const response = Object.assign( _.cloneDeep(_.omit(_.pick(result, toPick), toOmit)), { objectId: newId, createdAt: result.createdAt.toJSON() } ); return Promise.resolve(respond(201, response)); }).then((result) => { runHook(className, 'afterSave', newObject); return result; }); } function handlePutRequest(request) { const className = request.className; const collection = getCollection(className); const objId = request.objectId; const currentObject = collection[objId]; const now = new Date(); const data = request.data || {}; const ops = extractOps(data); if (!currentObject) { return Promise.resolve(respond(404, { code: 101, error: 'object not found for put', })); } const updatedObject = Object.assign( _.cloneDeep(currentObject), data, { updatedAt: now } ); applyOps(updatedObject, ops, className); const toOmit = ['createdAt', 'objectId'].concat(Array.from(getMask(className))); return runHook(className, 'beforeSave', updatedObject).then(result => { const changedKeys = getChangedKeys(updatedObject, result); collection[request.objectId] = updatedObject; const response = Object.assign( _.cloneDeep(_.omit(_.pick(result, Object.keys(ops).concat(changedKeys)), toOmit)), { updatedAt: now.toJSON() } ); return Promise.resolve(respond(200, response)); }).then((result) => { runHook(className, 'afterSave', updatedObject); return result; }); } function handleDeleteRequest(request) { const collection = getCollection(request.className); const objToDelete = collection[request.objectId]; return runHook(request.className, 'beforeDelete', objToDelete).then(() => { delete collection[request.objectId]; return Promise.resolve(respond(200, {})); }); } const HANDLERS = { GET: handleGetRequest, POST: handlePostRequest, PUT: handlePutRequest, DELETE: handleDeleteRequest, }; const MockRESTController = { request: (method, path, data, options) => { let result; if (path === 'batch') { debugPrint('BATCH', { method, path, data, options }); result = handleBatchRequest(method, path, data); } else { debugPrint('REQUEST', { method, path, data, options }); result = handleRequest(method, path, data); } return result.then(finalResult => { // Status of database after handling request above debugPrint('DB', db); debugPrint('RESPONSE', finalResult.response); return Promise.resolve(finalResult.response); }); }, ajax: () => { /* no-op */ }, }; /** * Mocks a Parse API server, by intercepting requests and storing/querying data locally * in an in-memory DB. */ function mockDB(parseModule) { Parse = parseModule; if (!mocked) { defaultController = Parse.CoreManager.getRESTController(); mocked = true; Parse.CoreManager.setRESTController(MockRESTController); } } /** * Restores the original RESTController. */ function unMockDB() { if (mocked) { Parse.CoreManager.setRESTController(defaultController); mocked = false; } } const MockDB = { mockDB, unMockDB, cleanUp, registerHook, mockUser, }; module.exports = MockDB;