@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
JavaScript
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