UNPKG

bookshelf-eloquent

Version:

Bookshelf extension that adds the functionality of eloquent.

1,349 lines (1,141 loc) 82.8 kB
'use strict'; // xtend is a basic utility library which allows you to extend an object by appending all of the properties // from each object in a list. When there are identical properties, the right-most property takes precedence. const extend = require('xtend'); const memo = require('memoizee'); const _ = require('lodash'); const result = require('lodash/result'); const pick = require('lodash/pick'); const isString = require('lodash/isString'); const isArray = require('lodash/isArray'); const isFunction = require('lodash/isFunction'); const isPlainObject = require('lodash/isPlainObject'); const isDate = require('lodash/isDate'); const isNumber = require('lodash/isNumber'); const union = require('lodash/union'); const at = require('lodash/at'); const drop = require('lodash/drop'); const clone = require('lodash/clone'); const isUndefined = require('lodash/isUndefined'); // Check if fn is an async function. // Source: https://github.com/tc39/ecmascript-asyncawait/issues/78 function isAsync(fn) { if (fn == null) { return false; } if (fn.constructor == null) { return false; } return fn.constructor.name === 'AsyncFunction'; } // Eloquent plugin - // Adds the functionality and function names of eloquent (like whereHas). // ----- module.exports = function(Bookshelf, options) { // Set default options values. const globalOptions = options || {}; if (globalOptions.withCountSuffix == null) globalOptions.withCountSuffix = 'Count'; // Source: https://bugs.mysql.com/bug.php?id=68760 if (globalOptions.roundDateTime == null) globalOptions.roundDateTime = true; if (globalOptions.overrideCollectionWhere == null) globalOptions.overrideCollectionWhere = false; const modelProto = Bookshelf.Model.prototype; const collectionProto = Bookshelf.Collection.prototype; const knex = Bookshelf.knex; // Extract all methods that will be overridden. const modelGet = modelProto.get; const modelHas = modelProto.has; const modelFetch = modelProto.fetch; const modelFetchAll = modelProto.fetchAll; const modelCount = modelProto.count; const modelDestroy = modelProto.destroy; const modelQuery = modelProto.query; const modelKnexBuilder = modelProto._builder; const modelResetQuery = modelProto.resetQuery; const collectionGet = collectionProto.get; const collectionAdd = collectionProto.add; const collectionFetch = collectionProto.fetch; const collectionFetchOne = collectionProto.fetchOne; const collectionCount = collectionProto.count; const collectionQuery = collectionProto.query; const collectionKnexBuilder = collectionProto._builder; const collectionResetQuery = collectionProto.resetQuery; // Build the extension object. let commonExt = {}; let modelExt = { constructor: function() { modelProto.constructor.apply(this, arguments); this.resetEloquent(); const options = arguments[1] || {}; this.eloquent.caseSensitive = (options.caseSensitive === true); }, }; commonExt.resetEloquent = function() { // Reset eloquent state. if (this.eloquent == null) { this.eloquent = {}; // TODO: if this is out of the if as it should be thwn the first // test that fails is with('posts.comments') this.eloquent.fetchOptions = {}; this.eloquent.queryBuilderTasksAsync = []; this.eloquent.withCountColumnsAsync = []; this.eloquent.withCountColumns = []; this.eloquent.relationColumns = []; this.eloquent.withs = {}; this.eloquent.knex = knex; this.eloquent.bookshelf = Bookshelf; } }; // --------------------------------------------------------------------------- // ------ Fake Sync ---------------------------------------------------------- // --------------------------------------------------------------------------- commonExt.fakeSync = async function(options) { options = await mergeOptions(this, options || {}); let sync = this.sync(options); options.query = sync.query; let columns = null; const queryContainsColumns = _(sync.query._statements) .filter({grouping: 'columns'}) .some('value.length'); if (options.columns) { // Normalize single column name into array. columns = isArray(options.columns) ? options.columns : [options.columns]; } else if (!queryContainsColumns) { // If columns have already been selected via the `query` method // we will use them. Otherwise, select all columns in this table. columns = [result(sync.syncing, 'tableName') + '.*']; } // Trigger fetching for any possible plugins. await sync.syncing.triggerThen('fetching', sync.syncing, columns, options); return sync; }; commonExt.buildQuery = async function(options) { options = await mergeOptions(this, options || {}); let sync = this.sync(options); options.query = sync.query; let columns = null; const queryContainsColumns = _(sync.query._statements) .filter({grouping: 'columns'}) .some('value.length'); if (options.columns) { // Normalize single column name into array. columns = isArray(options.columns) ? options.columns : [options.columns]; } else if (!queryContainsColumns) { // If columns have already been selected via the `query` method // we will use them. Otherwise, select all columns in this table. columns = [result(sync.syncing, 'tableName') + '.*']; } // Trigger fetching for any possible plugins. await sync.syncing.triggerThen('fetching', sync.syncing, columns, options); sync.query.select(columns); return sync; }; // --------------------------------------------------------------------------- // ------ Table alias -------------------------------------------------------- // --------------------------------------------------------------------------- commonExt.useTableAlias = function(alias) { if (this.eloquent.originalTableName == null) this.eloquent.originalTableName = this.tableName; this.tableName = alias; let overrideFrom = function(model, attrs, options) { if (!options.isEager || options.parentResponse) { let orgTableName = model.eloquent.originalTableName; let alias = model.tableName; let fromStr = knex.raw(orgTableName + ' as ' + alias).toString(); options.query.from(fromStr); } }; this.on('fetching', overrideFrom.bind(this)); this.on('fetching:collection', overrideFrom.bind(this)); return this; }; // --------------------------------------------------------------------------- // ------ Knex Where Methods ------------------------------------------------- // --------------------------------------------------------------------------- // Inspired by the knex clone function. function applyKnex(targetKnex, knexToApply) { // const cloned = new knexToApply.constructor(knexToApply.client); // targetKnex._method = knexToApply._method; // targetKnex._single = clone(knexToApply._single); // targetKnex._statements = clone(knexToApply._statements); targetKnex._statements = targetKnex._statements.concat(knexToApply._statements); // targetKnex._debug = knexToApply._debug; // `_option` is assigned by the `Interface` mixin. // if (!isUndefined(knexToApply._options)) { // targetKnex._options = clone(knexToApply._options); // } return targetKnex; } function wrapCallbackIntoQueryBuilderTask(instance, callback) { let data = { model: instance, callback: callback, knexToApply: null, }; // Async wrapper. let whereNestedQueryTask = (async(data) => { // Create a clean model instance. let modelInstance = data.model.constructor.forge(); // Check if the callback function is async. if (isAsync(data.callback)) await data.callback.call(modelInstance, modelInstance); else data.callback.call(modelInstance, modelInstance); // Await all query building tasks. await modelInstance.runQueryBuildingTasks(); // Store the query that was built. data.knexToApply = modelInstance.query(); })(data); // Push the task to the where array. instance.eloquent.queryBuilderTasksAsync.push(whereNestedQueryTask); // Override/tap the callback function. return function(qb) { if (data.knexToApply == null) throw new Error('Query building tasks of a nested ' + 'where query were not completed.'); applyKnex(qb, data.knexToApply); }; } function whereTemplate(name) { return function(...args) { // Override/tap if callback function. if ((args.length > 0) && isFunction(args[0])) args[0] = wrapCallbackIntoQueryBuilderTask(this, args[0]); return this.query(name, ...args); }; } modelExt.where = whereTemplate('where'); commonExt.whereNot = whereTemplate('whereNot'); // Synonims for where. Useful if we do not want // to override the where function on Collection. // Query where. To enable query where on collections. commonExt.qWhere = modelExt.where; commonExt.queryWhere = modelExt.where; commonExt.basicWhere = modelExt.where; if (globalOptions.overrideCollectionWhere) { commonExt.where = modelExt.where; } // Attach existing "knex where methods" to the model. const whereMethods = ['whereIn', 'whereNotIn', 'whereNull', 'whereNotNull', 'whereExists', 'whereNotExists', 'whereRaw', ]; for (let method of whereMethods) { commonExt[method] = function(...args) { return this.query(method, ...args); }; } commonExt.whereLike = function(columnName, value) { return this.query('where', columnName, 'like', value); }; commonExt.whereNotLike = function(columnName, value) { return this.query('where', columnName, 'not like', value); }; // --------------------------------------------------------------------------- // ------ Knex AndWhere Methods ---------------------------------------------- // --------------------------------------------------------------------------- commonExt.andWhere = whereTemplate('andWhere'); commonExt.andWhereNot = whereTemplate('andWhereNot'); for (let method of whereMethods) { let andMethodName = 'and' + method.substr(0, 1).toUpperCase() + method.substr(1); commonExt[andMethodName] = function(...args) { return this.query(andMethodName, ...args); }; } commonExt.andWhereLike = function(columnName, value) { return this.andWhere(columnName, 'like', value); }; commonExt.andWhereNotLike = function(columnName, value) { return this.andWhere(columnName, 'not like', value); }; // --------------------------------------------------------------------------- // ------ Knex OrWhere Methods ----------------------------------------------- // --------------------------------------------------------------------------- commonExt.orWhere = whereTemplate('orWhere'); commonExt.orWhereNot = whereTemplate('orWhereNot'); for (let method of whereMethods) { let orMethodName = 'or' + method.substr(0, 1).toUpperCase() + method.substr(1); commonExt[orMethodName] = function(...args) { return this.query(orMethodName, ...args); }; } commonExt.orWhereLike = function(columnName, value) { return this.orWhere(columnName, 'like', value); }; commonExt.orWhereNotLike = function(columnName, value) { return this.orWhere(columnName, 'not like', value); }; // --------------------------------------------------------------------------- // ------ Knex Where Between Methods ----------------------------------------- // --------------------------------------------------------------------------- const whereBetweenMethods = ['whereBetween', 'whereNotBetween', 'andWhereBetween', 'andWhereNotBetween', 'orWhereBetween', 'orWhereNotBetween']; for (let method of whereBetweenMethods) { commonExt[method] = function(columnName, a, b) { if (isArray(a)) return this.query(method, columnName, a); else return this.query(method, columnName, [a, b]); }; } // --------------------------------------------------------------------------- // ------ Knex Offset & Limit ------------------------------------------------ // --------------------------------------------------------------------------- commonExt.offset = function(...args) { return this.query('offset', ...args); }; commonExt.limit = function(...args) { return this.query('limit', ...args); }; commonExt.skip = function(...args) { return this.query('offset', ...args); }; commonExt.take = function(...args) { return this.query('limit', ...args); }; // --------------------------------------------------------------------------- // ------ Knex orderByRaw ---------------------------------------------------- // --------------------------------------------------------------------------- commonExt.orderByRaw = function(...args) { return this.query('orderByRaw', ...args); }; // --------------------------------------------------------------------------- // ------ Select, Delete, First, Get ----------------------------------------- // --------------------------------------------------------------------------- commonExt.runQueryBuildingTasks = async function() { let eloquent = this.eloquent; let fetchOptions = eloquent.fetchOptions; // Query building tasks. await Promise.all(eloquent.queryBuilderTasksAsync); eloquent.queryBuilderTasksAsync = []; // WithCount tasks. for (let withCountTask of eloquent.withCountColumnsAsync) { let result = await withCountTask; eloquent.withCountColumns.push(result.query); } return this; }; /** * Helper function that helps to merge the default bookshelf fetch * options parameter with the options that are built by this extension. * @param {object} eloquent * @param {object} options */ async function mergeOptions(instance, options) { await instance.runQueryBuildingTasks(); let eloquent = instance.eloquent; let fetchOptions = eloquent.fetchOptions; let withCountColumns = eloquent.withCountColumns; if ('columns' in fetchOptions) { // Force select relation attributes that are required for the with statement. fetchOptions.columns = union(fetchOptions.columns, eloquent.relationColumns); // TODO: Do we always want to select the idAttribute so that bookshelf can function normally? // fetchOptions.columns = union(fetchOptions.columns, // [instance.idAttribute]); } // copy any columns from withCountColumns to fetchOptions if (withCountColumns.length > 0) { // check if any columns already in the fetchOptions if (!('columns' in fetchOptions)) fetchOptions.columns = ['*']; // copy the columns for (let column of withCountColumns) fetchOptions.columns.push(column); } options = options || {}; if ('withRelated' in fetchOptions) { if ('withRelated' in options) { options.withRelated = formatWiths(options.withRelated, null); options.withRelated = extend(fetchOptions.withRelated, options.withRelated); } } return extend(fetchOptions, options); }; /** * Set which columns you want to select on fetch. * @param {string|string[]} attrs List of attributes that you want to get from the database. */ commonExt.select = function(attrs) { // If parameter attrs is not an array the wrap it into an array. if (!isArray(attrs)) attrs = [attrs]; // Set or replace the columns array. this.eloquent.fetchOptions.columns = attrs; return this; }; /** * Look at the bookshelf documentation. */ modelExt.destroy = async function(...args) { // Attach options that were built by eloquent/this extension. let options = {}; if (args.length >= 1) options = args[0]; options = await mergeOptions(this, options); args[0] = options; // Call the original fetchAll function with eager load wrapper. return await modelDestroy.apply(this, args); }; /** * Synonym for destroy. */ modelExt.delete = function(...args) { return this.destroy(...args); }; /** * Look at the bookshelf documentation. */ modelExt.fetch = async function fetch(options) { // Attach options that were built by eloquent/this extension. options = await mergeOptions(this, options); // Call the original fetch function with eager load wrapper. return await fetchWithEagerLoad.apply(this, [modelFetch, options]); }; /** * Synonym for fetch. */ modelExt.first = modelExt.fetch; /** * Synonym for fetchAll. * This one is a little bit tricky. Now it is also a synonym for fetchAll. * In eloquent function get() is similar to fetchAll() in bookshelf. * If the first parameter is a string we want to call the bookshelf get() function which gets an attribute. * Else we want to call the eloquent get() function which gets all result that match the built query. */ modelExt.get = function(...args) { if (isString(args[0])) return modelGet.apply(this, args); else return this.fetchAll(...args); }; /** * Look at the bookshelf documentation. */ modelExt.fetchAll = async function fetchAll(options) { // Attach options that were built by eloquent/this extension. options = await mergeOptions(this, options); // Call the original fetchAll function with eager load wrapper. return await fetchWithEagerLoad.apply(this, [modelFetchAll, options]); }; /** * Look at the bookshelf documentation. */ modelExt.count = async function(column, options) { let args = []; if (!isString(column)) options = column; else args.push(column); // Attach options that were built by eloquent/this extension. options = await mergeOptions(this, options); args.push(options); // Call the original fetchAll function with eager load wrapper. return await modelCount.apply(this, args); }; // --------------------------------------------------------------------------- // ------ Bookshelf Paranoia Support ----------------------------------------- // --------------------------------------------------------------------------- commonExt.withDeleted = function() { // Retrieve with soft deleted rows. this.eloquent.fetchOptions.withDeleted = true; // Chainable. return this; }; /** * Synonym for withDeleted. */ commonExt.withTrashed = commonExt.withDeleted; // --------------------------------------------------------------------------- // ------ Eager Loading ------------------------------------------------------ // --------------------------------------------------------------------------- async function fetchWithEagerLoad(fetchFunction, options) { // TODO: maybe check which columns are required for the // with related functionality before executing the fecth query. // Withs wrapper let result = await fetchFunction.apply(this, [options]); // make this work for arrays and single models (by wrapping single model into an array) let isSingle = true; let collection = { models: [], }; if (result !== null) { if ('models' in result) { // result is a CollectionBase collection = result; isSingle = false; } else { // result is a ModelBase collection.models = [result]; } } // define the unwrap function let localUnwrap = function(collection, isSingle) { // sanity check if (!('models' in collection)) throw new Error('Cannot unwrap something that is not a collection.'); // dont unwrap anything if it wasnt wrapped in the first place if (!isSingle) return collection; // return the single model that was found if (collection.models.length > 0) return collection.models[0]; // no models were found in the first place return null; }; // get the count of with relations let withsCount = Object.keys(this.eloquent.withs).length; if ((collection.models.length < 1) || (withsCount < 1)) // no need to get the with relations => just return the result return localUnwrap(collection, isSingle); // init ids variable to null let ids = null; // fetch all withs let loadRelationTasks = []; for (let withRelationText in this.eloquent.withs) { // get the relatedData let relation = this.eloquent.withs[withRelationText]; let rd = relation.relatedData; // Check if parent ids required. if ((rd.type === 'belongsToMany') || (rd.type === 'hasMany')) { // Load ids. ids = []; // extract the model id for each model for (let model of collection.models) { if (!(rd.parentIdAttribute in model.attributes)) { throw new Error(`Failed to eager load the ${rd.type} "${withRelationText}" ` + 'relation of the "' + rd.parentTableName + `" model. Column "${rd.parentIdAttribute}" needs to be selected ` + 'if you want to eager load this relation.'); } // push the model.id into the collection of ids ids.push(model.attributes[rd.parentIdAttribute]); } } // Apply the relation constraint switch (rd.type) { case 'belongsToMany': loadRelationTasks.push(eagerLoadBelongsToManyRelation.apply(this, [ids, collection, withRelationText])); break; case 'hasMany': loadRelationTasks.push(eagerLoadHasManyRelation.apply(this, [ids, collection, withRelationText])); break; case 'belongsTo': case 'hasOne': loadRelationTasks.push(eagerLoadBelongsToRelation.apply(this, [collection, withRelationText])); break; default: throw new Error(`Failed to eager load the ${rd.type} "${withRelationText}" ` + `relation of the "${rd.parentTableName}" model. ` + `Relation type "${rd.type}" not supported/implemented for the with statement.`); } } // Wait for all tasks to complete. await Promise.all(loadRelationTasks); // Return the result. return localUnwrap(collection, isSingle); }; async function eagerLoadBelongsToManyRelation(ids, collection, withRelationText) { // Get the relatedData and relation/relatedQuery. let relatedQuery = this.eloquent.withs[withRelationText]; let rd = relatedQuery.relatedData; // Remove relatedData to bypass bookshelf eager loading functionallity. delete relatedQuery.relatedData; // get the columns let relatedFkAttribute = rd.key('foreignKey'); let relatedIdAttribute = rd.targetIdAttribute; // create relation on every model in the collection let modelMap = new Map(); let {firstRelationName, firstRelationAlias} = parseWithRelation(withRelationText); for (let model of collection.models) { let modelId = model.attributes[rd.parentIdAttribute]; modelMap.set(modelId, model); // add the relation let newRelation = this.getRelation(firstRelationName); newRelation.models = []; newRelation._byId = {}; newRelation.length = 0; // relations attribute should already exist on each model model.relations[firstRelationAlias] = newRelation; } // build the pivot table query let pivotQuery = knex.select([rd.key('foreignKey'), rd.otherKey]) .from(rd.joinTableName).whereIn(rd.key('foreignKey'), ids); // fetch from pivot table let pivotRows = await pivotQuery; // build foreignKey and otherKey indexes //let foreignKeyIndex = new Map(); let otherKeyIndex = new Map(); for (let pivotRow of pivotRows) { let foreignKeyValue = pivotRow[rd.key('foreignKey')]; if (foreignKeyValue === null) continue; let otherKeyValue = pivotRow[rd.otherKey]; if (otherKeyValue === null) continue; if (!otherKeyIndex.has(otherKeyValue)) otherKeyIndex.set(otherKeyValue, []); otherKeyIndex.get(otherKeyValue).push(foreignKeyValue); } // apply the whereIn constraint to the relatedQuery relatedQuery.eloquent.relationColumns.push(relatedIdAttribute); relatedQuery.whereIn(relatedIdAttribute, Array.from(otherKeyIndex.keys())); // fetch from related table let relatedModels = await relatedQuery.get(); // index the relatedModels by their ids let relatedModelIndex = new Map(); for (let relatedModel of relatedModels.models) { if (!(relatedIdAttribute in relatedModel.attributes)) { throw new Error(`Failed to eager load the ${rd.type} "${withRelationText}" ` + 'relation of the "' + rd.parentTableName + `" model. Column "${rd.relatedIdAttribute}" needs to be selected ` + 'if you want to eager load this relation.'); } let relatedIdValue = relatedModel.attributes[relatedIdAttribute]; // push the related model to each related model from the collection let modelIdsList = []; if (otherKeyIndex.has(relatedIdValue)) modelIdsList = otherKeyIndex.get(relatedIdValue); for (let modelId of modelIdsList) { if (!modelMap.has(modelId)) continue; // Resolve the model. let model = modelMap.get(modelId); // Get the relation. let newRelation = model.relations[firstRelationAlias]; // Add the related model to the relation collection. newRelation.models.push(relatedModel); newRelation._byId[relatedIdValue] = relatedModel; newRelation._byId[relatedModel.cid] = relatedModel; newRelation.length++; } } }; async function eagerLoadHasManyRelation(ids, collection, withRelationText) { // Get the relatedData and relation/relatedQuery. let relatedQuery = this.eloquent.withs[withRelationText]; let rd = relatedQuery.relatedData; // Remove relatedData to bypass bookshelf eager loading functionallity. delete relatedQuery.relatedData; // get the columns let relatedFkAttribute = rd.key('foreignKey'); let relatedIdAttribute = rd.targetIdAttribute; // apply the whereIn constraint to the relatedQuery relatedQuery.whereIn(relatedFkAttribute, ids); // fetch from related table relatedQuery.eloquent.relationColumns.push(relatedFkAttribute); let relatedModels = await relatedQuery.get(); // build foreignKey and otherKey indexes let foreignKeyIndex = new Map(); for (let relatedModel of relatedModels.models) { if (!(relatedFkAttribute in relatedModel.attributes)) { throw new Error(`Failed to eager load the ${rd.type} "${withRelationText}" ` + 'relation of the "' + rd.parentTableName + `" model. Column "${rd.relatedFkAttribute}" needs to be selected ` + 'if you want to eager load this relation.'); } let foreignKeyValue = relatedModel.attributes[relatedFkAttribute]; if (foreignKeyValue === null) continue; if (!foreignKeyIndex.has(foreignKeyValue)) foreignKeyIndex.set(foreignKeyValue, []); foreignKeyIndex.get(foreignKeyValue).push(relatedModel); } // attach the relatedModels to the model(s) let {firstRelationName, firstRelationAlias} = parseWithRelation(withRelationText); for (let model of collection.models) { let rModels = []; let rById = {}; let modelId = model.attributes[rd.parentIdAttribute]; if (foreignKeyIndex.has(modelId)) rModels = foreignKeyIndex.get(modelId); for (let relatedModel of rModels) { if (relatedIdAttribute in relatedModel.attributes) rById[relatedModel.attributes[relatedIdAttribute]] = relatedModel; rById[relatedModel.cid] = relatedModel; } // add the relation let newRelation = this.getRelation(firstRelationName); newRelation.models = rModels; newRelation._byId = rById; newRelation.length = rModels.length; // relations attribute should already exist on each model model.relations[firstRelationAlias] = newRelation; } }; async function eagerLoadBelongsToRelation(collection, withRelationText) { // Get the relatedData and relation/relatedQuery. let relatedQuery = this.eloquent.withs[withRelationText]; let rd = relatedQuery.relatedData; // Remove relatedData to bypass bookshelf eager loading functionallity. delete relatedQuery.relatedData; // get the columns let relatedFkAttribute = rd.key('foreignKey'); let relatedIdAttribute = rd.targetIdAttribute; // build the fk ids array let fkIds = new Set(); // extract the foreignKey for each model for (let model of collection.models) { if (!(relatedFkAttribute in model.attributes)) { throw new Error(`Failed to eager load the ${rd.type} "${withRelationText}" ` + 'relation of the "' + rd.parentTableName + `" model. Column "${rd.relatedFkAttribute}" needs to be selected ` + 'if you want to eager load this relation.'); } // push the model.foreignKey into the collection of ids if (model.attributes[relatedFkAttribute] !== null) fkIds.add(model.attributes[relatedFkAttribute]); } // apply the whereIn constraint to the relatedQuery relatedQuery.whereIn(relatedIdAttribute, Array.from(fkIds)); // fetch from related table relatedQuery.eloquent.relationColumns.push(relatedIdAttribute); let relatedModels = await relatedQuery.get(); // index the relatedModels by their ids let relatedModelIndex = new Map(); for (let relatedModel of relatedModels.models) { if (!(relatedIdAttribute in relatedModel.attributes)) throw new Error('If you want to perform a with statement on a ' + 'related model then its id needs to be selected.'); // insert the related model into the index let relatedIdValue = relatedModel.attributes[relatedIdAttribute]; if (relatedIdValue !== null) relatedModelIndex.set(relatedIdValue, relatedModel); } // attach the relatedModels to the model(s) let {firstRelationName, firstRelationAlias} = parseWithRelation(withRelationText); for (let model of collection.models) { // add/create the relation let newRelation = this.getRelation(firstRelationName); // set the relation to be null by default model.attributes[firstRelationAlias] = null; if (model.attributes[relatedFkAttribute] === null) continue; let relatedId = model.attributes[relatedFkAttribute]; if (!relatedModelIndex.has(relatedId)) continue; let relatedModel = relatedModelIndex.get(relatedId); // copy over all own properties // TODO: this is a quick fix - find a better solution how to create relation from model let copyProperties = new Set([ 'attributes', '_previousAttributes', 'changed', 'relations', 'cid', 'id', '_events', '_eventsCount', 'eloquent', ]); for (var property in relatedModel) { if (!relatedModel.hasOwnProperty(property)) continue; if (!copyProperties.has(property)) continue; newRelation[property] = relatedModel[property]; } // relations attribute should already exist on each model model.relations[firstRelationAlias] = newRelation; } }; function formatWiths(relations, signleRelationSubquery = null) { // Validate arguments. if (isString(relations)) { let relObj = {}; relObj[relations] = signleRelationSubquery; relations = [relObj]; } else if (relations.constructor === Object) { relations = [relations]; } if (relations.constructor !== Array) throw new Error('Must pass an object/dictionary, ' + 'array or string for the relations argument.'); // add to the withRelated let withRelated = {}; for (let relObj of relations) { if (isString(relObj)) { // TODO: this probably cannot happen because of argument validation withRelated[relObj] = null; } else if (isPlainObject(relObj)) { for (let key in relObj) { if (!relObj.hasOwnProperty(key)) continue; withRelated[key] = relObj[key]; } } else { // TODO: maybe make this error more explanatory throw new Error('Must pass an object/dictionary, ' + 'array or string for the relations argument.'); } } return withRelated; }; // Support for aliases. function parseWithRelation(relationText) { let tokens = relationText.split(/\s/).map(t => t.trim()).filter(t => (t.length > 0)); let invalidFormatError = `Invalid relation name '${relationText}'. Correct format is "[relationName]" or "[relationName] as [alias]".`; let aliasTokens = []; let firstRelationAlias = null; if (tokens.length === 3) { if (tokens[1].toLowerCase() !== 'as') throw new Error(invalidFormatError); let relationAlias = tokens[2]; aliasTokens = relationAlias.split('.').map(t => t.trim()).filter(t => (t.length > 0)); if (aliasTokens.length > 0) firstRelationAlias = aliasTokens.shift(); } else if (tokens.length !== 1) throw new Error(invalidFormatError); let relationPath = tokens[0]; // Split relation name by . (dots) to handle nested/sub relations. tokens = relationPath.split('.').map(t => t.trim()).filter(t => (t.length > 0)); if (tokens.length < 1) throw new Error(`Invalid relation name '${relationPath}'.`); let firstRelationName = tokens.shift(); let hasSubRelation = (tokens.length > 0); let subRelationText = null; if (hasSubRelation) { subRelationText = tokens.join('.'); if (aliasTokens.length > 0) subRelationText += ' as ' + aliasTokens.join('.'); } if (firstRelationAlias == null) firstRelationAlias = firstRelationName; return { firstRelationName: firstRelationName, firstRelationAlias: firstRelationAlias, hasSubRelation: hasSubRelation, subRelationText: subRelationText, } } function parseWithCountRelation(relationText) { let tokens = relationText.split(/\s/).map(t => t.trim()).filter(t => (t.length > 0)); let relationAlias = null; let invalidFormatError = `Invalid relation name '${relationText}'. Correct format is "[relationName]" or "[relationName] as [alias]".`; if (tokens.length === 3) { if (tokens[1].toLowerCase() !== 'as') throw new Error(invalidFormatError); relationAlias = tokens[2]; } else if (tokens.length !== 1) throw new Error(invalidFormatError); let relationPath = tokens[0]; // If alias not given then generate it. if (relationAlias == null) { // Build the alias. let aliasTokens = relationPath.split('.'); if (aliasTokens.length < 1) throw new Error(`Invalid relation name '${relationPath}'.`); for (let i = 1; i < aliasTokens.length; i++) { let token = aliasTokens[i]; token = token.substr(0, 1).toUpperCase() + token.substr(1); aliasTokens[i] = token; } aliasTokens.push(globalOptions.withCountSuffix); relationAlias = aliasTokens.join(''); } return { relationPath: relationPath, relationAlias: relationAlias, }; } /** * @param {object|string|string[]} relationNames An object where keys are relation names and values are subquery functions or null. * Can also be a single relations name or an array of relation names. * @param {function} [signleRelationSubquery] Only takes effect if the "relationNames" is a single relation name (string). */ commonExt.with = function(relationNames, signleRelationSubquery = null) { // Validate arguments. // withRelated is an object where keys are relation names and values are callback functions or null let withRelated = formatWiths(relationNames, signleRelationSubquery); // Prepare all relations. for (let relationText in withRelated) { if (!withRelated.hasOwnProperty(relationText)) continue; // Check if the relationText is string. if (!isString(relationText)) throw new Error('Must pass an object, string or an array of strings ' + 'for the relationNames argument.'); // Parse relation text; let {firstRelationName, firstRelationAlias, hasSubRelation, subRelationText} = parseWithRelation(relationText); let relationKey = firstRelationName; if (firstRelationName !== firstRelationAlias) relationKey += ` as ${firstRelationAlias}`; // Get the relation and relationData. let relation = this.getRelation(firstRelationName).toModel(); let relatedData = relation.relatedData; // Check if this relation already exists in the withs => if not then create a new related query. if (!(relationKey in this.eloquent.withs)) { // Check if this is a supported relation if ((relatedData.type !== 'belongsToMany') && (relatedData.type !== 'belongsTo') && (relatedData.type !== 'hasMany') && (relatedData.type !== 'hasOne')) throw new Error('Relation type ' + relatedData.type + ' not supported/implemented for the with statement.'); // Add this relation to the withs. this.eloquent.withs[relationKey] = relation; } // Get the related query. let relatedQuery = this.eloquent.withs[relationKey]; // Get the callback. let callback = withRelated[relationText]; // Check if tihs is the leaf relation/token. if (hasSubRelation) { // This is not the leaf relation/token => pass the callback to the next sub relation/token. relatedQuery.with(subRelationText, callback); } else { // This is the leaf relation/token => apply the callback. // Check if the callback is a function. if (isFunction(callback)) callback(relatedQuery); // Apply the callback. } } // Chainable. return this; }; /** * @param {string} relationName Name of the relation that you want to eager load. * @param {string|string[]} attrs List of attributes on the related model that we want to get from database. * @param {function} [subquery] Optional nested query callback. */ commonExt.withSelect = function(relationName, attrs, subquery = null) { // Validate arguments. if (!isString(relationName)) throw new Error('Must pass a string for the relation name argument.'); // We want a list of attributes. if (attrs.constructor !== Array) attrs = [attrs]; // Use the existing "with" function. // Check if the subquery is a function. if (isFunction(subquery)) { return this.with(relationName, function(q) { q.select(attrs); subquery(q); }); } else { return this.with(relationName, function(q) { q.select(attrs); }); } }; // --------------------------------------------------------------------------- // ------ With Count --------------------------------------------------------- // --------------------------------------------------------------------------- /** * Helps to build the withCount subquery. * @param {Bookshelf Model} Model Current model. * @param {Knex query builder} subquery Current subquery. Treated as alias if it is a string. * @param {string} path Remaining relation path. */ async function withCountSubQuery(Model, subquery, path, baseTableName) { // Split path by . (dots) to handle nested/sub relations. let tokens = path.split('.'); // Check if we have at least one token. Sanity check. if (tokens.length < 1) throw new Error('Could not split relation path "' + path + '".'); // Pick the first relation name. let firstRelationName = tokens[0]; // Check if the relation exists. if (!(firstRelationName in Model)) throw new Error('Relation "' + firstRelationName + '" does not exist on the model "' + Model.tableName + '".'); // Construct the sub path (remaining path) tokens.shift(); let subPath = tokens.join('.'); // Get the relation data. let relation = Model.getRelation(firstRelationName).toModel(); let rd = relation.relatedData; let bookQuery = relation; // Apply the relation constraint. switch (rd.type) { case 'belongsToMany': // HasMany part. if (isString(subquery)) subquery = knex.raw('(??.??)', [subquery, rd.parentIdAttribute]); else subquery = subquery.select(rd.parentIdAttribute); // Pivot table part. subquery = knex.select(rd.otherKey) .from(rd.joinTableName) .whereIn(rd.key('foreignKey'), subquery); // BelongsTo part. bookQuery.whereIn(rd.targetIdAttribute, subquery); break; case 'hasMany': if (isString(subquery)) subquery = knex.raw('(??.??)', [subquery, rd.parentIdAttribute]); else subquery = subquery.select(rd.parentIdAttribute); bookQuery.whereIn(rd.key('foreignKey'), subquery); break; case 'belongsTo': case 'hasOne': if (isString(subquery)) subquery = knex.raw('??.??', [subquery, rd.key('foreignKey')]); else subquery = subquery.select(rd.key('foreignKey')); bookQuery.whereIn(rd.targetIdAttribute, subquery); break; default: throw new Error('Failed to eager load the "' + firstRelationName + '" relation of the "' + Model.tableName + '" model. Relation type ' + rd.type + ' not supported/implemented for the withCount statement.'); } // Handle circular relations. Set alias when there is a table name collision. if (bookQuery.tableName === baseTableName) bookQuery.useTableAlias(('t' === baseTableName) ? 't1' : 't'); if (tokens.length < 1) return bookQuery; else { let syncSubquery = (await bookQuery.fakeSync()).query; return withCountSubQuery(relation, syncSubquery, subPath, baseTableName); } } /** * @param {object|string|string[]} relationNames An object where keys are relation names and values are subquery functions or null. * Can also be a single relations name or an array of relation names. * @param {function} [signleRelationSubquery] If the "relationNames" parameter is a string you can pass the callback to this parameter. */ commonExt.withCount = function(relationNames, signleRelationSubquery = null) { // Validate arguments. // withRelated is an object where keys are relation names and values are callback functions or null let withRelated = formatWiths(relationNames, signleRelationSubquery); // Loop through all the relation names. Build the select queries. for (let relationText in withRelated) { if (!withRelated.hasOwnProperty(relationText)) continue; // Check if the relationName is string. if (!isString(relationText)) throw new Error('Must pass an object, string or an array of strings ' + 'for the relationNames argument.'); // Get the callback. let {relationPath, relationAlias} = parseWithCountRelation(relationText) let callback = withRelated[relationText]; // Async wrapper. let withCountSubQueryTask = (async(Model, relationPath, relationAlias, callback) => { // Build the withCount sub query. let subQuery = await withCountSubQuery(Model, Model.tableName, relationPath, Model.tableName); // Check if the callback is a function and apply the callback. if (isFunction(callback)) callback(subQuery); // Add to select subQuery = (await subQuery.fakeSync()) .query.count('*').as(relationAlias); // Wrap the result into an object to prevent execution on await. return {query: subQuery}; })(this, relationPath, relationAlias, callback); // Push the task to the withCount array. this.eloquent.withCountColumnsAsync.push(withCountSubQueryTask); } // Chainable. return this; }; // --------------------------------------------------------------------------- // ------ Where Has ---------------------------------------------------------- // --------------------------------------------------------------------------- /** * Helper function that composes a SQL query given the operator and operands. * @param {string} operator * @param {numeric|string} [operand1] * @param {numeric|string} [operand2] */ function composeOperator(operator, operand1 = null, operand2 = null) { // BETWEEN ... AND ... Check whether a value is within a range of values // = Equal operator // <=> NULL-safe equal to operator // > Greater than operator // >= Greater than or equal operator // IN() Check whether a value is within a set of values // IS Test a value against a boolean // IS NOT Test a value against a boolean // IS NOT NULL NOT NULL value test // IS NULL NULL value test // < Less than operator // <= Less than or equal operator // LIKE Simple pattern matching // NOT BETWEEN ... AND ... Check whether a value is not within a range of values // != Not equal operator // <> Not equal operator // NOT IN() Check whether a value is not within a set of values // NOT LIKE Negation of simple pattern matching const operators = { 'BETWEEN': ['BETWEEN ? AND ?', 2], '=': ['= ?', 1], '<=>': ['<=> ?', 1], '>': ['> ?', 1], '>=': ['>= ?', 1], 'IN': ['IN (?)', 1], 'IS': ['IS ?', 1], 'IS NOT': ['IS NOT ?', 1], 'IS NOT NULL': ['IS NOT NULL', 0], 'IS NULL': ['IS NULL', 0], '<': ['< ?', 1], '<=': ['<= ?', 1], 'LIKE': ['LIKE ?', 1], 'NOT BETWEEN': ['NOT BETWEEN ? AND ?', 2], '!=': ['!= ?', 1], '<>': ['<> ?', 1], 'NOT IN': ['NOT IN (?)', 1], 'NOT LIKE': ['NOT LIKE ?', 1], }; // convert the operator to upper case operator = operator.toUpperCase().trim(); // check if the operator is valid if (!(operator in operators)) { throw new Error("Unknown operator '" + operator + "'."); } let operatorData = operators[operator]; // build an array of operands let operands = []; if (operand1 !== null) { operands.push(operand1); if (operand2 !== null) { operands.push(operand2); } } // check if we have enough operands if (operatorData[1] > operands.length) { throw new Error("Missing operands. Operator '" + operator + "' needs " + operatorData[1] + ' operand(s).'); } // build the operator query return knex.raw(operatorData[0], operands).toString(); }; function buildWhereHasCallback(Model, relationName, callback = null, operator = null, operand1 = null, operand2 = null) { // Check if the relationName is string. if (!isString(relationName)) throw new Error('Must pass a string for the relation name argument.'); let whereHasCallback = async(q) => { // Build the withCount sub query. let subQuery = await withCountSubQuery(Model, Model.tableName, relationName, Model.tableName); // Check if the callback is a function and apply the callback. if (isFunction(callback)) callback(subQuery); // Fake sync sub query to trigger any plugins. subQuery = (await subQuery.fakeSync()).query; if (operator !== null) { // compose the operator string let operatorStr = composeOperator(operator, operand1, operand2); // count the subquery subQuery.count('*'); // compare the subquery count with the operator q.whereRaw('(' + subQuery.toString() + ') ' + operatorStr); } else { // Attach the where exists query to this model. q.whereExists(subQuery); } }; return whereHasCallback; } /** * Where statement on a related model count/existence with subQuery option. * @param {string} relationName Relation name by which we want to filter. * @param {function} [subQuery] This filter can be nested. * @param {string} [operator] Filter operator. * @param {numeric|string} [operand1] Filter operand1. * @param {numeric|string} [operand2] Filter operand2. */ function whereHasTemplate(whereName) { return function(relationName, subQueryCallback = null, operator = null, operand1 = null, operand2 = null) { // Build the whereHas callback. let whereHasCallback = buildWhereHasCallback(this, relationName, subQueryCallback, operator, operand1, operand2); this[whereName](whereHasCallback); return this; // Chainable. }; } commonExt.whereHas = whereHasTemplate('where'); commonExt.andWhereHas = whereHasTemplate('andWhere'); commonExt.orWhereHas = whereHasTemplate('orWhere'); commonExt.whereNotHas = whereHasTemplate('whereNot'); commonExt.andWhereNotHas = whereHasTemplate('andWhereNot'); commonExt.orWhereNotHas = whereHasTemplate('orWhereNot'); /** * Where statement on a related model count/existence. * @param {string} relationName Relation name by which we want to filter. * @param {string} [operator] Filter operator. * @param {numeric|string} [operand1] Filter operand1. * @param {numeric|string} [operand2] Filter operand2. */ commonExt.has = function(relationName, operator = null, operand1 = null, operand2 = null) { if (isString(relationName)) { // Check if the relation exists on this model. // Split relation name by . (dots) to handle nested/sub relations. let tokens = relationName.spl