UNPKG

persistanz

Version:

Object relational mapping (ORM) library with unique features.

615 lines (536 loc) 23.4 kB
"use strict"; var ModelMeta = require("./ModelMeta.js"); var helper = require("./helper.js"); class Alias { constructor (prefix, number, modelMeta, parentAliasObject, bridgeField, pers) { this.prefix = prefix; this.name = "#" + number; this.escaped = pers.escapeId(this.name); this.bridgeField = bridgeField; this.parent = parentAliasObject; this.modelMeta = modelMeta; } } module.exports = class DslResolver { constructor (persQuery) { this.pq = persQuery; this.pers = this.pq.pers; this.aliasCount = 0; this.objectAliasMap = {}; //indexed by their prefixes this.sqlClauses = { selects: [], discriminator: null, //{sql, value} where: null, //{sql, values}, from: null, limit: null, //{sql, values} orderBy: null, groupBy: null, having: null, //{sql, values} joins: null, //{sql, values} queryOptions: null, mainQuery: null, //{sql, values} subQueries: [], index: null, //{type: field/callback, callback, sql, aliasName, fieldName} limitlessQuery: null, //{sql, values} toManyIdsIn: null, //{sql, values} }; this.dsl = { //these are mostly input by the user by calling methods. from: null, where: null, limit: null, limitValues: [], orderBy: null, groupBy: null, selects: [], having: null, havingValues: [], withSelects: [], /*{withPart, fieldList}*/ aliasSelects: [], distinct: false, calcLimitless: false, queryOptions: null, whereValues:[], //entered by user to replace ?. index: null, toManyIdsIn: null, //someTableId IN (?, ?, ?...) toManyIdsInValues: null, //[value, value, value] }; } setDsl (what, value) { if (this.dsl[what] === undefined) throw new Error ("Internal error / setDsl : " + what); this.dsl[what] = value; } addDsl (what, value) { if (!Array.isArray(this.dsl[what])) throw new Error ("Internal error / addDsl : " + what); this.dsl[what].push(value); } generateJoins() { //a foreign key definition looks likes this: /* { name: 'test2', fkColumn: { name: 'test2Id', fkInfo: { toTable: 'Test2', toColumn: 'id' } }, isAutoGenerated: true, modelName: 'Test2' } */ var joins = [], values = []; for (var prefix in this.objectAliasMap) { if (prefix === '') continue; //don't produce a join for the FROM table. var aliasObject = this.objectAliasMap[prefix]; var eTableName = this.pers.escapeId(aliasObject.modelMeta.table.name); var eAlias = aliasObject.escaped; var eParentAlias = aliasObject.parent.escaped; var bfc = aliasObject.bridgeField.fkColumn; var eFkTo = this.pers.escapeId(bfc.fkInfo.toColumn); var eFkFrom = this.pers.escapeId(bfc.name); var j = `LEFT JOIN ${eTableName} ${eAlias} ON ${eAlias}.${eFkTo} = ${eParentAlias}.${eFkFrom}`; var discCondition = ""; //check if it has a discriminator column, and use it if so: if (aliasObject.modelMeta.discriminator != null) { var eDisc = this.pers.escapeId(aliasObject.modelMeta.discriminator); discCondition = ` AND ${eAlias}.${eDisc} = ?` values.push(aliasObject.modelMeta.name); j += discCondition; } joins.push(j); } if (!joins.length) return this.sqlClauses.joins = null; return this.sqlClauses.joins = { sql: joins.join("\n"), values } } createAlias (prefix, modelMeta, parentAliasObject, bridgeField) { if (!this.objectAliasMap[prefix]) { var number = this.aliasCount++; this.objectAliasMap[prefix] = new Alias(prefix, number, modelMeta, parentAliasObject, bridgeField, this.pers); } return this.objectAliasMap[prefix]; } //in: "1+1, SUM({test2.id}) as total, AVG({test2.count}) as avg" //out: [ '1+1, SUM("#1"."id") as total, AVG("#1"."title") as avg' ] resolveCurlyExpression (expression, forWhat) { var r=/\{([^\}]+)\}/g, regexResults, replaced = expression; while ((regexResults = r.exec(expression)) !== null) { var curly = regexResults[0].trim(); var exp = regexResults[1].trim(); var resolvedList = this.resolveFieldExpression(exp, forWhat); replaced = replaced.replace(curly, this.generateSqlFromField(resolvedList[0])); } return replaced; } build () { if (!this.sqlClauses.from) throw new Error(".from() is never called, from clause is missing."); this.processAliasSelect(); this.expandSelectWithToSelects(); this.processIndex(); this.processSelect(); this.processWhere(); this.processToManyIdsIn(); this.processGroupBy(); this.processHaving(); this.processLimit(); this.processOrderBy(); this.processQOptions(); //handles distinct too. this.generateJoins(); if (!this.sqlClauses.selects.length) throw new Error("No fields specified to be used in select clause for the query."); var c = this.sqlClauses; var select = c.selects.join(', '); var query = "SELECT "; if (c.queryOptions) query += `${c.queryOptions} `; query += `${select}\n`; query += `FROM ${c.from}\n`; if (c.joins != null) query += `${c.joins.sql}\n`; if (c.where != null || c.discriminator != null || c.toManyIdsIn != null) { var whereParts = []; if (c.where != null) whereParts.push(c.where.sql); if (c.discriminator != null) whereParts.push(c.discriminator.sql); if (c.toManyIdsIn != null) whereParts.push(c.toManyIdsIn.sql); if (c.where != null && whereParts.length > 1) whereParts[0] = "( " + whereParts[0] + " )"; query += "WHERE " + whereParts.join(" AND\n") + "\n"; } if (c.groupBy) query += `GROUP BY ${c.groupBy}\n`; if (c.having) query += `HAVING ${c.having.sql}\n`; var limitlessQuery = this.dsl.calcLimitless ? query : ''; if (c.orderBy) query += `ORDER BY ${c.orderBy}\n`; if (c.limit) query += `LIMIT ${c.limit.sql}\n`; //prepare values to be escaped. //order is joins, where, discriminator, toManyIdsIn, having, limit, var values = []; if (c.joins != null) values = values.concat(c.joins.values); if (c.where != null && c.where.values) values = values.concat(c.where.values); if (c.discriminator != null) values.push(c.discriminator.value); if (c.toManyIdsIn != null) values = values.concat(c.toManyIdsIn.values); if (c.having != null) values = values.concat(c.having.values); //limitlessQuery values are all values except that of limit clause. //so simply copy the array: var limitlessValues = limitlessQuery === '' ? null : values.slice(0); if (c.limit != null) values = values.concat(c.limit.values); c.mainQuery = { sql: query, values, }; if (this.dsl.calcLimitless) { var eCount = this.pers.escapeId("count"); var subAlias = this.pers.escapeId("#SOME_TABLE_ALIAS"); this.sqlClauses.limitlessQuery = { sql: `SELECT COUNT(*) AS ${eCount} FROM ( ${limitlessQuery} ) ${subAlias}`, values: limitlessValues, } } return this; } mapRows (rows) { var indexed = this.sqlClauses.index != null; var results = indexed ? new Map() : []; //Unroll loops, it may speed up to remove a conditional: if (! indexed) { for (var row of rows) results.push(this.mapRow(row).object); } else { //indexed for (var row of rows) { var mappedRow = this.mapRow(row); results.set(mappedRow.index, mappedRow.object); } } return results; } mapRow (row) { //create empty objects based on the map, keyed to aliases: var objects = {}; for (var prefix in this.objectAliasMap) { var aliasObject = this.objectAliasMap[prefix]; var object = new (aliasObject.modelMeta.model)(); objects[aliasObject.name] = object; } //extract columns: //TODO: This needs to be optimized. We are in the inner loop, so //try to avoid split, shift and join by analysing field mapping //before rows.forEach, and then only loop to assign properties. for (var aliasedColumn in row) { var parts = aliasedColumn.split('.'); var tableAlias = parts.shift(); //e.g: #1 var fieldPart = parts.join('.'); //e.g: title if (objects[tableAlias]) { //normal fields: var meta = ModelMeta.getByObject(this.pers, objects[tableAlias]); var value = meta.deserialize(fieldPart, row[aliasedColumn]); objects[tableAlias][fieldPart] = value; } else //.selectAlias exps don't map, they are attached to object #0. objects["#0"][aliasedColumn] = row[aliasedColumn]; } //connect objects to each other. for (var prefix in this.objectAliasMap) { var oMap = this.objectAliasMap[prefix]; //map tomany fields: if (this.pq.toManyQueries && this.pq.toManyQueries.size) { this.pq.toManyQueries.forEach( (toManyQuery) => { var alias = this.objectAliasMap[toManyQuery.prefixToParent]; var pkName = this.objectAliasMap[toManyQuery.prefixToParent].modelMeta.table.pks[0]; objects[alias.name][toManyQuery.toManyField.name] = toManyQuery.groupedRows.get(objects[alias.name][pkName]) || []; }); } //attach objects to each other: if (oMap.parent) { //root object doesn't have it, so we only deal with bridge fields. //we want bridgeFields to be null if the result brought a null id. var pkName = oMap.modelMeta.table.pks[0]; var parentObject = objects[oMap.parent.name]; parentObject[oMap.bridgeField.name] = objects[oMap.name][pkName] != null ? objects[oMap.name] : null; //if the user didn't exclusively wanted the pk, remove it. //don't attempt to delete properties from the fields that are nulled above: ///if (removeFields[anAlias] && objects[oMap.bindToAlias][oMap.propName]) ///delete objects[oMap.bindToAlias][oMap.propName][pkName]; if (typeof objects[oMap.name].afterLoad === 'function') helper.polycallBasedOnSignatureLength(objects[oMap.name].afterLoad, objects[oMap.name], [this.pq.options.tx], 2); } } //root object's afterLoad is called after all the bridge fields' objects. if (typeof objects["#0"].afterLoad === 'function') helper.polycallBasedOnSignatureLength(objects["#0"].afterLoad, objects["#0"], [this.pq.options.tx], 2); var index = null; if (this.sqlClauses.index) { var indexInfo = this.sqlClauses.index; if (indexInfo.type === 'field') { index = objects[indexInfo.aliasName][indexInfo.fieldName]; } else { //callback; index = indexInfo.callback(objects["#0"]); } } return {object: objects["#0"], index}; } processFrom() { var meta = ModelMeta.getByName(this.pers, this.dsl.from); var alias = this.createAlias("", meta, null, null); //okay, if discriminator, handle that too: if (meta.discriminator != null) { this.sqlClauses.discriminator = { sql: this.resolveCurlyExpression(`{${meta.discriminator}} = ?`, "discriminator"), value: meta.name, }; } var escapedTableName = this.pers.escapeId(meta.table.name); return this.sqlClauses.from = `${escapedTableName} ${alias.escaped}`; } expandSelectWithToSelects () { //collect all blocks, if block is string, split by ',': var fieldList = this.dsl.withSelects.map(wsObject => { var withPart = wsObject.withPart.trim(); var listPart = wsObject.fieldList; var list = Array.isArray(listPart) ? listPart : listPart.split(','); return list.map(part => { var trimmed = part.trim(); //put the ! to the beginning as it is the regular syntax. return trimmed.substr(0,1) === '!' ? `!${withPart}${this.pers.separator}${trimmed.substring(1)}` : `${withPart}${this.pers.separator}${trimmed}`; }); }); //merge these new select expressions into the selects list: this.dsl.selects = this.dsl.selects.concat([].concat.apply([], fieldList)); } //generates parts that go into the SELECT clause while creating necessary //aliases etc. processSelect () { //- simple fields directly connected to the table mentioned in the from: "name, age" //- *: "*", //- fields from connecting table "author.name, author.wife.age, author.*, author.specs.*" //- exclude fields: "!name, author.wife.*, !author.wife.age" //- to many: "author.pets.*, author.cars.*, !author.cars.year, author.pets.friends.*", //- select with .sw("author.wife","name,age,pet.*,!pet.vetName)" //handle empty select and resolve it to "*". if (! this.sqlClauses.selects.length && ! this.dsl.selects.length) this.dsl.selects.push("*"); //an item dsl.selects can be an array or string. if string split by ','. var expList = [], fieldDefs = []; for (var list of this.dsl.selects) { var asArray = Array.isArray(list) ? list : list.split(','); expList = expList.concat(asArray.map(p => p.trim())); for (var exp of expList) { var foundFieldDefs = this.resolveFieldExpression(exp, "select"); fieldDefs = fieldDefs.concat(foundFieldDefs); } } //filter out exclude fields: //separate the list into 2 sets, unique them and diff them: var excludeFields = fieldDefs.filter(f => f.isExclude); var includeFields = fieldDefs.filter(f => ! f.isExclude); var finalFields = {}; //make sure child query has parent's foreign key, otherwise select list may //come empty. if (this.pq.parentQuery) { var fkColumnName = this.pq.toManyField.fkColumn.name; var fkDef = this.createFieldDef([], fkColumnName, fkColumnName, false, true); includeFields.push(fkDef); } //filter out exclude fields. autoadded fields must not be filtered. for (var i of includeFields) { var toInclude = true; for (var e of excludeFields) { //filter out if excluded and not autoAdded if (i.canonical === e.canonical && ! i.autoAdded) { toInclude = false; break; } } if (!toInclude) continue; if (finalFields[i.canonical]) { //if already added, prioritise non-autoAdded if (! i.autoAdded || ! finalFields[i.canonical].autoAdded) finalFields[i.canonical].autoAdded = false; } else { finalFields[i.canonical] = i; } } var clauseParts = []; for (var canonical in finalFields) clauseParts.push(this.generateSqlFromField(finalFields[canonical], true)); return this.sqlClauses.selects = this.sqlClauses.selects.concat(clauseParts); } createFieldDef (prefixParts, columnName, alias, isExclude, autoAdded) { var parts = prefixParts.slice(0); //create new array as it is a ref, causing bugs. return { prefixParts: parts, columnName, sqlAsAlias: alias, isExclude, autoAdded, canonical: parts.concat([alias]).join('.'), } } generateSqlFromField (field, forSelect) { var aliasName = this.objectAliasMap[field.prefixParts.join('.')].name; var columnName = this.pers.escapeId(aliasName) + "." + this.pers.escapeId(field.columnName); if (! forSelect) return columnName; var columnAlias = this.pers.escapeId(`${aliasName}.${field.sqlAsAlias}`); return `${columnName} AS ${columnAlias}`; } //analyses a field expression in the form of a.b.c, !a.b.c, a.b.* //generates necessary join information as well as toMany queries on the way. //toMany() requires all of the "hidden" toMany queries to be resolved in an //expression so that the last toMany query is returned. Normally this information //is not available until exec() called, so if called by toMany() we recursively //analyse all the remaining parts of an expression to generate all toMany queries, //and return the last one found. //if called from processSelect() we need to return fieldDefs so that it can //further analyse exclude fields etc. //returns array of field defs, or a toManyQuery if called from toMany(). resolveFieldExpression (fieldExp, forWhat) { var exp = fieldExp.trim(); var isExclude = exp.substr(0, 1) === '!'; if (isExclude) exp = exp.substr(1); var parts = helper.split(exp, this.pers.separatorRegex); //handle special cases: if (isExclude) { if (forWhat !== 'select') throw new Error(`Expression ${fieldExp} is invalid as '!' operator cannot be used outside of a 'select' context.`); if (parts[parts.length - 1].trim() === '*') throw new Error(`Expression '${fieldExp}' is invalid as '!' operator cannot be used with '*'.`); } var partsSoFar = [], prefixPartsSoFar = []; var lastMeta = this.objectAliasMap[""].modelMeta; var requiredPkDefs = [], separator = this.pers.separator; //loop the parts for (var index = 0; index < parts.length; index++) { var p = parts[index].trim(); var pFieldName = p.replace(/\\/g, ''); var remainingParts = parts.slice(index + 1); partsSoFar.push(p); //1. check if it is toMany if (lastMeta.toManyFields && lastMeta.toManyFields[pFieldName]) { if (forWhat === 'index') throw new Error(`'${fieldExp}' resolves to a toMany field, which is invalid in index().`); var toManyField = lastMeta.toManyFields[pFieldName]; lastMeta = this.pers.modelMeta[toManyField.modelName]; var toManyQuery = this.pq._createToManyQuery(partsSoFar, toManyField, lastMeta); if (remainingParts.length) toManyQuery.s( (isExclude ? '!' : '') + remainingParts.join(separator)); if (forWhat === 'toMany') return ! remainingParts.length ? toManyQuery : toManyQuery.resolver.resolveFieldExpression(remainingParts.join(separator), "toMany"); //requiredPkDefs will be lost when called from toMany(), but this function //will be called again by processSelects(), which will add this anyway. return requiredPkDefs; } //2. check if it is a bridgeField: if (lastMeta.bridgeFields[pFieldName]) { var bridgeField = lastMeta.bridgeFields[pFieldName]; if (!remainingParts.length) if (forWhat != "toMany") //toMany() handles the return value and gives a better err msg. throw new Error(`Expression '${fieldExp}' resolves to a bridge field, a query needs columns to be added.`); lastMeta = this.pers.modelMeta[bridgeField.modelName]; var parent = this.objectAliasMap[partsSoFar.slice(0, index).join(separator)]; var columnName = bridgeField.fkColumn.fkInfo.toColumn; prefixPartsSoFar.push(p); //add the primary key of the foreign table, otherwise we may not know //if the join resulted in no columns or other fields are null. also //required because the query may result in no select fields. //also create the alias. All bridge field columns may be excluded by ! //later on, but extra join doesn't doesn't cause any problems, so we go ahead. if (! isExclude){ var reqPk = this.createFieldDef(prefixPartsSoFar, columnName, columnName, isExclude, true); requiredPkDefs.push(reqPk); this.createAlias(partsSoFar.join(separator), lastMeta, parent, bridgeField); } continue; } //3. if a * star field: if (p === '*') { if (remainingParts.length) throw new Error(`'${fieldExp}' cannot be resolved.`); if (forWhat === 'index') throw new Error("'*' is invalid in index."); var fields = Object.keys(lastMeta.table.columns) .map(c => this.createFieldDef(prefixPartsSoFar, c, c, false, false)); return [].concat(fields, requiredPkDefs); } //4. if a column ? if (lastMeta.table.columns[pFieldName]) { //is a column ? if (remainingParts.length) throw new Error(`'${fieldExp}' cannot be resolved.`); var fields = [this.createFieldDef(prefixPartsSoFar, pFieldName, pFieldName, isExclude, false)]; return [].concat(fields, requiredPkDefs); } //5. if an abstraction over affix ? for (var affixName in this.pq.abstractAffixes) { var affixObject = this.pq.abstractAffixes[affixName]; var possibleFieldName = affixObject.type == 'suffix' ? `${pFieldName}${affixObject.affix}` : `${affixObject.affix}${pFieldName}` ; if (lastMeta.table.columns[possibleFieldName]) { if (remainingParts.length) throw new Error(`'${fieldExp}' cannot be resolved.`); var fields = [this.createFieldDef(prefixPartsSoFar, possibleFieldName, pFieldName, isExclude, false)]; return [].concat(fields, requiredPkDefs); } } //nothing found: throw new Error(`'${fieldExp}' cannot be resolved.`); } } processIndex() { if (this.dsl.index == null) return; if (typeof this.dsl.index === "function") this.sqlClauses.index = {type: "callback", callback: this.dsl.index}; else { this.resolveFieldExpression(this.dsl.index, "index"); //genarate bridges if necessary this.pq.select(this.dsl.index); var parts = helper.split(this.dsl.index, this.pers.separatorRegex).map(p => p.trim()); var fieldName = parts.pop().replace(/\\/g, ''); var prefix = parts.join(this.pers.separator); var aliasName = this.objectAliasMap[prefix].name; this.sqlClauses.index = {type: "field", aliasName, fieldName}; } } processAliasSelect() { var results = this.dsl.aliasSelects.map(as => this.resolveCurlyExpression (as, "aliasSelect")) return this.sqlClauses.selects = this.sqlClauses.selects.concat(results); } processWhere() { if (this.dsl.where != null) return this.sqlClauses.where = { sql: this.resolveCurlyExpression(this.dsl.where, "where"), values: this.dsl.whereValues, }; } processToManyIdsIn() { if (this.dsl.toManyIdsIn != null) return this.sqlClauses.toManyIdsIn = { sql: this.resolveCurlyExpression(this.dsl.toManyIdsIn, "toManyIdsIn"), values: this.dsl.toManyIdsInValues, }; } processOrderBy() { if (this.dsl.orderBy != null) return this.sqlClauses.orderBy = this.resolveCurlyExpression(this.dsl.orderBy, "orderBy"); } processHaving() { if (this.dsl.having != null) return this.sqlClauses.having = { sql: this.resolveCurlyExpression(this.dsl.having, "having"), values: this.dsl.havingValues, } } processGroupBy() { if (this.dsl.groupBy != null) return this.sqlClauses.groupBy = this.resolveCurlyExpression(this.dsl.groupBy, "groupBy"); } processLimit() { if (this.dsl.limit != null) return this.sqlClauses.limit = { sql: this.dsl.limit, values: this.dsl.limitValues, }; } processQOptions() { this.sqlClauses.queryOptions = this.dsl.distinct ? "DISTINCT" : ''; if (this.dsl.queryOptions != null) this.sqlClauses.queryOptions += ` ${this.dsl.queryOptions}`; return this.sqlClauses.queryOptions; } }