UNPKG

mevn-orm

Version:
528 lines (527 loc) 18.4 kB
import { getDB } from './config.js'; import { getTableName, toSnakeCase } from './inflect.js'; import { createRelationshipMethods } from './relationships.js'; const DEFAULT_PER_PAGE = 15; /** * Array subclass returned by {@link Model.all} and {@link Model.paginate}. * * Extends `Array` so it remains compatible with array operations while adding * {@link ModelCollection.toArray | toArray()} for API serialisation. * * @typeParam T - Model instance type stored in the collection. */ class ModelCollection extends Array { /** * Serialises every model in the collection to a plain object. * * @returns Array of serialised records (respects each model's `hidden` fields). */ toArray() { return this.map((model) => model.toArray()); } } const toError = (error) => { if (error instanceof Error) { return error; } return new Error(String(error)); }; /** * ActiveRecord-style base model backed by Knex. * * Extend this class for each database table. Table names are inferred from the * class name unless overridden via `override table`. Use static methods for * queries and instance methods for row-level persistence. * * @example * ```ts * class User extends Model { * override fillable = ['name', 'email', 'password'] * override hidden = ['password'] * } * * const user = await User.create({ name: 'Jane', email: 'jane@example.com' }) * const page = await User.orderBy('name').paginate(10) * ``` */ class Model { #private; /** * Resolved database table name for this model class. * * Honours subclass `override table` declarations. */ static get currentTable() { return this.resolveTable(); } /** * Active scoped Knex query built by `where()`, `orderBy()`, and other chain methods. * * Consumed and reset by terminal methods such as `first()`, `all()`, and `paginate()`. */ static currentQuery; /** * Resolves the database table name for this model class. * * Instantiates the subclass to read its `table` property, so explicit * `override table` values are honoured on static query paths. * * @returns Resolved table name. */ static resolveTable() { return new this().table; } /** * Returns the active scoped query, initialising one against the model table when absent. * * Used internally by chain methods like `orderBy()` when called without a prior `where()`. */ static ensureCurrentQuery() { if (!this.currentQuery) { const table = this.resolveTable(); this.currentQuery = getDB()(table); } return this.currentQuery; } /** Attributes allowed through {@link Model.save | save()} mass assignment. */ fillable; /** Attributes excluded from {@link Model.toArray | toArray()} and stripped after reads. */ hidden; /** Snake_case singular name derived from the class name (used for default foreign keys). */ modelName; /** Database table this model maps to. Override when inference does not match your schema. */ table; /** Primary key value, set after insert or load. */ id; /** * Creates a model instance from a database row or plain object. * * @param properties - Initial attribute values. */ constructor(properties = {}) { Object.assign(this, properties); this.fillable = []; this.hidden = []; this.#private = ['fillable', 'hidden']; this.modelName = toSnakeCase(this.constructor.name); this.table = getTableName(this.constructor.name); } /** * Inserts the current model using {@link Model.fillable | fillable} attributes and reloads it. * * @returns This instance with database-assigned fields (including `id`) populated. */ async save() { try { const rows = {}; for (const field of this.fillable) { rows[field] = this[field]; } const inserted = await getDB()(this.table).insert(rows); const idValue = Array.isArray(inserted) ? inserted[0] : inserted; const id = typeof idValue === 'bigint' ? Number(idValue) : Number(idValue); const fields = await getDB()(this.table).where({ id }).first(); if (!fields) { throw new Error(`Failed to load inserted record for table "${this.table}"`); } Object.assign(this, fields); this.id = id; return this.stripColumns(this, true); } catch (error) { throw toError(error); } } /** * Updates the current row by primary key and returns a refreshed model instance. * * @param properties - Columns and values to update. * @returns Refreshed instance with updated attributes. * @throws When the instance has no `id`. */ async update(properties) { if (this.id === undefined) { throw new Error('Cannot update model without id'); } try { await getDB()(this.table).where({ id: this.id }).update(properties); const fields = await getDB()(this.table).where({ id: this.id }).first(); if (!fields) { throw new Error(`Failed to load updated record for table "${this.table}"`); } const next = new this.constructor(fields); return this.stripColumns(next); } catch (error) { throw toError(error); } } /** * Deletes the current row by primary key. * * @throws When the instance has no `id`. */ async delete() { if (this.id === undefined) { throw new Error('Cannot delete model without id'); } try { await getDB()(this.table).where({ id: this.id }).del(); } catch (error) { throw toError(error); } } /** * Bulk-updates rows in the model table. * * When preceded by {@link Model.where | where()}, only scoped rows are updated. * The query scope is reset after execution. * * @param properties - Columns and values to update. * @returns Number of rows updated. */ static async update(properties) { try { const table = this.resolveTable(); const query = this.currentQuery ?? getDB()(table); return await query.update(properties); } catch (error) { throw toError(error); } finally { this.currentQuery = undefined; } } /** * Bulk-deletes rows in the model table. * * When preceded by {@link Model.where | where()}, only scoped rows are deleted. * The query scope is reset after execution. * * @returns Number of rows deleted. */ static async destroy() { try { const table = this.resolveTable(); const query = this.currentQuery ?? getDB()(table); return await query.delete(); } catch (error) { throw toError(error); } finally { this.currentQuery = undefined; } } /** * Finds a single model by primary key. * * @param id - Primary key value. * @param columns - Columns to select (default `'*'`). * @returns Model instance, or `null` when not found. Preserves the derived class type. */ static async find(id, columns = '*') { const table = this.resolveTable(); try { const fields = await getDB()(table).where({ id }).first(columns); return fields ? new this(fields) : null; } catch (error) { throw toError(error); } } /** * Finds a model by primary key or throws when it does not exist. * * @param id - Primary key value. * @param columns - Columns to select (default `'*'`). * @returns Model instance. Preserves the derived class type. * @throws When no row matches the given id. */ static async findOrFail(id, columns = '*') { const found = await this.find(id, columns); if (!found) { throw new Error(`${this.name} with id "${id}" not found`); } return found; } /** * Inserts a row and returns the created model instance. * * @param properties - Column values to insert. * @returns Created model with `hidden` fields stripped. Preserves the derived class type. */ static async create(properties) { const table = this.resolveTable(); try { const inserted = await getDB()(table).insert(properties); const idValue = Array.isArray(inserted) ? inserted[0] : inserted; const id = typeof idValue === 'bigint' ? Number(idValue) : Number(idValue); const record = await getDB()(table).where({ id }).first(); if (!record) { throw new Error(`Failed to load created record for table "${table}"`); } const model = new this(record); return model.stripColumns(model); } catch (error) { throw toError(error); } } /** * Inserts multiple rows sequentially and returns created model instances. * * @param properties - Array of column value objects to insert. * @returns Created model instances in insertion order. */ static async createMany(properties) { if (properties.length === 0) { return []; } try { const records = []; for (const property of properties) { records.push(await this.create(property)); } return records; } catch (error) { throw toError(error); } } /** * Returns the first row matching `attributes`, or creates one with merged values. * * @param attributes - Lookup conditions. * @param values - Additional values used only when creating a new row. * @returns Existing or newly created model instance. */ static async firstOrCreate(attributes, values = {}) { const table = this.resolveTable(); try { const record = await getDB()(table).where(attributes).first(); if (record) { const model = new this(record); return model.stripColumns(model); } return this.create({ ...attributes, ...values }); } catch (error) { throw toError(error); } } /** * Starts a scoped query with a `where` clause. * * Chain further constraints (`orderBy`, `limit`, …) then call a terminal method * (`first`, `all`, `paginate`, `count`, `update`, `destroy`). * * @param conditions - Equality conditions passed to Knex `where`. * @returns Model constructor for chaining. */ static where(conditions = {}) { const table = this.resolveTable(); this.currentQuery = getDB()(table).where(conditions); return this; } /** * Appends an `orderBy` clause to the current scoped query. * * @param column - Column to sort by. * @param direction - Sort direction (`'asc'` or `'desc'`). Defaults to `'asc'`. * @returns Model constructor for chaining. */ static orderBy(column, direction = 'asc') { this.ensureCurrentQuery().orderBy(column, direction); return this; } /** * Appends a `limit` clause to the current scoped query. * * @param count - Maximum number of rows to return. * @returns Model constructor for chaining. */ static limit(count) { this.ensureCurrentQuery().limit(count); return this; } /** * Appends an `offset` clause to the current scoped query. * * @param count - Number of rows to skip (commonly paired with {@link Model.limit | limit()}). * @returns Model constructor for chaining. */ static offset(count) { this.ensureCurrentQuery().offset(count); return this; } /** * Returns the first model matching the current scope. * * When no scope is active, returns the first row in the table. * The query scope is reset after execution. * * @param columns - Columns to select (default `'*'`). * @returns First matching model, or `null` when none found. */ static async first(columns = '*') { try { const table = this.resolveTable(); const query = this.currentQuery ?? getDB()(table); const rows = await query.first(columns); return rows ? new this(rows) : null; } catch (error) { throw toError(error); } finally { this.currentQuery = undefined; } } /** * Returns all models matching the current scope. * * When no scope is active, returns every row in the table. * The query scope is reset after execution. * * @param columns - Columns to select (default `'*'`). * @returns {@link ModelCollection} of matching models. */ static async all(columns = '*') { try { const table = this.resolveTable(); const query = this.currentQuery ?? getDB()(table); const rows = await query.select(columns); const collection = new ModelCollection(); for (const row of rows) { collection.push(new this(row)); } return collection; } catch (error) { throw toError(error); } finally { this.currentQuery = undefined; } } /** * Returns a paginated result set for the current scope. * * Runs a count query and a data query against the scoped builder. * The query scope is reset after execution. * * @param perPage - Items per page (default `15`). * @param page - Page number, 1-based (default `1`). * @param columns - Columns to select (default `'*'`). * @returns Paginated data and metadata. * * @example * ```ts * const result = await Post.where({ published: true }).orderBy('created_at', 'desc').paginate(10, 2) * ``` */ static async paginate(perPage = DEFAULT_PER_PAGE, page = 1, columns = '*') { try { const table = this.resolveTable(); const baseQuery = this.currentQuery ?? getDB()(table); const countResult = await baseQuery.clone().count({ count: '*' }).first(); const total = countResult ? Number(countResult.count) : 0; const lastPage = Math.max(1, Math.ceil(total / perPage) || 1); const currentPage = Math.min(Math.max(1, page), lastPage); const offset = (currentPage - 1) * perPage; const rows = await baseQuery.clone().select(columns).limit(perPage).offset(offset); const collection = new ModelCollection(); for (const row of rows) { collection.push(new this(row)); } return { data: collection, total, per_page: perPage, current_page: currentPage, next_page: currentPage < lastPage ? currentPage + 1 : null, prev_page: currentPage > 1 ? currentPage - 1 : null, last_page: lastPage, }; } catch (error) { throw toError(error); } finally { this.currentQuery = undefined; } } /** * Returns a row count for the current scope. * * When no scope is active, counts all rows in the table. * The query scope is reset after execution. * * @param column - Column to count (default `'*'` for all rows). * @returns Matching row count. */ static async count(column = '*') { try { const table = this.resolveTable(); const query = this.currentQuery ?? getDB()(table); const result = await query.count({ count: column }).first(); if (!result) { return 0; } return Number(result.count); } catch (error) { throw toError(error); } finally { this.currentQuery = undefined; } } /** * Serialises the model to a plain object for API responses. * * Excludes ORM internals (`fillable`, `hidden`, `modelName`, `table`) and * any attributes listed in {@link Model.hidden | hidden}. * * @returns Plain data object safe to return from HTTP handlers. */ toArray() { const data = {}; const excluded = new Set([ 'fillable', 'hidden', 'modelName', 'table', ...(Array.isArray(this.hidden) ? this.hidden : []), ]); for (const [key, value] of Object.entries(this)) { if (excluded.has(key) || typeof value === 'function') { continue; } data[key] = value; } return data; } /** * Removes internal and hidden fields from a model instance in place. * * @param model - Model object to strip. * @param keepInternalState - When `true`, retains `fillable` and `hidden` keys. * @returns The same object reference with keys removed. */ stripColumns(model, keepInternalState = false) { const privateKeys = keepInternalState ? [] : this.#private; const hiddenKeys = Array.isArray(this.hidden) ? this.hidden : []; for (const key of [...privateKeys, ...hiddenKeys]) { delete model[key]; } return model; } } Object.assign(Model.prototype, createRelationshipMethods(getDB)); export { Model, ModelCollection }; export { HasOneRelation, HasManyRelation, BelongsToRelation, Relation } from './relation.js'; export { DB, getDB, configure, createKnexConfig, configureDatabase, setMigrationConfig, getMigrationConfig, makeMigration, migrateLatest, migrateRollback, migrateCurrentVersion, migrateList, } from './config.js';