UNPKG

@lykmapipo/predefine

Version:

A representation of stored and retrieved information that does not qualify to belongs to their own domain model.

1,056 lines (959 loc) 28.3 kB
import { forEach, get, isArray, isEmpty, map, pick, trim, uniqWith, } from 'lodash'; import { waterfall } from 'async'; import { idOf, compact, flat, mergeObjects, uniq } from '@lykmapipo/common'; import { isTest } from '@lykmapipo/env'; import { areSameInstance, createSchema, model, Mixed, toObjectIds, } from '@lykmapipo/mongoose-common'; import { localizedKeysFor, localizedValuesFor, localizedAbbreviationsFor, } from 'mongoose-locale-schema'; import actions from 'mongoose-rest-actions'; import exportable from '@lykmapipo/mongoose-exportable'; /* constants */ import { MODEL_NAME, COLLECTION_NAME, SCHEMA_OPTIONS, OPTION_SELECT, OPTION_AUTOPOPULATE, DEFAULT_NAMESPACE, NAMESPACES, DEFAULT_BUCKET, BUCKETS, DOMAINS, DEFAULT_LOCALE, CONTENT_TYPE_JSON, CONTENT_TYPE_GEOJSON, CONTENT_TYPE_TOPOJSON, uniqueIndexes, ensureBucketAndNamespace, stringsDefaultValue, createStringsSchema, numbersDefaultValue, createNumbersSchema, booleansDefaultValue, createBooleansSchema, createDatesSchema, createGeosSchema, createRelationsSchema, normalizeQueryFilter, mapToGeoJSONFeatureCollection, mapToTopoJSON, checkIfBucketExists, relationSchemaPaths, } from './utils'; import { findByNamespace, fakeByNamespace, findRecursiveByNamespace, findOneByNamespace, } from './predefine.plugins'; /** * @module Predefine * @name Predefine * @description A representation of stored and retrieved information * that does not qualify to belongs to their own domain model. * * @author lally elias <lallyelias87@gmail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @public * @example * * const { Predefine } = require('@lykmapipo/predefine'); * * const unit = { * namespace: 'Unit', * strings: { * name: { en: 'Kilogram' }, * code: 'Kg', * abbreviation: { en: 'Kg' } * } * }; * Predefine.create(unit, (error, created) => { ... }); * */ const PredefineSchema = createSchema( { /** * @name namespace * @description Machine readable namespace of a predefined. * * Its a super-class when do data modelling. * * @type {object} * @property {object} type - schema(data) type * @property {boolean} trim - force trimming * @property {boolean} required - mark field as required * @property {boolean} enum - list of acceptable values * @property {boolean} index - ensure database index * @property {boolean} searchable - allow for searching * @property {boolean} taggable - allow field use for tagging * @property {boolean} hide - mark field as hidden * @property {boolean} default - default value set when none provided * @property {object|boolean} fake - fake data generator options * * @since 0.1.0 * @version 0.1.0 * @instance * @example * Unit, Currency * */ namespace: { type: String, trim: true, required: true, enum: NAMESPACES, index: true, searchable: true, taggable: true, hide: !isTest(), fake: true, }, /** * @name bucket * @alias collection * @alias key * @description Machine readable collection name of a predefine. * * Its a table or collection name as when do database modelling. * * @type {object} * @property {object} type - schema(data) type * @property {boolean} required - mark required * @property {boolean} trim - force trimming * @property {boolean} enum - list of acceptable values * @property {boolean} index - ensure database index * @property {boolean} searchable - allow for searching * @property {boolean} taggable - allow field use for tagging * @property {boolean} hide - mark field as hidden * @property {boolean} default - default value set when none provided * @property {object|boolean} fake - fake data generator options * * @author lally elias <lallyelias87@gmail.com> * @since 0.1.0 * @version 0.1.0 * @instance * @example * units, currencies * */ bucket: { type: String, trim: true, required: true, enum: BUCKETS, index: true, searchable: true, taggable: true, hide: !isTest(), fake: true, }, /** * @name domain * @description Human readable domain of a predefined. * * Its a sub-class inherit from super-class(namespace) when do * data modelling. * * @type {object} * @property {object} type - schema(data) type * @property {boolean} trim - force trimming * @property {boolean} enum - list of acceptable values * @property {boolean} index - ensure database index * @property {boolean} searchable - allow for searching * @property {boolean} taggable - allow field use for tagging * @property {boolean} hide - mark field as hidden * @property {boolean} default - default value set when none provided * @property {object|boolean} fake - fake data generator options * * @since 1.19.0 * @version 0.1.0 * @instance * @example * Unit, Currency * */ domain: { type: String, trim: true, enum: DOMAINS, index: true, searchable: true, taggable: true, hide: !isTest(), fake: true, }, /** * @name strings * @description A map of strings to allow storing vary string fields to * a predefined. * * @type {object} * * @since 0.9.0 * @version 0.1.0 * @instance * @example * { * "code": "12TRTE" * "symbol": "$", * "color": "#CCCCCC" * } * */ strings: createStringsSchema(), /** * @name numbers * @description A map of numbers to allow storing vary number fields to * a predefined. * * @type {object} * * @since 0.9.0 * @version 0.1.0 * @instance * @example * { * "weight": 1 * "steps": 1 * } * */ numbers: createNumbersSchema(), /** * @name booleans * @description A map of booleans to allow storing vary boolean fields to * a predefined. * * @type {object} * * @since 0.9.0 * @version 0.1.0 * @instance * @example * { * "default": true * "preset": true * } * */ booleans: createBooleansSchema(), /** * @name dates * @description A map of dates to allow storing vary date fields to * a predefined. * * @type {object} * * @since 0.9.0 * @version 0.1.0 * @instance * @example * { * "startedAt": "2018-09-17T00:23:38.000Z" * "endedAt": "2019-09-17T09:23:38.046Z" * } * */ dates: createDatesSchema(), /** * @name geos * @description A map of geometries to allow storing vary geo fields to * a predefined. * * @type {object} * * @since 0.9.0 * @version 0.1.0 * @instance * @example * { * point: { * type: 'Point', * coordinates: [-76.80207859497996, 55.69469494228919] * } * } * */ geos: createGeosSchema(), /** * @name relations * @description Map of logical associated values of a predefined. They * reprents 1-to-1 relationship of other domain models with a predefine. * * @type {object} * * @since 0.4.0 * @version 0.1.0 * @instance * @example * { * "category": "5ce1a93ba7e7a56060e43997" * "unit": "5ce1a93ba7e7a56060e42981" * } * */ relations: createRelationsSchema(), /** * @name properties * @description A map of key value pairs to allow to associate * other meaningful information to a predefined. * * @type {object} * @property {object} type - schema(data) type * @property {object} fake - fake data generator options * * @since 0.1.0 * @version 0.1.0 * @instance * @example * { * "population": { * "male": 1700000, * "female": 2700000 * } * } * */ properties: { type: Map, of: Mixed, fake: (f) => f.helpers.createTransaction(), }, }, SCHEMA_OPTIONS, actions, exportable, findByNamespace, findRecursiveByNamespace, findOneByNamespace, fakeByNamespace ); /* *------------------------------------------------------------------------------ * Indexes *------------------------------------------------------------------------------ */ /** * @name index * @description ensure unique compound index on resource and action * to force unique predefine definition * * @author lally elias <lallyelias87@gmail.com> * @since 0.1.0 * @version 0.1.0 * @private */ PredefineSchema.index(uniqueIndexes(), { unique: true }); /* *------------------------------------------------------------------------------ * Hooks *------------------------------------------------------------------------------ */ /** * @name validate * @function validate * @description predefine schema pre validation hook * @param {Function} done callback to invoke on success or error * @returns {object|Error} valid instance or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.1.0 * @version 0.1.0 * @private */ PredefineSchema.pre('validate', function onPreValidate(done) { return this.preValidate(done); }); /* *------------------------------------------------------------------------------ * Instance *------------------------------------------------------------------------------ */ /** * @name preValidate * @function preValidate * @description predefine schema pre validation hook logic * @param {Function} done callback to invoke on success or error * @returns {object|Error} valid instance or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.1.0 * @version 0.1.0 * @instance */ PredefineSchema.methods.preValidate = function preValidate(done) { // ensure strings, values this.strings = stringsDefaultValue(this.strings); // ensure numbers, values this.numbers = numbersDefaultValue(this.numbers); // ensure booleans, values this.booleans = booleansDefaultValue(this.booleans); // ensure name for all locales this.strings.name = localizedValuesFor(this.strings.name); // ensure description for all locales this.strings.description = mergeObjects( localizedValuesFor(this.strings.name), localizedValuesFor(this.strings.description) ); // ensure abbreviation for all locales this.strings.abbreviation = mergeObjects( localizedAbbreviationsFor(this.strings.name), localizedValuesFor(this.strings.abbreviation) ); // ensure correct namespace and bucket const bucketOrNamespace = this.bucket || this.namespace; const bucketAndNamespace = ensureBucketAndNamespace(bucketOrNamespace); this.set(bucketAndNamespace); // ensure domain this.domain = this.domain || this.namespace; // ensure code this.strings.code = trim(this.strings.code) || this.strings.abbreviation[DEFAULT_LOCALE]; // continue return done(null, this); }; /* *------------------------------------------------------------------------------ * Statics *------------------------------------------------------------------------------ */ /* static constants */ PredefineSchema.statics.MODEL_NAME = MODEL_NAME; PredefineSchema.statics.COLLECTION_NAME = COLLECTION_NAME; PredefineSchema.statics.DEFAULT_NAMESPACE = DEFAULT_NAMESPACE; PredefineSchema.statics.OPTION_SELECT = OPTION_SELECT; PredefineSchema.statics.OPTION_AUTOPOPULATE = OPTION_AUTOPOPULATE; PredefineSchema.statics.NAMESPACES = NAMESPACES; PredefineSchema.statics.DEFAULT_BUCKET = DEFAULT_BUCKET; PredefineSchema.statics.BUCKETS = BUCKETS; /** * @name prepareSeedCriteria * @function prepareSeedCriteria * @description define seed data criteria * @param {object} seed predefined to be seeded * @returns {object} packed criteria for seeding * * @author lally elias <lallyelias87@gmail.com> * @since 0.2.0 * @version 0.1.0 * @static */ PredefineSchema.statics.prepareSeedCriteria = (seed) => { // TODO: convert seed to object if is instance // try use seed id as criteria if exists const id = idOf(seed); if (!isEmpty(id)) { return { _id: id }; } // otherwise use fields and releations for criteria let criteria = {}; const copyOfSeed = seed; copyOfSeed.name = localizedValuesFor(get(seed, 'strings.name')); // use fields to criteria const names = localizedKeysFor('strings.name'); const fieldsCriteria = flat( pick(copyOfSeed, 'namespace', 'bucket', 'domain', 'strings.code', ...names) ); criteria = mergeObjects(criteria, fieldsCriteria); // use non-empty relations to criteria const relationPaths = relationSchemaPaths(); const relationsCriteria = {}; forEach(relationPaths, (relationPath) => { // derive actual relation schema path const actualRelationPath = `relations.${relationPath}`; // collect relation value & convert to _id let relationValue = get(seed, actualRelationPath); relationValue = isArray(relationValue) ? map(relationValue, (val) => idOf(val)) : idOf(relationValue); // set relation relationsCriteria[actualRelationPath] = relationValue; }); criteria = mergeObjects(criteria, relationsCriteria); // return merged criteria return criteria; }; /** * @name getOneOrDefault * @function getOneOrDefault * @description Find existing predefine or default based on given criteria * @param {object} criteria valid query criteria * @param {Function} done callback to invoke on success or error * @returns {object|Error} found model or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.6.0 * @version 0.1.0 * @static * @example * * const criteria = { bucket: 'settings', _id: '...'}; * Predefine.getOneOrDefault(criteria, (error, found) => { ... }); * */ PredefineSchema.statics.getOneOrDefault = (criteria, done) => { // normalize criteria const { _id, namespace, bucket, domain, ...filters } = mergeObjects(criteria); const allowDefault = !isEmpty(namespace || bucket); const allowId = !isEmpty(_id); const allowFilters = !isEmpty(filters); const byDefault = mergeObjects({ namespace, bucket, domain, 'booleans.default': true, }); const byId = mergeObjects({ _id }); const byFilters = mergeObjects({ namespace, bucket }, filters); const or = compact([ allowId ? byId : undefined, allowFilters ? byFilters : undefined, allowDefault ? byDefault : undefined, ]); const filter = { $or: or }; // refs const Predefine = model(MODEL_NAME); // query return Predefine.findOne(filter).orFail().exec(done); }; /** * @name getByExtension * @function getByExtension * @description Find existing predefines and convert them to required format * @param {object} [optns={}] valid options * @param {Function} done callback to invoke on success or error * @returns {object|Error} found predefines or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.9.0 * @version 0.1.0 * @static * @example * * const optns = { filter: {...}, params: {ext: 'geojson'}, ...}; * Predefine.getByExtension(optns, (error, results) => { ... }); * */ PredefineSchema.statics.getByExtension = (optns, done) => { // ref const Predefine = model(MODEL_NAME); // normalize options const options = normalizeQueryFilter(optns); const { params: { ext = CONTENT_TYPE_JSON } = {} } = options; // ensure bucket exists const ensureBucket = (next) => { const bucket = get(options, 'params.bucket') || get(options, 'filters.bucket'); return checkIfBucketExists(bucket, (error) => next(error)); }; // fetch data const getList = (next) => Predefine.get(options, next); // transform by extension const transform = (result, next) => { // collect data const data = compact([].concat(result.data)); // reply with geojson if (ext === CONTENT_TYPE_GEOJSON) { const collection = mapToGeoJSONFeatureCollection(...data); return next(null, collection); } // reply with topojson if (ext === CONTENT_TYPE_TOPOJSON) { const topology = mapToTopoJSON(...data); return next(null, topology); } // always return json return next(null, result); }; // return return waterfall([ensureBucket, getList, transform], done); }; /** * @name postByExtension * @function postByExtension * @description Create predefine and convert it to required format * @param {object} [optns={}] valid options * @param {Function} done callback to invoke on success or error * @returns {object|Error} created predefine or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.9.0 * @version 0.1.0 * @static * @example * * const optns = { body: { ... }, params: {ext: 'geojson'}, ...}; * Predefine.postByExtension(optns, (error, results) => { ... }); * */ PredefineSchema.statics.postByExtension = (optns, done) => { // ref const Predefine = model(MODEL_NAME); // normalize options const options = mergeObjects(optns); const { body, params: { ext = CONTENT_TYPE_JSON } = {} } = options; // ensure bucket exists const ensureBucket = (next) => { const bucket = get(options, 'params.bucket') || get(options, 'filters.bucket'); return checkIfBucketExists(bucket, (error) => next(error)); }; // post data const post = (next) => Predefine.post(body, next); // transform by extension const transform = (result, next) => { // reply with geojson if (ext === CONTENT_TYPE_GEOJSON) { const collection = mapToGeoJSONFeatureCollection(result); return next(null, collection); } // reply with topojson if (ext === CONTENT_TYPE_TOPOJSON) { const topology = mapToTopoJSON(result); return next(null, topology); } // always return json return next(null, result); }; // return return waterfall([ensureBucket, post, transform], done); }; /** * @name getByIdByExtension * @function getByIdByExtension * @description Find existing predefine and convert it to required format * @param {object} [optns={}] valid options * @param {Function} done callback to invoke on success or error * @returns {object|Error} found predefine or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.9.0 * @version 0.1.0 * @static * @example * * const optns = { _id: ..., params: {ext: 'geojson'}, ...}; * Predefine.getByIdByExtension(optns, (error, results) => { ... }); * */ PredefineSchema.statics.getByIdByExtension = (optns, done) => { // ref const Predefine = model(MODEL_NAME); // normalize options const options = mergeObjects(optns); const { params: { ext = CONTENT_TYPE_JSON } = {} } = options; // ensure bucket exists const ensureBucket = (next) => { const bucket = get(options, 'params.bucket') || get(options, 'filters.bucket'); return checkIfBucketExists(bucket, (error) => next(error)); }; // fetch data by id const getById = (next) => Predefine.getById(options, next); // transform by extension const transform = (result, next) => { // reply with geojson if (ext === CONTENT_TYPE_GEOJSON) { const collection = mapToGeoJSONFeatureCollection(result); return next(null, collection); } // reply with topojson if (ext === CONTENT_TYPE_TOPOJSON) { const topology = mapToTopoJSON(result); return next(null, topology); } // always return json return next(null, result); }; // return return waterfall([ensureBucket, getById, transform], done); }; /** * @name patchByExtension * @function patchByExtension * @description Patch existing predefine and convert it to required format * @param {object} [optns={}] valid options * @param {Function} done callback to invoke on success or error * @returns {object|Error} updated predefine or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.9.0 * @version 0.1.0 * @static * @example * * const optns = { _id: ..., params: {ext: 'geojson'}, ...}; * Predefine.patchByExtension(optns, (error, results) => { ... }); * */ PredefineSchema.statics.patchByExtension = (optns, done) => { // ref const Predefine = model(MODEL_NAME); // normalize options const options = mergeObjects(optns); const { params: { ext = CONTENT_TYPE_JSON } = {}, _id, body } = options; // ensure bucket exists const ensureBucket = (next) => { const bucket = get(options, 'params.bucket') || get(options, 'filters.bucket'); return checkIfBucketExists(bucket, (error) => next(error)); }; // patch data const patch = (next) => Predefine.patch(_id, body, next); // transform by extension const transform = (result, next) => { // reply with geojson if (ext === CONTENT_TYPE_GEOJSON) { const collection = mapToGeoJSONFeatureCollection(result); return next(null, collection); } // reply with topojson if (ext === CONTENT_TYPE_TOPOJSON) { const topology = mapToTopoJSON(result); return next(null, topology); } // always return json return next(null, result); }; // return return waterfall([ensureBucket, patch, transform], done); }; /** * @name putByExtension * @function putByExtension * @description Put existing predefine and convert it to required format * @param {object} [optns={}] valid options * @param {Function} done callback to invoke on success or error * @returns {object|Error} updated predefine or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.9.0 * @version 0.1.0 * @static * @example * * const optns = { _id: ..., params: {ext: 'geojson'}, ...}; * Predefine.putByExtension(optns, (error, results) => { ... }); * */ PredefineSchema.statics.putByExtension = (optns, done) => { // ref const Predefine = model(MODEL_NAME); // normalize options const options = mergeObjects(optns); const { params: { ext = CONTENT_TYPE_JSON } = {}, _id, body } = options; // ensure bucket exists const ensureBucket = (next) => { const bucket = get(options, 'params.bucket') || get(options, 'filters.bucket'); return checkIfBucketExists(bucket, (error) => next(error)); }; // put data const put = (next) => Predefine.put(_id, body, next); // transform by extension const transform = (result, next) => { // reply with geojson if (ext === CONTENT_TYPE_GEOJSON) { const collection = mapToGeoJSONFeatureCollection(result); return next(null, collection); } // reply with topojson if (ext === CONTENT_TYPE_TOPOJSON) { const topology = mapToTopoJSON(result); return next(null, topology); } // always return json return next(null, result); }; // return return waterfall([ensureBucket, put, transform], done); }; /** * @name deleteByExtension * @function deleteByExtension * @description Delete existing predefine and convert it to required format * @param {object} [optns={}] valid options * @param {Function} done callback to invoke on success or error * @returns {object|Error} deleted predefine or error * * @author lally elias <lallyelias87@gmail.com> * @since 0.9.0 * @version 0.1.0 * @static * @example * * const optns = { _id: ..., params: {ext: 'geojson'}, ...}; * Predefine.deleteByExtension(optns, (error, results) => { ... }); * */ PredefineSchema.statics.deleteByExtension = (optns, done) => { // ref const Predefine = model(MODEL_NAME); // normalize options const options = mergeObjects(optns); const { params: { ext = CONTENT_TYPE_JSON } = {} } = options; // ensure bucket exists const ensureBucket = (next) => { const bucket = get(options, 'params.bucket') || get(options, 'filters.bucket'); return checkIfBucketExists(bucket, (error) => next(error)); }; // delete existing data const del = (next) => Predefine.del(options, next); // transform by extension const transform = (result, next) => { // reply with geojson if (ext === CONTENT_TYPE_GEOJSON) { const collection = mapToGeoJSONFeatureCollection(result); return next(null, collection); } // reply with topojson if (ext === CONTENT_TYPE_TOPOJSON) { const topology = mapToTopoJSON(result); return next(null, topology); } // always return json return next(null, result); }; // return return waterfall([ensureBucket, del, transform], done); }; /** * @name findChildren * @function findChildren * @description Find predefine children recursively using given criteria * @param {object} criteria valid parent query options * @param {Function} done callback to invoke on success or error * @returns {object|Error} found predefines or error * * @author lally elias <lallyelias87@gmail.com> * @since 1.9.0 * @version 0.2.0 * @static * @example * * const criteria = { _id: ... }; * Predefine.findChildren(criteria, (error, results) => { ... }); * // => [ Predefine{ ... }, ... ] * */ PredefineSchema.statics.findChildren = (criteria, done) => { // TODO: use $graphLookUp // ref const Predefine = model(MODEL_NAME); let results = []; // collect results const collectResults = (...updates) => { let collected = uniq([...results, ...updates]); collected = uniqWith(collected, areSameInstance); return collected; }; // find children by their parents const findKids = (conditions, next) => { // prepare query const query = Predefine.find(conditions).setOptions({ autopopulate: false, }); // execute query return query.exec((error, children) => { // back-off on error if (error) { return next(error); } // continue find children if (!isEmpty(children)) { results = collectResults(...children); const parentIds = uniq(toObjectIds(...children)); if (isEmpty(parentIds)) { return next(null, results); } return findKids({ 'relations.parent': { $in: parentIds } }, next); } // continue return next(null, results); }); }; // do find recursively return findKids(criteria, done); }; /** * @name findParents * @function findParents * @description Find predefine parent recursively using given criteria * @param {object} criteria valid child query options * @param {Function} done callback to invoke on success or error * @returns {object|Error} found predefines or error * * @author lally elias <lallyelias87@gmail.com> * @since 1.10.0 * @version 0.1.0 * @static * @example * * const criteria = { _id: ... }; * Predefine.findParents(criteria, (error, results) => { ... }); * // => [ Predefine{ ... }, ... ] * */ PredefineSchema.statics.findParents = (criteria, done) => { // TODO: use $graphLookUp // ref const Predefine = model(MODEL_NAME); let results = []; // collect results const collectResults = (...updates) => { let collected = uniq([...results, ...updates]); collected = uniqWith(collected, areSameInstance); return collected; }; // find parent by her children const findAncestors = (conditions, next) => { // prepare query const query = Predefine.find(conditions).setOptions({ autopopulate: false, }); // execute query return query.exec((error, ancestors) => { // back-off on error if (error) { return next(error); } // continue find parent if (!isEmpty(ancestors)) { results = collectResults(...ancestors); const ancestorIds = uniq(map(ancestors, 'relations.parent')); const parentIds = uniq(toObjectIds(...ancestorIds)); if (isEmpty(parentIds)) { return next(null, results); } return findAncestors({ _id: { $in: parentIds } }, next); } // continue return next(null, results); }); }; // do find recursively return findAncestors(criteria, done); }; /* export predefine model */ export default model(MODEL_NAME, PredefineSchema);