Jozef Cipa a48f9f3327
Fix lower() error when running on non-string column (#8419)
Signed-off-by: jozefcipa <jozef.cipa@strv.com>
2021-01-21 11:36:17 +01:00

359 lines
11 KiB
JavaScript

'use strict';
const _ = require('lodash');
const { each, prop, isEmpty } = require('lodash/fp');
const { singular } = require('pluralize');
const { toQueries, runPopulateQueries } = require('./utils/populate-queries');
const BOOLEAN_OPERATORS = ['or'];
/**
* Build filters on a bookshelf query
* @param {Object} options - Options
* @param {Object} options.model - Bookshelf model
* @param {Object} options.filters - Filters params (start, limit, sort, where)
*/
const buildQuery = ({ model, filters }) => qb => {
const joinsTree = buildJoinsAndFilter(qb, model, filters);
if (_.has(filters, 'where') && Array.isArray(filters.where) && filters.where.length > 0) {
qb.distinct();
}
if (_.has(filters, 'sort')) {
const clauses = filters.sort.map(buildSortClauseFromTree(joinsTree)).filter(c => !isEmpty(c));
const orderBy = clauses.map(({ order, alias }) => ({ order, column: alias }));
const orderColumns = clauses.map(({ alias, column }) => ({ [alias]: column }));
const columns = [`${joinsTree.alias}.*`, ...orderColumns];
qb.distinct()
.column(columns)
.orderBy(orderBy);
}
if (_.has(filters, 'start')) {
qb.offset(filters.start);
}
if (_.has(filters, 'limit') && filters.limit >= 0) {
qb.limit(filters.limit);
}
if (_.has(filters, 'publicationState')) {
runPopulateQueries(
toQueries({ publicationState: { query: filters.publicationState, model } }),
qb
);
}
};
/**
* Build a bookshelf sort clause (simple or deep) based on a joins tree
* @param tree - The joins tree that contains the aliased associations
*/
const buildSortClauseFromTree = tree => ({ field, order }) => {
if (!field.includes('.')) {
return {
column: `${tree.alias}.${field}`,
order,
alias: `_strapi_tmp_${tree.alias}_${field}`,
};
}
const [relation, attribute] = field.split('.');
for (const { alias, assoc } of Object.values(tree.joins)) {
if (relation === assoc.alias) {
return {
column: `${alias}.${attribute}`,
order,
alias: `_strapi_tmp_${alias}_${attribute}`,
};
}
}
return {};
};
/**
* Add joins and where filters
* @param {Object} qb - knex query builder
* @param {Object} model - Bookshelf model
* @param {Object} filters - The query filters
*/
const buildJoinsAndFilter = (qb, model, filters) => {
const { where: whereClauses = [], sort: sortClauses = [] } = filters;
/**
* Returns an alias for a name (simple incremental alias name)
* @param {string} name - name to alias
*/
const aliasMap = {};
const generateAlias = name => {
if (!aliasMap[name]) {
aliasMap[name] = 1;
}
const alias = `${name}_${aliasMap[name]}`;
aliasMap[name] += 1;
return alias;
};
/**
* Build a query joins and where clauses from a query tree
* @param {Object} qb - Knex query builder
* @param {Object} tree - Query tree
*/
const buildJoinsFromTree = (qb, queryTree) => {
// build joins
Object.keys(queryTree.joins).forEach(key => {
const subQueryTree = queryTree.joins[key];
buildJoin(qb, subQueryTree.assoc, queryTree, subQueryTree);
buildJoinsFromTree(qb, subQueryTree);
});
};
/**
* Add table joins
* @param {Object} qb - Knex query builder
* @param {Object} assoc - Models association info
* @param {Object} originInfo - origin from which you are making a join
* @param {Object} destinationInfo - destination with which we are making a join
*/
const buildJoin = (qb, assoc, originInfo, destinationInfo) => {
if (['manyToMany', 'manyWay'].includes(assoc.nature)) {
const joinTableAlias = generateAlias(assoc.tableCollectionName);
let originColumnNameInJoinTable;
if (assoc.nature === 'manyToMany') {
originColumnNameInJoinTable = `${joinTableAlias}.${singular(
destinationInfo.model.attributes[assoc.via].attribute
)}_${destinationInfo.model.attributes[assoc.via].column}`;
} else if (assoc.nature === 'manyWay') {
originColumnNameInJoinTable = `${joinTableAlias}.${singular(
originInfo.model.collectionName
)}_${originInfo.model.primaryKey}`;
}
qb.leftJoin(
`${originInfo.model.databaseName}.${assoc.tableCollectionName} AS ${joinTableAlias}`,
originColumnNameInJoinTable,
`${originInfo.alias}.${originInfo.model.primaryKey}`
);
qb.leftJoin(
`${destinationInfo.model.databaseName}.${destinationInfo.model.collectionName} AS ${destinationInfo.alias}`,
`${joinTableAlias}.${singular(originInfo.model.attributes[assoc.alias].attribute)}_${
originInfo.model.attributes[assoc.alias].column
}`,
`${destinationInfo.alias}.${destinationInfo.model.primaryKey}`
);
} else {
const externalKey =
assoc.type === 'collection'
? `${destinationInfo.alias}.${assoc.via || destinationInfo.model.primaryKey}`
: `${destinationInfo.alias}.${destinationInfo.model.primaryKey}`;
const internalKey =
assoc.type === 'collection'
? `${originInfo.alias}.${originInfo.model.primaryKey}`
: `${originInfo.alias}.${assoc.alias}`;
qb.leftJoin(
`${destinationInfo.model.databaseName}.${destinationInfo.model.collectionName} AS ${destinationInfo.alias}`,
externalKey,
internalKey
);
}
};
/**
* Create a query tree node from a key an assoc and a model
* @param {Object} model - Strapi model
* @param {Object} assoc - Strapi association
*/
const createTreeNode = (model, assoc = null) => {
return {
alias: generateAlias(model.collectionName),
assoc,
model,
joins: {},
};
};
// tree made to create the joins structure
const tree = {
alias: model.collectionName,
assoc: null,
model,
joins: {},
};
/**
* Returns the SQL path for a query field.
* Adds table to the joins tree
* @param {string} field a field used to filter
* @param {Object} tree joins tree
*/
const generateNestedJoins = (field, tree) => {
let [key, ...parts] = field.split('.');
const assoc = findAssoc(tree.model, key);
// if the key is an attribute add as where clause
if (!assoc) {
return `${tree.alias}.${key}`;
}
const assocModel = strapi.db.getModelByAssoc(assoc);
// if the last part of the path is an association
// add the primary key of the model to the parts
if (parts.length === 0) {
parts = [assocModel.primaryKey];
}
// init sub query tree
if (!tree.joins[key]) {
tree.joins[key] = createTreeNode(assocModel, assoc);
}
return generateNestedJoins(parts.join('.'), tree.joins[key]);
};
const generateNestedJoinsFromFields = each(field => generateNestedJoins(field, tree));
/**
* Format every where clauses whith the right table name aliases.
* Add table joins to the joins list
* @param {Array<{field, operator, value}>} whereClauses a list of where clauses
* @param {Object} context
* @param {Object} context.model model on which the query is run
*/
const buildWhereClauses = (whereClauses, { model }) => {
return whereClauses.map(whereClause => {
const { field, operator, value } = whereClause;
if (BOOLEAN_OPERATORS.includes(operator)) {
return { field, operator, value: value.map(v => buildWhereClauses(v, { model })) };
}
const path = generateNestedJoins(field, tree);
return {
field: path,
operator,
value,
};
});
};
/**
* Add queries on tree's joins (deep search, deep sort) based on given filters
* @param tree - joins tree
*/
const addFiltersQueriesToJoinTree = tree => {
_.each(tree.joins, value => {
const { alias, model } = value;
// PublicationState
runPopulateQueries(
toQueries({
publicationState: { query: filters.publicationState, model, alias },
}),
qb
);
addFiltersQueriesToJoinTree(value);
});
};
const aliasedWhereClauses = buildWhereClauses(whereClauses, { model });
aliasedWhereClauses.forEach(w => buildWhereClause({ qb, ...w }));
// Force needed joins for deep sort clauses
generateNestedJoinsFromFields(sortClauses.map(prop('field')));
buildJoinsFromTree(qb, tree);
addFiltersQueriesToJoinTree(tree);
return tree;
};
/**
* Builds a sql where clause
* @param {Object} options - Options
* @param {Object} options.qb - Bookshelf (knex) query builder
* @param {Object} options.model - Bookshelf model
* @param {Object} options.field - Filtered field
* @param {Object} options.operator - Filter operator (=,in,not eq etc..)
* @param {Object} options.value - Filter value
*/
const buildWhereClause = ({ qb, field, operator, value }) => {
if (Array.isArray(value) && !['or', 'in', 'nin'].includes(operator)) {
return qb.where(subQb => {
for (let val of value) {
subQb.orWhere(q => buildWhereClause({ qb: q, field, operator, value: val }));
}
});
}
switch (operator) {
case 'or':
return qb.where(orQb => {
value.forEach(orClause => {
orQb.orWhere(subQb => {
if (Array.isArray(orClause)) {
orClause.forEach(orClause =>
subQb.where(andQb => buildWhereClause({ qb: andQb, ...orClause }))
);
} else {
buildWhereClause({ qb: subQb, ...orClause });
}
});
});
});
case 'eq':
return qb.where(field, value);
case 'ne':
return qb.where(field, '!=', value);
case 'lt':
return qb.where(field, '<', value);
case 'lte':
return qb.where(field, '<=', value);
case 'gt':
return qb.where(field, '>', value);
case 'gte':
return qb.where(field, '>=', value);
case 'in':
return qb.whereIn(field, Array.isArray(value) ? value : [value]);
case 'nin':
return qb.whereNotIn(field, Array.isArray(value) ? value : [value]);
case 'contains':
return qb.whereRaw(`${fieldLowerFn(qb)} LIKE LOWER(?)`, [field, `%${value}%`]);
case 'ncontains':
return qb.whereRaw(`${fieldLowerFn(qb)} NOT LIKE LOWER(?)`, [field, `%${value}%`]);
case 'containss':
return qb.where(field, 'like', `%${value}%`);
case 'ncontainss':
return qb.whereNot(field, 'like', `%${value}%`);
case 'null': {
return value ? qb.whereNull(field) : qb.whereNotNull(field);
}
default:
throw new Error(`Unhandled whereClause : ${field} ${operator} ${value}`);
}
};
const fieldLowerFn = qb => {
// Postgres requires string to be passed
if (qb.client.config.client === 'pg') {
return 'LOWER(CAST(?? AS VARCHAR))';
}
return 'LOWER(??)';
};
const findAssoc = (model, key) => model.associations.find(assoc => assoc.alias === key);
module.exports = buildQuery;