UNPKG

@sportwarp/sportwarp-db

Version:
802 lines (700 loc) 20.3 kB
/* * moleculer-db * Copyright (c) 2017 Ice Services (https://github.com/ice-services/moleculer-db) * MIT Licensed */ "use strict"; const _ = require("lodash"); const Promise = require("bluebird"); const { MoleculerClientError, ValidationError } = require("moleculer").Errors; const { EntityNotFoundError } = require("./errors"); const MemoryAdapter = require("./memory-adapter"); /** * Service mixin to access database entities * * @name moleculer-db * @module Service */ module.exports = { // Must overwrite it name: "", // Store adapter (NeDB adapter is the default) adapter: null, /** * Default settings */ settings: { /** @type {String} Name of ID field. */ idField: "_id", /** @type {Array<String>?} Field filtering list. It must be an `Array`. If the value is `null` or `undefined` doesn't filter the fields of entities. */ fields: null, /** @type {Array?} Schema for population. [Read more](#populating). */ populates: null, /** @type {Number} Default page size in `list` action. */ pageSize: 10, /** @type {Number} Maximum page size in `list` action. */ maxPageSize: 100, /** @type {Number} Maximum value of limit in `find` action. Default: `-1` (no limit) */ maxLimit: -1, /** @type {Object|Function} Validator schema or a function to validate the incoming entity in `create` & 'insert' actions. */ entityValidator: null }, /** * Actions */ actions: { /** * Find entities by query. * * @actions * @cached * * @param {Array<String>?} populate - Populated fields. * @param {Array<String>?} fields - Fields filter. * @param {Number} limit - Max count of rows. * @param {Number} offset - Count of skipped rows. * @param {String} sort - Sorted fields. * @param {String} search - Search text. * @param {String} searchFields - Fields for searching. * @param {Object} query - Query object. Passes to adapter. * * @returns {Array<Object>} List of found entities. */ find: { cache: { keys: ["populate", "fields", "limit", "offset", "sort", "search", "searchFields", "query"] }, params: { populate: { type: "array", optional: true, items: "string" }, fields: { type: "array", optional: true, items: "string" }, limit: { type: "number", integer: true, min: 0, optional: true, convert: true }, offset: { type: "number", integer: true, min: 0, optional: true, convert: true }, sort: { type: "string", optional: true }, search: { type: "string", optional: true }, searchFields: { type: "array", optional: true }, query: { type: "object", optional: true } }, handler(ctx) { let params = this.sanitizeParams(ctx, ctx.params); return this.adapter.find(params) .then(docs => this.transformDocuments(ctx, params, docs)); } }, /** * Get count of entities by query. * * @actions * @cached * * @param {String} search - Search text. * @param {String} searchFields - Fields list for searching. * @param {Object} query - Query object. Passes to adapter. * * @returns {Number} Count of found entities. */ count: { cache: { keys: ["search", "searchFields", "query"] }, params: { search: { type: "string", optional: true }, searchFields: { type: "array", optional: true }, query: { type: "object", optional: true } }, handler(ctx) { let params = this.sanitizeParams(ctx, ctx.params); // Remove pagination params if (params && params.limit) params.limit = null; if (params && params.offset) params.offset = null; return this.adapter.count(params); } }, /** * List entities by filters and pagination results. * * @actions * @cached * * @param {Array<String>?} populate - Populated fields. * @param {Array<String>?} fields - Fields filter. * @param {Number} page - Page number. * @param {Number} pageSize - Size of a page. * @param {String} sort - Sorted fields. * @param {String} search - Search text. * @param {String} searchFields - Fields for searching. * @param {Object} query - Query object. Passes to adapter. * * @returns {Object} List of found entities and count. */ list: { cache: { keys: ["populate", "fields", "page", "pageSize", "sort", "search", "searchFields", "query"] }, params: { populate: { type: "array", optional: true, items: "string" }, fields: { type: "array", optional: true, items: "string" }, page: { type: "number", integer: true, min: 1, optional: true, convert: true }, pageSize: { type: "number", integer: true, min: 0, optional: true, convert: true }, sort: { type: "string", optional: true }, search: { type: "string", optional: true }, searchFields: { type: "array", optional: true }, query: { type: "object", optional: true } }, handler(ctx) { let params = this.sanitizeParams(ctx, ctx.params); let countParams = Object.assign({}, params); // Remove pagination params if (countParams && countParams.limit) countParams.limit = null; if (countParams && countParams.offset) countParams.offset = null; return Promise.all([ // Get rows this.adapter.find(params), // Get count of all rows this.adapter.count(countParams) ]).then(res => { return this.transformDocuments(ctx, params, res[0]) .then(docs => { return { // Rows rows: docs, // Total rows total: res[1], // Page page: params.page, // Page size pageSize: params.pageSize, // Total pages totalPages: Math.floor((res[1] + params.pageSize - 1) / params.pageSize) }; }); }); } }, /** * Create a new entity. * * @actions * * @returns {Object} Saved entity. */ create: { handler(ctx) { let entity = ctx.params; return this.validateEntity(entity) .then(entity => this.adapter.insert(entity)) .then(doc => this.transformDocuments(ctx, {}, doc)) .then(json => this.entityChanged("created", json, ctx).then(() => json)); } }, /** * Create many new entities. * * @actions * * @param {Object?} entity - Entity to save. * @param {Array.<Object>?} entities - Entities to save. * * @returns {Object|Array.<Object>} Saved entity(ies). */ insert: { params: { entity: { type: "object", optional: true }, entities: { type: "array", optional: true } }, handler(ctx) { let params = this.sanitizeParams(ctx, ctx.params); return Promise.resolve() .then(() => { if (Array.isArray(params.entities)) { return this.validateEntity(params.entities) .then(entities => this.adapter.insertMany(entities)); } else if (params.entity) { return this.validateEntity(params.entity) .then(entity => this.adapter.insert(entity)); } return Promise.reject(new MoleculerClientError("Invalid request! The 'params' must contain 'entity' or 'entities'!", 400)); }) .then(docs => this.transformDocuments(ctx, params, docs)) .then(json => this.entityChanged("created", json, ctx).then(() => json)); } }, /** * Get entity by ID. * * @actions * @cached * * @param {any|Array<any>} id - ID(s) of entity. * @param {Array<String>?} populate - Field list for populate. * @param {Array<String>?} fields - Fields filter. * @param {Boolean?} mapping - Convert the returned `Array` to `Object` where the key is the value of `id`. * * @returns {Object|Array<Object>} Found entity(ies). * * @throws {EntityNotFoundError} - 404 Entity not found */ get: { cache: { keys: ["id", "populate", "fields", "mapping"] }, params: { id: [ { type: "string" }, { type: "number" }, { type: "array" } ], populate: { type: "array", optional: true, items: "string" }, fields: { type: "array", optional: true, items: "string" }, mapping: { type: "boolean", optional: true } }, handler(ctx) { let params = this.sanitizeParams(ctx, ctx.params); let id = params.id; let origDoc; return this.getById(id, true) .then(doc => { if (!doc) return Promise.reject(new EntityNotFoundError(id)); origDoc = doc; return this.transformDocuments(ctx, ctx.params, doc); }) .then(json => { if (_.isArray(json) && params.mapping === true) { let res = {}; json.forEach((doc, i) => { const id = origDoc[i][this.settings.idField]; res[id] = doc; }); return res; } return json; }); } }, /** * Update an entity by ID. * > After update, clear the cache & call lifecycle events. * * @actions * * @returns {Object} Updated entity. * * @throws {EntityNotFoundError} - 404 Entity not found */ update: { handler(ctx) { let id; let sets = {}; // Convert fields from params to "$set" update object Object.keys(ctx.params).forEach(prop => { if (prop == "id" || prop == this.settings.idField) id = this.decodeID(ctx.params[prop]); else sets[prop] = ctx.params[prop]; }); return this.adapter.updateById(id, { "$set": sets }) .then(doc => { if (!doc) return Promise.reject(new EntityNotFoundError(id)); return this.transformDocuments(ctx, ctx.params, doc) .then(json => this.entityChanged("updated", json, ctx).then(() => json)); }); } }, /** * Remove an entity by ID. * * @actions * * @param {any} id - ID of entity. * @returns {Number} Count of removed entities. * * @throws {EntityNotFoundError} - 404 Entity not found */ remove: { params: { id: { type: "any" } }, handler(ctx) { let params = this.sanitizeParams(ctx, ctx.params); const id = this.decodeID(params.id); return this.adapter.removeById(id) .then(doc => { if (!doc) return Promise.reject(new EntityNotFoundError(params.id)); return this.transformDocuments(ctx, params, doc) .then(json => this.entityChanged("removed", json, ctx).then(() => json)); }); } } }, /** * Methods */ methods: { /** * Connect to database. */ connect() { return this.adapter.connect().then(() => { // Call an 'afterConnected' handler in schema if (_.isFunction(this.schema.afterConnected)) { try { return this.schema.afterConnected.call(this); } catch(err) { /* istanbul ignore next */ this.logger.error("afterConnected error!", err); } } }); }, /** * Disconnect from database. */ disconnect() { if (_.isFunction(this.adapter.disconnect)) return this.adapter.disconnect(); }, /** * Sanitize context parameters at `find` action. * * @param {Context} ctx * @param {any} origParams * @returns {Promise} */ sanitizeParams(ctx, params) { let p = Object.assign({}, params); // Convert from string to number if (typeof(p.limit) === "string") p.limit = Number(p.limit); if (typeof(p.offset) === "string") p.offset = Number(p.offset); if (typeof(p.page) === "string") p.page = Number(p.page); if (typeof(p.pageSize) === "string") p.pageSize = Number(p.pageSize); if (typeof(p.sort) === "string") p.sort = p.sort.replace(/,/, " ").split(" "); if (typeof(p.fields) === "string") p.fields = p.fields.replace(/,/, " ").split(" "); if (typeof(p.populate) === "string") p.populate = p.populate.replace(/,/, " ").split(" "); if (ctx.action.name.endsWith(".list")) { // Default `pageSize` if (!p.pageSize) p.pageSize = this.settings.pageSize; // Default `page` if (!p.page) p.page = 1; // Limit the `pageSize` if (this.settings.maxPageSize > 0 && p.pageSize > this.settings.maxPageSize) p.pageSize = this.settings.maxPageSize; // Calculate the limit & offset from page & pageSize p.limit = p.pageSize; p.offset = (p.page - 1) * p.pageSize; } // Limit the `limit` if (this.settings.maxLimit > 0 && p.limit > this.settings.maxLimit) p.limit = this.settings.maxLimit; return p; }, /** * Get entity(ies) by ID(s). * * @methods * @param {String|Number|Array} id - ID or IDs. * @param {Boolean} decoding - Need to decode IDs. * @returns {Object|Array<Object>} Found entity(ies). */ getById(id, decoding) { return Promise.resolve() .then(() => { if (_.isArray(id)) { if (decoding) id = id.map(id => this.decodeID(id)); return this.adapter.findByIds(id); } else { if (decoding) id = this.decodeID(id); return this.adapter.findById(id); } }); }, /** * Clear the cache & call entity lifecycle events * * @param {String} type * @param {Object|Array|Number} json * @param {Context} ctx * @returns {Promise} */ entityChanged(type, json, ctx) { return this.clearCache().then(() => { const eventName = `entity${_.capitalize(type)}`; if (this.schema[eventName] != null) { return this.schema[eventName].call(this, json, ctx); } }); }, /** * Clear cached entities * * @methods * @returns {Promise} */ clearCache() { this.broker.broadcast(`cache.clean.${this.name}`); if (this.broker.cacher) this.broker.cacher.clean(`${this.name}.*`); return Promise.resolve(); }, /** * Transform the fetched documents * * @param {Array|Object} docs * @param {Object} Params * @returns {Array|Object} */ transformDocuments(ctx, params, docs) { let isDoc = false; if (!Array.isArray(docs)) { if (_.isObject(docs)) { isDoc = true; docs = [docs]; } else return Promise.resolve(docs); } return Promise.resolve(docs) // Convert entity to JS object .then(docs => docs.map(doc => this.adapter.entityToObject(doc))) // Encode IDs .then(docs => docs.map(doc => { doc[this.settings.idField] = this.encodeID(doc[this.settings.idField]); return doc; })) // Populate .then(json => (ctx && params.populate) ? this.populateDocs(ctx, json, params.populate) : json) // TODO onTransformHook // Filter fields .then(json => { let fields = ctx && params.fields ? params.fields : this.settings.fields; // Compatibility with < 0.4 /* istanbul ignore next */ if (_.isString(fields)) fields = fields.split(" "); // Authorize the requested fields const authFields = this.authorizeFields(fields); return json.map(item => this.filterFields(item, authFields)); }) // Return .then(json => isDoc ? json[0] : json); }, /** * Filter fields in the entity object * * @param {Object} doc * @param {Array} fields Filter properties of model. * @returns {Object} */ filterFields(doc, fields) { // Apply field filter (support nested paths) if (Array.isArray(fields)) { let res = {}; fields.forEach(n => { const v = _.get(doc, n); if (v !== undefined) _.set(res, n, v); }); return res; } return doc; }, /** * Authorize the required field list. Remove fields which is not exist in the `this.settings.fields` * * @param {Array} fields * @returns {Array} */ authorizeFields(fields) { if (this.settings.fields && this.settings.fields.length > 0) { let res = []; if (Array.isArray(fields) && fields.length > 0) { fields.forEach(f => { if (this.settings.fields.indexOf(f) !== -1) { res.push(f); return; } if (f.indexOf(".") !== -1) { let parts = f.split("."); while (parts.length > 1) { parts.pop(); if (this.settings.fields.indexOf(parts.join(".")) !== -1) { res.push(f); break; } } } let nestedFields = this.settings.fields.filter(prop => prop.indexOf(f + ".") !== -1); if (nestedFields.length > 0) { res = res.concat(nestedFields); } }); //return _.intersection(f, this.settings.fields); } return res; } return fields; }, /** * Populate documents. * * @param {Context} ctx * @param {Array|Object} docs * @param {Array} populateFields * @returns {Promise} */ populateDocs(ctx, docs, populateFields) { if (!this.settings.populates || !Array.isArray(populateFields) || populateFields.length == 0) return Promise.resolve(docs); if (docs == null || !_.isObject(docs) || !Array.isArray(docs)) return Promise.resolve(docs); let promises = []; _.forIn(this.settings.populates, (rule, field) => { if (populateFields.indexOf(field) === -1) return; // skip // if the rule is a function, save as a custom handler if (_.isFunction(rule)) { rule = { handler: Promise.method(rule) }; } // If string, convert to object if (_.isString(rule)) { rule = { action: rule }; } rule.field = field; let arr = Array.isArray(docs) ? docs : [docs]; // Collect IDs from field of docs (flatten, compact & unique list) let idList = _.uniq(_.flattenDeep(_.compact(arr.map(doc => doc[field])))); // Replace the received models according to IDs in the original docs const resultTransform = (populatedDocs) => { arr.forEach(doc => { let id = doc[field]; if (_.isArray(id)) { let models = _.compact(id.map(id => populatedDocs[id])); doc[field] = models; } else { doc[field] = populatedDocs[id]; } }); }; if (rule.handler) { promises.push(rule.handler.call(this, idList, arr, rule, ctx)); } else if (idList.length > 0) { // Call the target action & collect the promises const params = Object.assign({ id: idList, mapping: true, populate: rule.populate }, rule.params || {}); promises.push(ctx.call(rule.action, params).then(resultTransform)); } }); return Promise.all(promises).then(() => docs); }, /** * Validate an entity by validator. * * @param {any} entity * @returns {Promise} */ validateEntity(entity) { if (!_.isFunction(this.settings.entityValidator)) return Promise.resolve(entity); let entities = Array.isArray(entity) ? entity : [entity]; return Promise.all(entities.map(entity => this.settings.entityValidator(entity))).then(() => entity); }, /** * Encode ID of entity. * * @methods * @param {any} id * @returns {any} */ encodeID(id) { return id; }, /** * Decode ID of entity. * * @methods * @param {any} id * @returns {any} */ decodeID(id) { return id; } }, /** * Service created lifecycle event handler */ created() { // Compatibility with < 0.4 if (_.isString(this.settings.fields)) { this.settings.fields = this.settings.fields.split(" "); } if (!this.schema.adapter) this.adapter = new MemoryAdapter(); else this.adapter = this.schema.adapter; this.adapter.init(this.broker, this); // Transform entity validation schema to checker function if (this.broker.validator && _.isObject(this.settings.entityValidator)) { const check = this.broker.validator.compile(this.settings.entityValidator); this.settings.entityValidator = entity => { const res = check(entity); if (res === true) return Promise.resolve(); else return Promise.reject(new ValidationError("Entity validation error!", null, res)); }; } }, /** * Service started lifecycle event handler */ started() { if (this.adapter) { return new Promise(resolve => { let connecting = () => { this.connect().then(resolve).catch(err => { setTimeout(() => { this.logger.error("Connection error!", err); this.logger.warn("Reconnecting..."); connecting(); }, 1000); }); }; connecting(); }); } /* istanbul ignore next */ return Promise.reject(new Error("Please set the store adapter in schema!")); }, /** * Service stopped lifecycle event handler */ stopped() { if (this.adapter) return this.disconnect(); }, // Export Memory Adapter class MemoryAdapter };