bookshelf-jsonapi-params
Version:
Automatically applies relations, filters, and more from the JSON API spec to your Bookshelf.js results
1,132 lines (946 loc) • 48.5 kB
JavaScript
// Load modules
/* eslint-disable prefer-const */
import {
assign as _assign,
forEach as _forEach,
forOwn as _forOwn,
has as _has,
hasIn as _hasIn,
includes as _includes,
isEmpty as _isEmpty,
isArray as _isArray,
isFunction as _isFunction,
isObject as _isObject,
isObjectLike as _isObjectLike,
isPlainObject as _isPlainObject,
isString as _isString,
pull as _pull,
forIn as _forIn,
keys as _keys,
map as _map,
filter as _filter,
sortBy as _sortBy,
initial as _initial,
replace as _replace,
get as _get,
set as _set,
last as _last,
uniq as _uniq,
union as _union,
cloneDeep as _cloneDeep,
defaults as _defaults
} from 'lodash';
import split from 'split-string';
import inflection from 'inflection';
import Paginator from 'bookshelf-page';
import jsonFields from './json-fields';
/**
* Exports a plugin to pass into the bookshelf instance, i.e.:
*
* import config from './knexfile';
* import knex from 'knex';
* import bookshelf from 'bookshelf';
*
* const Bookshelf = bookshelf(knex(config));
*
* Bookshelf.plugin('bookshelf-jsonapi-params');
*
* export default Bookshelf;
*
* The plugin attaches the `fetchJsonApi` instance method to
* the Bookshelf Model object.
*
* See methods below for details.
*/
export default (Bookshelf, options = {}) => {
_defaults(options, { nullString: 'null' });
// Load the pagination plugin if its not already there
if (!_get(Bookshelf, 'Model.fetchPage')) {
Bookshelf.plugin(Paginator);
}
/**
* Similar to {@link Model#fetch} and {@link Model#fetchAll}, but specifically
* uses parameters defined by the {@link https://jsonapi.org|JSON API spec} to
* build a query to further refine a result set.
*
* @param opts {object}
* Currently supports the `include`, `fields`, `sort`, `page` and `filter`
* parameters from the {@link https://jsonapi.org|JSON API spec}.
* @param type {string}
* An optional string that specifies the type of resource being retrieved.
* If not specified, type will default to the name of the table associated
* with the model.
* @return {Promise<Model|Collection|null>}
*/
const fetchJsonApi = function (opts, isCollection = true, type, additionalQuery) {
opts = _cloneDeep(opts) || {};
const internals = {};
const { include, fields, sort, page = {}, filter, group } = opts;
const filterTypes = ['like', 'not', 'lt', 'gt', 'lte', 'gte', 'or', 'and'];
// Do not add the global flag. The global flag will influence String.prototype.match and will
// return a list of matches instead of matching groups. Changing this will break existing code.
const aggregateFunctionRegex = /(count|sum|avg|max|min)\((.+)\)/;
// Get a reference to the field being used as the id
internals.idAttribute = this.constructor.prototype.idAttribute ?
this.constructor.prototype.idAttribute : 'id';
// Get a reference to the current model name. Note that if no type is
// explicitly passed, the tableName will be used
internals.modelName = this.constructor.prototype.tableName;
internals.modelType = type;
// Used to determine which casting syntax is valid
internals.client = Bookshelf.knex.client.config.client;
// Initialize an instance of the current model and clone the initial query
internals.model =
this.constructor.forge().query((qb) => _assign(qb, this.query().clone()));
/**
* Build a query for single filtering dependency object
* @param value {object}
* @param relationHash {object}
*/
internals.buildObjectLikeFilterDependencies = (value, relationHash) => {
if (!_isEmpty(value)){
_forEach(value, (_, typeKey) => {
// Add relations to the relationHash
internals.buildDependenciesHelper(typeKey, relationHash);
});
}
};
/**
* Build a query for array of relational dependencies of filtering
* @param filterList {array}
* @param relationHash {object}
*/
internals.buildOrFilterDependencies = (filterList, relationHash) => {
if (!_isEmpty(filterList)) {
_forEach(filterList, (value) => {
if (_isPlainObject(value)) {
internals.buildDependenciesLoopHelper(value, relationHash);
}
});
}
};
internals.buildDependenciesLoopHelper = (filterValues, relationHash) => {
// Loop through each filter value
_forEach(filterValues, (value, key) => {
// If the filter is "OR" or "AND" filter fragments array
if (_isArray(value) && (key === 'or' || key === 'and')) {
internals.buildOrFilterDependencies(value, relationHash);
}
// If the filter is not an equality filter
if (_isObjectLike(value) && !_isArray(value)){
internals.buildObjectLikeFilterDependencies(value, relationHash);
}
// If the filter is an equality filter
else {
internals.buildDependenciesHelper(key, relationHash);
}
});
};
/**
* Build a query for relational dependencies of filtering, grouping and sorting
* @param filterValues {object}
* @param groupValues {object}
* @param sortValues {object}
*/
internals.buildDependencies = (filterValues, groupValues, sortValues) => {
const relationHash = {};
// Find relations in filterValues
if (_isObjectLike(filterValues) && !_isEmpty(filterValues)){
internals.buildDependenciesLoopHelper(filterValues, relationHash);
}
// Find relations in sortValues
if (_isObjectLike(sortValues) && !_isEmpty(sortValues)){
// Loop through each sort value
_forEach(sortValues, (value) => {
// If the sort value is descending, remove the dash
if (value.indexOf('-') === 0){
value = value.substr(1);
}
// Add relations to the relationHash
internals.buildDependenciesHelper(value, relationHash);
});
}
// Find relations in groupValues
if (_isObjectLike(groupValues) && !_isEmpty(groupValues)){
// Loop through each group value
_forEach(groupValues, (value) => {
// Add relations to the relationHash
internals.buildDependenciesHelper(value, relationHash);
});
}
// Need to select model.* so all of the relations are not returned, also check if there is anything in fields object
if (_keys(relationHash).length && !_keys(fields).length){
internals.model.query((qb) => {
qb.select(`${internals.modelName}.*`);
});
}
// Recurse on each of the relations in relationHash
_forIn(relationHash, (value, key) => {
return internals.queryRelations(value, key, this, internals.modelName);
});
};
/**
* Recursive funtion to add relationships to main query to allow filtering and sorting
* on relationships by using left outer joins
* @param relation {object}
* @param relationKey {string}
* @param parent {object}
* @param parentKey {string}
*/
internals.queryRelations = (relation, relationKey, parentModel, parentKey) => {
// Add left outer joins for the relation
const relatedData = parentModel[relationKey]().relatedData;
internals.model.query((qb) => {
const foreignKey = relatedData.foreignKey ? relatedData.foreignKey : `${inflection.singularize(relatedData.parentTableName)}_${relatedData.parentIdAttribute}`;
if (relatedData.type === 'hasOne' || relatedData.type === 'hasMany'){
qb.leftOuterJoin(`${relatedData.targetTableName} as ${relationKey}`,
`${parentKey}.${relatedData.parentIdAttribute}`,
`${relationKey}.${foreignKey}`);
}
else if (relatedData.type === 'belongsTo'){
if (relatedData.throughTableName){
const throughTableAlias = `${relationKey}_${relatedData.throughTableName}_pivot`.replace(/\./g, '_');
qb.leftOuterJoin(`${relatedData.throughTableName} as ${throughTableAlias}`,
`${parentKey}.${relatedData.parentIdAttribute}`,
`${throughTableAlias}.${relatedData.throughIdAttribute}`);
qb.leftOuterJoin(`${relatedData.targetTableName} as ${relationKey}`,
`${throughTableAlias}.${foreignKey}`,
`${relationKey}.${relatedData.targetIdAttribute}`);
}
else {
qb.leftOuterJoin(`${relatedData.targetTableName} as ${relationKey}`,
`${parentKey}.${foreignKey}`,
`${relationKey}.${relatedData.targetIdAttribute}`);
}
}
else if (relatedData.type === 'belongsToMany'){
const otherKey = relatedData.otherKey ? relatedData.otherKey : `${inflection.singularize(relatedData.targetTableName)}_id`;
const joinTableName = relatedData.joinTableName ? relatedData.joinTableName : relatedData.throughTableName;
const joinTableAlias = `${relationKey}_${joinTableName}`.replace(/\./g, '_');
qb.leftOuterJoin(`${joinTableName} as ${joinTableAlias}`,
`${parentKey}.${relatedData.parentIdAttribute}`,
`${joinTableAlias}.${foreignKey}`);
qb.leftOuterJoin(`${relatedData.targetTableName} as ${relationKey}`,
`${joinTableAlias}.${otherKey}`,
`${relationKey}.${relatedData.targetIdAttribute}`);
}
else if (_includes(relatedData.type, 'morph')){
// Get the morph type and id
const morphType = relatedData.columnNames[0] ? relatedData.columnNames[0] : `${relatedData.morphName}_type`;
const morphId = relatedData.columnNames[1] ? relatedData.columnNames[0] : `${relatedData.morphName}_id`;
if (relatedData.type === 'morphOne' || relatedData.type === 'morphMany'){
qb.leftOuterJoin(`${relatedData.targetTableName} as ${relationKey}`, (qbJoin) => {
qbJoin.on(`${relationKey}.${morphId}`, '=', `${parentKey}.${relatedData.parentIdAttribute}`);
}).where(`${relationKey}.${morphType}`, '=', relatedData.morphValue);
}
else if (relatedData.type === 'morphTo'){
// Not implemented
}
}
});
if (!_keys(relation).length){
return;
}
_forIn(relation, (value, key) => {
return internals.queryRelations(value, key, parentModel[relationKey]().relatedData.target.forge(), relationKey);
});
};
/**
* Adds relations included in the column to the relationHash, used in buildDependencies
* @param column {string}
* @param relationHash {object}
*/
internals.buildDependenciesHelper = (column, relationHash) => {
// Split column on colons, example of column: 'relation.column:jsonbColumn.property:dataType'
let [key] = column.split(':');
if (_includes(key, '.')){
// The last item in the chain is a column name, not a table. Do not include column name in relationHash
key = key.substring(0, key.lastIndexOf('.'));
if (!_has(relationHash, key)){
let level = relationHash;
const relations = key.split('.');
let relationModel = this.clone();
// Traverse the relationHash object and set new relation if it does not exist
_forEach(relations, (relation) => {
// Check if valid relationship
if (typeof relationModel[relation] === 'function' && relationModel[relation]().relatedData.type){
if (!level[relation]){
level[relation] = {};
}
level = level[relation];
// Set relation model to the next item in the chain
relationModel = relationModel.related(relation).relatedData.target.forge();
}
else {
return false;
}
});
}
}
};
/**
* Build a query based on the `fields` parameter.
* @param fieldNames {object}
*/
internals.buildFields = (fieldNames = {}, expandedIncludes, includesMap) => {
if (_isObject(fieldNames) && !_isEmpty(fieldNames)) {
// Format column names
fieldNames = internals.formatColumnNames(fieldNames);
// Process fields for each type/relation
_forEach(fieldNames, (fieldValue, fieldKey) => {
// Add qualifying table name to avoid ambiguous columns
fieldNames[fieldKey] = _map(fieldNames[fieldKey], (value) => {
// Extract any aggregate function around the column name
let [column, jsonColumn, dataType] = value.split(':');
let aggregateFunction = null;
const match = aggregateFunctionRegex.exec(value);
if (match) {
aggregateFunction = match[1];
column = match[2];
}
if (!fieldKey || fieldKey === internals.modelType) {
if (!_includes(column, '.')) {
column = `${internals.modelName}.${column}`;
}
}
else {
column = `${fieldKey}.${column}`;
}
column = _filter([column, jsonColumn, dataType]).join(':');
return aggregateFunction ? { aggregateFunction, column } : column;
});
// Only process the field if it's not a relation. Fields
// for relations are processed in `buildIncludes()`
if (!_includes(expandedIncludes, fieldKey)) {
// Add columns to query
internals.model.query((qb) => {
if (!fieldKey){
qb.distinct();
}
let fieldsToSelect = fieldNames[fieldKey];
// JSON API considers relationships as fields, so we
// need to make sure the id of the relation is selected
if (!_isEmpty(includesMap.relations)) {
fieldsToSelect = _uniq(_union(fieldsToSelect, includesMap.requiredColumns.map((column) => `${internals.modelName}.${column}`)));
}
_forEach(fieldsToSelect, (column) => {
if (column.aggregateFunction) {
qb[column.aggregateFunction](`${column.column} as ${column.aggregateFunction}`);
}
else {
let [columnToSelect, jsonColumn, dataType] = column.split(':');
if (jsonColumn) {
jsonFields.buildSelect(qb, Bookshelf.knex, columnToSelect, jsonColumn, dataType);
}
else {
qb.select([columnToSelect]);
}
}
});
});
}
});
}
};
/**
* Build a query based on the `filters` parameter.
* @param filterValues {object|array}
*/
internals.buildFilters = (filterValues) => {
if (_isObjectLike(filterValues) && !_isEmpty(filterValues)) {
// format the column names of the filters
//filterValues = this.format(filterValues);
// build the filter query
internals.model.query(internals.applyFilterFragment(filterValues));
}
};
/**
* Takes in filterValues (fragment of filter configuration) and returns function to create filter
* @param filterValues {object|array} fragment of filter params
*/
internals.applyFilterFragment = (filterValues) => {
return (qb) => {
_forEach(filterValues, (value, key) => {
// If the value is a filter type
if (_isObjectLike(value) && !_isArray(value)) {
// Format column names of filter types
const filterTypeValues = value;
// Check if filter type is valid
if (_includes(filterTypes, key)) {
// Loop through each value for the valid filter type
_forEach(filterTypeValues, (typeValue, typeKey) => {
let [column, jsonColumn, dataType] = typeKey.split(':');
// Remove all but the last table name, need to get number of dots
column = internals.formatRelation(internals.formatColumnNames([column])[0]);
let valueArray = typeValue;
if (!_isArray(valueArray)) {
if (valueArray === null){
valueArray = [valueArray];
}
else {
valueArray = split(String(valueArray), { keepQuotes: true, sep: ',' });
}
}
if (jsonColumn) {
// Pass in the an equality filter for the same column name as last parameter for OR filtering with `like` and `equals`
let extraEqualityFilter = filterValues[typeKey];
if (extraEqualityFilter) {
if (!_isArray(extraEqualityFilter)) {
extraEqualityFilter = split(String(extraEqualityFilter), { keepQuotes: true, sep: ',' });
}
}
const filterObj = {
nullString: options.nullString,
qb,
knex: Bookshelf.knex,
filterType: key,
values: valueArray,
column,
jsonColumn,
dataType,
extraEqualityFilterValues: extraEqualityFilter
};
jsonFields.buildFilterWithType(filterObj);
}
else {
// Attach different query for each type
if (key === 'like') {
qb.where((qbWhere) => {
let where = 'where';
let textType = 'text';
if (internals.client === 'mysql' || internals.client === 'mssql') {
textType = 'char';
}
_forEach(valueArray, (val) => {
let likeQuery = `LOWER(CAST(:column: AS ${textType})) like LOWER(:value)`;
if (internals.client === 'pg') {
likeQuery = `CAST(:column: AS ${textType}) ilike :value`;
}
qbWhere[where](Bookshelf.knex.raw(likeQuery, {
value: `%${val}%`,
column
}));
// Change to orWhere after the first where
if (where === 'where') {
where = 'orWhere';
}
});
// If the key is in the top level filter, filter on orWhereIn
if (_hasIn(filterValues, typeKey)) {
// Determine if there are multiple filters to be applied
let equalityValue = filterValues[typeKey];
if (!_isArray(equalityValue)) {
equalityValue = split(String(equalityValue), { keepQuotes: true, sep: ',' });
}
internals.equalityFilter(qbWhere, column, equalityValue, 'orWhere');
}
});
}
else if (key === 'not') {
const hasNull = valueArray.length !== _pull(valueArray, null, options.nullString).length;
if (hasNull) {
qb.whereNotNull(column);
}
if (!_isEmpty(valueArray)) {
qb.whereNotIn(column, valueArray);
}
}
else if (key === 'lt') {
qb.where(column, '<', typeValue);
}
else if (key === 'gt') {
qb.where(column, '>', typeValue);
}
else if (key === 'lte') {
qb.where(column, '<=', typeValue);
}
else if (key === 'gte') {
qb.where(column, '>=', typeValue);
}
}
});
}
}
// If key is or and value is array
else if (_isArray(value) && key === 'or') {
qb.where((orStatements) => {
_forEach(value, (fragment) => {
orStatements.orWhere(internals.applyFilterFragment(fragment));
});
});
}
// If key is and and value is array
else if (_isArray(value) && key === 'and') {
qb.where((andStatements) => {
_forEach(value, (fragment) => {
andStatements.where(internals.applyFilterFragment(fragment));
});
});
}
// If the value is an equality filter
else {
// If the key is in the like filter, ignore the filter
if (!_hasIn(filterValues.like, key)) {
let [column, jsonColumn, dataType] = key.split(':');
// Remove all but the last table name, need to get number of dots
column = internals.formatRelation(internals.formatColumnNames([column])[0]);
if (!_isArray(value)) {
if (value === null){
value = [value];
}
else {
value = split(String(value), { keepQuotes: true, sep: ',' });
}
}
if (jsonColumn) {
const eqFilterObj = {
nullString: options.nullString,
qb,
knex: Bookshelf.knex,
filterType: 'equal',
values: value,
column,
jsonColumn,
dataType,
extraEqualityFilterValues: null
};
jsonFields.buildFilterWithType(eqFilterObj);
}
else {
internals.equalityFilter(qb, column, value);
}
}
}
});
};
};
/**
* Takes in value, query builder, and column name for creating an equality filter
* @param value {array}
* @param qb {object} The query builder
* @param column
*/
internals.equalityFilter = (qb, column, value, whereType = 'where') => {
const hasNull = value.length !== _pull(value, options.nullString, null).length;
if (hasNull) {
qb[whereType]((qbWhere) => {
qbWhere.whereNull(column);
if (!_isEmpty(value)) {
qbWhere.orWhereIn(column, value);
}
});
}
else {
qb[`${whereType}In`](column, value);
}
};
/**
* Takes in an attribute string like a.b.c.d and returns c.d, also if attribute
* looks like 'a', it will return tableName.a where tableName is the top layer table name
* @param attribute {string}
* @return {string}
*/
internals.formatRelation = (attribute) => {
let [column, jsonColumn, dataType] = attribute.split(':');
if (_includes(column, '.')){
const splitKey = column.split('.');
column = `${splitKey[splitKey.length - 2]}.${splitKey[splitKey.length - 1]}`;
}
// Add table name to before column name if no relation to avoid ambiguous columns
else {
column = `${internals.modelName}.${column}`;
}
return _filter([column, jsonColumn, dataType]).join(':');
};
/**
* Takes an array from attributes and returns the only the columns and removes the table names
* @param attributes {array}
* @return {array}
*/
internals.getColumnNames = (attributes) => {
return _map(attributes, (attribute) => {
return attribute.substr(attribute.lastIndexOf('.') + 1);
});
};
/**
* Build a query based on the `include` parameter.
* @param includeValues {array}
*/
internals.buildIncludes = (expandedIncludes, includesMap) => {
if (_isArray(expandedIncludes) && !_isEmpty(expandedIncludes)) {
const relations = [];
let includeFunctions = _last(expandedIncludes);
if (_isObject(includeFunctions)) {
expandedIncludes = _initial(expandedIncludes, includeFunctions);
}
_forEach(expandedIncludes, (relation) => {
if (_has(fields, relation) || _has(includeFunctions, relation)) {
let fieldNames = internals.formatColumnNames(_get(fields, relation));
let relationGetter = `relations.${relation.split('.').join('.relations.')}`;
let relationObject = _get(includesMap, relationGetter);
relations.push({
[relation]: (qb) => {
if (_has(includeFunctions, relation)) {
includeFunctions[relation](qb);
}
// Fetch existing columns from query builder and combine them with fieldNames
let selectFromQB = _filter(qb._statements, { grouping: 'columns' });
if (selectFromQB.length || fieldNames.length) {
// Combine the required columns with the desired columns
let requiredRelationColumns = _get(relationObject, 'requiredColumns');
let columnsToSelect = _uniq(_union(..._map(selectFromQB, 'value'), fieldNames, requiredRelationColumns));
// Clear existing columns
qb.clearSelect();
// Put the table name in front of each column in cases where there are joins in the subquery
columnsToSelect = _map(columnsToSelect, (fieldName) => {
// If there is already an existing table name in the query, do not replace it
if (_includes(fieldName, '.')) {
return fieldName;
}
return `${relationObject.tableName}.${fieldName}`;
});
// Select the columns
if (columnsToSelect.length) {
qb.columns(columnsToSelect);
}
}
}
});
}
else {
relations.push(relation);
}
});
// Assign the relations to the options passed to fetch/All
_assign(opts, { withRelated: relations });
}
};
/* *
* Used for generating columns to automatically include on a relationship
* @returns {Object} Object nested map of included relationships, where each relationship has all required columns for mapping relationships to their parents
*
* example:
* {
* model: personModel,
* tableName: 'person',
* requiredColumns: ['id'],
* relations: {
* pet: {
* model: petModel,
* tableName: 'pet',
* requiredColumns: ['id', 'pet_owner_id'], // id is required for the toy relationship
* relations: {
* toy: {
* model: toyModel,
* tableName: 'toy',
* requiredColumns: ['id', 'pet_id']
* }
* }
* }
* }
* }
*/
internals.setupIncludesMap = (expandedIncludes) => {
const includesMap = {
model: internals.model,
requiredColumns: [internals.model.idAttribute],
relations: {}
};
// expandedIncludes is ordered by the lowest amount of '.' characters
// Each item in expandedIncludes can be assumed to already be in the includes map by the time the forEach iteration reaches that item
_forEach(expandedIncludes, (includeValue) => {
// Ignore the value if it's not a string, there is another string to represent the includes that are functions
if (_isObject(includeValue)) {
return;
}
let splitIncludeValue = includeValue.split('.');
// Create a getter/setter string
// Getter string for 'a.b.c' will look like 'relations.a.relations.b.relations.c
let getterString = `relations.${splitIncludeValue.join('.relations.')}`;
// The parent model object, the one before this new relation
let parent = includesMap;
// If this relation is not on the first level, get the parent
if (splitIncludeValue.length > 1) {
parent = _get(includesMap, `relations.${_initial(splitIncludeValue).join('.relations.')}`);
}
// Get the model and relationship
let relationName = _last(splitIncludeValue);
let relatedData = parent.model[relationName]().relatedData;
let relationTableName = relatedData.target.prototype.tableName;
let relationModel = relatedData.target.forge();
// Initialize the relation by setting the object on the parent
let relationObject = {
// Always set the 'id' as required
model: relationModel,
tableName: relationTableName,
requiredColumns: [relationModel.idAttribute],
relations: {}
};
_set(includesMap, getterString, relationObject);
const foreignKey = relatedData.foreignKey ? relatedData.foreignKey : `${inflection.singularize(relatedData.parentTableName)}_${relatedData.parentIdAttribute}`;
if (relatedData.type === 'hasOne' || relatedData.type === 'hasMany'){
// Parent: is relatedData.parentIdAttribute, already set by default
// Relation: is foreignKey
relationObject.requiredColumns.push(foreignKey);
}
else if (relatedData.type === 'belongsTo'){
if (relatedData.throughTableName) {
const throughForeignKey = relatedData.throughForeignKey ? relatedData.throughForeignKey : `${inflection.singularize(relatedData.throughTableName)}_${relatedData.throughIdAttribute}`;
// Belongs To Through
// Parent: Use throughForeignKey or `${throughTableName}_${throughIdAttribute}`
parent.requiredColumns.push(throughForeignKey);
// Relation: is targetIdAttribute, set by default
}
else {
// Belongs To
// Parent: foreignKey
parent.requiredColumns.push(foreignKey);
// Relation: relatedData.targetIdAttribute, set by default
}
}
// Belongs To Many, do not need to set
// Parent: relatedData.parentIdAttribute, set by default
// Relation: relatedData.targetIdAttribute, set by default
});
return includesMap;
};
/**
* Expand each include so that each individual relation has an item to hook into fields
* turns ['a.b.c.d', 'e'] into ['a', 'e', 'a.b', 'a.b.c', 'a.b.c.d']
* @param include {array}
*/
internals.buildExpandedIncludes = (includes) => {
let expandedIncludes = [];
let includeFunctions = {};
_forEach(includes, (includeItem) => {
if (_isString(includeItem)) {
internals.expandedIncludesHelper(includeItem, expandedIncludes);
}
else if (_isObject(includeItem)) {
// Handle when query buidlers are passed into include
_forOwn(includeItem, (includeFunction, includeString) => {
internals.expandedIncludesHelper(includeString, expandedIncludes);
includeFunctions[includeString] = includeFunction;
});
}
});
// Sort ascending by the amount of `.` separator
expandedIncludes = _sortBy(_uniq(expandedIncludes), [
(item) => {
return _replace(item, /[^\.]/g, '').length;
}
]);
// Push the includedFunctions at the end, if there are any
if (!_isEmpty(includeFunctions)) {
expandedIncludes.push(includeFunctions);
}
return expandedIncludes;
};
internals.expandedIncludesHelper = (includeString, expandedIncludes) => {
let splitIncludes = includeString.split('.');
let splitLength = splitIncludes.length;
for (let i = 0; i < splitLength; ++i) {
expandedIncludes.push(splitIncludes.join('.'));
splitIncludes.splice(-1);
}
};
/**
* Build a query based on the `sort` parameter.
* @param sortValues {array}
*/
internals.buildSort = (sortValues = []) => {
if (_isArray(sortValues) && !_isEmpty(sortValues)) {
let sortDesc = [];
for (let i = 0; i < sortValues.length; ++i) {
// Determine if the sort should be descending
if (typeof sortValues[i] === 'string' && sortValues[i][0] === '-') {
sortValues[i] = sortValues[i].substring(1);
sortDesc.push(sortValues[i]);
}
}
// Format column names according to Model settings
sortDesc = internals.formatColumnNames(sortDesc);
sortValues = internals.formatColumnNames(sortValues);
_forEach(sortValues, (sortBy) => {
const sortType = sortDesc.indexOf(sortBy) === -1 ? 'asc' : 'desc';
if (sortBy) {
let [column, jsonColumn, dataType] = sortBy.split(':');
column = internals.formatRelation(column);
if (jsonColumn) {
internals.model.query((qb) => {
jsonFields.buildSort(qb, sortType, column, jsonColumn, dataType);
});
}
else {
internals.model.orderBy(column, sortType);
}
}
});
}
};
/**
* Build a query based on the `group` parameter.
* @param groupValues {array}
*/
internals.buildGroup = (groupValues = []) => {
if (!_isArray(groupValues)) {
groupValues = [groupValues];
}
if (!_isEmpty(groupValues)) {
groupValues = internals.formatColumnNames(groupValues);
internals.model.query((qb) => {
_forEach(groupValues, (groupBy) => {
qb.groupBy(groupBy);
});
});
}
};
/**
* Turn a column into its {@link Model#format} format
* leaving specified table names untouched.
* A helper function to formatColumnNames that does the work of formatting strictly on an array
* @param columnNames {array}
* @returns formattedColumnNames {array}
*/
internals.formatColumnCollection = (columnNames = []) => {
return _map(columnNames, (columnName) => {
const [column, jsonColumn, dataType] = columnName.split(':');
const columnComponents = column.split('.');
const lastIndex = columnComponents.length - 1;
const tableAttribute = columnComponents[lastIndex];
// this only gets hit for current model, not relationships
const isAggregateFunction = aggregateFunctionRegex.test(tableAttribute);
if (isAggregateFunction) {
const [source, aggregateFunction, aggregateFunctionTableAttribute] = tableAttribute.match(aggregateFunctionRegex);
const formattedTableAttribute = _keys(this.format({ [aggregateFunctionTableAttribute]: undefined }))[0];
columnComponents[lastIndex] = `${aggregateFunction}(${formattedTableAttribute})`;
}
else {
const formattedTableAttribute = _keys(this.format({ [tableAttribute]: undefined }))[0];
columnComponents[lastIndex] = formattedTableAttribute;
}
return _filter([columnComponents.join('.'), jsonColumn, dataType]).join(':');
});
};
/**
* Processes incoming parameters that represent columns names and
* formats them using the internal {@link Model#format} function.
* @param columnNames {array|object}
* @returns formattedColumnNames {array|object}
*/
internals.formatColumnNames = (columnNames = []) => {
if (_isArray(columnNames)) {
return internals.formatColumnCollection(columnNames);
}
// process an object for which each value is a collection of columns to be formatted
_forOwn(columnNames, (columnCollection, columnNameKey) => {
columnNames[columnNameKey] = internals.formatColumnCollection(columnCollection);
});
return columnNames;
};
/**
* Determines if the specified relation is a `belongsTo` type.
* @param relationName {string}
* @param model {object}
* @return {boolean}
*/
internals.isBelongsToRelation = (relationName, model) => {
if (!model.related(relationName)){
return false;
}
const relationType = model.related(relationName).relatedData.type.toLowerCase();
if (relationType !== undefined &&
relationType === 'belongsto') {
return true;
}
return false;
};
/**
* Determines if the specified relation is a `many` type.
* @param relationName {string}
* @param model {object}
* @return {boolean}
*/
internals.isManyRelation = (relationName, model) => {
if (!model.related(relationName)){
return false;
}
const relationType = model.related(relationName).relatedData.type.toLowerCase();
if (relationType !== undefined &&
relationType.indexOf('many') > 0) {
return true;
}
return false;
};
/**
* Determines if the specified relation is a `hasone` type.
* @param relationName {string}
* @param model {object}
* @return {boolean}
*/
internals.ishasOneRelation = (relationName, model) => {
if (!model.related(relationName)){
return false;
}
const relationType = model.related(relationName).relatedData.type.toLowerCase();
if (relationType !== undefined &&
relationType === 'hasone'){
return true;
}
return false;
};
////////////////////////////////
/// Process parameters
////////////////////////////////
// Apply relational dependencies for filters, grouping and sorting
internals.buildDependencies(filter, group, sort);
// Apply filters
internals.buildFilters(filter);
// Apply grouping
internals.buildGroup(group);
// Apply sorting
internals.buildSort(sort);
// Apply relations
const expandedIncludes = internals.buildExpandedIncludes(include);
const includesMap = internals.setupIncludesMap(expandedIncludes);
internals.buildIncludes(expandedIncludes, includesMap);
// Apply sparse fieldsets
internals.buildFields(fields, expandedIncludes, includesMap);
// Apply extra query which was passed in as a parameter
if (_isFunction(additionalQuery)){
internals.model.query(additionalQuery);
}
// Assign default paging options if they were passed to the plugin
// and no pagination parameters were passed directly to the method.
if (isCollection &&
_isEmpty(page) &&
_has(options, 'pagination')) {
_assign(page, options.pagination);
}
// internals.model.query(qb => {
// console.log(qb.toQuery());
// });
// Apply paging
if (isCollection &&
_isObject(page) &&
!_isEmpty(page)) {
const pageOptions = _assign(opts, page);
return internals.model.fetchPage(pageOptions);
}
// Determine whether to return a Collection or Model
// Call `fetchAll` to return Collection
if (isCollection) {
return internals.model.fetchAll(opts);
}
// Otherwise, call `fetch` to return Model
return internals.model.fetch(opts);
};
// Add `fetchJsonApi()` method to Bookshelf Model/Collection prototypes
Bookshelf.Model.prototype.fetchJsonApi = fetchJsonApi;
Bookshelf.Model.fetchJsonApi = function (...args) {
return this.forge().fetchJsonApi(...args);
};
Bookshelf.Collection.prototype.fetchJsonApi = function (...args) {
return fetchJsonApi.apply(this.model.forge(), ...args);
};
};