UNPKG

@nymphjs/nymph

Version:

Nymph.js - Nymph ORM

961 lines 33 kB
import { ConfigDefaults as defaults } from './conf/index.js'; import Entity from './Entity.js'; import { ClassNotAvailableError, InvalidParametersError, } from './errors/index.js'; /** * An object relational mapper for Node.js. * * Written by Hunter Perrin for SciActive. * * @author Hunter Perrin <hperrin@port87.com> * @copyright SciActive Inc * @see http://nymph.io/ */ export default class Nymph { /** * The Nymph config. */ config; /** * The Nymph instance that this one was cloned from, or null if it's not a * clone. */ parent; /** * The Nymph database driver. */ driver; /** * An optional Tilmeld user/group manager instance. */ tilmeld = undefined; /** * A simple map of names to Entity classes. */ entityClasses = {}; /** * The entity class for this instance of Nymph. */ Entity; connectCallbacks = []; disconnectCallbacks = []; queryCallbacks = []; beforeGetEntityCallbacks = []; beforeGetEntitiesCallbacks = []; beforeSaveEntityCallbacks = []; afterSaveEntityCallbacks = []; failedSaveEntityCallbacks = []; beforeDeleteEntityCallbacks = []; afterDeleteEntityCallbacks = []; failedDeleteEntityCallbacks = []; beforeDeleteEntityByIDCallbacks = []; afterDeleteEntityByIDCallbacks = []; failedDeleteEntityByIDCallbacks = []; beforeNewUIDCallbacks = []; afterNewUIDCallbacks = []; failedNewUIDCallbacks = []; beforeSetUIDCallbacks = []; afterSetUIDCallbacks = []; failedSetUIDCallbacks = []; beforeRenameUIDCallbacks = []; afterRenameUIDCallbacks = []; failedRenameUIDCallbacks = []; beforeDeleteUIDCallbacks = []; afterDeleteUIDCallbacks = []; failedDeleteUIDCallbacks = []; beforeStartTransactionCallbacks = []; afterStartTransactionCallbacks = []; beforeCommitTransactionCallbacks = []; afterCommitTransactionCallbacks = []; beforeRollbackTransactionCallbacks = []; afterRollbackTransactionCallbacks = []; /** * Initialize Nymph. * * @param config The Nymph configuration. * @param driver The Nymph database driver. * @param tilmeld The Tilmeld user/group manager instance, if you want to use it. * @param parent Used internally by Nymph. Don't set this. */ constructor(config, driver, tilmeld, parent) { this.config = { ...defaults, ...config }; this.driver = driver; this.parent = parent ?? null; this.Entity = this.addEntityClass(Entity); if (parent) { for (const name in parent.entityClasses) { if (name === 'Entity' || parent.entityClasses[name].skipOnClone) { continue; } this.addEntityClass(parent.entityClasses[name]); } const events = [ 'connect', 'disconnect', 'query', 'beforeGetEntity', 'beforeGetEntities', 'beforeSaveEntity', 'afterSaveEntity', 'failedSaveEntity', 'beforeDeleteEntity', 'afterDeleteEntity', 'failedDeleteEntity', 'beforeDeleteEntityByID', 'afterDeleteEntityByID', 'failedDeleteEntityByID', 'beforeNewUID', 'afterNewUID', 'failedNewUID', 'beforeSetUID', 'afterSetUID', 'failedSetUID', 'beforeRenameUID', 'afterRenameUID', 'failedRenameUID', 'beforeDeleteUID', 'afterDeleteUID', 'failedDeleteUID', 'beforeStartTransaction', 'afterStartTransaction', 'beforeCommitTransaction', 'afterCommitTransaction', 'beforeRollbackTransaction', 'afterRollbackTransaction', ]; for (let event of events) { const prop = event + 'Callbacks'; // @ts-ignore: The callback should be the right type here. const callbacks = parent[prop]; for (let callback of callbacks) { if (callback.skipOnClone) { continue; } this.on(event, callback); } } } this.config.debugInfo('nymph', 'Nymph loaded.'); if (tilmeld != null) { this.tilmeld = tilmeld; } this.driver.init(this); this.config.debugInfo('nymph', 'Nymph driver loaded.'); if (this.tilmeld) { this.tilmeld.init(this); this.config.debugInfo('nymph', 'Tilmeld loaded.'); } } /** * Add your class to this instance. * * This will create a class that extends your class within this instance of * Nymph and return it. You can then use this class's constructor and methods, * which will use this instance of Nymph. * * Because this creates a subclass, don't use the class * returned from `getEntityClass` to check with `instanceof`. */ addEntityClass(EntityClass) { if (EntityClass.class in this.entityClasses) { this.config.debugLog('nymph', `Adding duplicate class "${EntityClass.class}". This may be a mistake.`); } // Check that it doesn't have the same etype as any other class. for (const ExistingClass of Object.values(this.entityClasses)) { if (EntityClass.ETYPE === ExistingClass.ETYPE && EntityClass.class !== EntityClass.class) { this.config.debugLog('nymph', `Adding class "${EntityClass.class}" with same etype as existing class "${ExistingClass.class}". This may be a mistake.`); } } const nymph = this; this.entityClasses[EntityClass.class] = class extends EntityClass { static nymph = nymph; constructor(...args) { super(...args); } }; return this.entityClasses[EntityClass.class]; } getEntityClass(className) { let key = null; if (typeof className === 'string') { key = className; } else { key = className.class; } if (key in this.entityClasses) { return this.entityClasses[key]; } this.config.debugError('nymph', `Tried to use missing class "${key}".`); throw new ClassNotAvailableError(`Tried to use missing class "${key}".`); } /** * Get the class that uses the specified etype. * * Note that it is fine, though unusual, for two classes to use the same * etype. However, this can lead to very hard to diagnose bugs, so is * generally discouraged. */ getEntityClassByEtype(etype) { for (const EntityClass of Object.values(this.entityClasses)) { if (EntityClass.ETYPE === etype) { return EntityClass; } } this.config.debugError('nymph', `Tried to use missing class by etype "${etype}".`); throw new ClassNotAvailableError(`Tried to use missing class by etype "${etype}".`); } /** * Get a clone of this instance with cloned classes and event listeners. * * @returns A clone of this instance. */ clone() { return new Nymph(this.config, this.driver.clone(), this.tilmeld?.clone(), this); } /** * Connect to the database. * * @returns Whether the instance is connected to the database. */ async connect() { try { const result = this.driver.connect(); for (let callback of this.connectCallbacks) { if (callback) { await callback(this, result); } } this.config.debugInfo('nymph', 'Driver connected.'); return await result; } catch (e) { this.config.debugError('nymph', `Failed to connect: ${e}`); throw e; } } /** * Disconnect from the database. * * @returns Whether the instance is connected to the database. */ async disconnect() { try { const result = this.driver.disconnect(); for (let callback of this.disconnectCallbacks) { if (callback) { await callback(this, result); } } this.config.debugInfo('nymph', 'Driver disconnected.'); return await result; } catch (e) { this.config.debugError('nymph', `Failed to disconnect: ${e}`); throw e; } } /** * Run all the query callbacks on a query. */ runQueryCallbacks(options, selectors) { for (let callback of this.queryCallbacks) { if (callback) { callback(this, options, selectors); } } } /** * Start an atomic transaction and returns a new instance of Nymph. * * All proceeding changes using this new instance will wait to be written to * the database's permanent storage until commit() is called. You can also * undo all the changes since this function ran with rollback(). * * Transactions will nest as long as every name is unique. Internally, Nymph * uses names prefixed with "nymph-". * * @returns A new instance of Nymph that should be used for the transaction. */ async startTransaction(name) { try { for (let callback of this.beforeStartTransactionCallbacks) { if (callback) { await callback(this, name); } } this.config.debugLog('nymph', `Starting transaction "${name}".`); const result = await this.driver.startTransaction(name); for (let callback of this.afterStartTransactionCallbacks) { if (callback) { await callback(this, name, result); } } return result; } catch (e) { this.config.debugError('nymph', `Failed to start transaction: ${e}`); throw e; } } /** * Commit the named transaction. * * After this is called, the transaction instance should be discarded. * * @returns True on success, false on failure. */ async commit(name) { try { for (let callback of this.beforeCommitTransactionCallbacks) { if (callback) { await callback(this, name); } } this.config.debugLog('nymph', `Committing transaction "${name}".`); const result = await this.driver.commit(name); for (let callback of this.afterCommitTransactionCallbacks) { if (callback) { await callback(this, name, result); } } return result; } catch (e) { this.config.debugError('nymph', `Failed to commit transaction: ${e}`); throw e; } } /** * Rollback the named transaction. * * After this is called, the transaction instance should be discarded. * * @returns True on success, false on failure. */ async rollback(name) { try { for (let callback of this.beforeRollbackTransactionCallbacks) { if (callback) { await callback(this, name); } } this.config.debugLog('nymph', `Rolling back transaction "${name}".`); const result = await this.driver.rollback(name); for (let callback of this.afterRollbackTransactionCallbacks) { if (callback) { await callback(this, name, result); } } return result; } catch (e) { this.config.debugError('nymph', `Failed to roll back transaction: ${e}`); throw e; } } /** * Check if there is any open transaction. * * @returns True if there is a transaction. */ async inTransaction() { return await this.driver.inTransaction(); } /** * Increment or create a unique ID and return the new value. * * Unique IDs, or UIDs are similar to GUIDs, but numeric and sequential. * * A UID can be used to identify an object when the GUID doesn't suffice. On * a system where a new entity is created many times per second, referring * to something by its GUID may be unintuitive. However, the component * designer is responsible for assigning UIDs to the component's entities. * Beware that if a UID is incremented for an entity, and the entity cannot * be saved, there is no safe, and therefore, no recommended way to * decrement the UID back to its previous value. * * If newUID() is passed the name of a UID which does not exist yet, one * will be created with that name, and assigned the value 1. If the UID * already exists, its value will be incremented. The new value will be * returned. * * @param name The UID's name. * @returns The UID's new value, or null on failure. */ async newUID(name) { try { for (let callback of this.beforeNewUIDCallbacks) { if (callback) { await callback(this, name); } } let result; try { result = this.driver.newUID(name); } catch (e) { for (let callback of this.failedNewUIDCallbacks) { if (callback) { await callback(this, e); } } throw e; } for (let callback of this.afterNewUIDCallbacks) { if (callback) { await callback(this, result); } } return await result; } catch (e) { this.config.debugError('nymph', `Failed to create UID "${name}": ${e}`); throw e; } } /** * Get the current value of a unique ID. * @param name The UID's name. * @returns The UID's value, or null on failure and if it doesn't exist. */ async getUID(name) { try { return await this.driver.getUID(name); } catch (e) { this.config.debugError('nymph', `Failed to get UID "${name}": ${e}`); throw e; } } /** * Set the value of a UID. * * @param name The UID's name. * @param value The value. * @returns True on success, false on failure. */ async setUID(name, value) { try { for (let callback of this.beforeSetUIDCallbacks) { if (callback) { await callback(this, name, value); } } let result; try { result = this.driver.setUID(name, value); } catch (e) { for (let callback of this.failedSetUIDCallbacks) { if (callback) { await callback(this, e); } } throw e; } for (let callback of this.afterSetUIDCallbacks) { if (callback) { await callback(this, result); } } return await result; } catch (e) { this.config.debugError('nymph', `Failed to set UID "${name}": ${e}`); throw e; } } /** * Delete a unique ID. * * @param name The UID's name. * @returns True on success, false on failure. */ async deleteUID(name) { try { for (let callback of this.beforeDeleteUIDCallbacks) { if (callback) { await callback(this, name); } } let result; try { result = this.driver.deleteUID(name); } catch (e) { for (let callback of this.failedDeleteUIDCallbacks) { if (callback) { await callback(this, e); } } throw e; } for (let callback of this.afterDeleteUIDCallbacks) { if (callback) { await callback(this, result); } } return await result; } catch (e) { this.config.debugError('nymph', `Failed to delete UID "${name}": ${e}`); throw e; } } /** * Rename a unique ID. * * @param oldName The old name. * @param newName The new name. * @returns True on success, false on failure. */ async renameUID(oldName, newName) { try { for (let callback of this.beforeRenameUIDCallbacks) { if (callback) { await callback(this, oldName, newName); } } let result; try { result = this.driver.renameUID(oldName, newName); } catch (e) { for (let callback of this.failedRenameUIDCallbacks) { if (callback) { await callback(this, e); } } throw e; } for (let callback of this.afterRenameUIDCallbacks) { if (callback) { await callback(this, result); } } return await result; } catch (e) { this.config.debugError('nymph', `Failed to rename UID "${oldName}" to "${newName}": ${e}`); throw e; } } /** * Save an entity to the database. * * If the entity has never been saved (has no GUID), a variable "cdate" * is set on it with the current Unix timestamp. * * The variable "mdate" is set to the current Unix timestamp. * * @param entity The entity. * @returns True on success, false on failure. */ async saveEntity(entity) { try { for (let callback of this.beforeSaveEntityCallbacks) { if (callback) { await callback(this, entity); } } let result; try { result = this.driver.saveEntity(entity); } catch (e) { for (let callback of this.failedSaveEntityCallbacks) { if (callback) { await callback(this, e); } } throw e; } for (let callback of this.afterSaveEntityCallbacks) { if (callback) { await callback(this, result); } } return await result; } catch (e) { this.config.debugError('nymph', `Failed to save entity: ${e}`); throw e; } } async getEntities(options = {}, ...selectors) { if (options.source === 'client' && options.return === 'object') { throw new InvalidParametersError('Object return type not allowed from client.'); } try { for (let callback of this.beforeGetEntitiesCallbacks) { if (callback) { await callback(this, options, selectors); } } return await this.driver.getEntities(options, ...selectors); } catch (e) { this.config.debugError('nymph', `Failed to get entities: ${e}`); throw e; } } async getEntity(options = {}, ...selectors) { if (options.source === 'client' && options.return === 'object') { throw new InvalidParametersError('Object return type not allowed from client.'); } try { // Set up options and selectors. if (typeof selectors[0] === 'string') { selectors = [{ type: '&', guid: selectors[0] }]; } options.limit = 1; for (let callback of this.beforeGetEntityCallbacks) { if (callback) { await callback(this, options, selectors); } } const entities = await this.driver.getEntities(options, ...selectors); if (options.return === 'count') { return entities; } if (!entities || !entities.length) { return null; } return entities[0]; } catch (e) { this.config.debugError('nymph', `Failed to get entity: ${e}`); throw e; } } /** * Delete an entity from the database. * * @param entity The entity. * @returns True on success, false on failure. */ async deleteEntity(entity) { try { for (let callback of this.beforeDeleteEntityCallbacks) { if (callback) { await callback(this, entity); } } let result; try { result = this.driver.deleteEntity(entity); } catch (e) { for (let callback of this.failedDeleteEntityCallbacks) { if (callback) { await callback(this, e); } } throw e; } for (let callback of this.afterDeleteEntityCallbacks) { if (callback) { await callback(this, result); } } return await result; } catch (e) { this.config.debugError('nymph', `Failed to delete entity: ${e}`); throw e; } } /** * Delete an entity from the database by its GUID. * * @param guid The entity's GUID. * @param className The entity's class name. * @returns True on success, false on failure. */ async deleteEntityByID(guid, className) { try { for (let callback of this.beforeDeleteEntityByIDCallbacks) { if (callback) { await callback(this, guid, className); } } let result; try { result = this.driver.deleteEntityByID(guid, className); } catch (e) { for (let callback of this.failedDeleteEntityByIDCallbacks) { if (callback) { await callback(this, e); } } throw e; } for (let callback of this.afterDeleteEntityByIDCallbacks) { if (callback) { await callback(this, result); } } return await result; } catch (e) { this.config.debugError('nymph', `Failed to delete entity by ID: ${e}`); throw e; } } /** * Export entities to a local file. * * This is the file format: * * ``` * #nex2 * # The above line must be the first thing in the file. * # Comments begin with # * # And can have white space before them. * # This defines a UID. * <name/of/uid>[5] * <another uid>[8000] * # For UIDs, the name is in angle brackets (<>) and the value follows * # in square brackets ([]). * # This starts a new entity. * {1234abcd}<etype>[tag,list,with,commas] * # For entities, the GUID is in curly brackets ({}), then the etype in * # angle brackets, then the comma separated tag list follows in square * # brackets ([]). * # Properties are stored like this: * # propname=JSON.stringify(value) * abilities=["system/admin"] * groups=[] * inheritAbilities=false * name="admin" * # White space before/after "=" and at beginning/end of line is ignored. * username = "admin" * {2}<etype>[tag,list] * another="This is another entity." * newline="\n" * ``` * * @param filename The file to export to. * @returns True on success, false on failure. */ async export(filename) { try { return await this.driver.export(filename); } catch (e) { this.config.debugError('nymph', `Failed to export: ${e}`); throw e; } } /** * Export entities to the console. * * @returns True on success, false on failure. */ async exportPrint() { try { return await this.driver.exportPrint(); } catch (e) { this.config.debugError('nymph', `Failed to export: ${e}`); throw e; } } /** * Import entities from a file. * * @param filename The file to import from. * @returns True on success, false on failure. */ async import(filename) { try { return await this.driver.import(filename); } catch (e) { this.config.debugError('nymph', `Failed to import: ${e}`); throw e; } } /** * Import a single entity. * * @param entity The entity data to import. */ async importEntity(entity) { try { return await this.driver.importEntity(entity); } catch (e) { this.config.debugError('nymph', `Failed to import: ${e}`); throw e; } } /** * Import a single entity's tokens. * * This does not update any of the actual data, only the tokens used for full * text search. * * Be careful with this function, since you can overwrite existing tokens with * data that is out of sync. The DB _should_ prevent you from inserting tokens * for an entity that doesn't exist, but it _won't_ prevent you from inserting * tokens for properties that don't exist on an entity. * * @param entity The entity data to import. */ async importEntityTokens(entity) { try { return await this.driver.importEntityTokens(entity); } catch (e) { this.config.debugError('nymph', `Failed to import: ${e}`); throw e; } } /** * Import a single entity's Tilmeld access control properties. * * This does not update any of the other data, only the access control * columns. * * @param entity The entity data to import. */ async importEntityTilmeldAC(entity) { try { return await this.driver.importEntityTilmeldAC(entity); } catch (e) { this.config.debugError('nymph', `Failed to import: ${e}`); throw e; } } /** * Detect whether the database needs to be migrated. * * If not false (so, one of the string values), the database should be * exported with an old version of Nymph, then imported into a fresh database * with this version. Sometimes, a live migration can be done instead, which * will require no downtime. You should still export the database beforehand, * in case anything goes wrong. * * 'json' means the data tables are missing the JSON field, and the DB * **must** be exported and imported to fix it. * * 'tokens' means the FTS token tables are missing, and you can get away with * a live migration with `liveMigration('tokenTables')` and doing * `importEntityTokens(entity)` for each entity. * * 'tilmeldColumns' means the entity columns for "user", "group", and access * controls are missing, and you can get away with a live migration with * `liveMigration('tilmeldColumns')` and doing * `importEntityTilmeldAC(entity)` for each entity. DO NOT update to any * version later than 1.0.0-beta.109 before this is done, or access controls * WILL NOT work, and everyone will have access to every entity! */ async needsMigration() { return await this.driver.needsMigration(); } /** * Perform a live migration on the DB. * * A 'tokenTables' migration will simply add the missing token tables. It will * **not** fill them. You must use `importEntityTokens` on each entity after * running this to fill the token tables and enable full text search matching * on existing entities. * * A 'tilmeldColumns' migration will simply add the missing Tilmeld columns * and indexes to each entity table. It will **not** fill them, and you must * fill them before updating to the latest version of Nymph, or everyone will * be able to access every entity! You must use `importEntityTilmeldAC` on * each entity after running this. * * A 'tilmeldRemoveOldRows' migration will remove all of the old access * control rows in the data tables that are no longer used as of * 1.0.0-beta.110. MAKE SURE YOU HAVE RUN `importEntityTilmeldAC` ON ALL * ENTITIES BEFORE RUNNING THIS, OR THE DATA WILL BE IRRETRIEVABLY LOST! */ async liveMigration(migrationType) { return await this.driver.liveMigration(migrationType); } /** * Returns a list of the etypes stored in the DB. */ async getEtypes() { return await this.driver.getEtypes(); } /** * Get all the indexes defined for a specific etype. * * @param etype The etype to get indexes for. */ async getIndexes(etype) { return await this.driver.getIndexes(etype); } /** * Add an index on a specific property to an etype. * * Nymph adds general indexes on etypes, but they span across all properties, * so they can only speed up a query so much. You can add indexes that are * specific to a property with this function, which will speed up queries for * that property. If an etype only has one or two properties, you may not need * a specific index, but if your etypes include many properties or properties * that generally contain large amounts of data, adding a specific index can * drastically improve the performance of your queries. * * The scope of the index will affect which queries it will speed up: * * - data: This will speed up comparisons and equality checks in queries. * - references: This will speed up ref and qref clauses in queries. * - tokens: This will speed up search clauses in queries. * * The name must be unique within the etype and scope or the existing index * will be overwritten. * * You do not need to add reference indexes for the "user" and "group" * properties, because these are already specifically indexed by Nymph. * * @param etype The etype to add the index on. * @param definition The definition of the index. */ async addIndex(etype, definition) { this.driver.checkIndexName(definition.name); return await this.driver.addIndex(etype, definition); } /** * Delete an index from an etype. * * @param etype The etype to delete the index on. * @param scope The scope of the index. * @param name The name of the index to delete. */ async deleteIndex(etype, scope, name) { this.driver.checkIndexName(name); return await this.driver.deleteIndex(etype, scope, name); } on(event, callback) { const prop = (event + 'Callbacks'); if (!(prop in this)) { throw new Error('Invalid event type.'); } // @ts-ignore: The callback should be the right type here. this[prop].push(callback); return () => this.off(event, callback); } off(event, callback) { const prop = (event + 'Callbacks'); if (!(prop in this)) { return false; } // @ts-ignore: The callback should always be the right type here. const i = this[prop].indexOf(callback); if (i > -1) { // @ts-ignore: The callback should always be the right type here. this[prop].splice(i, 1); } return true; } } //# sourceMappingURL=Nymph.js.map