persistanz
Version:
Object relational mapping (ORM) library with unique features.
639 lines (524 loc) • 27.4 kB
JavaScript
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;
}
}