'use strict'; const SchemaType = require('./schematype'); const Types = require('./types'); const Promise = require('bluebird'); const { getProp, setProp, delProp } = require('./util'); const PopulationError = require('./error/population'); const { isPlainObject } = require('is-plain-object'); /** * @callback queryFilterCallback * @param {*} data * @return {boolean} */ /** * @callback queryCallback * @param {*} data * @return {void} */ /** * @callback queryParseCallback * @param {*} a * @param {*} b * @returns {*} */ /** * @typedef PopulateResult * @property {string} path * @property {*} model */ const builtinTypes = new Set(['String', 'Number', 'Boolean', 'Array', 'Object', 'Date', 'Buffer']); const getSchemaType = (name, options) => { const Type = options.type || options; const typeName = Type.name; if (builtinTypes.has(typeName)) { return new Types[typeName](name, options); } return new Type(name, options); }; const checkHookType = type => { if (type !== 'save' && type !== 'remove') { throw new TypeError('Hook type must be `save` or `remove`!'); } }; const hookWrapper = fn => { if (fn.length > 1) { return Promise.promisify(fn); } return Promise.method(fn); }; /** * @param {Function[]} stack */ const execSortStack = stack => { const len = stack.length; return (a, b) => { let result; for (let i = 0; i < len; i++) { result = stack[i](a, b); if (result) break; } return result; }; }; const sortStack = (path_, key, sort) => { const path = path_ || new SchemaType(key); const descending = sort === 'desc' || sort === -1; return (a, b) => { const result = path.compare(getProp(a, key), getProp(b, key)); return descending && result ? result * -1 : result; }; }; class UpdateParser { static updateStackNormal(key, update) { return data => { setProp(data, key, update); }; } static updateStackOperator(path_, ukey, key, update) { const path = path_ || new SchemaType(key); return data => { const result = path[ukey](getProp(data, key), update, data); setProp(data, key, result); }; } constructor(paths) { this.paths = paths; } /** * Parses updating expressions and returns a stack. * * @param {Object} updates * @param {queryCallback[]} [stack] * @private */ parseUpdate(updates, prefix = '', stack = []) { const { paths } = this; const { updateStackOperator } = UpdateParser; const keys = Object.keys(updates); let path, prefixNoDot; if (prefix) { prefixNoDot = prefix.substring(0, prefix.length - 1); path = paths[prefixNoDot]; } for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; const update = updates[key]; const name = prefix + key; // Update operators if (key[0] === '$') { const ukey = `u${key}`; // First-class update operators if (prefix) { stack.push(updateStackOperator(path, ukey, prefixNoDot, update)); } else { // Inline update operators const fields = Object.keys(update); const fieldLen = fields.length; for (let j = 0; j < fieldLen; j++) { const field = fields[i]; stack.push(updateStackOperator(paths[field], ukey, field, update[field])); } } } else if (isPlainObject(update)) { this.parseUpdate(update, `${name}.`, stack); } else { stack.push(UpdateParser.updateStackNormal(name, update)); } } return stack; } } /** * @private */ class QueryParser { constructor(paths) { this.paths = paths; } /** * * @param {string} name * @param {*} query * @return {queryFilterCallback} */ queryStackNormal(name, query) { const path = this.paths[name] || new SchemaType(name); return data => path.match(getProp(data, name), query, data); } /** * * @param {string} qkey * @param {string} name * @param {*} query * @return {queryFilterCallback} */ queryStackOperator(qkey, name, query) { const path = this.paths[name] || new SchemaType(name); return data => path[qkey](getProp(data, name), query, data); } /** * @param {Array} arr * @param {queryFilterCallback[]} stack The function generated by query is added to the stack. * @return {void} * @private */ $and(arr, stack) { for (let i = 0, len = arr.length; i < len; i++) { stack.push(this.execQuery(arr[i])); } } /** * @param {Array} query * @return {queryFilterCallback} * @private */ $or(query) { const stack = this.parseQueryArray(query); const len = stack.length; return data => { for (let i = 0; i < len; i++) { if (stack[i](data)) return true; } return false; }; } /** * @param {Array} query * @return {queryFilterCallback} * @private */ $nor(query) { const stack = this.parseQueryArray(query); const len = stack.length; return data => { for (let i = 0; i < len; i++) { if (stack[i](data)) return false; } return true; }; } /** * @param {*} query * @return {queryFilterCallback} * @private */ $not(query) { const stack = this.parseQuery(query); const len = stack.length; return data => { for (let i = 0; i < len; i++) { if (!stack[i](data)) return true; } return false; }; } /** * @callback queryWherecallback * @return {boolean} * @this {QueryPerser} */ /** * @param {queryWherecallback} fn * @return {queryFilterCallback} * @private */ $where(fn) { return data => Reflect.apply(fn, data, []); } /** * Parses array of query expressions and returns a stack. * * @param {Array} arr * @return {queryFilterCallback[]} * @private */ parseQueryArray(arr) { const stack = []; this.$and(arr, stack); return stack; } /** * Parses normal query expressions and returns a stack. * * @param {Object} queries * @param {String} prefix * @param {queryFilterCallback[]} [stack] The function generated by query is added to the stack passed in this argument. If not passed, a new stack will be created. * @return {void} * @private */ parseNormalQuery(queries, prefix, stack = []) { const keys = Object.keys(queries); for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; const query = queries[key]; if (key[0] === '$') { stack.push(this.queryStackOperator(`q${key}`, prefix, query)); continue; } const name = `${prefix}.${key}`; if (isPlainObject(query)) { this.parseNormalQuery(query, name, stack); } else { stack.push(this.queryStackNormal(name, query)); } } } /** * Parses query expressions and returns a stack. * * @param {Object} queries * @return {queryFilterCallback[]} * @private */ parseQuery(queries) { /** @type {queryFilterCallback[]} */ const stack = []; const keys = Object.keys(queries); for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; const query = queries[key]; switch (key) { case '$and': this.$and(query, stack); break; case '$or': stack.push(this.$or(query)); break; case '$nor': stack.push(this.$nor(query)); break; case '$not': stack.push(this.$not(query)); break; case '$where': stack.push(this.$where(query)); break; default: if (isPlainObject(query)) { this.parseNormalQuery(query, key, stack); } else { stack.push(this.queryStackNormal(key, query)); } } } return stack; } /** * Returns a function for querying. * * @param {Object} query * @return {queryFilterCallback} * @private */ execQuery(query) { const stack = this.parseQuery(query); const len = stack.length; return data => { for (let i = 0; i < len; i++) { if (!stack[i](data)) return false; } return true; }; } } class Schema { /** * Schema constructor. * * @param {Object} schema */ constructor(schema) { this.paths = {}; this.statics = {}; this.methods = {}; this.hooks = { pre: { save: [], remove: [] }, post: { save: [], remove: [] } }; this.stacks = { getter: [], setter: [], import: [], export: [] }; if (schema) { this.add(schema); } } /** * Adds paths. * * @param {Object} schema * @param {String} prefix */ add(schema, prefix = '') { const keys = Object.keys(schema); const len = keys.length; if (!len) return; for (let i = 0; i < len; i++) { const key = keys[i]; const value = schema[key]; this.path(prefix + key, value); } } /** * Gets/Sets a path. * * @param {String} name * @param {*} obj * @return {SchemaType | undefined} */ path(name, obj) { if (obj == null) { return this.paths[name]; } let type; let nested = false; if (obj instanceof SchemaType) { type = obj; } else { switch (typeof obj) { case 'function': type = getSchemaType(name, {type: obj}); break; case 'object': if (obj.type) { type = getSchemaType(name, obj); } else if (Array.isArray(obj)) { type = new Types.Array(name, { child: obj.length ? getSchemaType(name, obj[0]) : new SchemaType(name) }); } else { type = new Types.Object(); nested = Object.keys(obj).length > 0; } break; default: throw new TypeError(`Invalid value for schema path \`${name}\``); } } this.paths[name] = type; this._updateStack(name, type); if (nested) this.add(obj, `${name}.`); } /** * Updates cache stacks. * * @param {String} name * @param {SchemaType} type * @private */ _updateStack(name, type) { const { stacks } = this; stacks.getter.push(data => { const value = getProp(data, name); const result = type.cast(value, data); if (result !== undefined) { setProp(data, name, result); } }); stacks.setter.push(data => { const value = getProp(data, name); const result = type.validate(value, data); if (result !== undefined) { setProp(data, name, result); } else { delProp(data, name); } }); stacks.import.push(data => { const value = getProp(data, name); const result = type.parse(value, data); if (result !== undefined) { setProp(data, name, result); } }); stacks.export.push(data => { const value = getProp(data, name); const result = type.value(value, data); if (result !== undefined) { setProp(data, name, result); } else { delProp(data, name); } }); } /** * Adds a virtual path. * * @param {String} name * @param {Function} [getter] * @return {SchemaType.Virtual} */ virtual(name, getter) { const virtual = new Types.Virtual(name, {}); if (getter) virtual.get(getter); this.path(name, virtual); return virtual; } /** * Adds a pre-hook. * * @param {String} type Hook type. One of `save` or `remove`. * @param {Function} fn */ pre(type, fn) { checkHookType(type); if (typeof fn !== 'function') throw new TypeError('Hook must be a function!'); this.hooks.pre[type].push(hookWrapper(fn)); } /** * Adds a post-hook. * * @param {String} type Hook type. One of `save` or `remove`. * @param {Function} fn */ post(type, fn) { checkHookType(type); if (typeof fn !== 'function') throw new TypeError('Hook must be a function!'); this.hooks.post[type].push(hookWrapper(fn)); } /** * Adds a instance method. * * @param {String} name * @param {Function} fn */ method(name, fn) { if (!name) throw new TypeError('Method name is required!'); if (typeof fn !== 'function') { throw new TypeError('Instance method must be a function!'); } this.methods[name] = fn; } /** * Adds a static method. * * @param {String} name * @param {Function} fn */ static(name, fn) { if (!name) throw new TypeError('Method name is required!'); if (typeof fn !== 'function') { throw new TypeError('Static method must be a function!'); } this.statics[name] = fn; } /** * Apply getters. * * @param {Object} data * @return {void} * @private */ _applyGetters(data) { const stack = this.stacks.getter; for (let i = 0, len = stack.length; i < len; i++) { stack[i](data); } } /** * Apply setters. * * @param {Object} data * @return {void} * @private */ _applySetters(data) { const stack = this.stacks.setter; for (let i = 0, len = stack.length; i < len; i++) { stack[i](data); } } /** * Parses database. * * @param {Object} data * @return {Object} * @private */ _parseDatabase(data) { const stack = this.stacks.import; for (let i = 0, len = stack.length; i < len; i++) { stack[i](data); } return data; } /** * Exports database. * * @param {Object} data * @return {Object} * @private */ _exportDatabase(data) { const stack = this.stacks.export; for (let i = 0, len = stack.length; i < len; i++) { stack[i](data); } return data; } /** * Parses updating expressions and returns a stack. * * @param {Object} updates * @return {queryCallback[]} * @private */ _parseUpdate(updates) { return new UpdateParser(this.paths).parseUpdate(updates); } /** * Returns a function for querying. * * @param {Object} query * @return {queryFilterCallback} * @private */ _execQuery(query) { return new QueryParser(this.paths).execQuery(query); } /** * Parses sorting expressions and returns a stack. * * @param {Object} sorts * @param {string} [prefix] * @param {queryParseCallback[]} [stack] * @return {queryParseCallback[]} * @private */ _parseSort(sorts, prefix = '', stack = []) { const { paths } = this; const keys = Object.keys(sorts); for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; const sort = sorts[key]; const name = prefix + key; if (typeof sort === 'object') { this._parseSort(sort, `${name}.`, stack); } else { stack.push(sortStack(paths[name], name, sort)); } } return stack; } /** * Returns a function for sorting. * * @param {Object} sorts * @return {queryParseCallback} * @private */ _execSort(sorts) { const stack = this._parseSort(sorts); return execSortStack(stack); } /** * Parses population expression and returns a stack. * * @param {String|Object} expr * @return {PopulateResult[]} * @private */ _parsePopulate(expr) { const { paths } = this; const arr = []; if (typeof expr === 'string') { const split = expr.split(' '); for (let i = 0, len = split.length; i < len; i++) { arr[i] = { path: split[i] }; } } else if (Array.isArray(expr)) { for (let i = 0, len = expr.length; i < len; i++) { const item = expr[i]; arr[i] = typeof item === 'string' ? { path: item } : item; } } else { arr[0] = expr; } for (let i = 0, len = arr.length; i < len; i++) { const item = arr[i]; const key = item.path; if (!key) { throw new PopulationError('path is required'); } if (!item.model) { const path = paths[key]; const ref = path.child ? path.child.options.ref : path.options.ref; if (!ref) { throw new PopulationError('model is required'); } item.model = ref; } } return arr; } } Schema.prototype.Types = Types; Schema.Types = Schema.prototype.Types; module.exports = Schema;