UNPKG

@justlep/camo

Version:

A class-based Object-Document Mapper (ODM) for NeDB

623 lines (548 loc) 23.4 kB
import { isArray, ValidationError, isNumber } from './validate.js'; import {isNativeId} from './client.js'; import { COLLECTION_NAME, IS_BASE_DOCUMENT, IS_EMBEDDED, SCHEMA_1PROP_KEYS, SCHEMA_ALL_KEYS, SCHEMA_ARRAY_KEYS, SCHEMA_REF_1_DOC_KEYS, SCHEMA_REF_1_OR_N_EMBD_KEYS, SCHEMA_REF_N_DOCS_KEYS, ST_IS_TYPED_ARRAY, IS_DOCUMENT, ST_IS_CUSTOM_TYPE, SCHEMA_BASIC_FROM_DATA, SCHEMA_ALL_ENTRIES_NO_ID, ST_IS_EMBED_ARRAY, SCHEMA_ALL_ENTRIES_FOR_JSON } from './symbols.js'; import {generateSchemaForDocument} from './schema.js'; import {deprecate} from './util.js'; const EMPTY_HOOK = async () => null; /** * @typedef {Object} CamoPopulateTarget * @property {BaseDocument[]|string[]} ref1DocsAndProps - like [doc1, 'foo', doc2, 'bar', ...] * @property {(BaseDocument|string)[]} refNDocsAndProps - as above, but foo and bar are array properties of doc1/doc2 */ /** * @type {Map<typeof BaseDocument, Object.<string, CamoPopulateTarget>>} */ let docClassToIdPopTargetMap = new Map(); /** @type {Map<typeof BaseDocument, Object>} */ const schemaByDocClass = new Map(); /** * @property {string} _id * @property {CamoSchema} _schema * @abstract */ export class BaseDocument { /** * Get custom collection name. * The name is autogenerated by the lowercase class' name with an 's' appended. * Can be overridden individually by derived classes. * * @returns {String} */ static collectionName() { return this.hasOwnProperty(COLLECTION_NAME) ? this[COLLECTION_NAME] : (this[COLLECTION_NAME] = this.name.toLowerCase() + 's'); //eslint-disable-line } /** * @return {string} * @internal */ collectionName() { return this.constructor.collectionName(); } /** * User-defined schema. * Expected to be overridden by derived Document/EmbeddedDocument classes. * @return {?Object|function():Object} */ static SCHEMA = null; /** * Set schema * @param {Object} extension * @deprecated - override static SCHEMA in derived classes instead */ schema(extension) { if (schemaByDocClass.has(this.constructor)) { return; } deprecate(`Defining schemas by calling this.schema() is deprecated. Override static field ${this.constructor.name}.SCHEMA instead.`); if (!extension || typeof extension !== 'object') { return; } for (const key of Object.keys(extension)) { if (key[0] === '_' || key === 'toString') { throw new Error(`Forbidden schema key name "${key}"`); } this[key] = extension[key]; } } /* * Pre/post Hooks * * To add a hook, the extending class just needs * to override the appropriate hook method below. */ /** * Get default value * * @param {String} schemaKey Key of current schema * @returns {*} */ _getDefaultValueForKey(schemaKey) { if (schemaKey === '_id') { return null; } let defaultForKey = this._schema[schemaKey]?.default; return (typeof defaultForKey === 'function') ? defaultForKey() : defaultForKey; } /** * Validate current document * The method throw errors if document has invalid value */ validate() { for (const key of this._schema[SCHEMA_ALL_KEYS]) { /** @type {CamoSchemaEntry} */ let schemaEntry = this._schema[key]; let {type: schemaType, match, min, max, choices, validate, required} = schemaEntry, isArrayTypeEntry = schemaEntry[ST_IS_TYPED_ARRAY], value = this[key]; if (required) { if (value === undefined || value === null || (!(typeof value === 'number' || value instanceof Date || typeof value === 'boolean') && !Object.keys(value).length)) { throw new ValidationError(this, key, 'is required but empty'); } } if (validate && !validate(value)) { throw new ValidationError(this, key, 'was rejected by custom validate()'); } if (value === null || value === undefined | schemaEntry[ST_IS_CUSTOM_TYPE]) { continue; } // simple types (String, Number, Boolean, Buffer, Date) if (isArrayTypeEntry) { let {elementType} = schemaType, values = value; // just a plural-named alias for clarity in this block if (!isArray(values)) { throw new ValidationError(this, key, `should be array of ${elementType.name}, but is ${typeof values}`); } if (!elementType[IS_BASE_DOCUMENT]) { // Boolean[], Number[], String[], Date[], Buffer[] let expectsDate = elementType === Date; for (const elem of values) { if (elem?.constructor !== elementType && !(expectsDate && isNumber(elem))) { throw new ValidationError(this, key, `contains a non-${elementType.name} element`); } } // from here, it's a packed, uni-type Array with all elements having the correct type if (match && ~values.findIndex(s => !match.test(s))) { throw new ValidationError(this, key, 'contains a non-matching string element'); } if (min !== undefined && ~values.findIndex(n => n < min)) { throw new ValidationError(this, key, `contains an element less than min ${min}`); } if (max !== undefined && ~values.findIndex(n => n > max)) { throw new ValidationError(this, key, `contains an element greater than max ${max}`); } } else if (elementType[IS_EMBEDDED]) { // EmbeddedDocument[] for (const embedded of values) { if (!(embedded instanceof elementType)) { throw new ValidationError(this, key, `contains a non-${elementType.name} element`); } embedded.validate(); } } else if (elementType[IS_DOCUMENT]) { // Document[] for (const elem of values) { if (!isNativeId(elem instanceof elementType ? elem._id : elem)) { throw new ValidationError(this, key, `contains a non-${elementType.name} element or an un-saved one`); } } } else { throw new Error(`Unexpected elemType ${elementType.name} - This should never happen!`); } } else if (!schemaType[IS_BASE_DOCUMENT]) { // Boolean, Number, String, Date, Buffer if (value.constructor !== schemaType) { // no longer supporting Numbers in Date properties throw new ValidationError(this, key, `should be ${schemaType.name}, but is ${typeof value}`); } if (match && !match.test(value)) { throw new ValidationError(this, key, 'does not match the Regex'); } if (choices && !choices.includes(value)) { throw new ValidationError(this, key, `must be one of choices [${choices.join(',')}], but got ${value}`); } if (min !== undefined && value < min) { throw new ValidationError(this, key, `has a value less than min ${min}`); } if (max !== undefined && value > max) { throw new ValidationError(this, key, `has a value greater than max ${max}`); } } else if (schemaType[IS_EMBEDDED]) { if (!(value instanceof schemaType)) { throw new ValidationError(this, key, `should be ${schemaType.name}, but is ${typeof value}`); } value.validate(); } else if (schemaType[IS_BASE_DOCUMENT]) { let isDocInstance = value instanceof schemaType; if (!isDocInstance && !isNativeId(value)) { throw new ValidationError(this, key, `should be ${schemaType.name}, but is ${typeof value}`); } if (isDocInstance && !value._id) { throw new ValidationError(this, key, ''); } } else { throw new Error(`Unexpected schemaType ${schemaType.name} - This should never happen!`); } } } /** @internal */ _createIndexesOnce() { } /** * Create new document from data * * @param {Object} [data] * @returns {this} */ static create(data) { if (data) { return this._fromData(data); } return this._instantiate(); } /** * Create new document from self. * If no schema was created for this (sub)class yet, * it will be created ONCE and cached in the prototype, * so we never generate a schema twice. * * @returns {BaseDocument} */ static _instantiate() { let instance = new this(), schema = schemaByDocClass.get(this); if (!schema) { let parentClass = Object.getPrototypeOf(this); if (parentClass !== BaseDocument && !schemaByDocClass.has(parentClass)) { // ensure that all classes up the inheritance tree have an initialized _schema (issue #4) parentClass._instantiate(); } schema = generateSchemaForDocument(instance); schemaByDocClass.set(this, schema); this.prototype._schema = schema; instance._createIndexesOnce(); // console.log('Generated schema for %s, total schemas: %s; schema: %s', this.name, schemaByDocClass.size, schema); } // apply schema default values for (const key of schema[SCHEMA_ARRAY_KEYS]) { instance[key] = instance._getDefaultValueForKey(key) || []; } for (const key of schema[SCHEMA_1PROP_KEYS]) { instance[key] = instance._getDefaultValueForKey(key); } return instance; } /** * @param {Object|Object[]} data - raw POJO(s) from database * @return {BaseDocument|BaseDocument[]} one or many document instances */ static _fromData(data) { let docs = []; for (const obj of isArray(data) ? data : [data]) { let instance = this._instantiate(), /** @type {CamoSchema} */ schema = instance._schema; for (const key of Object.keys(obj)) { if (key === '_id') { instance._id = obj._id; continue; } let objVal = obj[key], schemaEntry = schema[key]; if (!schemaEntry) { if (key === '__proto__') { throw new Error('Data key __proto__ is not allowed'); } if (instance.onUnknownData !== NOP) { instance.onUnknownData(key, objVal); } continue; } let newVal = (objVal === null || objVal === undefined) ? instance._getDefaultValueForKey(key) : objVal; if (newVal === null || newVal === undefined) { instance[key] = newVal; continue; } let schemaType = schemaEntry.type, basicMapperFn = schemaEntry[SCHEMA_BASIC_FROM_DATA]; if (basicMapperFn) { instance[key] = basicMapperFn(newVal); } else if (schemaEntry[ST_IS_TYPED_ARRAY]) { // Initialize array of EmbeddedDocuments instance[key] = isArray(newVal) ? newVal.map(schemaType.mapFromData) : []; } else if (schemaEntry[ST_IS_CUSTOM_TYPE]) { instance[key] = schemaType.fromData(newVal); } else { throw new Error(`Unexpected flow for data key "${key}" - This should never happen`); } } docs.push(instance); } return (docs.length === 1) ? docs[0] : docs; } /** * Can be overridden in derived classes for data migration purposes and the likes. * Invoked when creating an instance manually or by create() with a data object containing * keys with no corresponding entry in the Document's schema. * * @param {string} dataKey * @param {*} dataVal */ onUnknownData(dataKey, dataVal) { } populate() { return BaseDocument.populate(this); } /** * Populates document references * * @param {BaseDocument[]|BaseDocument} docOrDocs * @param {?Array} [fields] if an array, only the contained field names will be populated * @returns {Promise} */ static populate(docOrDocs, fields) { if (!docOrDocs) { return Promise.resolve([]); } /** @type {BaseDocument[]} */ let documents = isArray(docOrDocs) ? docOrDocs : [docOrDocs]; if (!documents.length) { return Promise.resolve([]); } // Load all 1-level-deep references, Find all unique keys needed to be loaded... // Assumption here: all documents in the database will have the same schema let firstSchema = documents[0]._schema, useFields = fields && Array.isArray(fields) && fields.length; docClassToIdPopTargetMap.clear(); // Handle multi-reference keys (example schema: { myDocs: [MyDocumentClass] }) for (const key of firstSchema[SCHEMA_REF_N_DOCS_KEYS]) { if (useFields && fields.indexOf(key) < 0) { continue; } for (const doc of documents) { let referencedIds = doc[key]; if (isArray(referencedIds) && referencedIds.length) { let referencedDocClass = firstSchema[key].type.elementType, id2popTarget = docClassToIdPopTargetMap.get(referencedDocClass); if (!id2popTarget) { docClassToIdPopTargetMap.set(referencedDocClass, id2popTarget = Object.create(null)); } for (const id of referencedIds) { if (id2popTarget[id]) { id2popTarget[id].refNDocsAndProps.push(doc, key); } else { id2popTarget[id] = { refNDocsAndProps: [doc, key], ref1DocsAndProps: [] }; } } doc[key] = []; // flush the ids -> referenced values are pushed in later instead } } } // Handle single reference keys (example schema: { myDoc: MyDocumentClass }) for (const key of firstSchema[SCHEMA_REF_1_DOC_KEYS]) { if (useFields && fields.indexOf(key) < 0) { continue; } for (const doc of documents) { let referencedId = doc[key]; if (referencedId && isNativeId(referencedId)) { let referencedDocClass = firstSchema[key].type, id2popTarget = docClassToIdPopTargetMap.get(referencedDocClass); if (!id2popTarget) { docClassToIdPopTargetMap.set(referencedDocClass, id2popTarget = Object.create(null)); } if (id2popTarget[referencedId]) { id2popTarget[referencedId].ref1DocsAndProps.push(doc, key); } else { id2popTarget[referencedId] = { refNDocsAndProps: [], ref1DocsAndProps: [doc, key] }; } } } } let loadPromises = []; for (const [docClass, id2popTarget] of docClassToIdPopTargetMap.entries()) { loadPromises.push(docClass.find({_id: {$in: Object.keys(id2popTarget)}}, {populate: false}).then(foundDocs => { for (const foundDoc of foundDocs) { let {ref1DocsAndProps, refNDocsAndProps} = id2popTarget[foundDoc._id]; for (let i = 0, len = ref1DocsAndProps.length; i < len; i++) { let targetDoc = ref1DocsAndProps[i], targetProp = ref1DocsAndProps[++i]; targetDoc[targetProp] = foundDoc; } for (let i = 0, len = refNDocsAndProps.length; i < len; i++) { let targetDoc = refNDocsAndProps[i], targetProp = refNDocsAndProps[++i]; targetDoc[targetProp].push(foundDoc); } } })); } // ...and finally execute all promises and return our fully loaded documents. return Promise.all(loadPromises).then(() => docOrDocs); } /** * For JSON.stringify * * @returns {*} * @override */ toJSON() { let obj = this._toData(true); for (const key of Object.keys(obj)) { let val = obj[key]; if (val && val.toJSON) { obj[key] = val.toJSON(); } else if (isArray(val)) { obj[key] = val.map(v => (v && v.toJSON) ? v.toJSON() : v); } } return obj; } /** * Returns a JSON object copy of a Document/EmbeddedDocument. * Values of type EmbeddedDocument are converted to POJOs, while Document-type values are preserved. * The returned document serves as the raw basis for both {@link toJSON} and {@link Document.save}. * * IMPORTANT: This methods adheres strictly to the schema, * i.e. the output contains only such properties that actually have a schema entry! * * @param {boolean} [isForJson] * @returns {Object} */ _toData(isForJson) { let dataObj = Object.create(null); for (const schemaEntry of this._schema[isForJson ? SCHEMA_ALL_ENTRIES_FOR_JSON : SCHEMA_ALL_ENTRIES_NO_ID]) { let key = schemaEntry._key, val = this[key]; if (schemaEntry.type[IS_EMBEDDED]) { dataObj[key] = val ? val._toData(isForJson) : undefined; } else if (schemaEntry[ST_IS_EMBED_ARRAY]) { dataObj[key] = isArray(val) ? (val.length ? val.map(v => v._toData(isForJson)) : []) : undefined; } else { dataObj[key] = val; } } return dataObj; } /** * @return {BaseDocument[]} - will NOT contain falsy values */ _getSelfAndEmbeddeds() { let arr = [this]; for (const key of this._schema[SCHEMA_REF_1_OR_N_EMBD_KEYS]) { let emb = this[key]; if (isArray(emb)) { arr.push(...emb.filter(o => !!o)); } else if (emb) { arr.push(emb); } } return arr; } /** * Runs for a given hook name the respective hook functions of the Document and all of its EmbeddedDocuments * * @param {string} hookName * @return {Promise} * @internal */ _executeHook(hookName) { let hookPromises = [], totalPromises = 0; for (const doc of this._getSelfAndEmbeddeds()){ if (doc[hookName] === EMPTY_HOOK) { continue; // most likely case frequently } let hookFn = doc[hookName]; if (typeof hookFn !== 'function') { return Promise.reject(`Hook ${doc.constructor.name}.${hookName} is not a function`); } let promise = hookFn.apply(doc); if (promise && promise instanceof Promise) { totalPromises = hookPromises.push(promise); } } return totalPromises === 1 ? hookPromises[0] : totalPromises ? Promise.all(hookPromises) : Promise.resolve(); } /** @deprecated */ get id() { throw new Error('Document.id - use Document._id instead'); } /** @deprecated */ set id(x) { throw new Error('Document.id - use Document._id instead'); } } BaseDocument.prototype._schema = Object.create(null); BaseDocument.prototype._schema[SCHEMA_ALL_KEYS] = []; BaseDocument[IS_BASE_DOCUMENT] = true; BaseDocument.prototype[IS_BASE_DOCUMENT] = true; const NOP = () => {}; /** * @type {Map<string, CamoUnknownDataKeyHandler>} */ const _unknownDataKeyHandlers = new Map([ ['throw', function throwOnUnknownData(dataKey) { throw new Error(`Unknown data key "${dataKey}" for new ${this.constructor.name}`); }], ['ignore', NOP], ['accept', function acceptUnkownData(dataKey, dataVal) { this[dataKey] = dataVal; }], ['logAndAccept', function logAndAcceptUnkownData(dataKey, dataVal) { this[dataKey] = dataVal; console.log(`Accepted unknown data key "${dataKey}" for new ${this.constructor.name}`); }], ['logAndIgnore', function logAndIgnoreUnkownData(dataKey, dataVal) { console.log(`Ignored unknown data key "${dataKey}" for new ${this.constructor.name}`); }] ]); _unknownDataKeyHandlers.set('default', _unknownDataKeyHandlers.get('throw')); /** * @param {'throw'|'ignore'|'accept'|'logAndAccept'|'logAndIgnore'|'default'|CamoUnknownDataKeyHandler} modeOrFn */ export const setUnknownDataKeyBehavior = (modeOrFn) => { let handler = typeof modeOrFn === 'function' ? modeOrFn : _unknownDataKeyHandlers.get(modeOrFn); if (!handler) { throw new Error('Invalid argument for setUnknownDataKeyBehavior()'); } BaseDocument.prototype.onUnknownData = handler; }; setUnknownDataKeyBehavior('default'); /** * @return {CamoUnknownDataKeyHandler} * @internal */ export const _getUnknownDataKeyBehavior = () => BaseDocument.prototype.onUnknownData; Object.assign(BaseDocument.prototype, { preValidate: EMPTY_HOOK, postValidate: EMPTY_HOOK, preSave: EMPTY_HOOK, postSave: EMPTY_HOOK, preDelete: EMPTY_HOOK, postDelete: EMPTY_HOOK }); /** * @callback CamoUnknownDataKeyHandler * @param {string} dataKey * @param {*} dataValue * @this {BaseDocument} - the document instance the data is intended for */