UNPKG

@lordfokas/magic-orm

Version:

A class-based ORM in TypeScript. Unorthodox and extremely opinionated, made to fit my specific use cases.

432 lines 17.3 kB
import { v4 as UUIDv4 } from 'uuid'; const UUIDv0 = () => '000000000000-0000-0000-0000-00000000'; import { Logger } from '@lordfokas/loggamus'; import { SelectBuilder, UpdateBuilder } from './QueryBuilder.js'; import { Serializer } from './Serializer.js'; let $logger = Logger.getDefault(); /** Define a new logger to send output to */ export function useLogger(logger) { $logger = logger; } export class Entity { static Serializer = Serializer; static #links = {}; static #expands = {}; uuid; // #region Static Relationship Shortcuts // =================================================== /** Create link-expansion relationships between entities. */ static link(child, parent) { if (!parent) return { to: (...ps) => ps.map(p => Entity.link(child, p)) }; if (!Entity.#links[child.name]) Entity.#links[child.name] = {}; const links = Entity.#links[child.name]; links[parent.$config.linkname] = parent; if (!Entity.#expands[parent.name]) Entity.#expands[parent.name] = {}; const expands = Entity.#expands[parent.name]; expands[child.$config.expandname] = child; } /** Apply an async function to every link of this Entity */ static async forEachLink(entity, fn) { const name = entity.constructor.name; if (!Entity.#links[name]) return; const links = Object.values(Entity.#links[name]); for (const link of links) await fn(link); } /** Apply an async function to every expansion of this Entity */ static async forEachExpand(entity, fn) { const name = entity.constructor.name; if (!Entity.#expands[name]) return; const expands = Object.values(Entity.#expands[name]); for (const expand of expands) await fn(expand); } // #endregion // #region Static Composite Write // ========================================================== /** Create an Entity in the database, along with all dependencies and relationships. */ static async createComposite(db, entity, skip) { return await db.atomic(async () => { await Entity.#insertLinks(db, entity); await entity.insert(db, skip); await Entity.#insertExpands(db, entity); }); } /** Insert all of an Entity's links (parents). These are required before the Entity is inserted */ static async #insertLinks(db, entity) { return await Entity.forEachLink(entity, async (lnk) => { const parent = entity[lnk.$config.linkname]; if (!parent) return; parent.insert(db); entity[`uuid_${lnk.$config.linkname}`] = parent.uuid; }); } /** Insert this Entity's expands (children). This has to be the last insertion step */ static async #insertExpands(db, entity) { return await Entity.forEachExpand(entity, async (exp) => { const children = entity[exp.$config.expandname]; if (!children || children.length < 1) return; const link = this.$config.linkname; for (const child of children) { child[`uuid_${link}`] = entity.uuid; } await this.bulkInsert(db, children); }); } static async inflate(db, inflate, ...params) { const { self, links, expands } = this.$config.inflates[inflate]; // load self and links' main bodies with a single query const query = await this[self.exec](false, ...params, ...self.params); for (const link of links) { query.join(await link.type[link.exec](false, ...link.params), link.reverse); } if (db === false) return query; // if DB then proceed with execution const results = await query.execute(db); const chains = query.chains(); if (results.rows.length == 0) return []; // recursively construct self and links const entities = await Promise.all(results.rows.map(async (row) => { const entity = new this().$ingest(row); for (const link of links) { const childType = link.type; const child = new childType().$ingest(row); chains.filter((c) => c.parent == childType).map(c => { this.recursiveLink(child, row, c.child, chains); }); // @ts-ignore spread argument must have tuple type bla bla bla, ssssh TS, this works. await child.recursiveExpand(db, ...link.params); entity.useLink(child); } return entity; })); // higher order entities indexed by uuid const uuids = entities.map(entity => entity.uuid); const index = entities.reduce((map, entity) => { map[entity.uuid] = entity; return map; }, {}); // load expands for (const expand of expands) { if (expand.noBulk) { for (const entity of entities) { const type = expand.type; const children = await type[expand.exec](db, ...expand.params, [ { col: `uuid_${this.$config.linkname}`, var: entity.uuid } ]); const exp = type.$config.expandname; entity[exp] = children; } } else { const type = expand.type; for (const entity of entities) { entity[type.$config.expandname] = []; } const link = `uuid_${this.$config.linkname}`; const children = await type[expand.exec](db, ...expand.params, [{ col: link, in: uuids }]); children.map((child) => index[child[link]].useExpand(child)); } } return entities; } /** Recursively build parent entities from joined tables in the query */ static recursiveLink(entity, row, type, chains) { const child = new type().$ingest(row); chains?.filter(c => c.parent == type).map(c => { Entity.recursiveLink(child, row, c.child); }); entity.useLink(child); } /** * Recursively build child entities by querying the database again and awaiting results. * Also applies to children of linked parent entities, including self. */ async recursiveExpand(db, inflate) { const map = this.$config.inflates[inflate]; if (map === undefined) return; const { self, links, expands } = map; const linkname = this.$config.linkname; for (const expand of expands) { const type = expand.type; const dlos = await type[expand.exec](db, ...expand.params, [ { col: `uuid_${linkname}`, var: this.uuid } ]); const exp = type.$config.expandname; this[exp] = dlos; } for (const link of links) { const child = this[link.type.$config.linkname]; if (child) { await child.recursiveExpand(db, ...link.params); } } } // #endregion // #region Static Primitive Shortcuts // ====================================================== /** Get one entity from this table, by UUID. */ static async uuid(db, uuid, select = '*') { return await this.read(db, select, [ { col: 'uuid', var: uuid } ]); } /** Get all the entities from this table */ static async all(db, select = '*') { return await this.read(db, select); } /** Get all entities from this table where {field} is in {list}. */ static async in(db, field, list, select = '*') { return await this.read(db, select, [ { col: field, in: list } ]); } /** Extract from the Entity the values of the given columns */ static #data(entity, cols) { const vals = []; for (const col of cols) vals.push(entity[col]); return vals; } /** Insert all Entities in the given list as a single query. All Entities must be of this type. */ static async bulkInsert(db, entities) { for (const entity of entities) { entity.generateUUID(); } return await this.create(db, ...entities); } /** Create one or more Entities in the database. If many, a bulk query is written. */ static async create(db, ...entities) { const cols = entities[0].prioritizeUUIDs(); const vals = []; const sql = ["INSERT INTO " + this.$config.table + " ( " + cols.join(', ') + " )"]; if (entities.length == 1) { vals.push(...Entity.#data(entities[0], cols)); sql.push("VALUES ( " + ('?'.repeat(cols.length).split('').join(', ')) + " )"); } else { const rows = []; for (const entity of entities) { vals.push(...Entity.#data(entity, cols)); rows.push("( " + ('?'.repeat(cols.length).split('').join(', ')) + " )"); } sql.push('VALUES ' + rows.join(',\n ')); } return await db.execute(sql, vals); } /** Update one or more database rows with the data contained in this Entity */ static async update(db, entity, update = '*', filters = []) { const superset = this.$config.fields[update]; if (!superset) throw new Error(`No such field set: ${update}`); if (filters.length < 1) throw new Error('Cannot update table with no filters'); const existing = Object.keys(entity); const fields = superset.filter(f => f != 'uuid' && existing.includes(f)); const query = new UpdateBuilder(entity, fields).filter(filters, this); return await query.execute(db); } static async read(db, select = '*', filters = []) { const query = this.select(select, filters); if (db === false) return query; const result = await query.execute(db); return result.rows.map((row) => new this().$ingest(row)); } /** Get a SelectBuilder for a set of columns and filters */ static select(select = '*', filters = []) { const fields = this.$config.fields[select]; if (!fields) throw new Error(`No such field set: ${select}`); const query = new SelectBuilder(this, this.ALIAS(...fields)).filter(filters, this); const order = this.$config.order; if (order) query.order(this.COL(...order)); return query; } /** Convert boolean fields from string '0' and '1' to primitive false and true. */ static #booleans(entity) { const booleans = entity.$config.booleans; if (Array.isArray(booleans)) { for (const key of booleans) { if (typeof entity[key] === 'string') { entity[key] = (entity[key] === '1'); } } } } // #endregion // #region Instance Methods // ================================================================ /** Build an Entity from a given object */ constructor(obj) { if (obj) { Object.assign(this, obj); } } /** * Build an Entity from a database row. * Scans this row for fields that belong to the same table as this object. * Any matching fields are injected into the object. * This is done by expecting fields to be prefixed with the table's 2-letter code. */ $ingest(row) { const prefix = this.$config.prefix + '_'; for (const [k, v] of Object.entries(row)) { if (k.startsWith(prefix)) { this[k.substring(3)] = v; } } Entity.#booleans(this); return this; } /** Use this entity as a link (instance of parent entity) */ useLink(entity) { this[entity.$config.linkname] = entity; } /** Use this entity as an expand (instance of child entity) */ useExpand(entity) { const field = entity.$config.expandname; if (this[field]) { this[field].push(entity); } else { this[field] = [entity]; } } /** Generate a UUID for this Entity. Will fail if the field is already filled. */ generateUUID() { if (this.uuid) throw new Error('Insert failed: Entity already contains a UUID'); this.uuid = this.constructor.UUID(); } /** Generate a zero UUID for this Entity. Will fail if the field is already filled. */ generateZERO() { if (this.uuid) throw new Error('Insert failed: Entity already contains a UUID'); this.uuid = this.constructor.ZERO(); } /** Get the list of fields in this Entity, with UUIDs in front and sorted */ prioritizeUUIDs(exclude = false) { const uuids = []; const fields = []; for (const field of Object.keys(this)) { if (field.includes('uuid')) { uuids.push(field); } else { fields.push(field); } } uuids.sort(); const cols = [...uuids, ...fields]; return exclude ? cols.filter(c => !exclude.includes(c)) : cols; } /** * Insert this Entity into the database. * If skip isn't present, a UUID will be generated automatically. */ async insert(db, skip = false) { if (skip !== 'skip_uuid_gen') this.generateUUID(); return await this.constructor.create(db, this); } /** * Update this Entity's DB record. Optionally specify a stricter list of fields to update. * Will fail if a UUID isn't present. */ async update(db, fields = '*') { if (!this.uuid) throw new Error('Update failed: Entity doesn\'t contain a UUID'); return await this.constructor .update(db, this, fields, [{ col: 'uuid', var: this.uuid }]); } /** Upserts (update or insert) this Entity, depending on wether or not this object has a UUID. */ async upsert(db, fields = '*') { if (this.uuid) return await this.update(db, fields); else return await this.insert(db); } // #endregion // #region SQL Utils // ======================================================================= /** * Creates a list of fields for a SELECT query, aliased as XX_col_name * where XX is this table's 2-letter code. */ static ALIAS(...columns) { const p = this.$config.prefix; return columns.map(c => `${p}.${c} AS "${p}_${c}"`).join(', '); } /** Creates a list of fields for a SELECT query */ static COL(...columns) { const p = this.$config.prefix; return columns.map(c => `${p}.${c}`).join(', '); } /** Returns this table aliased with its 2-letter code for use in queries. */ static TABLE() { return `${this.$config.table} ${this.$config.prefix}`; } /** Generate a zero-filled UUID with an appropriate size for this table's PK. */ static ZERO() { return this.UUID(UUIDv0); } /** * Generate a UUID with an appropriate size for this table's PK. * A different generator can be provided, default is UUID v4. */ static UUID(gen = UUIDv4) { let octets; switch (this.$config.uuidsize) { case 'small': octets = gen().split('').reverse().join('').substring(0, 12); break; case 'standard': octets = gen(); break; case 'long': octets = gen() + '-' + (gen().substring(0, 17).split('').reverse().join('')); break; case 'huge': octets = gen() + '-' + gen(); break; default: throw new Error(`No such UUID size: ${this.$config.uuidsize}`); } return `${this.$config.prefix}::${octets}`; } // #endregion // #region Serialization // =================================================================== /** Transforms a JSON structure into concrete entities */ static fromJSON(data) { const result = Serializer.fromJSON(data); this.$validateOwnType(result); return result; } /** Transforms an object into concrete entities */ static fromObject(data) { const result = Serializer.fromObject(data); this.$validateOwnType(result); return result; } /** Validate that a type has a correct structure */ static $validateOwnType(obj) { if (!(obj instanceof Entity)) { throw new Error("Input payload is not a recognized model"); } if (obj.constructor !== this) { throw new Error(`Type mismatch: expected ${this.name} but got ${obj.constructor.name}`); } } // #endregion /** Shortcut to get the class config from an instance */ get $config() { return this.constructor["$config"]; } } //# sourceMappingURL=Entity.js.map