trilogy
Version:
TypeScript SQLite layer with support for both native C++ & pure JavaScript drivers.
591 lines (590 loc) • 23.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.connect = exports.Trilogy = void 0;
const tslib_1 = require("tslib");
const path_1 = require("path");
const fs_1 = require("fs");
const knex = require("knex");
const model_1 = require("./model");
const helpers_1 = require("./helpers");
const schema_helpers_1 = require("./schema-helpers");
const sqljs_handler_1 = require("./sqljs-handler");
const util_1 = require("./util");
const types = require("./types");
const ensureExists = (atPath) => {
try {
fs_1.closeSync(fs_1.openSync(atPath, 'wx'));
}
catch (_a) { }
};
const initOptions = (path, options) => (Object.assign(Object.assign({ client: 'sqlite3', dir: process.cwd() }, types.TrilogyOptions.check(options)), { connection: { filename: path } }));
/**
* Initialize a new datastore instance, creating a SQLite database file at
* the provided path if it does not yet exist, or reading it if it does.
*
* It's recommended to use the {@link connect} function to create instances
* rather than instantiating this class directly.
*
* @remarks
* If path is exactly `':memory:'`, no file will be created and a memory-only
* store will be used. This doesn't persist any of the data.
*/
class Trilogy {
/**
* @param path File path or `':memory:'` for in-memory storage
* @param options Configuration for this trilogy instance
*/
constructor(path, options = {}) {
util_1.invariant(path, 'trilogy constructor must be provided a file path');
const obj = this.options = initOptions(path, options);
if (path === ':memory:') {
obj.connection.filename = path;
}
else {
obj.connection.filename = path_1.resolve(obj.dir, path);
// ensure the directory exists
util_1.makeDirPath(path_1.dirname(obj.connection.filename));
}
this.isNative = obj.client === 'sqlite3';
if (path !== ':memory:') {
ensureExists(obj.connection.filename);
}
const config = { client: 'sqlite3', useNullAsDefault: true };
if (this.isNative) {
this.knex = knex(Object.assign(Object.assign({}, config), { connection: obj.connection }));
}
else {
this.knex = knex(config);
this.pool = sqljs_handler_1.pureConnect(this);
}
this._definitions = new Map();
}
/**
* Array of all model names defined on the instance.
*/
get models() {
return [...this._definitions.keys()];
}
/**
* Define a new model with the provided schema, or return the existing
* model if one is already defined with the given name.
*
* @param name Name of the model
* @param schema Object defining the schema of the model
* @param options Configuration for this model instance
*/
async model(name, schema, options = {}) {
if (this._definitions.has(name)) {
return this._definitions.get(name);
}
const model = new model_1.default(this, name, schema, options);
this._definitions.set(name, model);
const opts = schema_helpers_1.toKnexSchema(model, types.ModelOptions.check(options));
const check = this.knex.schema.hasTable(name);
const query = this.knex.schema.createTable(name, opts);
if (this.isNative) {
if (!await check) {
// tslint:disable-next-line:await-promise
await query;
}
}
else {
if (!await helpers_1.runQuery(this, check, { needResponse: true })) {
await helpers_1.runQuery(this, query);
}
}
return model;
}
/**
* Synchronously retrieve a model if it exists. If that model doesn't exist
* an error will be thrown.
*
* @param name Name of the model
*
* @throws if `name` has not already been defined
*/
getModel(name) {
return util_1.invariant(this._definitions.get(name), `no model defined by the name '${name}'`);
}
/**
* First checks if the model's been defined with trilogy, then runs an
* existence query on the database, returning `true` if the table exists
* or `false` if it doesn't.
*
* @param name Name of the model
*/
async hasModel(name) {
if (!this._definitions.has(name)) {
return false;
}
const query = this.knex.schema.hasTable(name);
return helpers_1.runQuery(this, query, { needResponse: true });
}
/**
* Removes the specified model from trilogy's definition and the database.
*
* @param name Name of the model to remove
*/
async dropModel(name) {
if (!this._definitions.has(name)) {
return false;
}
const query = this.knex.schema.dropTableIfExists(name);
await helpers_1.runQuery(this, query, { needResponse: true });
this._definitions.delete(name);
return true;
}
/**
* Allows running any arbitrary query generated by trilogy's `knex` instance.
* If the result is needed, pass `true` as the second argument, otherwise the
* number of affected rows will be returned ( if applicable ).
*
* @param query Any query built with `knex`
* @param [needResponse] Whether to return the result of the query
*/
async raw(query, needResponse) {
return helpers_1.runQuery(this, query, { needResponse });
}
/**
* Drains the connection pools and releases connections to any open database
* files. This should always be called at the end of your program to
* gracefully shut down, and only once since the connection can't be reopened.
*/
async close() {
if (this.isNative) {
return this.knex.destroy();
}
else {
return this.pool.drain();
}
}
async create(modelName, object, options) {
const model = this.getModel(modelName);
return model.create(object, options);
}
async find(location, criteria, options) {
const [table, column] = location.split('.', 2);
const model = this.getModel(table);
if (column) {
return model.findIn(column, criteria, options);
}
else {
return model.find(criteria, options);
}
}
async findOne(location, criteria, options) {
const [table, column] = location.split('.', 2);
const model = this.getModel(table);
if (column) {
return model.findOneIn(column, criteria, options);
}
else {
return model.findOne(criteria, options);
}
}
async findOrCreate(modelName, criteria, creation, options) {
const model = this.getModel(modelName);
return model.findOrCreate(criteria, creation, options);
}
/**
* Modify the properties of an existing object. While optional, if `data`
* contains no properties no update queries will be run.
*
* @param modelName Name of the model
* @param criteria Criteria used to restrict selection
* @param data Updates to be made on matching objects
* @param options
*/
async update(modelName, criteria, data, options) {
const model = this.getModel(modelName);
return model.update(criteria, data, options);
}
/**
* 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 modelName Name of the model
* @param criteria Criteria used to restrict selection
* @param data Updates to be made on matching objects
* @param options
*/
async updateOrCreate(modelName, criteria, data, options) {
const model = this.getModel(modelName);
return model.updateOrCreate(criteria, data, options);
}
async get(location, criteria, defaultValue) {
const [table, column] = location.split('.', 2);
util_1.invariant(column, 'property name is required, ex: `get("users.rank")`');
const model = this.getModel(table);
return model.get(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 location Model name and a column in dot-notation
* @param criteria Criteria used to restrict selection
* @param value Value returned if the result doesn't exist
*/
async set(location, criteria, value) {
const [table, column] = location.split('.', 2);
util_1.invariant(column, 'property name is required, ex: `set("users.rank")`');
const model = this.getModel(table);
return model.set(column, criteria, value);
}
async getRaw(location, criteria, defaultValue) {
const [table, column] = location.split('.', 2);
util_1.invariant(column, 'property name is required, ex: `getRaw("users.rank")`');
const model = this.getModel(table);
return model.getRaw(column, criteria, defaultValue);
}
/**
* Works exactly like `set` but bypasses setters when updating the target value.
*
* @param location Model name and a column in dot-notation
* @param criteria Criteria used to restrict selection
* @param value Value returned if the result doesn't exist
*/
async setRaw(location, criteria, value) {
const [table, column] = location.split('.', 2);
util_1.invariant(column, 'property name is required, ex: `setRaw("users.rank")`');
const model = this.getModel(table);
return model.setRaw(column, criteria, value);
}
/**
* Increment the value of a given model's property by the specified amount,
* which defaults to `1` if not provided.
*
* @param location Model name and a column in dot-notation
* @param criteria Criteria used to restrict selection
* @param amount
*/
async increment(location, criteria, amount) {
const [table, column] = location.split('.', 2);
const model = this.getModel(table);
return model.increment(column, criteria, amount);
}
/**
* Decrement the value of a given model's property by the specified amount,
* which defaults to `1` if not provided.
*
* @param location Model name and a column in dot-notation
* @param criteria Criteria used to restrict selection
* @param amount
*/
async decrement(location, criteria, amount, allowNegative) {
const [table, column] = location.split('.', 2);
const model = this.getModel(table);
return model.decrement(column, criteria, amount, allowNegative);
}
/**
* Delete objects matching `criteria` from the given model.
*
* @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 modelName Name of the model
* @param criteria Criteria used to restrict selection
*/
async remove(modelName, criteria) {
const model = this.getModel(modelName);
return model.remove(criteria);
}
/**
* Delete all objects from the given model.
*
* @param modelName Name of the model
*/
async clear(modelName) {
const model = this.getModel(modelName);
return model.clear();
}
/**
* Count the number of objects in the given model.
*
* @param location Model name and an optional column in dot-notation
* @param criteria Criteria used to restrict selection
* @param options
*/
async count(location, criteria, options) {
if (location == null && criteria == null && options == null) {
const query = this.knex('sqlite_master')
.whereNot('name', 'sqlite_sequence')
.where({ type: 'table' })
.count('* as count');
return helpers_1.runQuery(this, query, { needResponse: true })
.then(([{ count }]) => count);
}
const [table, column] = (location !== null && location !== void 0 ? location : '').split('.', 2);
const model = this.getModel(table);
return column
? model.countIn(column, criteria, options)
: model.count(criteria, options);
}
/**
* Find the minimum value contained in the model, comparing all values in
* `column` that match the given criteria.
*
* @param location Model name and a column in dot-notation
* @param criteria Criteria used to restrict selection
* @param options
*/
async min(location, criteria, options) {
const [table, column] = location.split('.', 2);
util_1.invariant(column, 'property name is required, ex: `min("users.rank")`');
const model = this.getModel(table);
return model.min(column, criteria, options);
}
/**
* Find the maximum value contained in the model, comparing all values in
* `column` that match the given criteria.
*
* @param location Model name and a column in dot-notation
* @param criteria Criteria used to restrict selection
* @param options
*/
async max(location, criteria, options) {
const [table, column] = location.split('.', 2);
util_1.invariant(column, 'property name is required, ex: `max("users.rank")`');
const model = this.getModel(table);
return model.max(column, criteria, options);
}
/**
* The `onQuery` hook is called each time a query is run on the database,
* and receives the query in string form.
*
* @param [modelName] Optional, name of the model this subscriber will attach to
* @param fn Function called when the hook is triggered
* @param options
*
* @returns Unsubscribe function that removes the subscriber when called
*/
onQuery(...args) {
// tslint:disable-next-line:no-empty
let fn = () => { };
let location = '';
let options = {
includeInternal: false
};
if (args.length === 1) {
fn = args[0];
}
if (args.length >= 2) {
if (typeof args[0] === 'string') {
location = args[0];
}
else if (typeof args[1] === 'function') {
fn = args[0];
}
if (typeof args[1] === 'function') {
fn = args[1];
}
else {
options = Object.assign(Object.assign({}, options), args[1]);
}
}
if (args.length === 3) {
options = args[2] || options;
}
// console.log({ location, fn, options })
if (location !== '') {
// all queries run on the model identified by `location`
return this.getModel(location).onQuery(fn, options);
}
// all queries run across all defined models
const unsubs = Array.from(new Array(this._definitions.size));
let i = -1;
this._definitions.forEach(model => {
unsubs[++i] = model.onQuery(fn, options);
});
return () => unsubs.every(unsub => unsub());
}
/**
* Before an object is created, the beforeCreate hook is called with the
* object.
*
* @remarks
* This hook occurs before casting, so if a subscriber to this hook
* modifies the incoming object those changes will be subject to casting.
* It's also possible to prevent the object from being created entirely
* by returning the EventCancellation symbol from a subscriber callback.
*
* @param [modelName] Optional, name of the model this subscriber will attach to
* @param fn Function called when the hook is triggered
*
* @returns Unsubscribe function that removes the subscriber when called
*/
beforeCreate(...args) {
if (args.length === 2) {
// all creations run on the model identified by `scope`
const [location, fn] = args;
return this.getModel(location).beforeCreate(fn);
}
else {
// all creations run across all defined models
const [fn] = args;
const unsubs = Array.from(new Array(this._definitions.size));
let i = -1;
this._definitions.forEach(model => {
unsubs[++i] = model.beforeCreate(fn);
});
return () => unsubs.every(unsub => unsub());
}
}
/**
* When an object is created, that object is returned to you and the
* `afterCreate` hook is called with it.
*
* @param [modelName] Optional, name of the model this subscriber will attach to
* @param fn Function called when the hook is triggered
*
* @returns Unsubscribe function that removes the subscriber when called
*/
afterCreate(...args) {
if (args.length === 2) {
// all creations run on the model identified by `scope`
const [location, fn] = args;
return this.getModel(location).afterCreate(fn);
}
else {
// all creations run across all defined models
const [fn] = args;
const unsubs = Array.from(new Array(this._definitions.size));
let i = -1;
this._definitions.forEach(model => {
unsubs[++i] = model.afterCreate(fn);
});
return () => unsubs.every(unsub => unsub());
}
}
/**
* Prior to an object being updated the `beforeUpdate` hook is called with the
* update delta, or the incoming changes to be made, as well as the criteria.
*
* @remarks
* Casting occurs after this hook. A subscriber could choose to cancel the
* update by returning the EventCancellation symbol or alter the selection
* criteria.
*
* @param [modelName] Optional, name of the model this subscriber will attach to
* @param fn Function called when the hook is triggered
*
* @returns Unsubscribe function that removes the subscriber when called
*/
beforeUpdate(...args) {
if (args.length === 2) {
// all updates run on the model identified by `scope`
const [location, fn] = args;
return this.getModel(location).beforeUpdate(fn);
}
else {
// all updates run across all defined models
const [fn] = args;
const unsubs = Array.from(new Array(this._definitions.size));
let i = -1;
this._definitions.forEach((model) => {
unsubs[++i] = model.beforeUpdate(fn);
});
return () => unsubs.every(unsub => unsub());
}
}
/**
* Subscribers to the `afterUpdate` hook receive modified objects after they
* are updated.
*
* @param [modelName] Optional, name of the model this subscriber will attach to
* @param fn Function called when the hook is triggered
*
* @returns Unsubscribe function that removes the subscriber when called
*/
afterUpdate(...args) {
if (args.length === 2) {
// all updates run on the model identified by `scope`
const [location, fn] = args;
return this.getModel(location).afterUpdate(fn);
}
else {
// all updates run across all defined models
const [fn] = args;
const unsubs = Array.from(new Array(this._definitions.size));
let i = -1;
this._definitions.forEach(model => {
unsubs[++i] = model.afterUpdate(fn);
});
return () => unsubs.every(unsub => unsub());
}
}
/**
* Before object removal, the criteria for selecting those objects is passed
* to the `beforeRemove` hook.
*
* @remarks
* Casting occurs after this hook. Subscribers can modify the selection
* criteria or prevent the removal entirely by returning the `EventCancellation`
* symbol.
*
* @param [modelName] Optional, name of the model this subscriber will attach to
* @param fn Function called when the hook is triggered
*
* @returns Unsubscribe function that removes the subscriber when called
*/
beforeRemove(...args) {
if (args.length === 2) {
// all removals run on the model identified by `scope`
const [location, fn] = args;
return this.getModel(location).beforeRemove(fn);
}
else {
// all removals run across all defined models
const [fn] = args;
const unsubs = Array.from(new Array(this._definitions.size));
let i = -1;
this._definitions.forEach((model) => {
unsubs[++i] = model.beforeRemove(fn);
});
return () => unsubs.every(unsub => unsub());
}
}
/**
* A list of any removed objects is passed to the `afterRemove` hook.
*
* @param [modelName] Optional, name of the model this subscriber will attach to
* @param fn Function called when the hook is triggered
*
* @returns Unsubscribe function that removes the subscriber when called
*/
afterRemove(...args) {
if (args.length === 2) {
// all removals run on the model identified by `scope`
const [location, fn] = args;
return this.getModel(location).afterRemove(fn);
}
else {
// all removals run across all defined models
const [fn] = args;
const unsubs = Array.from(new Array(this._definitions.size));
let i = -1;
this._definitions.forEach(model => {
unsubs[++i] = model.afterRemove(fn);
});
return () => unsubs.every(unsub => unsub());
}
}
}
exports.Trilogy = Trilogy;
var hooks_1 = require("./hooks");
Object.defineProperty(exports, "EventCancellation", { enumerable: true, get: function () { return hooks_1.EventCancellation; } });
var model_2 = require("./model");
Object.defineProperty(exports, "Model", { enumerable: true, get: function () { return model_2.default; } });
tslib_1.__exportStar(require("./types"), exports);
/**
* Initialize a new datastore instance, creating a SQLite database file at
* the provided path if it does not yet exist, or reading it if it does.
*
* @param path File path or `':memory:'` for memory-only storage
* @param options Configuration for this trilogy instance
*/
exports.connect = (path, options) => new Trilogy(path, options);