carthage
Version:
An API Server and Framework with scaffold functions for node.js
1,017 lines (767 loc) • 27.1 kB
JavaScript
'use strict';
const ItemArray = require('./item_array.js');
const ModelArray = require('./model_array.js');
const utilities = require('./utilities.js');
/**
* The query composer (ORM)
* @class
*/
class Composer {
/**
* Created by Model#query, used for composing SQL queries based on Models
* @param {Carthage.Model} Model The model class the composer is querying from
* @param {Carthage.Composer} [parent=null] The composer's parent (another composer instance)
*/
constructor(Model, parent) {
this.db = Model.prototype.db;
this.Model = Model;
this._parent = parent || null;
this._command = null;
}
/**
* Given rows with repeated data (due to joining in multiple children), return only parent models (but include references to their children)
* @param {Array} rows Rows from sql result
* @param {Boolean} grouped Are these models grouped, if so, different procedure
* @return {Carthage.ModelArray}
* @private
*/
__parseModelsFromRows__(rows, grouped) {
if (grouped) {
return ItemArray.from(rows);
}
if (!rows.length) {
return new ModelArray(this.Model);
}
let keys = Object.keys(rows[0]);
let cache = {};
let mainCache = {};
cache[this.Model.name] = mainCache;
let columns = keys
.filter(k => k[0] !== '$');
let columnsObject = columns
.reduce((columns, k) => {
columns[k] = null;
return columns;
}, {});
let joinsObject = keys
.filter(k => k[0] === '$')
.reduce((joinsObject, k) => {
let mid = k.indexOf('$', 1);
let name = k.substring(1, mid)
let field = k.substring(mid + 1);
let relationship = this.Model.relationship(name);
joinsObject[name] = joinsObject[name] || {};
let rModel = relationship.getModel()
joinsObject[name].Model = rModel;
cache[rModel.name] = {};
joinsObject[name].name = name;
joinsObject[name].key = k;
joinsObject[name].multiple = relationship.immediateMultiple();
joinsObject[name].columns = joinsObject[name].columns || [];
joinsObject[name].columns.push(field);
joinsObject[name].columnsObject = joinsObject[name].columnsObject || {};
joinsObject[name].columnsObject[field] = null;
joinsObject[name].cachedModel = null;
return joinsObject;
}, {});
let joins = Object
.keys(joinsObject)
.sort((a, b) => a.length > b.length ? 1 : -1)
.map(k => joinsObject[k]);
let models = new ModelArray(this.Model);
rows.forEach(row => {
let model = mainCache[row.id];
if (!model) {
model = mainCache[row.id] = new this.Model(columns.reduce((obj, k) => {
obj[k] = row[k];
return obj;
}, columnsObject), true);
models.push(model);
}
joins.forEach(join => {
let id = row[`\$${join.name}\$id`];
let name = join.name;
let names = name.split('__');
let joinName = names.pop();
let parentName = names.join('__');
let parentModel = parentName ? joinsObject[parentName].cachedModel : model;
if (join.multiple) {
parentModel && (parentModel.joined(joinName) || parentModel.setJoined(joinName, new ModelArray(join.Model)));
}
if (!id) {
return;
}
let joinCache = cache[join.Model.name];
let joinModel = join.cachedModel = joinCache[id];
if (!joinModel) {
joinModel = join.cachedModel = joinCache[id] = new join.Model(join.columns.reduce((obj, k) => {
obj[k] = row[`\$${join.name}\$${k}`];
return obj;
}, join.columnsObject), true)
}
if (join.multiple) {
let modelArray = parentModel.joined(joinName);
!modelArray.has(joinModel) && modelArray.push(joinModel);
} else {
parentModel.joined(joinName) || parentModel.setJoined(joinName, joinModel);
}
});
});
return models;
}
/**
* Collapses linked list of queries into an array (for .reduce, .map etc)
* @return {Array}
* @private
*/
__collapse__() {
let composerArray = [];
let composer = this;
while (composer) {
composerArray.unshift(composer);
composer = composer._parent;
}
return composerArray;
}
/**
* Removes last limit command from a collapsed array of composer commands
* @param {Array} [composerArray] Array of composer commands
* @return {Array}
* @private
*/
__removeLastLimitCommand__(composerArray) {
let found = composerArray.map(c => c._command && c._command.type).lastIndexOf('limit');
(found !== -1) && composerArray.splice(found, 1);
return composerArray;
}
/**
* Gets last limit command from a collapsed array of composer commands
* @param {Array} [composerArray] Array of composer commands
* @return {Array}
* @private
*/
__getLastLimitCommand__(composerArray) {
let found = composerArray.map(c => c._command && c._command.type).lastIndexOf('limit');
return found >= 0 ? composerArray.splice(found, 1)[0] : null;
}
/**
* Determines whether this composer query represents a grouped query or not
* @return {Boolean}
* @private
*/
__isGrouped__() {
return this.__collapse__().filter(c => c._command && c._command.type === 'groupBy').length > 0;
}
/**
* Reduces an array of composer queries to a single query information object
* @param {Array} [composerArray]
* @return {Object} Looks like {commands: [], joins: []}
* @private
*/
__reduceToQueryInformation__(composerArray) {
let joins = {};
let commands = composerArray.reduce((p, c) => {
let composerCommand = c._command || {type: 'where', data: {comparisons: []}};
if (composerCommand.type === 'join') {
let curJoinName = composerCommand.data.name;
let curJoinData = composerCommand.data.joinData;
joins[curJoinName] = curJoinData;
Object.keys(joins)
.filter(joinName => joinName !== curJoinName)
.forEach(joinName => {
if (curJoinName.indexOf(joinName) === 0) {
joins[curJoinName] = joins[joinName].concat(curJoinData.slice(joins[joinName].length));
delete joins[joinName];
} else if (joinName.indexOf(curJoinName) === 0) {
joins[joinName][curJoinData.length - 1] = curJoinData[curJoinData.length - 1];
delete joins[curJoinName];
}
});
return p;
}
let lastCommand = p[p.length - 1];
let command = {
where: null,
limit: null,
orderBy: [],
groupBy: [],
aggregate: []
};
p.push(command);
if (
lastCommand && (
!lastCommand[composerCommand.type] ||
lastCommand[composerCommand.type] instanceof Array
)
) {
command = lastCommand;
p.pop();
}
if (command[composerCommand.type] instanceof Array) {
command[composerCommand.type].push(
Object.keys(composerCommand.data).reduce((p, c) => {
return (p[c] = composerCommand.data[c], p);
}, {})
);
} else {
command[composerCommand.type] = Object.keys(composerCommand.data).reduce((p, c) => {
return (p[c] = composerCommand.data[c], p);
}, {});
}
return p;
}, []);
return {
commands: commands,
joins: joins
}
}
/**
* Reduces an array of commands from query informtion to a SQL query
* @param {Array} [commandArray]
* @param {Array} [includeColumns=*] Which columns to include, includes all by default
* @return {Object} Looks like {sql: [], params: []}
* @private
*/
__reduceCommandsToQuery__(commandArray, includeColumns) {
let lastAggregate = null;
return commandArray.reduce((prev, command, i) => {
if (command.aggregate.length && command.groupBy.length) {
lastAggregate = command.aggregate;
}
let table = `t${i}`;
let multiFilter = this.db.adapter.createMultiFilter(table, command.where ? command.where.comparisons : []);
let params = this.db.adapter.getParamsFromMultiFilter(multiFilter);
let joins = null;
let columns = includeColumns || lastAggregate || this.Model.columnNames();
columns = columns
.map(c => typeof c !== 'string' ? c : {columnNames: [c], alias: c, transformation: v => v})
.map(c => Object.keys(c).reduce((p, k) => { return (p[k] = c[k], p); }, {}));
!command.groupBy.length && columns.forEach(c => {
c.transformation = v => v;
c.columnNames = [c.alias];
});
return {
sql: this.db.adapter.generateSelectQuery(
prev.sql || {table: this.Model.table()},
table,
columns,
multiFilter,
joins,
command.groupBy,
command.orderBy,
command.limit,
prev.params.length
),
params: prev.params.concat(params)
}
}, {sql: null, params: []});
}
/**
* Retrieve all joined column data for a given join
* @param {string} joinName The name of the join relationship
* @private
*/
__joinedColumns__(joinName) {
let relationship = this.Model.relationships().findExplicit(joinName);
return relationship.getModel().columnNames().map(columnName => {
return {
name: joinName,
table: relationship.getModel().table(),
columnNames: [columnName],
alias: `\$${joinName}\$${columnName}`,
transformation: v => v
};
});
}
/**
* Generate a SQL query and its associated parameters from the current composer instance
* @param {Array} [includeColumns=*] Which columns to include, includes all by default
* @param {boolean} [disableJoins=false] Disable joins if you just want a subset of data
* @return {Object} Has "params" and "sql" properties.
* @private
*/
__generateQuery__(includeColumns, disableJoins) {
disableJoins = disableJoins || this.__isGrouped__();
let queryInfo = this.__reduceToQueryInformation__(this.__collapse__());
let query = this.__reduceCommandsToQuery__(queryInfo.commands, includeColumns);
return disableJoins ? query : this.__addJoinsToQuery__(
query,
queryInfo,
includeColumns
);
}
/**
* Generate a SQL count query
* @param {boolean} [useLimit=false] Generates COUNT using limit command as well
* @return {Object} Has "params" and "sql" properties.
* @private
*/
__generateCountQuery__(useLimit) {
let collapsed = this.__collapse__();
collapsed = useLimit ? collapsed : this.__removeLastLimitCommand__(collapsed);
let queryInfo = this.__reduceToQueryInformation__(collapsed);
let query = this.__reduceCommandsToQuery__(queryInfo.commands);
query.sql = this.db.adapter.generateCountQuery(query.sql, 'c');
return query;
}
/**
* Add Joins to a query from queryInfo
* @param {Object} query Must be format {sql: '', params: []}
* @param {Object} queryInfo Must be format {commands: [], joins: []}
* @param {Array} [includeColumns=*] Which columns to include, includes all by default
* @return {Object} Has "params" and "sql" properties.
* @private
*/
__addJoinsToQuery__(query, queryInfo, includeColumns) {
let columns = includeColumns || this.Model.columnNames();
let joins = queryInfo.joins;
Object.keys(joins).forEach(joinName => {
joins[joinName].forEach(j => {
columns = columns.concat(this.__joinedColumns__(j.joinAlias));
});
});
joins = Object.keys(joins).map(k => joins[k]);
let params = query.params.slice();
joins.forEach(join => {
join.forEach(j => {
params = params.concat(this.db.adapter.getParamsFromMultiFilter(j.multiFilter));
});
});
// Set join OrderBys... in reverse order
let orderBy = queryInfo.commands.reduce((arr, command) => {
command.orderBy && (arr = command.orderBy.concat(arr));
return arr;
}, []);
// When doing joins, we count paramOffset as the last where parameter length
// Because we add in a bunch of parameters at the end.
return {
sql: this.db.adapter.generateSelectQuery(
query.sql,
'j',
columns,
null,
joins,
null,
orderBy,
null,
query.params.length
),
params: params
};
}
/**
* When using Composer#where, format all provided comparisons
* @param {Object} comparisons Comparisons object. {age__lte: 27}, for example.
* @param {Carthage.Model} Model the model to use as the basis for comparison. Default to current model.
* @return {Array}
* @private
*/
__parseComparisons__(comparisons, Model) {
Model = Model || this.Model;
let comparators = this.db.adapter.comparators;
let columnLookup = Model.columnLookup();
return Object.keys(comparisons)
.map(comparison => {
let column = comparison.split('__');
let rel = null;
let joinName;
let comparator = column.pop();
if (!comparators[comparator]) {
column.push(comparator);
comparator = 'is';
}
if (column.length > 1) {
joinName = column.slice(0, column.length - 1).join('__');
rel = Model.relationship(joinName);
column = column.slice(column.length - 1);
}
let table = null;
let joined = false;
let joins = null;
if (rel) {
// if it's not found, return null...
if (!rel.getModel().hasColumn(column[0])) {
return null;
}
table = rel.getModel().table();
joined = true;
joins = rel.joins('w');
}
let columnName = column[0];
// block out bad column names
if (!rel && !Model.hasColumn(columnName)) {
return null;
}
return {
table: table,
columnName: columnName,
comparator: comparator,
value: comparisons[comparison],
joined: joined,
joins: joins
};
})
.filter(v => {
return !!v;
});
}
__filterHidden__(Model, comparisonsArray) {
comparisonsArray = (comparisonsArray || []).filter(c => c);
let comparators = this.db.adapter.comparators;
return comparisonsArray.map(comparisons => {
Object.keys(comparisons).forEach(comparison => {
let cModel = Model;
let column = comparison.split('__');
let comparator = column.pop();
!comparators[comparator] && column.push(comparator);
let field = column.pop();
let relName = column.join('__');
if (relName) {
let rel = cModel.relationship(relName);
if (!rel) {
return;
}
cModel = rel.getModel();
}
if (cModel.isHidden(field)) {
delete comparisons[comparison];
}
});
if (Object.keys(comparisons).length === 0) {
return null;
}
return comparisons;
}).filter(comparisons => comparisons);
}
/**
* Add comparisons to SQL WHERE clause. Does not allow filtering if Model.hides() has been called.
* @param {Object} comparisons Comparisons object. {age__lte: 27}, for example.
* @return {Carthage.Composer} new Composer instance
*/
safeWhere(comparisonsArray) {
if (!(comparisonsArray instanceof Array)) {
comparisonsArray = [].slice.call(arguments);
}
return this.where(
this.__filterHidden__(
this.Model,
comparisonsArray
)
);
}
/**
* Join in a relationship. Filters out hidden fields from comparisons.
* @param {string} joinName The name of the joined relationship
* @param {array} comparisonsArray comparisons to perform on this join (can be overloaded)
*/
safeJoin(joinName, comparisonsArray) {
if (!(comparisonsArray instanceof Array)) {
comparisonsArray = [].slice.call(arguments, 1);
}
let relationship = this.Model.relationship(joinName);
if (!relationship) {
return this;
}
return this.join(
joinName,
this.__filterHidden__(
relationship.getModel(),
comparisonsArray
)
);
}
/**
* Add comparisons to SQL WHERE clause.
* @param {Object} comparisons Comparisons object. {age__lte: 27}, for example.
* @return {Carthage.Composer} new Composer instance
*/
where(comparisonsArray) {
if (!(comparisonsArray instanceof Array)) {
comparisonsArray = [].slice.call(arguments);
}
comparisonsArray = comparisonsArray.map(comparisons => {
return Object.keys(comparisons).reduce((p, c) => { return (p[c] = comparisons[c], p); }, {});
});
let order = null;
let offset = null;
let count = null;
comparisonsArray.forEach(comparisons => {
if ('__order' in comparisons) {
order = comparisons.__order.split(' ');
delete comparisons.__order;
}
if ('__offset' in comparisons || '__count' in comparisons) {
offset = comparisons.__offset;
count = comparisons.__count;
delete comparisons.__offset;
delete comparisons.__count;
}
});
if (order || offset || count) {
let composer = this;
order && (composer = composer.orderBy(order[0], order[1]));
(offset || count) && (composer = composer.limit(offset || 0, count || 0));
return composer.where(comparisonsArray);
}
this._command = {
type: 'where',
data: {
comparisons: comparisonsArray
.map(comparisons => this.__parseComparisons__(comparisons))
.filter(f => f.length)
}
};
return new Composer(this.Model, this);
}
/**
* Order by field belonging to the current Composer instance's model.
* @param {string} field Field to order by
* @param {string} direction Must be 'ASC' or 'DESC'
* @return {Carthage.Composer} new Composer instance
*/
orderBy(field, direction) {
let transformation;
let fields = [];
if (typeof field === 'function') {
fields = utilities.getFunctionParameters(field);
transformation = field;
} else {
fields = [field];
transformation = v => `${v}`;
}
fields.forEach(field => {
if (!this.Model.hasColumn(field)) {
throw new Error(`Cannot order by ${field}, it does not belong to ${this.Model.name}`);
}
});
this._command = {
type: 'orderBy',
data: {
columnNames: fields,
transformation: transformation,
direction: ({'asc': 'ASC', 'desc': 'DESC'}[(direction + '').toLowerCase()] || 'ASC')
}
};
return new Composer(this.Model, this);
}
/**
* Limit to an offset and count
* @param {number} offset The offset at which to set the limit. If this is the only argument provided, it will be the count instead.
* @param {number} count The number of results to be returned. Can be omitted, and if omitted, first argument is used for count.
* @return {Carthage.Composer} new Composer instance
*/
limit(offset, count) {
if (this._command) {
return new Composer(this.Model, this).limit(offset, count);
}
if (count === undefined) {
count = offset;
offset = 0;
}
count = parseInt(count);
offset = parseInt(offset);
this._command = {
type: 'limit',
data: {
count: count,
offset: offset
}
};
return new Composer(this.Model, this);
}
/**
* Join in a relationship.
* @param {string} joinName The name of the joined relationship
* @param {array} comparisonsArray comparisons to perform on this join (can be overloaded)
*/
join(joinName, comparisonsArray, orderBy, count, offset) {
// FIXME: validate orderBy
orderBy = orderBy || '';
count = Math.max(0, count | 0);
offset = Math.max(0, offset | 0);
if (!(comparisonsArray instanceof Array)) {
comparisonsArray = [].slice.call(arguments, 1);
}
let relationship = this.Model.relationships().findExplicit(joinName);
if (!relationship) {
throw new Error(`Model ${this.Model.name} does not have relationship "${joinName}".`);
}
let composer = this;
while (composer) {
if (composer._command && composer._command.type === 'join' && composer._command.data.name === joinName) {
return this;
}
composer = composer._parent;
}
let joinData = relationship.joins();
joinData[joinData.length - 1].joinAlias = joinName;
joinData[joinData.length - 1].prevAlias = joinName.split('__').slice(0, -1).join('__');
joinData[joinData.length - 1].multiFilter = this.db.adapter.createMultiFilter(
joinName,
comparisonsArray
.map(comparisons => this.__parseComparisons__(comparisons, relationship.getModel()))
.filter(f => f.length)
);
// FIXME: implement properly
joinData[joinData.length - 1].orderBy = orderBy;
joinData[joinData.length - 1].offset = offset;
joinData[joinData.length - 1].count = count;
this._command = {
type: 'join',
data: {
name: joinName,
joinData: joinData
}
};
return new Composer(this.Model, this);
}
/**
* Groups by a specific field, or a transformation on a field
* @param {String} column The column to group by
*/
groupBy(column) {
let columns;
let transformation;
if (typeof column === 'function') {
columns = utilities.getFunctionParameters(column);
transformation = column;
} else {
columns = [column];
transformation = v => `${v}`;
}
this._command = {
type: 'groupBy',
data: {
columnNames: columns,
transformation: transformation
}
};
return new Composer(this.Model, this).aggregate(column);
}
/**
* Aggregates a field
* @param {String} alias The alias for the new aggregate field
* @param {Function} transformation The transformation to apply to create the aggregate
*/
aggregate(alias, transformation) {
let columns;
if (typeof alias === 'function') {
columns = utilities.getFunctionParameters(alias);
transformation = alias;
alias = columns.join('___');
} else if (typeof transformation === 'function') {
columns = utilities.getFunctionParameters(transformation);
} else {
columns = [alias]
transformation = v => v;
}
this._command = {
type: 'aggregate',
data: {
alias: alias,
columnNames: columns,
transformation: transformation
}
};
return new Composer(this.Model, this);
}
/**
* Counts the results in the query
* @param {function} callback Supplied with an error and the integer value of the count
*/
count(callback) {
let countQuery = this.__generateCountQuery__(true);
this.db.query(countQuery.sql, countQuery.params, (err, result) => {
callback(err, (((result && result.rows) || [])[0] || {}).__total__ || 0);
});
}
/**
* Execute the query you've been composing.
* @param {function({Error}, {Carthage.ModelArray})} callback The method to execute when the query is complete
*/
end(callback) {
let query = this.__generateQuery__();
let countQuery = this.__generateCountQuery__();
let grouped = this.__isGrouped__();
let limitCommand = this.__getLastLimitCommand__(this.__collapse__());
let offset = limitCommand ? limitCommand._command.data.offset : 0;
let total = 0;
this.db.query(countQuery.sql, countQuery.params, (err, result) => {
let total = (((result && result.rows) || [])[0] || {}).__total__ || 0;
if (!total) {
let models = this.__parseModelsFromRows__([], grouped);
models.setMeta({offset: offset, total: total});
return callback.call(this, err, models);
}
this.db.query(query.sql, query.params, (err, result) => {
let rows = result ? (result.rows || []).slice() : [];
let models = this.__parseModelsFromRows__(rows, grouped);
models.setMeta({offset: offset, total: total});
callback.call(this, err, models);
});
});
}
/**
* Shortcut for .limit(1).end(callback) that only returns a model object or error if not found
* @param {Function} callback Callback to execute, provides an error and model parameter
*/
first(callback) {
return this.limit(1).end((err, models) => {
if (!err && !models.length) {
err = new Error(`No records for ${this.Model.name} found in your query`);
}
callback(err, models[0]);
});
}
/**
* Execute query as an update query, changed all fields specified.
* @param {Object} fields The object containing columns (keys) and associated values you'd like to update
* @param {function({Error}, {Carthage.ModelArray})} callback The callback for the update query
*/
update(fields, callback) {
if (this.__isGrouped__()) {
throw new Error('Cannot update grouped queries');
}
let query = this.__generateQuery__(['id'], true);
let columns = Object.keys(fields);
let params = columns.map(c => fields[c]);
let columnNames = columns.filter((v, i) => typeof params[i] !== 'function');
let columnFunctions = columns
.map((v, i) => [v, params[i]])
.filter((v, i) => typeof params[i] === 'function');
params = params.filter(v => typeof v !== 'function');
query.sql = this.db.adapter.generateUpdateAllQuery(
this.Model.table(),
'id',
columnNames,
columnFunctions,
query.params.length,
query.sql
);
query.params = query.params.concat(params);
return this.db.query(query.sql, query.params, (err, result) => {
let rows = result ? (result.rows || []).slice() : [];
if (err) {
let models = this.__parseModelsFromRows__(rows);
return callback.call(this, err, models);
}
let ids = result.rows.map(row => row.id);
/* Grab all items with ids, sorted by order */
/* Only need to grab joins and order */
let composerArray = this.__collapse__()
.filter(composer => composer._command)
.filter(composer => composer._command.type === 'orderBy' || composer._command.type === 'join');
// Add in id filter
composerArray.unshift(new Composer(this.Model).where({id__in: ids})._parent);
let queryInfo = this.__reduceToQueryInformation__(composerArray);
let query = this.__reduceCommandsToQuery__(queryInfo.commands);
query = this.__addJoinsToQuery__(query, queryInfo);
return this.db.query(query.sql, query.params, (err, result) => {
let rows = result ? (result.rows || []).slice() : [];
let models = this.__parseModelsFromRows__(rows);
callback.call(this, err, models);
});
});
}
}
module.exports = Composer;