UNPKG

persistanz

Version:

Object relational mapping (ORM) library with unique features.

680 lines (601 loc) 28.9 kB
"use strict"; var co = require("co"); var schemax = require("schemax"); var Transaction = require("./PersTransaction.js"); var helper = require("./helper.js"); var privateProps = new WeakMap(); //private properties var ModelMeta = require("./ModelMeta.js"); var PersQuery = require("./PersQuery.js"); class Persistanz { _ (prop, value) { var p = privateProps.get(this); if (value === undefined) return p[prop]; return p[prop] = value; } constructor (dbConfig, options) { var defaultOptions={ baseModel: null, //all internal objects extends this model extend: true, //user models extend internal models models: [], }; this.dbConfig = dbConfig; this.options = options ? Object.assign(defaultOptions, options) : defaultOptions; this.schema = null; /** * Underlying PersAdapter instance. * Use this to perform row queryies. * @type {persistanz.PersAdapter} */ this.adapter = null; this.abstractAffixes = {}; //keys are affixes, values are: {"type": "suffix/prefix", "affix":"_gb/gb_"}; /** * An array of all models registered to persistanz. * Access your models as pers.model.ModelName * @type {Model[]} */ this.models = {}; /** * An alias to [Persistanz.models](#Persistanz#models) */ this.m = this.models; /** * Holds information about the models and entire structure used internally. * This should never be modified or deleted. The purpose of exposing this property is to debug and allow user of the library to have this meta information which can be used, for example, to build CRUD pages for an admin panel. */ this.modelMeta = {}; //holds model info. privateProps.set(this, { rawSchema: null, modelDefs: {}, modelMetaByTableName: {}, //tablename => {modelName1: model1, modelName2: model2 ...} }); this.ormErrors = []; this.ignoreTables = null; //[] of table names. this.separator = '.'; this.separatorRegex = null; //will be fixed in create. } /** * Basically a debug helper method that returns summary information about the models as well as their bridge and toMany fields. * In projects with lots of tables and foreign key columns, the developer may not immediately see which extra fields are added automatically. So this method is useful to see what models, bridge and toMany fields are autogenerated by persistanz. * If modelName is provided, displays information about only that model. Otherwise, all models are listed. * @param {string=} modelName An optional model name to filter. * @return {string} Information about models. */ getConfigSummary (modelName) { return ModelMeta.getConfigSummary(this, modelName); } /** * Creates and prepares the persistanz object. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} Returns a promise object resolving to true. */ create (cb) { var fn = co.wrap(function * (pers) { //create adapter instance, connect and create internal schema data. var dbConfig = (typeof pers.dbConfig === "string") ? helper.parseDbUrl(pers.dbConfig) : pers.dbConfig; if (["mysql", "mysql2", "postgres", "sqlite3"].indexOf(dbConfig.adapter) < 0) throw new Error("Database adapter is not specified or not supported."); //extract schema information: var rawSchema = yield schemax.extract(dbConfig); pers.ignoreTables = helper.identifyIgnoreTables(rawSchema, pers.options); //make adjustments to the raw schema for easier use: pers.schema = helper.persifyRawSchema(rawSchema, pers.ignoreTables, pers.ormErrors); ModelMeta.createModels(pers, pers.options.models, true); if (pers.ormErrors.length) helper.OrmError.throwAll(pers.ormErrors); ModelMeta.autoGenerateBridgeFields(pers); ModelMeta.setUserBridgeFields(pers); ModelMeta.autoGenerateToManyFields(pers); ModelMeta.setUserToManyFields(pers); //separator: if (pers.options.separator != null) pers.separator = helper.checkSeparator(pers.options.separator); pers.separatorRegex = new RegExp("([^\\\\])\\" + pers.separator, "g"); //connect before throwing remaining errors: pers.adapter = new (require("./adapters/"+dbConfig.adapter+".js"))(dbConfig); yield pers.adapter.connect(); if (pers.ormErrors.length) helper.OrmError.throwAll(pers.ormErrors); return true; }); return helper.polycall(fn, cb, this, this); } /** * Forces database connection pool to drain. * All open db connections are destroyed gracefully as soon as they become idle. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} Returns a promise. */ destroy (cb) { var fn = co.wrap(function *(pers) { return yield pers.adapter.close(); }); return helper.polycall(fn, cb, null, this); } /** * Escapes and returns the expression which is a table or column name to be used in an SQL query. * @param {string} expression Expression to escape * @return {string} The escaped string */ escapeId (expression) { return this.adapter.escapeId(expression); } /** * When persistanz can't find a * column listed in clauses it attempts to add the affix to the given field name and * sees if the new version matches a column name. * * To clear a particular affix, pass _null_ as type argument. * To clear all abstractions pass _null_ as affix argument. * @example * pers.setAbstractAffix("discounted_", "prefix"); * //if Product doesn't have price column but a discounted_price * //column, the returning object will have a .price column whose * //value is filled with the value of discount_price: * pers.loadById("Product", 98, "id, price", function(err, product){ * console.log(product.price); //value of discounted_price column * }); * //clear the affix: * pers.setAbstractAffix("discounted_", null); * //clear all affixes: * pers.setAbstractAffix(null); * @see [Field abstraction over affix](#field-abstraction-over-affix) * @param {string|null} affix A string that will be appended or prepended to all the field names when persistanz can't find the given field. * @param [type='suffix'] {string} Possible values are "prefix" and "suffix". Depending on this value, affix will be appended or prepended. * @return {undefined} */ setAbstractAffix (affix, type) { if (affix == null) return this.abstractAffixes = {}; if (type === null) return delete this.abstractAffixes[affix]; if (type == undefined) type = "suffix"; if (type !== "suffix" && type !== "prefix") throw new Error("Abstract affix type must be one of suffix, prefix"); this.abstractAffixes[affix] = {type, affix}; } /** * Returns a transation object. "BEGIN" query is executed automatically. * @param {object} transactionOptions Currently unused, pass null. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise.Transaction} Returns a promise resolving to a transaction object */ getTransaction (transactionOptions, cb) { var fn = co.wrap(function *(pers) { let isolated = yield pers.adapter.acquire(); let tx = new Transaction(isolated, transactionOptions); yield tx.begin(); return tx; }); return helper.polycall(fn, cb, null, this); } /** * Prepares and returns a PersQuery instance. * * Has an alias: **q()** * @param [tx=null] {persistanz.PersTransaction} A Transaction instance. * @return {PersQuery} A PersQuery instance. */ query (tx) { return new PersQuery({ tx, pers: this, modelMeta: ModelMeta, modelMetaByTableName: this._("modelMetaByTableName"), }); } /** * An alias to [Persistanz.query()](#Persistanz#query) */ q (tx) { return this.query(tx); } /** * Queries the database and returns the object whose id and modelName is specified or null if no row found. * @see [PersQuery.select](#PersQuery#select) on how to use the fieldList * @param {string} modelName Load for this model * @param {string|numeric} id Primary key * @param [fieldList='*'] {string|Array} fields A comma separated field list or an array of fields to be added in the select clause. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the loaded object. */ loadById (modelName, id, fieldList, cb){ return this._loadById(null, modelName, id, fieldList, cb); } /** * Queries the database and returns the object whose id and modelName is specified or null if no row found, in a transaction context. * @see [PersQuery.select](#PersQuery#select) on how to use the fieldList * @param {persistanz.PersTransaction} tx A transaction instance obtained by calling Persistanz.getTransaction(). * @param {string|numeric} id Primary key * @param [fieldList='*'] {string|Array} fields A comma separated field list or an array of fields to be added in the select clause. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the loaded object. */ loadByIdX (tx, modelName, id, fields, cb) { if (!(tx instanceof Transaction)) return helper.txError("loadByIdX", cb); return this._loadById(tx, modelName, id, fields, cb); } /** * Loads the properties of an object from the database according to the given field list. * The primary key of the object must already be set. * @example * var c=new Customer(); * c.id=1; * pers.hydrate(c, "name", function(err, result){ * //c is now hydrated. * console.log(c.name); //logs a name. * console.log(c===result) //logs true. * }); * @param {object} object A model instance to be hydrated. * @param [fieldList='*'] {string|Array} A comma separated field list or an array of fields to be added in the select clause. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the same object, but now hydrated. */ hydrate (object, fieldList, cb) { return this._hydrate(null, object, fieldList, cb); } /** * Loads the properties of an object from the database in a transaction context according to the given field list. * The primary key of the object must already be set. * @param {persistanz.PersTransaction} tx A transaction object. * @param {object} object A model instance to be hydrated. * @param [fieldList='*'] {string|Array} A comma separated field list or an array of fields to be added in the select clause. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the same object, but now hydrated. */ hydrateX (tx, object, fieldList, cb) { if (!(tx instanceof Transaction)) return helper.txError("hydrateX", cb); return this._hydrate(tx, object, fieldList, cb); } _hydrate (tx, object, fieldList, cb) { var fn = co.wrap(function *(pers){ var modelMeta = ModelMeta.getByObject(pers, object); var pkName = modelMeta.table.pks[0]; var pkValue = object[pkName]; if (pkValue == null) throw new Error("Can't hydrate without the primary key is set."); //TODO: Maybe we should not map the object because that will create a //new object with constructor() and getters/setters called which may not be desirable. var fresh = yield pers._loadById(tx, modelMeta.name, pkValue, fieldList); if (!fresh) throw new Error("Object cannot be hydrated because it is not found in the database."); //copy all object properties: return Object.assign(object, fresh); }); return helper.polycallTx(tx, fn, cb, null, this); } _cast (fromObject, toModel) { //does not check, used from q(). var object = new toModel(); return Object.assign(object, fromObject); } /** * Casts fromObject to a model whose name is toName. * * Objects are created with the new keyword, so the constructor is run. * Note that persistanz uses Object.assign, so unrelated columns that are * not in the class (if they exist in your object) are copied too. * This function is internally used and rarely needed in user code as * .saveAs and .insertAs methods automatically calls .cast, thus are less verbose. * @example pz.cast({name: "zubi"}, "Customer"); * @param {object} fromObject Any javascript object to be typecasted to one of the models * @param {string} toModelName Name of of the model to cast the object to * @return {object} An object whose type name is toName */ cast (fromObject, toModelName) { var toModel = ModelMeta.getModelByName(this, toModelName); return this._cast(fromObject, toModel); } /** * Same as [Persistanz.save](#Persistanz#save), except that it always tries to execute an INSERT. * In some cases this is needed if you want to insert an object with a * predefined primary key instead of relying on database auto-increment. * @see [Persistanz.save](#Persistanz#save) * @param {object} object An object already typecast to one of the models. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ insert (object, cb) { return this._save(null, object, true, cb); } /** * Tries to save the given object using and INSERT command, in a transaction context. * @see [Persistanz.save](#Persistanz#save) and [Persistanz.insert](#Persistanz#insert) * @param {persistanz.PersTransaction} tx A transaction instance obtained by calling Persistanz.getTransaction(). * @param {object} object An instance of a model. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ insertX (tx, object, cb) { if (!(tx instanceof Transaction)) return helper.txError("insertX", cb); return this._save(tx, object, true, cb); } /** * Casts the given generic object to model whose name is modelName, then attempts to perform an INSERT query. * Saving process and return value is identical to Persistanz.save. * * @see [Persistanz.save](#Persistanz#save), [Persistanz.saveAs](#Persistanz#saveAs) and [Persistanz.insert](#Persistanz#insert) * @param {object} object An object. * @param {string} modelName The passed object will be inserted as if it is an instance of a model whose name is modelName. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ insertAs (object, modelName, cb) { return this._saveAs(null, object, modelName, true, cb); } /** * Casts the given generic object to model whose name is modelName, * then attempts to perform an INSERT query in the context of a transaction. * * @see [Persistanz.save](#Persistanz#save), [Persistanz.saveAs](#Persistanz#saveAs) and [Persistanz.insert](#Persistanz#insert) * @param {persistanz.PersTransaction} tx A transaction instance obtained by calling Persistanz.getTransaction(). * @param {object} object An object. * @param {string} modelName The passed object will be inserted as if it is an instance of a model whose name is modelName. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ insertAsX (tx, object, modelName, cb) { if (!(tx instanceof Transaction)) return helper.txError("insertAsX", cb); return this._saveAs(tx, object, modelName, true, cb); } /** * Tries to save the given object. If the primary key is set, attempts to do an UPDATE query, * if not, an INSERT query. After the call, the object's primary key is set if it was an insert operation. * Return value is an object (saveResult) with the following members: * * **object**: The object you passed in. After an INSERT query, the primary key is set. * * **status**: Can be "saved", "not-saved" or "cancelled". * If beforeSave hook is run and returned false, the value reads "cancelled". * If saving didn't change any rows this value reads "not-saved". * This happens if an update query was perfomred and there was no row with the primary key of the object. * * **command**: "insert" or "update", depending on whether the primary key was set before the save. * * **lastInsertId**: The id of the saved object or null if the command was update. * * @param {object} object An instance of a model. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ save (object, cb) { return this._save(null, object, false, cb); } /** * Tries to save the given object in a transaction context. * @see [Persistanz.save](#Persistanz#save) * @param {persistanz.PersTransaction} tx A transaction instance obtained by calling Persistanz.getTransaction(). * @param {object} object An instance of a model. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ saveX (tx, object, cb) { if (!(tx instanceof Transaction)) return helper.txError("saveX", cb); return this._save(tx, object, false, cb); } /** * Casts the given generic object to model whose name is modelName, then attempts to save it. * Saving process and return value is identical to Persistanz.save. * * @see [Persistanz.save](#Persistanz#save) * @param {object} object An object. * @param {string} modelName The passed object will be saved as if it is an instance of a model whose name is modelName. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ saveAs (object, modelName, cb) { return this._saveAs(null, object, modelName, false, cb); } /** * Casts the given generic object to model whose name is modelName, then attempts to save it within a transaction context. * This is essentially a Persistanz.saveAs within a transaction. * * @see [Persistanz.saveAs](#Persistanz#saveAs) * @param {persistanz.PersTransaction} tx A transaction instance obtained by calling Persistanz.getTransaction(). * @param {object} object An object. * @param {string} modelName The passed object will be saved as if it is an instance of a model whose name is modelName. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type. */ saveAsX (tx, object, modelName, cb) { if (!(tx instanceof Transaction)) return helper.txError("saveAsX", cb); return this._saveAs(tx, object, modelName, false, cb); } /** * Attempts to delete a row from the database whose primary key is given in id * and model name is given in modelName. * Returns a saveResult object but .command member reads "delete". * If beforeDelete hook is called and returned false, status member of the saveResult reads "cancelled". * If no rows are deleted, status member of the saveResult reads "not-deleted". * @param {string} modelName Name of the model from whose table the row will be deleted. * @param {string|numeric} id A primary key value. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type, with the .command field which reads "delete" */ deleteById (modelName, id, cb) { return this._deleteById(null, modelName, id, cb); } /** * Attempts to delete a row from the database whose primary key is given in id * and model name is given in modelName in a transaction context. * @see [Persistanz.deleteById](#Persistanz#deleteById) * @param {persistanz.PersTransaction} tx A transaction instance obtained by calling Persistanz.getTransaction(). * @param {string} modelName Name of the model from whose table the row will be deleted. * @param {string|numeric} id A primary key value. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type, with the command which reads "delete" */ deleteByIdX (tx, modelName, id, cb) { if (!(tx instanceof Transaction)) return helper.txError("deleteByIdX", cb); return this._deleteById(tx, modelName, id, cb); } /** * Tries to delete the passed object. If the object has no set primary key value, the method fails with an error. * @param {object} object An instance of a model. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type, with the .command field reads "delete" */ deleteObject (object, cb) { return this._deleteObject(null, object, cb); } /** * Tries to delete the passed object in the context of a transaction. * If the object has no set primary key value, the method fails with an error. * @see [Persistanz.deleteObject](#Persistanz#deleteObject) * @param {persistanz.PersTransaction} tx A transaction instance obtained by calling Persistanz.getTransaction(). * @param {object} object An instance of a model. * @param {function=} cb An optional callback in the (err, result) signature. * @return {Promise} A promise resolving to the saveResult type, with the .command field reads "delete" */ deleteObjectX (tx, object, cb) { if (!(tx instanceof Transaction)) return helper.txError("deleteObjectX", cb); return this._deleteObject(tx, object, cb); } _deleteById(tx, modelName, id, cb) { var object = this._castOrError(tx, {}, modelName, cb); if (!object) return; //callback one returns here. var modelMeta = ModelMeta.getByObject(this, object); object[modelMeta.table.pks[0]] = id; return this._deleteObject(tx, object, cb); } _deleteObject(tx, object, cb){ var fn = co.wrap(function *(pers) { var conn = tx || pers.adapter; var modelMeta = ModelMeta.getByObject(pers, object); var pkName = modelMeta.table.pks[0], table = modelMeta.table; var pkValue = object[pkName]; if (pkValue == null) throw new Error("Object must have their primary key set to be deleted."); var ret = { status: null, object: object, command: "delete" } var continueDelete = true; if (typeof object.beforeDelete === 'function') continueDelete = object.beforeDelete.length === 2 ? yield helper.promisifyCall(object.beforeDelete, object, [tx]) : yield object.beforeDelete(tx); //returns promise if (!continueDelete) { ret.status = "cancelled"; return ret; } var eTableName = pers.escapeId(table.name); var ePkName = pers.escapeId(pkName) var mainQuery; mainQuery = `DELETE FROM ${eTableName} WHERE ${ePkName} = ?`; var queryResult = yield conn.exec(mainQuery, pkValue); ret.status = queryResult.rowCount ? "deleted" : "not-deleted"; if (typeof object.afterDelete === 'function' && ret.status==="deleted") object.afterDelete.length === 2 ? yield helper.promisifyCall(object.afterDelete, object, [tx]) : yield object.afterDelete(tx); return ret; }); return helper.polycallTx(tx, fn, cb, null, this); } //cast may throw, which is not okay if cbs are used. we catch errors here. _castOrError (tx, fromObject, modelName, cb) { var o; try { o = this.cast(fromObject, modelName); } catch(err) { tx && tx.isActive() && tx.rollback(); if (cb) { cb(err); return; } throw new Error(err.message); } return o; } _loadById (tx, modelName, id, fields, cb) { var fn = co.wrap(function *(pers){ var pkName = ModelMeta.getByName(pers, modelName).table.pks[0]; var isEmpty = fields == null || fields === '' || (Array.isArray(fields) && ! fields.length); var select = isEmpty ? '*' : fields; var r = yield pers.q(tx).from(modelName).w(`{${pkName}} = ?`, id).s(select).one(); return r || null; }); return helper.polycallTx(tx, fn, cb, null, this); } _saveAs (tx, object, modelName, forceInsert, cb) { var o = this._castOrError(tx, object, modelName, cb); if (!o) return; //callback one returns here. return this._save(tx, o, forceInsert, cb); } _save (tx, object, forceInsert, cb) { var fn = co.wrap(function *(pers) { var conn = tx || pers.adapter; var modelMeta = ModelMeta.getByObject(pers, object); var table = modelMeta.table, pkName = table.pks[0], pkValue = object[pkName]; var saveProps = [], values = []; var isInsert = pkValue === undefined || forceInsert; var isAutoIncrement = modelMeta.table.columns[pkName].isAI; //reject inserts without a set pk and non-autoincrement: if (isInsert && pkValue === undefined && ! isAutoIncrement) throw new Error("Can't insert without the primary key is set when the table has not an auto-increment pk."); var ret={ object: object, status: null, //saved or cancelled (if cancelled by before save); command: isInsert ? "insert" : "update", lastInsertId: null } var continueSave = true; if (typeof object.beforeSave === 'function') continueSave = yield helper.polycallBasedOnSignatureLength(object.beforeSave, object, [tx, ret.command], 3); if (!continueSave) { ret.status = "cancelled"; return ret; } //Select the columns to be saved. Exclude unset properties. for (var columnName in table.columns) { if (object[columnName] !== undefined) { saveProps.push(pers.escapeId(columnName)); values.push(modelMeta.serialize(columnName, object[columnName])); } else { //undefined columns may still use serializion.default: if (isInsert && modelMeta.serialization[columnName] && modelMeta.serialization[columnName].default != null) { saveProps.push(pers.escapeId(columnName)); typeof modelMeta.serialization[columnName].default === "function" ? values.push(modelMeta.serialization[columnName].default()) : values.push(modelMeta.serialization[columnName].default) } } } //if subclass, check first if discrimitor is set. If not set. if (modelMeta.discriminator != null && object[modelMeta.discriminator] == undefined) { saveProps.push(pers.escapeId(modelMeta.discriminator)); values.push(modelMeta.serialize(modelMeta.discriminator, modelMeta.name)); } if (!values.length) throw new Error("Can't save: no fields on the object are set."); var eTableName = pers.escapeId(table.name); var ePkName = pers.escapeId(pkName) var mainQuery; if (isInsert) { var fields = saveProps.join(', '); //http://stackoverflow.com/questions/12503146/create-an-array-with-same-element-repeated-multiple-times-in-javascript var placeHolders = Array(values.length).fill("?").join(', '); mainQuery = `INSERT INTO ${eTableName} (${fields}) values (${placeHolders})`; if (pers.adapter.name === 'postgres') mainQuery += " RETURNING " + ePkName; } else { var fieldValues = saveProps.map(c => `${c} = ?`).join(', '); values.push(modelMeta.serialize(pkName, pkValue)); mainQuery = `UPDATE ${eTableName} SET ${fieldValues} WHERE ${ePkName} = ?`; } var queryResult = yield conn.exec(mainQuery, values); //rowCount is very important, if 0, not saved. var lastInsertId = null; if (!queryResult.rowCount) ret.status="not-saved"; else { ret.status="saved"; if (isInsert) { //pg always returns the lastInsertId as we used "RETURNING" for inserts. if (pers.adapter.name === 'postgres') lastInsertId = modelMeta.deserialize(pkName, queryResult.lastInsertId); else lastInsertId = isAutoIncrement ? modelMeta.deserialize(pkName, queryResult.lastInsertId) : object[pkName]; object[pkName] = lastInsertId; } } ret.object = object; ret.lastInsertId = lastInsertId; if (typeof object.afterSave === 'function' && ret.status==="saved") yield helper.polycallBasedOnSignatureLength(object.afterSave, object, [tx, ret.command], 3); return ret; }); return helper.polycallTx(tx, fn, cb, null, this); } } module.exports = Persistanz;