trilogy
Version:
TypeScript SQLite layer with support for both native C++ & pure JavaScript drivers.
448 lines (447 loc) • 18.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const hooks_1 = require("./hooks");
const helpers = require("./helpers");
const schema_helpers_1 = require("./schema-helpers");
const util_1 = require("./util");
const types = require("./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
*/
class Model extends hooks_1.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 = schema_helpers_1.normalizeSchema(schema, options);
this.cast = new schema_helpers_1.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(hooks_1.Hook.BeforeCreate, object, options);
if (prevented)
return;
const insertion = this.cast.toDefinition(object, options);
const [returning, cleanup] = await schema_helpers_1.createTrigger(this, schema_helpers_1.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 = !util_1.isEmpty(result)
? this.cast.fromDefinition(util_1.firstOrValue(result), options)
: undefined;
await this._callHook(hooks_1.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 = util_1.firstOrValue(response);
if (util_1.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 schema_helpers_1.createTrigger(this, schema_helpers_1.TriggerEvent.Update);
const { prevented } = await this._callHook(hooks_1.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(hooks_1.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 => util_1.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(hooks_1.Hook.BeforeUpdate, [{}, criteria]);
if (prevented)
return [];
const cast = Number(amount);
if (Number.isNaN(cast))
amount = 1;
if (amount === 0)
return [];
const [returning, cleanup] = await schema_helpers_1.createTrigger(this, schema_helpers_1.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(hooks_1.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(hooks_1.Hook.BeforeUpdate, [{}, criteria]);
if (prevented)
return [];
const cast = Number(amount);
if (Number.isNaN(cast))
amount = 1;
if (amount === 0)
return [];
const [returning, cleanup] = await schema_helpers_1.createTrigger(this, schema_helpers_1.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(hooks_1.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(hooks_1.Hook.BeforeRemove, criteria);
if (prevented)
return [];
if (!helpers.isValidWhere(criteria) ||
(util_1.isObject(criteria) && !Object.keys(criteria).length))
return [];
const [returning, cleanup] = await schema_helpers_1.createTrigger(this, schema_helpers_1.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(hooks_1.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);
}
}
exports.default = Model;
async function baseCount(model, column, criteria, options = {}) {
util_1.invariant(column && util_1.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(util_1.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(util_1.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) {
util_1.invariant(model.schema[column], `no column by the name '${column}' is defined in '${model.name}'`);
return model.update(criteria, {
[column]: value
}, options);
}