UNPKG

@lordfokas/magic-orm

Version:

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

375 lines 16 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; uuid; // #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) { if (this.isSubtype()) { // Puts the full call chain into a transaction so that if anything fails no insert is committed. const prefixes = [this.$config.prefix]; let model = this; while (model.isSubtype()) { model = model.getSupertype(); prefixes.push(model.$config.prefix); } ; return await db.atomic(async () => await this.create_chain(db, ...entities), `MULTI-INSERT ${prefixes.reverse().join(" -> ")}`); } else { return await this.create_chain(db, ...entities); } } /** Actually create the entities respecting the inheritance chain. */ static async create_chain(db, ...entities) { this.beforeCreate(...entities); if (this.isSubtype()) { await this.getSupertype().create_chain(db, ...entities); } const whitelist = this.$config.fields['*']; const cols = entities[0].prioritizeUUIDs().filter(c => whitelist.includes(c)); 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 = []) { if (filters.length < 1) throw new Error('Cannot update table with no filters'); // handle polymorphic updates if (this.isSubtype()) { // determine tables to update let models = []; let model = this; let data = entity; do { // @ts-ignore FIXME: this is a fucky-wucky. How to solve? const fields = model.getFields(data, update, true).filter(f => f != 'uuid'); if (fields.length > 0) { models.push({ model: model, data: data, fields: fields }); } if (model.isSubtype()) { update = model.$config.chain[update]; if (!update) break; model = model.getSupertype(); data = new model(data); } else break; } while (true); // determine update strategy (multi vs single) if (models.length > 1) { models = models.reverse(); const uuid = filters.length == 1 && filters[0].col === 'uuid'; // temporary limitation, won't implement feature until needed if (!uuid) throw new Error("Unsupported: Cannot currently do MULTI-UPDATE except via uuid filters"); return await db.atomic(async () => { for (const entry of models) { await entry.model.do_update(db, entry.data, entry.fields, filters); } }, `MULTI-UPDATE ${models.map(m => m.model.$config.prefix).join(" -> ")}`); } else if (models.length == 1) { // @ts-ignore FIXME: this is a fucky-wucky. How to solve? return await models[0].model.do_update(db, models[0].data, models[0].fields, filters); } throw new Error("Cannot update table with no columns to change"); } return await this.do_update(db, entity, this.getFields(entity, update), filters); } static async do_update(db, entity, fields, filters = []) { fields = fields.filter(f => f != 'uuid'); if (fields.length < 1) throw new Error('Cannot update table with no columns to change'); const query = new UpdateBuilder(entity, fields).filter(filters, this); return await query.execute(db); } static async read(db, select = '*', filters = []) { const fields = this.$config.fields[select]; if (!fields) throw new Error(`No such field set: ${select}`); const own = this.$config.fields["*"]; const local = filters.filter(f => own.includes(f.col)); filters = filters.filter(f => !local.includes(f)); if (!this.isSubtype() && filters.length > 0) { throw new Error(`Column(s) ${filters.map(f => "'" + f.col + "'").join(', ')} not found in table ${this.$config.table}`); } // Create the query itself const query = new SelectBuilder(this, this.ALIAS(fields)).filter(local, this); const order = this.$config.order; if (order) query.order(this.COL(order)); // Join table we inherit from if the fieldset generates any other joins if (this.isSubtype() && this.$config.chain[select]) { const parent = await this.getSupertype().read_parent(this.$config.prefix, this.$config.chain[select], filters); if (parent) query.join(parent, this.$config.inherits); } if (db === false) return query; const result = await query.execute(db); return result.rows.map((row) => new this().$ingest(row)); } /** Create queries for table inheritance. */ static async read_parent(prefix, select, filters) { const own = this.$config.fields["*"]; const local = filters.filter(f => own.includes(f.col)); filters = filters.filter(f => !local.includes(f)); if (!this.isSubtype() && filters.length > 0) { throw new Error(`Column(s) ${filters.map(f => "'" + f.col + "'").join(', ')} not found in table ${this.$config.table}`); } let fields = this.$config.fields[select]; let query; if (fields) { // only generate a query for joining this table if the fieldset exists fields = fields.filter(f => f != 'uuid'); query = new SelectBuilder(this, this.ALIAS(fields, this.$config.prefix, prefix)).filter(local, this); } // Join table we inherit from if the fieldset generates any other joins if (this.isSubtype() && this.$config.chain[select]) { const parent = await this.getSupertype().read_parent(this.$config.prefix, this.$config.chain[select], filters); if (!query) return parent; // if we're not adding ourselves, return parent directly if (parent) query.join(parent, this.$config.inherits); // only join if we have fields from parent and ourselves } if (!query) $logger.warn(`Inefficient query at ${this.name}/{select} - Use an unset chain instead`); return query; // our table if it was selected, joined with parent if also selected, or undefined if subtype and not selected } static $of(row, fn) { const dlo = new this().$ingest(row); if (fn) fn(dlo, row); return dlo; } static isSubtype() { return typeof this.$config.inherits === "object"; } static getSupertype() { return Serializer.lookup(this.$config.inherits.parentClass); } static getFields(entity, fields = '*', allowNull = false) { const all = this.$config.fields[fields]; if (all === undefined && allowNull) return []; return Object.keys(entity).filter(f => all.includes(f)); } /** Hook to fire before creation to make adjustements to entities. */ static beforeCreate(...entity) { } /** 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, prefix = this.$config.prefix) { prefix = prefix + '_'; for (const [k, v] of Object.entries(row)) { if (k.startsWith(prefix)) { this[k.substring(3)] = v; } } Entity.#booleans(this); return this; } /** 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, p_tbl = this.$config.prefix, p_col = p_tbl) { return columns.map(c => `${p_tbl}.${c} AS "${p_col}_${c}"`).join(', '); } /** Creates a list of fields for a SELECT query */ static COL(columns, prefix = this.$config.prefix) { return columns.map(c => `${prefix}.${c}`).join(', '); } /** Returns this table aliased with its 2-letter code for use in queries. */ static TABLE(prefix = this.$config.prefix) { return `${this.$config.table} ${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