UNPKG

@justlep/camo

Version:

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

317 lines (273 loc) 10.8 kB
import {hasOwnProp} from './util.js'; import {getClientInstance as DB, toCanonicalId} from './client.js'; import {_getUnknownDataKeyBehavior, BaseDocument, setUnknownDataKeyBehavior} from './base-document.js'; import {isArray} from './validate.js'; import { IS_DOCUMENT, SCHEMA_ALL_ENTRIES, SCHEMA_REF_1_DOC_KEYS, SCHEMA_REF_N_DOCS_KEYS } from './symbols.js'; /** * @abstract */ export class Document extends BaseDocument { static SCHEMA = () => ({_id: DB().nativeIdType()}); constructor() { super(); this._id = null; if (arguments.length) { throw new Error('No arguments allowed for Document'); } } /** * Purges a list of properties from the actual database file. * @param {string[]} propNames - the property names (must not be part of the schema) * @param {boolean} [returnIds] - if true, the return object's `ids` property will contain the ids of updated documents * @param {boolean} [shouldCompactIfPurged] if true, the data file will be compacted if any docs where changed * @return {Promise<{totalUpdates: number, ids: ?string[]}>} */ static async purgeObsoleteProperties(propNames, returnIds, shouldCompactIfPurged) { const propsUnsetMap = Object.create(null); for (const name of propNames) { if (!name || typeof name !== 'string' || name.startsWith('_')) { throw new Error('Invalid property name to purge: ' + name); } if (hasOwnProp(this.SCHEMA, name)) { throw new Error(`Cannot purge property "${name}" (part of the schema)`); } propsUnsetMap[name] = true; } const query = {$or: propNames.map(propName => ({[propName]: {$exists: true}}))}; const ids = returnIds ? [] : null; const oldUnknownDataKeyBehavior = _getUnknownDataKeyBehavior(); let totalUpdates = 0, doc; try { setUnknownDataKeyBehavior('ignore'); // eslint-disable-next-line no-cond-assign while (doc = await this.findOneAndUpdate(query, propsUnsetMap, {unset: true, populate: false})) { for (const name of propNames) { delete doc[name]; } totalUpdates++; ids?.push(doc._id); } if (totalUpdates && shouldCompactIfPurged) { await new Promise((resolve, reject) => DB()._getDb(this.collectionName()).persistence.compactDatafile(err => err ? reject(err) : resolve())); } } finally { setUnknownDataKeyBehavior(oldUnknownDataKeyBehavior); } return {totalUpdates, ids}; } /** * Save (upsert) current document * * @returns {Promise} */ async save() { await this._executeHook('preValidate'); this.validate(); // Validate the assigned type, choices, and min/max await this._executeHook('postValidate'); await this._executeHook('preSave'); let docToSave = this._toData(), schema = this._schema; // turn Document-type values into canonical ids if (schema[SCHEMA_REF_1_DOC_KEYS].length) { for (const key of schema[SCHEMA_REF_1_DOC_KEYS]) { if (docToSave[key]?.[IS_DOCUMENT]) { docToSave[key] = toCanonicalId(docToSave[key]._id); } } } // turn Document-type arrays into arrays of canonical ids if (schema[SCHEMA_REF_N_DOCS_KEYS].length) { for (const key of schema[SCHEMA_REF_N_DOCS_KEYS]) { let val = docToSave[key]; if (isArray(val) && val.length) { docToSave[key] = val.map(v => v && v[IS_DOCUMENT] ? toCanonicalId(v._id) : v); } } } // TODO skipping the following Embedded-handling part, // as it has all been done by _toData() already, // and no hooks can have added/modified any embeddeds since then. // Think about once again, then delete this section // Replace EmbeddedDocument references with just their data // for (const key of this._schema[SCHEMA_REF_1_OR_N_EMBD_KEYS]) { // let val = this[key]; // if (!val) { // continue; // } // if (val[IS_EMBEDDED]) { // docToSave[key] = val._toData(); // } else if (isArray(val) && val.length) { // docToSave[key] = val.map(v => v && (v[IS_EMBEDDED] ? v._toData() : v)); // } // } let newId = await DB().save(this.collectionName(), this._id, docToSave); if (this._id === null) { this._id = newId; } await this._executeHook('postSave'); return this; } /** * Delete current document * * @returns {Promise<number>} # of deleted documents */ async delete() { await this._executeHook('preDelete'); let totalDeleted = await DB().delete(this.collectionName(), this._id); await this._executeHook('postDelete'); return totalDeleted; } /** * Delete one document in current collection * * @param {Object} query Query * @returns {Promise} */ static deleteOne(query) { return DB().deleteOne(this.collectionName(), query); } /** * Delete many documents in current collection * * @param {Object} [query] Query * @returns {Promise} */ static deleteMany(query) { if (query === undefined || query === null) { query = {}; } return DB().deleteMany(this.collectionName(), query); } /** * Find one document in current collection * * @param {Object} query Query * @param {?DocumentFindOptions} [options] * @returns {Promise<this>} */ static async findOne(query, options) { let data = await DB().findOne(this.collectionName(), query); if (!data) { return null; } let doc = this._fromData(data), populate = getPopulateOption(options); return populate ? await this.populate(doc, populate) : doc; } /** * Find one document and update it in current collection * * @param {Object} query Query * @param {Object} values * @param {{[upsert]: boolean, [multi]: boolean, [populate]: string[]|boolean}} [options] * @returns {Promise<?this>} - the document after update, null if none found to update */ static async findOneAndUpdate(query, values, options = {}) { if (arguments.length < 2) { throw new Error('findOneAndUpdate requires at least 2 arguments. Got ' + arguments.length + '.'); } let data = await DB().findOneAndUpdate(this.collectionName(), query, values, options); if (!data) { return null; } let doc = this._fromData(data), populate = getPopulateOption(options); return populate ? this.populate(doc, populate) : doc; } /** * Find one document and delete it in current collection * * @param {Object} query Query * @param {Object} options * @returns {Promise<number>} - number of removed documents */ static findOneAndDelete(query, options) { if (arguments.length < 1) { throw new Error(`findOneAndDelete requires at least 1 argument. Got ${arguments.length}.`); } return DB().findOneAndDelete(this.collectionName(), query, options); } /** * Find documents * * @param {Object} query Query * @param {Object} [options] * @returns {Promise<this[]>} */ static async find(query, options = {}) { if (query === undefined || query === null) { query = {}; } if (typeof options !== 'object') { throw new Error('Invalid options'); } let data = await DB().find(this.collectionName(), query, options); if (!data || isArray(data) && !data.length) { return []; } let docOrDocs = this._fromData(data), populate = getPopulateOption(options); if (populate) { await this.populate(docOrDocs, populate); } return isArray(docOrDocs) ? docOrDocs : [docOrDocs]; } /** * Get count documents in current collection by query * * @param {Object} query Query * @returns {Promise<number>} */ static count(query) { return DB().count(this.collectionName(), query); } /** * Creates indexes. * Invoked only once per document class, just after the derived class' _schema was added to its prototype. * @override * @internal */ _createIndexesOnce() { for (const schemaEntry of this._schema[SCHEMA_ALL_ENTRIES]) { if (schemaEntry.unique || schemaEntry.indexed) { // console.log(`Created index for field "${schemaEntry._key}" of collection "${this.constructor.collectionName()}"`); DB().createIndex(this.constructor.collectionName(), schemaEntry._key, {unique: !!schemaEntry.unique}); } } } /** * Clear current collection * * @returns {Promise} */ static clearCollection() { return DB().clearCollection(this.collectionName()); } } /** * @typedef {Object} DocumentFindOptions * @property {boolean|string[]} [populate] */ /** * Extracts a populate option from a given Object (null allowed). * Default return value is true (meaning "populate all") if no populate option was found. * * @param {?DocumentFindOptions} opts - a thing that contains a 'populate' property or not * @return {boolean|string[]} - false meaning don't populate at all, true -> populate all, string[] -> only those fields */ const getPopulateOption = (opts) => { if (!opts || !hasOwnProp(opts, 'populate') || opts.populate === true) { return true; } return isArray(opts.populate) && opts.populate.length ? opts.populate : false; }; Document[IS_DOCUMENT] = true; Document.prototype[IS_DOCUMENT] = true;