Files
threshold-ui/addons/api/addon/utils/sqlite-query.js
T
Lisbet Alvarez d0924f7e6d fix: 🐛 max depth error for active sessions filter in targets (#3004)
* 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
2025-09-17 15:32:39 -07:00

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})` : '');