UNPKG

trilogy

Version:

TypeScript SQLite layer with support for both native C++ & pure JavaScript drivers.

445 lines (444 loc) 18.2 kB
import { Hooks, Hook } from './hooks'; import * as helpers from './helpers'; import { Cast, normalizeSchema, createTrigger, TriggerEvent } from './schema-helpers'; import { invariant, isEmpty, isString, isObject, isNil, toArray, firstOrValue } from './util'; import * as types from './types'; /** * Instances of `Model` manage the casting of values back and forth between the * SQLite backend and their corresponding JavaScript types as well as calling * hooks. * * Models are created using a trilogy instance's `model` method and are not * intended to be created directly. * * @internal */ export default class Model extends Hooks { /** * @param ctx trilogy instance used as a context for the model * @param name Name associated with this model and used in the backend * @param schema An object defining the fields & types of objects * @param options */ constructor(ctx, name, schema, options) { super(); this.ctx = ctx; this.name = name; this.options = options; this.schema = normalizeSchema(schema, options); this.cast = new Cast(this); } /** * Create an object on the given model. `object` should match the model's * defined schema but values will cast into types as needed. * * @param object Data to insert * @param options */ async create(object, options = {}) { const { prevented } = await this._callHook(Hook.BeforeCreate, object, options); if (prevented) return; const insertion = this.cast.toDefinition(object, options); const [returning, cleanup] = await createTrigger(this, TriggerEvent.Insert); const query = this.ctx.knex.raw(this.ctx.knex(this.name) .insert(insertion) .toString() .replace(/^insert/i, 'INSERT OR IGNORE')); await helpers.runQuery(this.ctx, query, { model: this }); const result = await helpers.runQuery(this.ctx, returning, { model: this, needResponse: true, internal: true }); await cleanup(); const created = !isEmpty(result) ? this.cast.fromDefinition(firstOrValue(result), options) : undefined; await this._callHook(Hook.AfterCreate, created, options); return created; } /** * Find all objects matching a given criteria. * * @param criteria Criteria used to restrict selection * @param options */ async find(criteria, options = {}) { types.FindOptions.check(options); const order = options.random ? 'random' : options.order; let query = this.ctx.knex(this.name).select(); query = helpers.buildWhere(query, this.cast.toDefinition(criteria || {}, Object.assign({ raw: true }, options))); if (order) query = helpers.buildOrder(query, order); if (options.limit) query = query.limit(options.limit); if (options.skip) query = query.offset(options.skip); const response = await helpers.runQuery(this.ctx, query, { model: this, needResponse: true }); if (!Array.isArray(response)) { return response ? [response] : []; } return response.map(object => { return this.cast.fromDefinition(object, options); }); } /** * Find all objects matching a given criteria and extract the values * at `column`. * * @param column Property name of objects to extract the value from * @param criteria Criteria used to restrict selection * @param options */ async findIn(column, criteria, options) { const response = await this.find(criteria, options); return response.map(object => { return this.cast.fromColumnDefinition(column, object[column], options); }); } /** * Find a single object matching a given criteria. The first matching * object is returned. * * @param criteria Criteria used to restrict selection * @param options */ async findOne(criteria, options = {}) { types.FindOptions.check(options); const order = options.random ? 'random' : options.order; let query = this.ctx.knex(this.name).first(); query = helpers.buildWhere(query, this.cast.toDefinition(criteria || {}, Object.assign({ raw: true }, options))); if (order) query = helpers.buildOrder(query, order); if (options.skip) query = query.offset(options.skip); const response = await helpers.runQuery(this.ctx, query, { model: this, needResponse: true }); const result = firstOrValue(response); if (isNil(result)) return undefined; return this.cast.fromDefinition(result, options); } /** * Find a single object matching a given criteria and extract the value * at `column`. The first matching object is returned. * * @param column Property name of the selected object to extract the value from * @param criteria Criteria used to restrict selection * @param options */ async findOneIn(column, criteria, options = {}) { return this.findOne(criteria, options) .then(object => object != null ? object[column] : undefined); } /** * Find a matching object based on the given criteria, or create it if it * doesn't exist. When creating the object, a merged object created from * `criteria` and `creation` will be used, with the properties from * `creation` taking precedence. * * @param criteria Criteria to search for * @param creation Data used to create the object if it doesn't exist * @param options */ async findOrCreate(criteria, creation = {}, options) { return (await this.findOne(criteria, options) || this.create(Object.assign(Object.assign({}, criteria), creation))); } /** * Modify the properties of an existing object. While optional, if `data` * contains no properties no update queries will be run. * * @param criteria Criteria used to restrict selection * @param data Updates to be made on matching objects * @param options */ async update(criteria = {}, data = {}, options = {}) { types.UpdateOptions.check(options); if (Object.keys(data).length < 1) return []; const [returning, cleanup] = await createTrigger(this, TriggerEvent.Update); const { prevented } = await this._callHook(Hook.BeforeUpdate, [data, criteria], options); if (prevented) return []; const typedData = this.cast.toDefinition(data, options); const typedCriteria = this.cast.toDefinition(criteria, options); let query = this.ctx.knex(this.name).update(typedData); query = helpers.buildWhere(query, typedCriteria); await helpers.runQuery(this.ctx, query, { model: this }); const updatedRaw = await helpers.runQuery(this.ctx, returning, { model: this, needResponse: true, internal: true }); const updated = updatedRaw.map(object => { return this.cast.fromDefinition(object, options); }); await cleanup(); await this._callHook(Hook.AfterUpdate, updated, options); return updated; } /** * Update an existing object or create it if it doesn't exist. If creation * is necessary a merged object created from `criteria` and `data` will be * used, with the properties from `data` taking precedence. * * @param criteria Criteria used to restrict selection * @param data Updates to be made on matching objects * @param options */ async updateOrCreate(criteria, data, options = {}) { const found = await this.find(criteria, options); if (!found || !found.length) { return this.create(Object.assign(Object.assign({}, criteria), data), options) .then(res => toArray(res)); } else { return this.update(criteria, data, options); } } /** * Works similarly to the `get` methods in lodash, underscore, etc. Returns * the value at `column` or, if it does not exist, the supplied `defaultValue`. * Essentially a useful shorthand for some `find` scenarios. * * @param column Property name of the object to extract the value from * @param criteria Criteria used to restrict selection * @param defaultValue Value returned if the result doesn't exist */ get(column, criteria, defaultValue) { return baseGet(this, column, criteria, defaultValue); } /** * Works similarly to the `set` methods in lodash, underscore, etc. Updates * the value at `column` to be `value` where the given criteria is met. * * @param column Property name of the object at which to set the value * @param criteria Criteria used to restrict selection * @param value Value returned if the result doesn't exist */ set(column, criteria, value) { return baseSet(this, column, criteria, value); } /** * Works exactly like `get` but bypasses getters and retrieves the raw database value. * * @param column Property name of the object to extract the value from * @param criteria Criteria used to restrict selection * @param defaultValue Value returned if the result doesn't exist */ getRaw(column, criteria, defaultValue) { return baseGet(this, column, criteria, defaultValue, { raw: true }); } /** * Works exactly like `set` but bypasses setters when updating the target value. * * @param column Property name of the object at which to set the value * @param criteria Criteria used to restrict selection * @param value Value returned if the result doesn't exist */ setRaw(column, criteria, value) { return baseSet(this, column, criteria, value, { raw: true }); } /** * Increment the value of a given model's property by the specified amount, * which defaults to `1` if not provided. * * @param column Property at which to increment the value * @param criteria Criteria used to restrict selection * @param amount */ async increment(column, criteria, amount) { const { prevented } = await this._callHook(Hook.BeforeUpdate, [{}, criteria]); if (prevented) return []; const cast = Number(amount); if (Number.isNaN(cast)) amount = 1; if (amount === 0) return []; const [returning, cleanup] = await createTrigger(this, TriggerEvent.Update); let query = this.ctx.knex(this.name).increment(column, amount); query = helpers.buildWhere(query, criteria); const affected = await helpers.runQuery(this.ctx, query, { model: this }); if (affected === 0) return []; const updatedRaw = await helpers.runQuery(this.ctx, returning, { model: this, needResponse: true, internal: true }); const updated = updatedRaw.map(object => { return this.cast.fromDefinition(object, {}); }); await cleanup(); await this._callHook(Hook.AfterUpdate, updated); return updated; } /** * Decrement the value of a given model's property by the specified amount, * which defaults to `1` if not provided. * * @param column Property at which to decrement the value * @param criteria Criteria used to restrict selection * @param amount */ async decrement(column, criteria, amount, allowNegative) { const { prevented } = await this._callHook(Hook.BeforeUpdate, [{}, criteria]); if (prevented) return []; const cast = Number(amount); if (Number.isNaN(cast)) amount = 1; if (amount === 0) return []; const [returning, cleanup] = await createTrigger(this, TriggerEvent.Update); const raw = allowNegative ? '?? - ?' : 'MAX(0, ?? - ?)'; const query = helpers.buildWhere(this.ctx.knex(this.name).update({ [column]: this.ctx.knex.raw(raw, [column, amount]) }), criteria); const affected = await helpers.runQuery(this.ctx, query, { model: this }); if (affected === 0) return []; const updatedRaw = await helpers.runQuery(this.ctx, returning, { model: this, needResponse: true, internal: true }); const updated = updatedRaw.map(object => { return this.cast.fromDefinition(object, {}); }); await cleanup(); await this._callHook(Hook.AfterUpdate, updated); return updated; } /** * Delete objects from this model that match `criteria`. * * @remarks * If `criteria` is empty or absent, nothing will be done. This is a safeguard * against unintentionally deleting everything in the model. Use `clear` if * you really want to remove all rows. * * @param criteria Criteria used to restrict selection */ async remove(criteria) { const { prevented } = await this._callHook(Hook.BeforeRemove, criteria); if (prevented) return []; if (!helpers.isValidWhere(criteria) || (isObject(criteria) && !Object.keys(criteria).length)) return []; const [returning, cleanup] = await createTrigger(this, TriggerEvent.Delete); let query = this.ctx.knex(this.name).del(); query = helpers.buildWhere(query, criteria); const deleteCount = await helpers.runQuery(this.ctx, query, { model: this }); if (deleteCount === 0) return []; const deleted = await helpers.runQuery(this.ctx, returning, { model: this, needResponse: true, internal: true }); await cleanup(); await this._callHook(Hook.AfterRemove, deleted); return deleted; } /** * Delete all objects from this model. */ clear() { const query = this.ctx.knex(this.name).truncate(); return helpers.runQuery(this.ctx, query, { model: this }); } /** * Count the number of objects in this model. * * @param criteria Criteria used to restrict selection * @param options */ async count(criteria, options = {}) { return baseCount(this, '*', criteria, options); } /** * Count the number of objects in this model, selecting on column (meaning * `NULL` values are not counted). * * @param column Property name to select on * @param criteria Criteria used to restrict selection * @param options */ async countIn(column, criteria, options = {}) { return baseCount(this, column, criteria, options); } /** * Find the minimum value contained in this model, comparing all values in * `column` that match the given criteria. * * @param column Property name to inspect * @param criteria Criteria used to restrict selection * @param options */ async min(column, criteria, options = {}) { return baseMinMax(this, 'min', column, criteria, options); } /** * Find the maximum value contained in this model, comparing all values in * `column` that match the given criteria. * * @param column Property name to inspect * @param criteria Criteria used to restrict selection * @param options */ async max(column, criteria, options) { return baseMinMax(this, 'max', column, criteria, options); } } async function baseCount(model, column, criteria, options = {}) { invariant(column && isString(column), `invalid column: expected string, got ${typeof column}`); types.AggregateOptions.check(options); const val = `${column} as count`; const builder = model.ctx.knex(model.name); let query = options.distinct ? builder.countDistinct(val) : builder.count(val); query = helpers.buildWhere(query, model.cast.toDefinition(criteria || {}, Object.assign({ raw: true }, options))); if (options.group) query = query.groupBy(toArray(options.group)); const res = await helpers.runQuery(model.ctx, query, { model, needResponse: true }); if (!Array.isArray(res)) return 0; return res[0].count; } async function baseMinMax(model, method, column, criteria, options = {}) { types.AggregateOptions.check(options); const val = `${column} as ${method}`; let query = model.ctx.knex(model.name)[method](val); query = helpers.buildWhere(query, model.cast.toDefinition(criteria || {}, Object.assign({ raw: true }, options))); if (options.group) query = query.groupBy(toArray(options.group)); const res = await helpers.runQuery(model.ctx, query, { model, needResponse: true }); if (!Array.isArray(res)) return undefined; return res[0][method]; } async function baseGet(model, column, criteria, defaultValue, options) { const data = await model.findOneIn(column, criteria, options); return data !== null && data !== void 0 ? data : defaultValue; } async function baseSet(model, column, criteria, value, options) { invariant(model.schema[column], `no column by the name '${column}' is defined in '${model.name}'`); return model.update(criteria, { [column]: value }, options); }