@nymphjs/nymph
Version:
Nymph.js - Nymph ORM
814 lines • 27 kB
JavaScript
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@gmail.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;
}
}
/**
* Detect whether the database needs to be migrated.
*
* If true, the database should be exported with an old version of Nymph, then
* imported into a fresh database with this version.
*/
async needsMigration() {
return await this.driver.needsMigration();
}
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