UNPKG

persistanz

Version:

Object relational mapping (ORM) library with unique features.

639 lines (524 loc) 27.4 kB
"use strict" var helper = require("./helper.js"); class BridgeField { constructor (name, fkColumn, modelName, isAutoGenerated) { this.name = name; this.fkColumn = fkColumn; this.isAutoGenerated = isAutoGenerated; this.modelName = modelName; } } class ToManyField { constructor (name, fkColumn, modelName, isAutoGenerated) { this.name = name; this.fkColumn = fkColumn; this.isAutoGenerated = isAutoGenerated; this.modelName = modelName; } } module.exports = class ModelMeta { constructor(name, table, model, isAuto, modelDef, superMeta, serialization) { this.name = name; this.table = table; //table in the schema, not the name this.model = model; //the function or class this.isAutoGenerated = isAuto; this.userOptions = modelDef; this.bridgeFields = {}; //will be set later. this.toManyFields = {}; this.superMeta = superMeta; this.serialization = serialization; } serialize(columnName, value) { if (this.serialization[columnName] != null) { var ser = this.serialization[columnName]; switch (ser.type) { case "json": case "JSON": return JSON.stringify(value); case "custom": return this.serialization[columnName].options.serialize(value); } } return value; } deserialize(columnName, value) { if (this.serialization[columnName] != null) { var ser = this.serialization[columnName]; switch (ser.type) { case "json": case "JSON": return JSON.parse(value); case "custom": return this.serialization[columnName].options.deserialize(value); } } return value; } static getConfigSummary (pers, byModel) { function getModelInfo(modelName) { var meta = pers.modelMeta[modelName]; if (!meta) return `\n[No such model registered: '${modelName}'.]`; var str = ` ${modelName}: table = ${meta.table.name}, discriminator = ${meta.discriminator || '[none]'} , autogenerated = ${meta.isAutoGenerated} Bridge fields:`; if (Object.keys(meta.bridgeFields).length) for (var bfName in meta.bridgeFields) { var bf = meta.bridgeFields[bfName]; str += `\n - ${bf.name}: model = ${bf.modelName}, fk = ${bf.fkColumn.name}, autogenerated = ${bf.isAutoGenerated}`; } else str += " [none]"; str += `\ntoMany fields:`; if (meta.toManyFields && Object.keys(meta.toManyFields).length) for (var toManyName in meta.toManyFields) { var tm = meta.toManyFields[toManyName]; str += `\n - ${tm.name}: model = ${tm.modelName}, fk = ${tm.fkColumn.name}, autogenerated = ${tm.isAutoGenerated}`; } else str += " [none]"; return str; } var strs = []; var ignoredTables = ! pers.ignoreTables.length ? "[none]" : pers.ignoreTables.join(', '); strs.push(`\nIgnored tables: ${ignoredTables}`); strs.push("\nModels:"); if (byModel != null) strs.push(getModelInfo(byModel)); else { //organize alphabetically: var modelNames = Object.keys(pers.modelMeta).sort(); for (var modelName of modelNames) strs.push (getModelInfo(modelName)); } var str = strs.join("\n---------------------------------------------------------------------------") + "\n"; return str; } static getByName (pers, modelName) { var modelMeta = pers.modelMeta[modelName]; if (!modelMeta) throw new Error("No model with the name '"+modelName+"' is registered."); return modelMeta; } static getModelByName (pers, modelName) { if (modelName == undefined) throw new Error("Model name is missing."); var model = pers.models[modelName]; if (!model) throw new Error("No model with the name '"+modelName+"' is registered."); return model; } static getByObject (pers, object) { if (typeof object !== "object" || object.constructor == null || object.constructor.name === '') throw new Error("Object is null or has no constructor or constructor name."); var modelName = object.constructor.name; return ModelMeta.getByName(pers, modelName); } //Creates user models from the options: static createModels (pers, modelDefs, generateDefaultModels, superMeta) { //user models first: if (!modelDefs || modelDefs.constructor !== Array) { var message = "'models' property in the options must be an array."; pers.ormErrors.push(new helper.OrmError(message, "generic", true)); } else { for (var modelDef of modelDefs) { if (typeof modelDef === "function") { //modelDef is the model itself, the name must map to a table: var modelName = modelDef.prototype.constructor.name; if (pers.ignoreTables.indexOf(modelName) != -1) continue; ModelMeta.registerModel(pers, modelDef, pers.options.extend, modelName, modelDef, false, superMeta); } else { //user actually gave options var tableName = (modelDef && modelDef.table) ? modelDef.table : null; if (pers.ignoreTables.indexOf(tableName) != -1) continue; var model, auto; var extend = modelDef.extend !== undefined ? modelDef.extend : pers.options.extend; if (typeof modelDef.model === 'string') { //use this name for a sys generated model //basically a rename of a default model: model = ModelMeta.generateModel(pers, modelDef.model, pers.options.baseModel, extend); auto = true; } else { //normal constructor function model = modelDef.model; auto = false; } if (tableName == null) tableName = model.prototype.constructor.name; var registeredMeta = ModelMeta.registerModel(pers, model, extend, tableName, modelDef, auto, superMeta); //subModels: if (modelDef.submodels != null) { var msg = null; if (!modelDef.submodels instanceof Array) { var msg = "Submodels field in one of the model options is not an array."; pers.ormErrors.push(new helper.OrmError(msg, "generic", true)) } else { for (var subModel in modelDef.submodels) modelDef.submodels[subModel].table = tableName; var m = registeredMeta instanceof ModelMeta ? registeredMeta : null; ModelMeta.createModels(pers, modelDef.submodels, false, m); } } } } } if (!generateDefaultModels) return; //default models: for (var tableName in pers.schema.tables) { if (pers.ignoreTables.indexOf(tableName) != -1) continue; //do not overwrite user models. if (pers._("modelMetaByTableName")[tableName]) continue; var aModel = ModelMeta.generateModel(pers, tableName, pers.options.baseModel); ModelMeta.registerModel(pers, aModel, false, tableName, null, true, null); } } static setUserBridgeFields(pers) { //creates fixable generic orm error var E = msg => pers.ormErrors.push(new helper.OrmError(msg, "generic", true)); for (var option of pers.options.models) { //find modelMeta from option: if (typeof option === 'function') continue; //can't define any options. var modelName = typeof option.model === 'string' ? option.model : option.model.prototype.constructor.name; var modelMeta = pers.modelMeta[modelName]; if (modelMeta.userOptions.bridgeFields == null) continue; var columns = modelMeta.table.columns; var tableName = modelMeta.table.name; for (var bfName in modelMeta.userOptions.bridgeFields) { var bfDef = modelMeta.userOptions.bridgeFields[bfName]; var fkName = bfDef.fkColumn, bfModelName = bfDef.modelName ; var msg = null; if (typeof bfName != 'string' || bfName.trim() === '') msg = `Model definition for '${modelName}' has an invalid bridge field name.`; if (!msg && (fkName == null || bfModelName == null)) msg = `Bridge field definition '${bfName}' in model '${modelName}' has ` + `missing 'fkColumn' or 'modelName' property (or both).`; if (!msg && (!columns[fkName] || !columns[fkName].fkInfo)) //check fk column: msg = `Bridge field '${bfName}' definition for model '${modelName}' specifies ` + `'${fkName}' as the foreign key column, but the table '${tableName} ` + `either has no such column or the column is not a foreign key column.`; if (!msg && columns[bfName]) //prevent name collision msg = `Bridge field named '${bfName}' for model '${modelName}' would ` + `cause a name collision because the table '${tableName}' already has a column with the same name.`; if (!msg) { //fkName is okay, check which models this is applicable: var toTableName = columns[fkName].fkInfo.toTable; var possibleModelNames = Object.keys(pers._("modelMetaByTableName")[toTableName]); var namesList = possibleModelNames.map(pmn => `'${pmn}'`).join(', '); if (possibleModelNames.indexOf(bfModelName) < 0) msg = `'modelName' property of bridge field '${bfName}' definition for model '${modelName}' ` + `specifies an invalid model name. Its value must be one of the following: [${namesList}].`; } if (msg) { E(msg); continue; } //Remove any autogenerated bfs using the same fk. for (var aBfName in modelMeta.bridgeFields) { var autoBf = modelMeta.bridgeFields[aBfName]; if (! autoBf.isAutoGenerated) continue; if (autoBf.fkColumn.name === fkName) delete modelMeta.bridgeFields[aBfName]; } //finally, create and assign. modelMeta.bridgeFields[bfName] = new BridgeField(bfName, columns[fkName], bfModelName, false); } } } static setUserToManyFields (pers) { //creates fixable generic orm error var E = msg => pers.ormErrors.push(new helper.OrmError(msg, "generic", true)); var modelOptions = Object.keys(pers.modelMeta).map(modelName => pers.modelMeta[modelName].userOptions); for (var option of modelOptions) { //find modelMeta from option: if (typeof option === 'function' || ! option) continue; //can't define any options. var modelName = typeof option.model === 'string' ? option.model : option.model.prototype.constructor.name; var modelMeta = pers.modelMeta[modelName]; if (modelMeta.userOptions.toManyFields == null) continue; var columns = modelMeta.table.columns; var tableName = modelMeta.table.name; for (var toManyName in modelMeta.userOptions.toManyFields) { var toManyDef = modelMeta.userOptions.toManyFields[toManyName]; var fkName = toManyDef.fkColumn, toManyModelName = toManyDef.modelName; var msg = null; if (typeof toManyName != 'string' || toManyName.trim() === '') msg = `Model definition for '${modelName}' has an invalid toMany field name.`; if (!msg && (toManyName == null || toManyModelName == null)) msg = `toMany field definition '${toManyName}' in model '${modelName}' has ` + `missing 'fkColumn' or 'modelName' property (or both).`; var remoteModelMeta = pers.modelMeta[toManyModelName]; var remoteColumns; if (! msg && remoteModelMeta == undefined) { msg = `toMany field '${toManyName}' definition for model '${modelName}' specifies ` + `'${toManyModelName}' as modelName but no such model is registered.`; } if (remoteModelMeta) remoteColumns = remoteModelMeta.table.columns; if (!msg && (!remoteColumns[fkName] || !remoteColumns[fkName].fkInfo)) //check fk column: msg = `toMany field '${toManyName}' definition for model '${modelName}' specifies ` + `'${fkName}' as a foreign key column, but the table '${remoteModelMeta.table.name} ` + `either has no such column or the column is not a foreign key column.`; if (!msg && columns[toManyName]) //prevent name collision msg = `toMany field named '${toManyName}' for model '${modelName}' would ` + `cause a name collision because the table '${tableName}' already has a column with the same name.`; if (!msg && modelMeta.bridgeFields[toManyName]) //prevent name collision msg = `toMany field named '${toManyName}' for model '${modelName}' would ` + `cause a name collision because the model '${modelName}' already has a bridge field with the same name.`; if (!msg) { //fkName is okay, check which models this is applicable: var toTableName = remoteColumns[fkName].fkInfo.toTable; var possibleModelNames = Object.keys(pers._("modelMetaByTableName")[toTableName]); if (possibleModelNames.indexOf(modelName) < 0) msg = `toMany field definition '${toManyName}' in '${modelName}' is invalid. `+ `The specified fkColumn value '${fkName}' does not map to the table of ` + `child model '${toManyModelName}', which is '${remoteModelMeta.table.name}'.`; } if (msg) { E(msg); continue; } //Remove all autogenerated toMany fields with the same fk and toManyModelName: for (var aToManyName in modelMeta.toManyFields) { var auto2m = modelMeta.toManyFields[aToManyName]; if (! auto2m.isAutoGenerated) continue; if (auto2m.fkColumn.name === fkName && auto2m.modelName === toManyModelName) delete modelMeta.toManyFields[aToManyName]; } //finally, create and assign. modelMeta.toManyFields[toManyName] = new ToManyField(toManyName, remoteColumns[fkName], toManyModelName, false); } } } static autoGenerateToManyFields (pers) { for (var modelName in pers.modelMeta) { var modelMeta = pers.modelMeta[modelName]; //we are not going to create toMany fields from scratch, instead //we are going to look at the already created bridgeFields in modelMeta //and create toMany fields in modelMetas referred to by them. for (var bridgeFieldName in modelMeta.bridgeFields) { var bf = modelMeta.bridgeFields[bridgeFieldName]; var remoteMeta = pers.modelMeta[bf.modelName]; //remoteMeta = product var toManyName = helper.createToManyFieldName(modelName); //orderItems //if remoteMeta already has such toMany, skip: if ( remoteMeta.toManyFields[toManyName] || remoteMeta.bridgeFields[toManyName] || remoteMeta.table.columns[toManyName] ) continue; remoteMeta.toManyFields[toManyName] = new ToManyField(toManyName, bf.fkColumn, modelName, true); } } } static autoGenerateBridgeFields (pers) { for (var modelName in pers.modelMeta) { var modelMeta = pers.modelMeta[modelName]; var table = modelMeta.table; var msg = null; var bridgeFields = {}; var fkNames = Object.keys(table.columns).filter(c => table.columns[c].fkInfo); for (var fkName of fkNames) { var bfName = helper.createBridgeFieldName(fkName); if (bfName == null) { msg = "A suitable name for a bridge field mapping to the foreign key " + `'${table.name}.${fkName}' could not be found. Bridge field not autocreated.`; //pers.ormErrors.push(new helper.OrmError(msg, "bridge", true, table.name, fkName)); continue; } if (table.columns[bfName]) { msg = `An autogenerated bridge field name '${bfName}' for model` + ` '${modelName}' would create a name collision with a column. Bridge field not autocreated.`; //pers.ormErrors.push(new helper.OrmError(msg, "bridge", true, table.name, fkName)); continue; } if (bridgeFields[bfName]) { msg = `An autogenerated bridge field name '${bfName}' for model` + ` '${modelName}' would create a name collision with another bridge field:`+ ` ${bridgeFields[bfName].modelName}.${bridgeFields[bfName].fkColumn.name}. Bridge field not autocreated.`; //pers.ormErrors.push(new helper.OrmError(msg, "bridge", true, table.name, fkName)); continue; } var fkToTable = table.columns[fkName].fkInfo.toTable; var possibleModelNames = Object.keys(pers._("modelMetaByTableName")[fkToTable]); if (possibleModelNames.length === 1) { bridgeFields[bfName] = new BridgeField(bfName, table.columns[fkName], possibleModelNames[0], true); continue; } /************ hairy part, more than one models match ***************/ //We process models with genericStorage and their children differently. //Models and their children marked genericStorage are processed first. //So, first, generate a descendancy tree covering each possible model //if they are of gs. Process the descendancy tree by trying to generate //bfs from the children using the "similar name" algorithm. If this //doesn't produce a bf, generate for the parent. During this process //mark all of the models that are involved. // //In stage 2, we process only the models that are not marked in phase 1. //Sort the unprocessed models so that parents come first, children last. //For each of the unprocessed models first try to create a bf for the //original fk name so that they are not overwritten by what follows. //Finally, try to create new bfs whose names are derived from the //fk model and not fk column name. This allows bfs mapping to models //defined in subclasses section. // //Order in which the whole process runs is important, so each step must //check if the intended bf name has already been created and should not //overwrite if so. //Only for genericStorage. Uses "similar name" algo only to produce a //bf. Returns true if a new bf is generated, false otherwise. function handleTree(parentModelName, children) { for (var childModelName of children) { if (childModelName.toLowerCase() === bfName.toLowerCase() && ! bridgeFields[bfName]) { bridgeFields[bfName] = new BridgeField(bfName, table.columns[fkName], childModelName, true); return true; } } return false; } //create a hierarchy tree for genericStorage. //keys are parentModelNames, values are arrays of descendant model names. var trees = {}; var processedModelNames = []; //loop the possibles to extract parents that are marked gs: for (var possibleModelName in pers._("modelMetaByTableName")[fkToTable]) { var possibleModel = pers._("modelMetaByTableName")[fkToTable][possibleModelName]; if (possibleModel.userOptions && possibleModel.userOptions.genericStorage) trees[possibleModel.name] = []; } //loop the possibles to extract descendants of gs parents: for (var possibleModelName in pers._("modelMetaByTableName")[fkToTable]) { var possibleModel = pers._("modelMetaByTableName")[fkToTable][possibleModelName]; if (possibleModel.superMeta && possibleModel.superMeta.userOptions.genericStorage) { trees[possibleModel.superMeta.name].push(possibleModelName); } } //process each tree. if children can't generate a bf, parent should: for (var parentModelName in trees) { var generated = handleTree(parentModelName, trees[parentModelName]); if (! generated && ! bridgeFields[bfName]) //no bf for children, generate for parent: bridgeFields[bfName] = new BridgeField(bfName, table.columns[fkName], parentModelName, true); //mark all children and their parent processed: processedModelNames = processedModelNames.concat(processedModelNames, trees[parentModelName]); processedModelNames.push(parentModelName) } //gs stuff is done, now find all possibles that were not processed above: var unprocessed = possibleModelNames.filter(psn => processedModelNames.indexOf(psn) == -1); //we need to prioritize the best matching model (assume the parent). //therefore put the parents first: unprocessed = unprocessed.sort( (a, b) => { var ma = pers.modelMeta[a]; var mb = pers.modelMeta[b]; if (mb.superMeta && mb.superMeta.name === a) return -1; //a first if (ma.superMeta && ma.superMeta.name === b) return +1; //b first return 0; }); //try to create a bf for each model for the original fk we have. //finally, try to craete an additional bf for each model based on the //model name (not the fk column name). for (var possibleModelName of unprocessed) { //attempt on existing fks: if (! bridgeFields[bfName]) bridgeFields[bfName] = new BridgeField(bfName, table.columns[fkName], possibleModelName, true); //then try to create bfs for matching names: var aBfName = helper.createBridgeFieldNameFromModelName(possibleModelName); if (table.columns[aBfName] || bridgeFields[aBfName]) continue; bridgeFields[aBfName] = new BridgeField(aBfName, table.columns[fkName], possibleModelName, true); } } modelMeta.bridgeFields = bridgeFields; } } static registerModel(pers, model, extend, tableName, modelDef, isAuto, superMeta) { var OrmError = helper.OrmError; //we will throw these: var msg; //creates fixable generic orm error var E = msg => pers.ormErrors.push(new OrmError(msg, "generic", true)); //Errors related to bridge fields and discriminator are logged but don't //prevent modelmeta creation. Not supported features in the database do. if (typeof model !== "function") return E("One of your models is not a function or a class."); var modelName = model.prototype.constructor.name; if (modelName === '') return E("One of the model options declares an anonymous function as a model. " + "Anonymous functions and classes cannot be used as models."); if (tableName == null || tableName.trim() === "") return E("Database table of one of your models cannot be determined."); if (!pers.schema.tables[tableName]) return E("One of the model options references '" + tableName + "', but there " +"is no such table in the database. Did you forget to add 'table' directive?"); if (pers.modelMeta[modelName]) return E("A model with the name '" + modelName + "' is already registered.'"); if (extend) { var fake = ModelMeta.generateModel(pers, modelName); //Object.getOwnPropertyDescriptors() is not available in node v4.7. //We use "in" operator to check the existence of a property because it //looks into the protoype chain. We never want to override user defined //properties. var protoProps = Object.getOwnPropertyNames(fake.prototype); for (var pp of protoProps) { //don't overwrite constructor and an already existing property. if (pp === 'constructor' || pp in model.prototype) continue; var descriptor = Object.getOwnPropertyDescriptor(fake.prototype, pp); Object.defineProperty(model.prototype, pp, descriptor); } var staticProps = Object.getOwnPropertyNames(fake); for (var pp of staticProps) { //don't overwrite prototype, name and already existing properties. if (pp === 'prototype' || pp === 'name' || pp in model) continue; var descriptor = Object.getOwnPropertyDescriptor(fake, pp); Object.defineProperty(model, pp, descriptor); } } //discriminator: var disc = modelDef && modelDef.discriminator ? modelDef.discriminator : null; //check discriminator column exists: if (disc != null && ! pers.schema.tables[tableName].columns[disc]) E(`Discriminator column '${disc}' does not exist in the table '${tableName}'.`); //serializition: var serialization = {}; //copy parent's serialization rules: if (superMeta && Object.keys(superMeta.serialization).length) for (var s in superMeta.serialization) serialization[s] = superMeta.serialization[s]; //add or overwrite its own serialization rules: if (modelDef && modelDef.serialization) for (var columnName in modelDef.serialization) serialization[columnName] = modelDef.serialization[columnName]; var modelDefForMeta = typeof modelDef === "function" ? null : modelDef; var mm = new ModelMeta(modelName, pers.schema.tables[tableName], model, isAuto, modelDefForMeta, superMeta, serialization); if (disc != null) mm.discriminator = disc; pers.modelMeta[modelName] = mm; pers.models[modelName] = model; //keep model def to table name mapping: if (!pers._("modelMetaByTableName")[tableName]) pers._("modelMetaByTableName")[tableName] = {}; pers._("modelMetaByTableName")[tableName][modelName] = mm; return mm; } static generateModel (pers, name, baseModel, extend) { if (!baseModel) baseModel = class {}; var model = null; var model = ! (extend === undefined || extend === true) ? class extends baseModel { constructor () { super(); } } : class extends baseModel { constructor () { super(); } save (cb) { return pers.save(this, cb); } insert (cb) { return pers.insert(this, cb); } delete (cb) { return pers.deleteObject(this, cb); } hydrate (fieldList, cb) { return pers.hydrate(this, fieldList, cb); } saveX (tx, cb) { return pers.saveX(tx, this, cb); } insertX (tx, cb) { return pers.insertX(tx, this, cb); } deleteX (tx, cb) { return pers.deleteObjectX(tx, this, cb); } hydrateX (tx, fieldList, cb) { return pers.hydrateX(tx, this, fieldList, cb); } static cast (object) { return pers.cast(object, name); } static loadById (id, fields, cb) { return pers.loadById(name, id, fields, cb); } static save (object,cb) { return pers.saveAs(object, name, cb); } static insert (object,cb) { return pers.insertAs(object, name, cb); } static deleteById (id, cb) { return pers.deleteById(name, id, cb); } static loadByIdX (tx, id, fields, cb) { return pers.loadByIdX(tx, name, id, fields, cb); } static saveX (tx, object,cb) { return pers.saveAsX(tx, object, name, cb); } static insertX (tx, object,cb) { return pers.insertAsX(tx, object, name, cb); } static deleteByIdX (tx, id, cb) { return pers.deleteByIdX(tx, name, id, cb); } static query (tx) { return pers.q(tx).f(name); } static q (tx) { return pers.q(tx).f(name); } } //we cannot set constructor name, but a getter is enough as we only read. Object.defineProperty(model.prototype.constructor, "name", { get: function() { return name; } }); return model; } }