* fix: 🐛 max depth error for active sessions filter in targets * refactor: 💡 revert route and convert operators in parser * refactor: 💡 add expr limit * refactor: 💡 add parameters to IN/NOT IN conditions * test: 💍 fix tests * docs: ✏️ add comment
234 lines
7.2 KiB
JavaScript
234 lines
7.2 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import { modelMapping } from 'api/services/sqlite';
|
|
import { searchTables } from 'api/services/sqlite';
|
|
import { typeOf } from '@ember/utils';
|
|
import { underscore } from '@ember/string';
|
|
|
|
/**
|
|
* Takes a POJO representing a filter query and builds a SQL query.
|
|
*
|
|
* @example
|
|
* const filter = generateSQLExpressions(type, {
|
|
* search: generated',
|
|
* filters: {
|
|
* scope_id: {
|
|
* logicalOperator: 'and',
|
|
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
|
* },
|
|
* },
|
|
* });
|
|
*/
|
|
export function generateSQLExpressions(
|
|
resource,
|
|
query = {},
|
|
{ page, pageSize, select } = {},
|
|
) {
|
|
let { search, filters, sort = {} } = query;
|
|
const conditions = [];
|
|
const parameters = [];
|
|
const tableName = underscore(resource);
|
|
|
|
addFilterConditions({ filters, parameters, conditions });
|
|
addSearchConditions({ search, resource, tableName, parameters, conditions });
|
|
|
|
const orderByClause = constructOrderByClause(resource, sort);
|
|
|
|
const whereClause = conditions.length ? `WHERE ${and(conditions)}` : '';
|
|
|
|
const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : '';
|
|
if (paginationClause) {
|
|
parameters.push(pageSize, (page - 1) * pageSize);
|
|
}
|
|
|
|
const selectClause = `SELECT ${select ? select.join(', ') : '*'} FROM "${tableName}"`;
|
|
|
|
return {
|
|
// Replace any empty newlines or leading whitespace on each line to be consistent with formatting
|
|
// This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution.
|
|
sql: `
|
|
${selectClause}
|
|
${whereClause}
|
|
${orderByClause}
|
|
${paginationClause}`
|
|
.replace(/^\s+/gm, '')
|
|
.trim(),
|
|
parameters,
|
|
};
|
|
}
|
|
|
|
function addFilterConditions({ filters, parameters, conditions }) {
|
|
if (!filters) {
|
|
return;
|
|
}
|
|
|
|
for (const [key, filterArrayOrObject] of Object.entries(filters)) {
|
|
// Filter can be an array or an object with a `values` property
|
|
const filterValueArray = Array.isArray(filterArrayOrObject)
|
|
? filterArrayOrObject
|
|
: filterArrayOrObject.values;
|
|
|
|
if (!filterValueArray || !filterValueArray.length) {
|
|
continue;
|
|
}
|
|
|
|
// When attribute conditions are a series of equals/notEquals
|
|
// replace with in/notIn to avoid hitting the sqlite maximum
|
|
// expression tree depth.
|
|
const firstOperator = Object.keys(filterValueArray[0])[0];
|
|
const allOperatorsEqual = filterValueArray
|
|
.flatMap((item) => Object.keys(item))
|
|
.every((op) => op === firstOperator);
|
|
if (
|
|
filterValueArray.length > 1 &&
|
|
(firstOperator === 'equals' || firstOperator === 'notEquals') &&
|
|
allOperatorsEqual
|
|
) {
|
|
const operation = firstOperator === 'equals' ? 'in' : 'notIn';
|
|
const values = filterValueArray
|
|
.filter((f) => f)
|
|
.map((filterObjValue) => {
|
|
let value = Object.values(filterObjValue)[0];
|
|
if (typeOf(value) === 'date') {
|
|
value = value.toISOString();
|
|
}
|
|
parameters.push(value);
|
|
return value;
|
|
});
|
|
const filterCondition = `${key}${OPERATORS[operation](values)}`;
|
|
conditions.push(parenthetical(filterCondition));
|
|
continue;
|
|
}
|
|
|
|
const filterConditions = filterValueArray
|
|
.filter((f) => f)
|
|
.map((filterObjValue) => {
|
|
let [operation, value] = Object.entries(filterObjValue)[0];
|
|
|
|
// SQLite needs to be working with ISO strings
|
|
if (typeOf(value) === 'date') {
|
|
value = value.toISOString();
|
|
}
|
|
|
|
// Handle LIKE operator separately
|
|
if (operation === 'contains') {
|
|
parameters.push(`%${value}%`);
|
|
} else {
|
|
parameters.push(value);
|
|
}
|
|
|
|
return `${key}${OPERATORS[operation]}`;
|
|
});
|
|
|
|
const { logicalOperator } = filterArrayOrObject;
|
|
// Default to `or` if we don't have a logical operator
|
|
conditions.push(
|
|
parenthetical(
|
|
logicalOperator === 'and'
|
|
? and(filterConditions)
|
|
: or(filterConditions),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function addSearchConditions({
|
|
search,
|
|
resource,
|
|
tableName,
|
|
parameters,
|
|
conditions,
|
|
}) {
|
|
if (!search) {
|
|
return;
|
|
}
|
|
|
|
if (searchTables.has(resource)) {
|
|
// Use the special prefix indicator "*" for full-text search
|
|
parameters.push(`"${search}"*`);
|
|
// Use a subquery to match against the FTS table with rowids as SQLite is
|
|
// much more efficient with FTS queries when using rowids or MATCH (or both).
|
|
// We could have also used a join here but a subquery is simpler.
|
|
conditions.push(
|
|
`rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const fields = Object.keys(modelMapping[resource]);
|
|
const searchConditions = parenthetical(
|
|
or(
|
|
fields.map((field) => {
|
|
parameters.push(`%${search}%`);
|
|
return `${field}${OPERATORS['contains']}`;
|
|
}),
|
|
),
|
|
);
|
|
conditions.push(searchConditions);
|
|
}
|
|
|
|
function constructOrderByClause(resource, sort) {
|
|
const defaultOrderByClause = 'ORDER BY created_time DESC';
|
|
|
|
const { attributes, customSort, direction, isCoalesced } = sort;
|
|
const sortDirection = direction === 'desc' ? 'DESC' : 'ASC';
|
|
|
|
// We have to check if the attributes are valid for the resource
|
|
// as we can't use parameterized queries for ORDER BY
|
|
const validAttributes = Object.keys(modelMapping[resource] ?? {});
|
|
if (attributes?.some((attr) => !validAttributes.includes(attr))) {
|
|
return modelMapping[resource]?.created_time ? defaultOrderByClause : '';
|
|
}
|
|
|
|
if (customSort?.attributeMap) {
|
|
const whenClauses = Object.keys(customSort.attributeMap).reduce(
|
|
(acc, key) => {
|
|
return acc + `WHEN '${key}' THEN '${customSort.attributeMap[key]}' `;
|
|
},
|
|
'',
|
|
);
|
|
return `ORDER BY CASE ${attributes.join(', ')} ${whenClauses}END ${sortDirection}`;
|
|
} else if (attributes?.length > 0) {
|
|
const commaSeparatedVals = attributes.join(', ');
|
|
|
|
// In places where `collate nocase` is used, it is to ensure case is ignored on the initial sort.
|
|
// Then, a sort on the same condition is performed to ensure upper-case strings are given preference in a tie.
|
|
if (isCoalesced) {
|
|
return `ORDER BY COALESCE(${attributes.join(', ')}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
|
|
}
|
|
|
|
const attributesWithNoCollate = attributes
|
|
.map((attr) => `${attr} COLLATE NOCASE ${sortDirection}`)
|
|
.join(', ');
|
|
const attributesWithDirection = attributes
|
|
.map((attr) => `${attr} ${sortDirection}`)
|
|
.join(', ');
|
|
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
|
|
} else if (modelMapping[resource]?.created_time) {
|
|
return defaultOrderByClause;
|
|
} else {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Comparison Operators
|
|
const OPERATORS = {
|
|
equals: ' = ?',
|
|
notEquals: ' != ?',
|
|
gt: ' > ?',
|
|
gte: ' >= ?',
|
|
lt: ' < ?',
|
|
lte: ' <= ?',
|
|
contains: ' LIKE ?',
|
|
in: (values) => ` IN (${values.map(() => '?').join(', ')})`,
|
|
notIn: (values) => ` NOT IN (${values.map(() => '?').join(', ')})`,
|
|
};
|
|
|
|
// Logical Operators
|
|
const and = (clauses) => clauses.join(' AND ');
|
|
const or = (clauses) => clauses.join(' OR ');
|
|
const parenthetical = (expression) => (expression ? `(${expression})` : '');
|