UNPKG

@knorm/knorm

Version:

A JavaScript ORM written using ES6 classes

755 lines (754 loc) 27.5 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const lodash_1 = require("lodash"); const Field_1 = require("./Field"); const Virtual_1 = require("./Virtual"); const Query_1 = require("./Query"); /** * Creates model instances and allows setting, getting, validating and casting * data before and/or after database operations. */ class Model { /** * Creates a {@link Model} instance. * * @param {object} [data] Data to assign to the instance. This data can be * anything: data for fields (including virtual fields) or any arbitrary data. * If data is provided, it's set via {@link Model#setData}. */ constructor(data = {}) { const config = this.constructor.config; const unique = config.unique; const fields = config.fields; const virtuals = config.virtuals; const descriptors = Object.values(virtuals).reduce((descriptors, virtual) => { const { name, get, set } = virtual; descriptors[name] = { get, set, enumerable: true }; return descriptors; }, {}); descriptors._config = { value: { config, unique, fields, virtuals } }; Object.defineProperties(this, descriptors); this.setData(data); } // TODO: strict mode: throw if the field-name is not a valid field getField(field) { return this._config.fields[field]; } // TODO: strict mode: throw if the field-name is not a valid field getFields(fields) { if (!fields || !fields.length) { return Object.values(this._config.fields); } return fields.map((field) => { if (typeof field === 'string') { return this.getField(field); } return field; }); } // TODO: v2: make async setDefaults({ fields } = {}) { this.getFields(fields).forEach((field) => { const name = field.name; if (this[name] === undefined) { const defaultValue = field.getDefault(this); if (defaultValue !== undefined) { this[name] = defaultValue; } } }); return this; } /** * Sets an instance's data. * * ::: tip INFO * - Keys with `undefined` values are skipped. * - Virtuals with no setters are skipped. * ::: * * @param {object} data The data to assign to the instance. This could contain * anything, including field values (including virtual fields) and other * arbitrary data. * * @returns {Model} The same model instance * * @todo strict mode: throw if a virtual has no setter * @todo strict mode: check if all fields in the data are valid field names */ setData(data) { const virtuals = this._config.virtuals; Object.entries(data).forEach(([key, value]) => { if (virtuals[key] && !virtuals[key].set) { return; } if (value !== undefined) { this[key] = value; } }); return this; } getFieldData({ fields } = {}) { return this.getFields(fields).reduce((data, field) => { const name = field.name; const value = this[name]; if (value !== undefined) { data[name] = value; } return data; }, {}); } _getVirtualGetters(virtuals) { if (!virtuals || !virtuals.length) { virtuals = Object.values(this._config.virtuals) .filter((virtual) => !!virtual.get) .map((virtual) => virtual.name); } return virtuals; } // TODO: return `undefined` if a virtual has no getter instead of throwing (but throw in strict mode) // TODO: strict mode: throw if the virtual-name is not a valid virtual _getVirtualData(name) { const virtual = this._config.virtuals[name]; if (!virtual.get) { throw new Error(`Virtual '${this.constructor.name}.${name}' has no getter`); } return virtual.get.call(this); } getVirtualData({ virtuals } = {}) { return __awaiter(this, void 0, void 0, function* () { virtuals = this._getVirtualGetters(virtuals); const data = {}; yield Promise.all(virtuals.map((name) => __awaiter(this, void 0, void 0, function* () { data[name] = yield this._getVirtualData(name); }))); return data; }); } _isPromise(value) { return typeof value === 'object' && typeof value.then === 'function'; } getVirtualDataSync({ virtuals } = {}) { virtuals = this._getVirtualGetters(virtuals); return virtuals.reduce((data, name) => { const value = this._getVirtualData(name); if (!this._isPromise(value)) { data[name] = value; } return data; }, {}); } getData({ fields, virtuals } = {}) { return __awaiter(this, void 0, void 0, function* () { const fieldData = this.getFieldData({ fields }); const virtualData = yield this.getVirtualData({ virtuals }); return Object.assign(fieldData, virtualData); }); } getDataSync({ fields, virtuals } = {}) { const fieldData = this.getFieldData({ fields }); const virtualData = this.getVirtualDataSync({ virtuals }); return Object.assign(fieldData, virtualData); } validate({ fields } = {}) { return __awaiter(this, void 0, void 0, function* () { yield Promise.all(this.getFields(fields).map((field) => __awaiter(this, void 0, void 0, function* () { const name = field.name; const value = this[name]; yield field.validate(value, this); }))); return this; }); } // TODO: v2: make async cast({ fields, forSave, forFetch } = {}) { this.getFields(fields).forEach((field) => { const name = field.name; const value = this[name]; if (value !== undefined) { const newValue = field.cast(value, this, { forSave, forFetch }); if (newValue !== undefined) { this[name] = newValue; } } }); return this; } getQuery(options = {}, { forInsert } = {}) { const query = this.constructor.query; query.setOptions(options).first(true); if (options.require === undefined) { query.require(true); } if (forInsert) { return query; } const primaryField = this._config.config.primary; const primaryFieldValue = this[primaryField]; if (primaryFieldValue !== undefined) { return query.where({ [primaryField]: primaryFieldValue }); } const uniqueFields = this._config.unique; if (uniqueFields.length) { const uniqueFieldAdded = uniqueFields.some((field) => { const value = this[field]; if (value !== undefined) { query.where({ [field]: value }); return true; } return false; }); if (uniqueFieldAdded) { return query; } } throw new Error(`${this.constructor.name}: primary field (\`${primaryField}\`) is not set`); } /** * Inserts a single row into the database. * * @param {object} [options] {@link Query} options. * * @returns {Promise} A `Promise` that resolves with the same instance * populated with the inserted row from the dabatase. The fields to be * returned in the data can be configured with the {@link Query#fields} or * {@link Query#returning} options. */ insert(options) { return __awaiter(this, void 0, void 0, function* () { const row = yield this.getQuery(options, { forInsert: true }).insert(this); return row ? this.setData(row).cast({ forFetch: true }) : row; }); } /** * Updates a single row in the database. * * ::: warning NOTE * This method requires a value for either a [primary or a unique * field](/guides/fields.md#primary-and-unique-fields) to be set on the * instance in order to know what row to update. * ::: * * ::: tip INFO * This method sets the {@link Query#first} (return only the first row) and * {@link Query#require} (throw a {@link NoRowsError} if no row is matched for * update) query options. However, the {@link Query#require} option can be * disabled via the `options` param. * ::: * * @param {object} [options] {@link Query} options. * * @returns {Promise} A `Promise` that resolves with the same instance * populated with the updated row from the dabatase. The fields to be returned * in the data can be configured with the {@link Query#fields} or * {@link Query#returning} options. * * @todo throw {@link ModelError} instead of plain `Error` */ update(options) { return __awaiter(this, void 0, void 0, function* () { const row = yield this.getQuery(options).update(this); return row ? this.setData(row).cast({ forFetch: true }) : row; }); } /** * Either inserts or updates a single row in the database, depending on * whether a value for the primary field is set or not. * * @param {object} [options] {@link Query} options. * * @returns {Promise} A `Promise` that resolves with the same instance * populated with the inserted or updated row from the dabatase. The fields to * be returned in the data can be configured with the {@link Query#fields} or * {@link Query#returning} options. * * @todo throw {@link ModelError} instead of plain `Error` */ save(options) { return __awaiter(this, void 0, void 0, function* () { return this[this._config.config.primary] === undefined ? this.insert(options) : this.update(options); }); } /** * Fetches a single row from the database. * * ::: warning NOTE * This method requires a value for either a [primary or a unique * field](/guides/fields.md#primary-and-unique-fields) to be set on the * instance in order to know what row to fetch. * ::: * * ::: tip INFO * This method sets the {@link Query#first} (return only the first row) and * {@link Query#require} (throw a {@link NoRowsError} if no row is matched for * fetching) query options. However, the {@link Query#require} option can be * disabled via the `options` param. * ::: * * @param {object} [options] {@link Query} options. * * @returns {Promise} A `Promise` that resolves with the same instance * populated with data fetched from the database. The fields to be returned in * the data can be configured with the {@link Query#fields} or * {@link Query#returning} options. * * @todo throw {@link ModelError} instead of plain `Error` */ fetch(options) { return __awaiter(this, void 0, void 0, function* () { const row = yield this.getQuery(options).fetch(); return row ? this.setData(row).cast({ forFetch: true }) : row; }); } /** * Deletes a single row from the database. * * ::: warning NOTE * This method requires a value for either a [primary or a unique * field](/guides/fields.md#primary-and-unique-fields) to be set on the * instance in order to know what row to delete. * ::: * * ::: tip INFO * This method sets the {@link Query#first} (return only the first row) and * {@link Query#require} (throw a {@link NoRowsError} if no row is matched for * deleting) query options. However, the {@link Query#require} option can be * disabled via the `options` param. * ::: * * @param {object} [options] {@link Query} options. * * @returns {Promise} A `Promise` that resolves with the same instance * populated with the row deleted from the dabatase. The fields to be returned * in the data can be configured with the {@link Query#fields} or * {@link Query#returning} options. * * @todo throw {@link ModelError} instead of plain `Error` */ delete(options) { return __awaiter(this, void 0, void 0, function* () { const row = yield this.getQuery(options).delete(); return row ? this.setData(row).cast({ forFetch: true }) : row; }); } /** * Inserts a single or multiple rows into the database. * * @param {Model|object|array} data The data to insert. Can be a plain object, * a {@link Model} instance or an array of objects or {@link Model} instances. * @param {object} [options] {@link Query} options * * ::: tip INFO * This method directly proxies to {@link Query#insert}. * ::: */ static insert(data, options) { return __awaiter(this, void 0, void 0, function* () { return this.query.insert(data, options); }); } /** * Updates a single or multiple rows in the database. * * @param {Model|object|array} data The data to update. Can be a plain object, * a {@link Model} instance or an array of objects or instances. * @param {object} [options] {@link Query} options * * ::: tip INFO * This method directly proxies to {@link Query#update}. * ::: */ static update(data, options) { return __awaiter(this, void 0, void 0, function* () { return this.query.update(data, options); }); } /** * Either inserts or updates a single row or multiple rows in the database. * * @param {Model|object|array} data The data to update. Can be a plain object, * a {@link Model} instance or an array of objects or instances. * @param {object} [options] {@link Query} options * * ::: tip INFO * This method directly proxies to {@link Query#save}. * ::: */ static save(data, options) { return __awaiter(this, void 0, void 0, function* () { return this.query.save(data, options); }); } /** * Fetches a single or multiple rows from the database. * * @param {object} [options] {@link Query} options * * ::: tip INFO * This method directly proxies to {@link Query#fetch}. * ::: */ static fetch(options) { return __awaiter(this, void 0, void 0, function* () { return this.query.fetch(options); }); } /** * Deletes a single or multiple rows from the database. * * @param {object} [options] {@link Query} options * * ::: tip INFO * This method directly proxies to {@link Query#delete}. * ::: */ static delete(options) { return __awaiter(this, void 0, void 0, function* () { return this.query.delete(options); }); } // depeded on by knorm-relations static addField(field) { const { name } = field; if (this.prototype[name] !== undefined) { throw new Error(`${this.name}: cannot add field \`${name}\` ` + `(\`${this.name}.prototype.${name}\` is already assigned)`); } if (this._config._virtuals[name] !== undefined) { throw new Error(`${this.name}: cannot add field \`${name}\` ` + `(\`${name}\` is a virtual)`); } if (field.primary) { this._config._primary = name; } else if (this._config._primary === name) { this._config._primary = undefined; } if (field.updated === false) { this._config.notUpdated.push(name); } else { const index = this._config.notUpdated.indexOf(name); if (index > -1) { this._config.notUpdated.splice(index, 1); } } if (field.unique) { this._config.unique.push(name); } else { const index = this._config.unique.indexOf(name); if (index > -1) { this._config.unique.splice(index, 1); } } if (field.methods) { const formattedName = lodash_1.upperFirst(name); this[`fetchBy${formattedName}`] = function (value, options) { return __awaiter(this, void 0, void 0, function* () { // eslint-disable-next-line new-cap return new this({ [name]: value }).fetch(options); }); }; this[`updateBy${formattedName}`] = function (value, data, options) { return __awaiter(this, void 0, void 0, function* () { data = Object.assign({}, data, { [name]: value }); // eslint-disable-next-line new-cap return new this(data).update(options); }); }; this[`deleteBy${formattedName}`] = function (value, options) { return __awaiter(this, void 0, void 0, function* () { // eslint-disable-next-line new-cap return new this({ [name]: value }).delete(options); }); }; } if (!this._config.fieldNames.includes(name)) { this._config.fieldNames.push(name); } this._config._fields[name] = field; this._config.fieldsToColumns[name] = field.column; } // depended on by knorm-relations static removeField(field) { const { name, methods, primary, unique } = field; if (!this._config._fields[name]) { return; } if (this._config._primary === name) { this._config._primary = undefined; } // we use `delete` because it's easier to work with and we don't care about // performance here. it's expected that `removeField` is called syncronously // when the server is starting up delete this._config._fields[name]; delete this._config.fieldsToColumns[name]; const notUpdatedIndex = this._config.notUpdated.indexOf(name); if (notUpdatedIndex > -1) { this._config.notUpdated.splice(notUpdatedIndex, 1); } const uniqueIndex = this._config.unique.indexOf(name); if (uniqueIndex > -1) { this._config.unique.splice(uniqueIndex, 1); } const fieldNameIndex = this._config.fieldNames.indexOf(name); if (fieldNameIndex > -1) { this._config.fieldNames.splice(fieldNameIndex, 1); } if (methods && (primary || unique)) { const formattedName = lodash_1.upperFirst(name); delete this[`fetchBy${formattedName}`]; delete this[`updateBy${formattedName}`]; delete this[`deleteBy${formattedName}`]; } } static addVirtual(virtual) { const { name } = virtual; if (this.prototype[name] !== undefined) { throw new Error(`${this.name}: cannot add virtual \`${name}\` ` + `(\`${this.name}.prototype.${name}\` is already assigned)`); } if (this.config._fields[name] !== undefined) { throw new Error(`${this.name}: cannot add virtual \`${name}\` ` + `(\`${name}\` is a field)`); } this._config._virtuals[name] = virtual; } // depeded on by knorm-relations static createConfig() { const model = this; const config = { model, _fields: {}, _virtuals: {}, _options: {}, fieldsToColumns: {}, unique: [], notUpdated: [], fieldNames: [], }; Object.defineProperties(config, { primary: { get() { if (!config._primary) { throw new Error(`\`${model.name}\` has no primary field`); } return config._primary; }, }, fields: { get() { return config._fields; }, set(fields) { Object.entries(fields).forEach(([name, config]) => { if (typeof config === 'string') { config = { type: config }; } if (config instanceof model.Field) { config = Object.assign({}, config.config, { model }); } else { config = Object.assign({}, config, { name, model }); } model.addField(new model.Field(config)); }); }, }, virtuals: { get() { return config._virtuals; }, set(virtuals) { Object.entries(virtuals).forEach(([name, descriptor]) => { model.addVirtual(new model.Virtual({ name, model, descriptor })); }); }, }, options: { get() { return config._options; }, set(options) { config._options = lodash_1.merge(config._options, options); }, }, }); return config; } // TODO: document `Model.config = {}` for models that inherit others but have // no config static set config({ schema, table, fields, virtuals, options }) { if (!this._config) { const value = this.createConfig(); Object.defineProperty(this, '_config', { value, writable: true }); } else if (this._config.model !== this) { const parentConfig = this._config; this._config = this.createConfig(); this._config.schema = parentConfig.schema; this._config.table = parentConfig.table; this._config.fields = parentConfig._fields; this._config.virtuals = parentConfig._virtuals; this._config.options = parentConfig._options; } if (!this.knorm.models[this.name]) { this.knorm.addModel(this); } if (schema) { this._config.schema = schema; } if (table) { this._config.table = table; } if (fields) { this._config.fields = fields; } if (virtuals) { this._config.virtuals = virtuals; } if (options) { this._config.options = options; } } static get config() { return this._config; } static set schema(schema) { this.config = { schema }; } static get schema() { return this.config.schema; } static set table(table) { this.config = { table }; } static get table() { return this.config.table; } /** * As a getter, returns the fields added to the model or a model that this * model inherits. As a setter, sets the model's fields or overrides fields * added to a parent model. * * @type {object} */ static set fields(fields) { this.config = { fields }; } static get fields() { return this.config.fields; } static set virtuals(virtuals) { this.config = { virtuals }; } static get virtuals() { return this.config.virtuals; } static set options(options) { this.config = { options }; } static get options() { return this.config.options; } static get query() { const query = new this.Query(this); if (this._config && this._config._options && this._config._options.query) { query.setOptions(this._config._options.query); } return query; } static get where() { return new this.Query.Where(); } } exports.Model = Model; /** * A reference to the {@link Knorm} instance. * * ::: tip * This is the same instance assigned to the {@link Model.knorm} static * property, just added as a convenience for use in instance methods. * ::: */ Model.prototype.knorm = null; /** * A reference to the {@link Knorm} instance. * * ::: tip * This is the same instance assigned to the {@link Model#knorm} instance * property, just added as a convenience for use in static methods. * ::: */ Model.knorm = null; /** * The model registry. This is an object containing all the models added to the * ORM, keyed by name. See [model registry](/guides/models.md#model-registry) * for more info. * * ::: tip * This is the same object assigned to the {@link Model.models} static property, * just added as a convenience for use in instance methods. * ::: * * @type {object} */ Model.prototype.models = {}; /** * The model registry. This is an object containing all the models added to the * ORM, keyed by name. See [model registry](/guides/models.md#model-registry) * for more info. * * ::: tip * This is the same object assigned to the {@link Model#models} instance * property, just added as a convenience for use in static methods. * ::: * * @type {object} */ Model.models = {}; /** * For models accessed within a transaction, this is reference to the * {@link Transaction} instance. * * ::: warning NOTE * This is only set for {@link Model} instances that are accessed within a * transaction, otherwise it's set to `null`. * ::: * * ::: tip * This is the same instance assigned to the {@link Model.transaction} static * property, just added as a convenience for use in static methods. * ::: */ Model.prototype.transaction = null; /** * For models accessed within a transaction, this is reference to the * {@link Transaction} instance. * * ::: warning NOTE * This is only set for {@link Model} classes within a transaction, otherwise * it's set to `null`. * ::: * * ::: tip * This is the same instance assigned to the {@link Model#transaction} instance * property, just added as a convenience for use in static methods. * ::: */ Model.transaction = null; Model.Field = Field_1.Field; Model.Virtual = Virtual_1.Virtual; Model.Query = Query_1.Query;