UNPKG

@nymphjs/nymph

Version:

Nymph.js - Nymph ORM

962 lines 34.3 kB
import { difference, intersection, isEqual } from 'lodash-es'; import { guid } from '@nymphjs/guid'; import { EntityConflictError, EntityIsSleepingReferenceError, InvalidParametersError, InvalidStateError, } from './errors/index.js'; import { entitiesToReferences, referencesToEntities, uniqueStrings, } from './utils.js'; /** * Database abstraction object. * * Provides a way to access, manipulate, and store data in Nymph. * * The GUID is not set until the entity is saved. GUIDs must be unique forever, * even after deletion. It's the job of the Nymph DB driver to make sure no two * entities ever have the same GUID. This is generally done by using a large * randomly generated ID. * * Each entity class has an etype that determines which table(s) in the database * it belongs to. If two entity classes have the same etype, their data will be * stored in the same table(s). This isn't a good idea, however, because * references to an entity store a class name, not an etype. * * Tags are used to classify entities. Where an etype is used to separate data * by tables, tags can be used to separate entities within a table. You can * define specific tags to be protected, meaning they cannot be added/removed * from the client. It can be useful to allow user defined tags, such as for * blog posts. * * Simply calling $delete() will not unset the entity. It will still take up * memory. Likewise, simply calling unset will not delete the entity from the * DB. * * Some notes about $equals() and $is(), the replacements for "==": * * The == operator will likely not give you the result you want, since two * instances of the same entity will fail that check, even though they represent * the same data in the database. * * $equals() performs a more strict comparison of the entity to another. Use * $equals() instead of the == operator when you want to check both the entities * they represent, and the data inside them. In order to return true for * $equals(), the entity and object must meet the following criteria: * * - They must be entities. * - They must have equal GUIDs, or both must have no GUID. * - Their data and tags must be equal. * * $is() performs a less strict comparison of the entity to another. Use $is() * instead of the == operator when the entity's data may have been changed, but * you only care if they represent the same entity. In order to return true, the * entity and object must meet the following criteria: * * - They must be entities. * - They must have equal GUIDs, or both must have no GUID. * - If they have no GUIDs, their data and tags must be equal. * * Some notes about saving entities in other entity's properties: * * Entities use references in the DB to store an entity in their properties. The * reference is stored as an array with the values: * * - 0 => The string 'nymph_entity_reference' * - 1 => The referenced entity's GUID. * - 2 => The referenced entity's class name. * * Since the referenced entity's class name (meaning the `class` static * property, not the name of the class itself) is stored in the reference on the * parent entity, if you change the class name in an update, you need to * reassign all referenced entities of that class and resave. * * When an entity is loaded, it does not request its referenced entities from * Nymph. Instead, it creates instances without data called sleeping references. * When you first access an entity's data, if it is a sleeping reference, it * will fill its data from the DB. You can call clearCache() to turn all the * entities back into sleeping references. */ export default class Entity { /** * The instance of Nymph to use for queries. */ static nymph = {}; /** * A unique name for this type of entity used to separate its data from other * types of entities in the database. */ static ETYPE = 'entity'; /** * The lookup name for this entity. * * This is used for reference arrays (and sleeping references) and client * requests. */ static class = 'Entity'; $nymph; guid = null; cdate = null; mdate = null; tags = []; /** * The data proxy handler. */ $dataHandler; /** * The actual data store. */ $dataStore; /** * The actual sdata store. */ $sdata; /** * The data proxy object. */ $data; /** * Whether this instance is a sleeping reference. */ $isASleepingReference = false; /** * The reference to use to wake. */ $sleepingReference = null; /** * A promise that resolved when the entity's data is wake. */ $wakePromise = null; /** * Properties that will not be serialized into JSON with toJSON(). This * can be considered a denylist, because these properties will not be set * with incoming JSON. * * Clients CAN still determine what is in these properties, unless they are * also listed in searchRestrictedData. */ $privateData = []; /** * Whether this entity should publish changes to PubSub servers. */ static pubSubEnabled = true; /** * Whether this entity should be accessible on the frontend through the REST * server. * * If this is false, any request from the client that attempts to use this * entity will fail. */ static restEnabled = true; /** * Properties that will not be searchable from the frontend. If the frontend * includes any of these properties in any of their clauses, they will be * filtered out before the search is executed. */ static searchRestrictedData = []; /** * Properties that can only be modified by server side code. They will still * be visible on the frontend, unlike $privateData, but any changes to them * that come from the frontend will be ignored. * * In addition to what's listed here, all of the access control properties * will be included when Tilmeld is being used. These are: * * - acUser * - acGroup * - acOther * - acRead * - acWrite * - acFull * - user * - group * * You should modify these through client enabled methods or the $save method * instead, for safety. */ $protectedData = []; /** * If this is defined, then it lists the only properties that will be * accepted from incoming JSON. Any other properties will be ignored. * * If you use an allowlist, you don't need to use protectedData, since you * can simply leave those entries out of allowlistData. */ $allowlistData; /** * Tags that can only be added/removed by server side code. They will still be * visible on the frontend, but any changes to them that come from the * frontend will be ignored. */ $protectedTags = []; /** * If this is defined, then it lists the only tags that will be accepted from * incoming JSON. Any other tags will be ignored. */ $allowlistTags; /** * The names of methods allowed to be called by the frontend with serverCall. */ $clientEnabledMethods = []; /** * The names of static methods allowed to be called by the frontend with * serverCallStatic. */ static clientEnabledStaticMethods = []; /** * Whether to use "skipAc" when accessing entity references. */ $skipAc = false; /** * The AC properties' values when the entity was loaded. */ $originalAcValues = null; /** * This is used to hold a generated GUID for a new entity. */ $guaranteedGUID = null; /** * Initialize an entity. */ constructor(..._rest) { this.$nymph = this.constructor.nymph; this.$dataHandler = { has: (data, name) => { this.$check(); return name in data || name in this.$sdata; }, get: (data, name) => { this.$check(); if (this.$sdata.hasOwnProperty(name)) { data[name] = referencesToEntities(JSON.parse(this.$sdata[name]), this.$nymph, this.$skipAc); delete this.$sdata[name]; } if (data.hasOwnProperty(name)) { return data[name]; } return undefined; }, set: (data, name, value) => { this.$check(); if (this.$sdata.hasOwnProperty(name)) { delete this.$sdata[name]; } data[name] = value; return true; }, deleteProperty: (data, name) => { this.$check(); if (this.$sdata.hasOwnProperty(name)) { return delete this.$sdata[name]; } if (data.hasOwnProperty(name)) { return delete data[name]; } return true; }, defineProperty: (data, name, descriptor) => { this.$check(); if (this.$sdata.hasOwnProperty(name)) { delete this.$sdata[name]; } Object.defineProperty(data, name, descriptor); return true; }, getOwnPropertyDescriptor: (data, name) => { this.$check(); if (this.$sdata.hasOwnProperty(name)) { data[name] = referencesToEntities(JSON.parse(this.$sdata[name]), this.$nymph, this.$skipAc); delete this.$sdata[name]; } return Object.getOwnPropertyDescriptor(data, name); }, ownKeys: (data) => { this.$check(); return Object.getOwnPropertyNames(data).concat(Object.getOwnPropertyNames(this.$sdata)); }, }; this.$dataStore = {}; this.$sdata = {}; this.$data = new Proxy(this.$dataStore, this.$dataHandler); return new Proxy(this, { has: (entity, name) => { if (typeof name !== 'string' || name in entity || name.substring(0, 1) === '$') { return name in entity; } this.$check(); return name in entity.$data; }, get: (entity, name) => { if (typeof name !== 'string' || name in entity || name.substring(0, 1) === '$') { if (name === 'tags' || name === 'cdate' || name === 'mdate') { this.$check(); } return entity[name]; } this.$check(); if (name in entity.$data) { return entity.$data[name]; } return undefined; }, set: (entity, name, value) => { if (typeof name !== 'string' || name in entity || name.substring(0, 1) === '$') { if (name === 'tags' || name === 'cdate' || name === 'mdate') { this.$check(); } entity[name] = value; } else { this.$check(); entity.$data[name] = value; } return true; }, deleteProperty: (entity, name) => { if (name in entity) { return delete entity[name]; } else if (name in entity.$data) { this.$check(); return delete entity.$data[name]; } return true; }, getPrototypeOf: (entity) => { return entity.constructor.prototype; }, defineProperty: (entity, name, descriptor) => { if (typeof name !== 'string' || name in entity || name.substring(0, 1) === '$') { if (name === 'tags' || name === 'cdate' || name === 'mdate') { this.$check(); } Object.defineProperty(entity, name, descriptor); } else { this.$check(); Object.defineProperty(entity.$data, name, descriptor); } return true; }, getOwnPropertyDescriptor: (entity, name) => { if (typeof name !== 'string' || name in entity || name.substring(0, 1) === '$') { if (name === 'tags' || name === 'cdate' || name === 'mdate') { this.$check(); } return Object.getOwnPropertyDescriptor(entity, name); } else { this.$check(); return Object.getOwnPropertyDescriptor(entity.$data, name); } }, ownKeys: (entity) => { this.$check(); return Object.getOwnPropertyNames(entity).concat(Object.getOwnPropertyNames(entity.$data)); }, }); } /** * Create or retrieve a new entity instance. * * Note that this will always return an entity, even if the GUID is not found. * * @param guid An optional GUID to retrieve. */ static async factory(guid) { const entity = new this(); if (guid != null) { const entity = await this.nymph.getEntity({ class: this, }, { type: '&', guid }); if (entity != null) { return entity; } } return entity; } /** * Create a new entity instance. */ static factorySync() { return new this(); } /** * Create a new sleeping reference instance. * * Sleeping references won't retrieve their data from the database until they * are readied with `$wake()` or a parent's `$wakeAll()`. * * @param reference The Nymph Entity Reference to use to wake. * @returns The new instance. */ static factoryReference(reference) { const entity = new this(); entity.$referenceSleep(reference); return entity; } /** * Get an array of strings that **must** be unique across the current etype. * * When you try to save another entity with any of the same unique strings, * Nymph will throw an error. * * The default implementation of this static method instantiates the entity, * assigns all of the given data, then calls `$getUniques` and returns its * output. This can have a performance impact if a lot of extra processing * happens during any of these steps. You can override this method to * calculate the unique strings faster, but you must return the same strings * that would be returned by `$getUniques`. * * @returns Resolves to an array of entity's unique constraint strings. */ static async getUniques({ guid, cdate, mdate, tags, data, sdata, }) { const entity = new this(); if (guid != null) { entity.guid = guid; } if (cdate != null) { entity.cdate = cdate; } if (mdate != null) { entity.mdate = mdate; } entity.tags = tags; entity.$putData(data, sdata); return await entity.$getUniques(); } toJSON() { if (this.$isASleepingReference) { return this.$sleepingReference; } const obj = { class: this.constructor.class, guid: this.guid, cdate: this.cdate, mdate: this.mdate, tags: [...this.tags], data: {}, }; for (const key of [ ...Object.keys(this.$dataStore), ...Object.keys(this.$sdata), ]) { if (this.$privateData.indexOf(key) === -1) { obj.data[key] = entitiesToReferences(this.$data[key], true); } } return obj; } $getGuaranteedGUID() { if (this.guid !== null) { return this.guid; } if (this.$guaranteedGUID == null) { this.$guaranteedGUID = guid(); } return this.$guaranteedGUID; } $addTag(...tags) { this.$check(); if (tags.length < 1) { return; } this.tags = uniqueStrings([...this.tags, ...tags]); } $arraySearch(array, strict = false) { this.$check(); if (!Array.isArray(array)) { return -1; } for (let i = 0; i < array.length; i++) { const curEntity = array[i]; if (strict ? this.$equals(curEntity) : this.$is(curEntity)) { return i; } } return -1; } $clearCache() { this.$check(); this.$putData(this.$getData(false, true), this.$getSData()); } $getClientEnabledMethods() { return this.$clientEnabledMethods; } async $delete() { this.$check(); return await this.$nymph.deleteEntity(this); } $equals(object) { this.$check(); if (!(object instanceof Entity)) { return false; } if (this.guid || object.guid) { if (this.guid !== object.guid) { return false; } } if (object.cdate !== this.cdate) { return false; } if (object.mdate !== this.mdate) { return false; } const obTags = [...object.tags].sort(); const myTags = [...this.tags].sort(); if (!isEqual(obTags, myTags)) { return false; } const obData = object.$getData(true, true); const myData = this.$getData(true, true); return isEqual(obData, myData); } $getData(includeSData = false, referenceOnlyExisting) { this.$check(); if (includeSData) { // Access all the serialized properties to initialize them. for (const key in this.$sdata) { const _unused = this[key]; } } return entitiesToReferences({ ...this.$dataStore }, referenceOnlyExisting); } $getSData() { this.$check(); return this.$sdata; } async $getUniques() { return []; } $getOriginalAcValues() { this.$check(); return this.$originalAcValues ?? this.$getCurrentAcValues(); } $getCurrentAcValues() { this.$check(); return { user: this.$getAcUid(), group: this.$getAcGid(), acUser: this.$data.acUser ?? null, acGroup: this.$data.acGroup ?? null, acOther: this.$data.acOther ?? null, acRead: this.$getAcReadIds(), acWrite: this.$getAcWriteIds(), acFull: this.$getAcFullIds(), }; } $getAcUid() { if ('user' in this.$sdata) { const userValue = JSON.parse(this.$sdata.user); if (Array.isArray(userValue) && userValue[0] === 'nymph_entity_reference') { return userValue[1]; } } return (this.$data.user?.guid ?? null); } $getAcGid() { if ('group' in this.$sdata) { const groupValue = JSON.parse(this.$sdata.group); if (Array.isArray(groupValue) && groupValue[0] === 'nymph_entity_reference') { return groupValue[1]; } } return (this.$data.group?.guid ?? null); } $getAcReadIds() { return (this.$data.acRead?.map((entity) => entity.guid) ?? null); } $getAcWriteIds() { return (this.$data.acWrite?.map((entity) => entity.guid) ?? null); } $getAcFullIds() { return (this.$data.acFull?.map((entity) => entity.guid) ?? null); } $getValidatable() { this.$check(); // Access all the serialized properties to initialize them. for (const key in this.$sdata) { const _unused = this[key]; } return { guid: this.guid, cdate: this.cdate, mdate: this.mdate, tags: this.tags, ...referencesToEntities(this.$dataStore, this.$nymph, this.$skipAc), }; } $getTags() { this.$check(); return this.tags; } $hasTag(...tags) { this.$check(); if (!tags.length) { return false; } for (const tag of tags) { if (this.tags.indexOf(tag) === -1) { return false; } } return true; } $inArray(array, strict = false) { return this.$arraySearch(array, strict) !== -1; } $is(object) { if (!(object instanceof Entity)) { return false; } if (this === object) { return true; } if (this.guid != null || object.guid != null) { return this.guid === object.guid; } this.$check(); if (typeof object.$getData !== 'function') { return false; } else { const obTags = [...object.tags].sort(); const myTags = [...this.tags].sort(); if (!isEqual(obTags, myTags)) { return false; } const obData = object.$getData(true, true); const myData = this.$getData(true, true); return isEqual(obData, myData); } } $jsonAcceptData(input, allowConflict = false) { this.$check(); if (this.guid != input.guid) { throw new EntityConflictError('Tried to accept JSON input for the wrong entity.'); } // Accept the modified date. const mdate = input.mdate ?? 0; const thismdate = this.mdate ?? 0; if (mdate < thismdate && !allowConflict) { throw new EntityConflictError('This entity is newer than JSON input.'); } this.mdate = input.mdate; // Accept the tags. const currentTags = this.$getTags(); const protectedTags = intersection(this.$protectedTags, currentTags); let tags = difference(input.tags, this.$protectedTags); if (this.$allowlistTags != null) { tags = intersection(tags, this.$allowlistTags); } this.$removeTag(...currentTags); this.$addTag(...protectedTags, ...tags); // Accept the data. const data = { ...input.data }; const privateData = {}; for (const name of this.$privateData) { if (name in this.$data) { privateData[name] = this.$data[name]; } if (name in data) { delete data[name]; } } const protectedData = {}; const protectedProps = [...this.$protectedData]; if (this.$nymph.tilmeld) { protectedProps.push('acUser'); protectedProps.push('acGroup'); protectedProps.push('acOther'); protectedProps.push('acRead'); protectedProps.push('acWrite'); protectedProps.push('acFull'); if ((this.constructor.class !== 'User' && this.constructor.class !== 'Group') || !this.$nymph.tilmeld.currentUser || (!this.$nymph.tilmeld.currentUser.abilities?.includes('tilmeld/admin') && !this.$nymph.tilmeld.currentUser.abilities?.includes('system/admin'))) { protectedProps.push('user'); protectedProps.push('group'); } } for (const name of protectedProps) { if (name in this.$data) { protectedData[name] = this.$data[name]; } if (name in data) { delete data[name]; } } let nonAllowlistData = {}; if (this.$allowlistData != null) { nonAllowlistData = { ...this.$getData(true, true) }; for (const name of this.$allowlistData) { delete nonAllowlistData[name]; } for (const name in data) { if (this.$allowlistData.indexOf(name) === -1) { delete data[name]; } } } this.$putData({ ...nonAllowlistData, ...data, ...protectedData, ...privateData, }); } $jsonAcceptPatch(patch, allowConflict = false) { this.$check(); if (this.guid != patch.guid) { throw new EntityConflictError('Tried to accept JSON patch for the wrong entity.'); } // Accept the modified date. const mdate = patch.mdate ?? 0; const thismdate = this.mdate ?? 0; if (mdate < thismdate && !allowConflict) { throw new EntityConflictError('This entity is newer than JSON patch.'); } this.mdate = patch.mdate; const protectedProps = [...this.$protectedData]; if (this.$nymph.tilmeld) { protectedProps.push('acUser'); protectedProps.push('acGroup'); protectedProps.push('acOther'); protectedProps.push('acRead'); protectedProps.push('acWrite'); protectedProps.push('acFull'); if ((this.constructor.class !== 'User' && this.constructor.class !== 'Group') || !this.$nymph.tilmeld.currentUser || (!this.$nymph.tilmeld.currentUser.abilities?.includes('tilmeld/admin') && !this.$nymph.tilmeld.currentUser.abilities?.includes('system/admin'))) { protectedProps.push('user'); protectedProps.push('group'); } } for (const name in patch.set) { if ((this.$allowlistData != null && this.$allowlistData.indexOf(name) === -1) || protectedProps.indexOf(name) !== -1 || this.$privateData.indexOf(name) !== -1) { continue; } this.$data[name] = referencesToEntities(patch.set[name], this.$nymph, this.$skipAc); } for (const name of patch.unset) { if ((this.$allowlistData != null && this.$allowlistData.indexOf(name) === -1) || protectedProps.indexOf(name) !== -1 || this.$privateData.indexOf(name) !== -1) { continue; } delete this.$data[name]; } for (const tag of patch.addTags) { if ((this.$allowlistTags != null && this.$allowlistTags.indexOf(tag) === -1) || this.$protectedTags.indexOf(tag) !== -1) { continue; } this.$addTag(tag); } for (const tag of patch.removeTags) { if ((this.$allowlistTags != null && this.$allowlistTags.indexOf(tag) === -1) || this.$protectedTags.indexOf(tag) !== -1) { continue; } this.$removeTag(tag); } } $putData(data, sdata, _source) { this.$check(); const mySdata = sdata ?? this.$getSData(); for (const name in data) { delete mySdata[name]; } for (const name in this.$dataStore) { delete this.$dataStore[name]; } for (const name in data) { this.$dataStore[name] = referencesToEntities(data[name], this.$nymph, this.$skipAc); } this.$sdata = mySdata; // Set original AC values if not set.. if (this.$originalAcValues == null) { this.$originalAcValues = this.$getCurrentAcValues(); } } /** * Set up a sleeping reference. * @param array $reference The reference to use to wake. */ $referenceSleep(reference) { if (reference.length !== 3 || reference[0] !== 'nymph_entity_reference' || typeof reference[1] !== 'string' || typeof reference[2] !== 'string') { throw new InvalidParametersError('referenceSleep expects parameter 1 to be a valid Nymph entity ' + 'reference.'); } const thisClass = this.constructor.class; if (reference[2] !== thisClass) { throw new InvalidParametersError('referenceSleep can only be called with an entity reference of the ' + `same class. Given class: ${reference[2]}; this class: ${thisClass}.`); } this.$isASleepingReference = true; this.guid = reference[1]; this.$sleepingReference = reference; } /** * Check if this is a sleeping reference and throw an error if so. */ $check() { if (this.$isASleepingReference || this.$sleepingReference != null) { throw new EntityIsSleepingReferenceError('This entity is in a sleeping reference state. You must use .$wake() to wake it.'); } } /** * Check if this is a sleeping reference. */ $asleep() { return this.$isASleepingReference || this.$sleepingReference != null; } /** * Wake from a sleeping reference. */ $wake() { if (!this.$isASleepingReference || this.$sleepingReference == null) { this.$wakePromise = null; return Promise.resolve(this); } if (this.$sleepingReference?.[1] == null) { throw new InvalidStateError('Tried to wake a sleeping reference with no GUID.'); } if (!this.$wakePromise) { const EntityClass = this.$nymph.getEntityClass(this.$sleepingReference[2]); this.$wakePromise = this.$nymph .getEntity({ class: EntityClass, skipAc: this.$skipAc, }, { type: '&', guid: this.$sleepingReference[1] }) .then((entity) => { if (entity == null || entity.guid == null) { return Promise.reject(new InvalidStateError('The sleeping reference could not be retrieved.')); } this.$isASleepingReference = false; this.$sleepingReference = null; this.guid = entity.guid; this.tags = entity.tags; this.cdate = entity.cdate; this.mdate = entity.mdate; this.$putData(entity.$getData(false, true), entity.$getSData(), 'server'); return this; }) .finally(() => { this.$wakePromise = null; }); } return this.$wakePromise; } $wakeAll(level) { return new Promise((resolve, reject) => { // Run this once this entity is wake. const wakeProps = () => { let newLevel; // If level is undefined, keep going forever, otherwise, stop once we've // gone deep enough. if (level !== undefined) { newLevel = level - 1; } if (newLevel !== undefined && newLevel < 0) { resolve(this); return; } const promises = []; // Go through data looking for entities to wake. for (let [key, value] of Object.entries(this.$data)) { if (value instanceof Entity && value.$isASleepingReference) { promises.push(value.$wakeAll(newLevel)); } else if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { if (value[i] instanceof Entity && value[i].$isASleepingReference) { promises.push(value[i].$wakeAll(newLevel)); } } } } if (promises.length) { Promise.all(promises).then(() => resolve(this), (errObj) => reject(errObj)); } else { resolve(this); } }; if (this.$isASleepingReference) { this.$wake().then(wakeProps, (errObj) => reject(errObj)); } else { wakeProps(); } }); } async $refresh() { await this.$wake(); if (this.guid == null) { return false; } const refresh = await this.$nymph.getEntity({ class: this.$nymph.getEntityClass(this.constructor), skipCache: true, skipAc: this.$skipAc, }, { type: '&', guid: this.guid }); if (refresh == null) { return 0; } this.tags = refresh.tags; this.cdate = refresh.cdate; this.mdate = refresh.mdate; this.$putData(refresh.$getData(false, true), refresh.$getSData(), 'server'); return true; } $removeTag(...tags) { this.$check(); this.tags = difference(this.tags, tags); } async $save() { await this.$wake(); return await this.$nymph.saveEntity(this); } $toReference(existingOnly) { if (this.$isASleepingReference && this.$sleepingReference != null) { return this.$sleepingReference; } if (existingOnly && this.guid == null) { return this; } return [ 'nymph_entity_reference', this.$getGuaranteedGUID(), this.constructor.class, ]; } $useSkipAc(skipAc) { this.$skipAc = !!skipAc; } } //# sourceMappingURL=Entity.js.map