2025-08-06 11:09:14 -04:00
|
|
|
/**
|
2026-01-28 17:09:06 -08:00
|
|
|
* Copyright IBM Corp. 2021, 2026
|
2025-08-06 11:09:14 -04:00
|
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { modelMapping } from 'api/services/sqlite';
|
|
|
|
|
import { typeOf } from '@ember/utils';
|
2025-08-22 11:38:12 -04:00
|
|
|
import { underscore } from '@ember/string';
|
2025-10-31 16:58:05 -04:00
|
|
|
import { assert } from '@ember/debug';
|
2025-08-06 11:09:14 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 = [];
|
2025-08-22 11:38:12 -04:00
|
|
|
const tableName = underscore(resource);
|
2025-11-03 17:28:11 -05:00
|
|
|
const joins = [];
|
2025-08-06 11:09:14 -04:00
|
|
|
|
2025-11-03 17:28:11 -05:00
|
|
|
addFilterConditions({ filters, parameters, conditions, joins, tableName });
|
2025-08-22 11:38:12 -04:00
|
|
|
addSearchConditions({ search, resource, tableName, parameters, conditions });
|
2025-08-06 11:09:14 -04:00
|
|
|
|
2025-10-31 16:58:05 -04:00
|
|
|
const selectClause = constructSelectClause(select, tableName);
|
2025-11-03 17:28:11 -05:00
|
|
|
const joinClause = constructJoinClause(joins);
|
|
|
|
|
const orderByClause = constructOrderByClause(resource, tableName, sort);
|
2025-08-06 11:09:14 -04:00
|
|
|
const whereClause = conditions.length ? `WHERE ${and(conditions)}` : '';
|
|
|
|
|
|
|
|
|
|
const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : '';
|
|
|
|
|
if (paginationClause) {
|
|
|
|
|
parameters.push(pageSize, (page - 1) * pageSize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}
|
2025-11-03 17:28:11 -05:00
|
|
|
${joinClause}
|
2025-08-06 11:09:14 -04:00
|
|
|
${whereClause}
|
|
|
|
|
${orderByClause}
|
|
|
|
|
${paginationClause}`
|
|
|
|
|
.replace(/^\s+/gm, '')
|
|
|
|
|
.trim(),
|
|
|
|
|
parameters,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 17:28:11 -05:00
|
|
|
function addFilterConditions({
|
|
|
|
|
filters,
|
|
|
|
|
parameters,
|
|
|
|
|
conditions,
|
|
|
|
|
joins,
|
|
|
|
|
tableName,
|
|
|
|
|
}) {
|
2025-08-06 11:09:14 -04:00
|
|
|
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;
|
|
|
|
|
|
2025-11-03 17:28:11 -05:00
|
|
|
if (!filterValueArray || !filterValueArray.length || key === 'joins') {
|
2025-08-06 11:09:14 -04:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 15:32:39 -07:00
|
|
|
// 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';
|
2025-11-03 17:28:11 -05:00
|
|
|
const nullOperator =
|
|
|
|
|
firstOperator === 'equals' ? 'IS NULL' : 'IS NOT NULL';
|
2025-09-17 15:32:39 -07:00
|
|
|
const values = filterValueArray
|
2025-11-03 17:28:11 -05:00
|
|
|
.filter(
|
|
|
|
|
(filterObjValue) =>
|
|
|
|
|
filterObjValue && Object.values(filterObjValue)[0] !== null,
|
|
|
|
|
)
|
2025-09-17 15:32:39 -07:00
|
|
|
.map((filterObjValue) => {
|
|
|
|
|
let value = Object.values(filterObjValue)[0];
|
|
|
|
|
if (typeOf(value) === 'date') {
|
|
|
|
|
value = value.toISOString();
|
|
|
|
|
}
|
|
|
|
|
parameters.push(value);
|
|
|
|
|
return value;
|
|
|
|
|
});
|
2025-11-03 17:28:11 -05:00
|
|
|
|
|
|
|
|
// Add a check for null values as an IN clause will mistakenly use `=` for null values.
|
|
|
|
|
// We'll add an extra OR clause to handle nulls separately.
|
|
|
|
|
const isNullValue = filterValueArray.some(
|
|
|
|
|
(filterObjValue) => Object.values(filterObjValue)[0] === null,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const filterCondition = `"${tableName}".${key}${OPERATORS[operation](values)}${isNullValue ? ` OR "${tableName}".${key} ${nullOperator}` : ''}`;
|
2025-09-17 15:32:39 -07:00
|
|
|
conditions.push(parenthetical(filterCondition));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-06 11:09:14 -04:00
|
|
|
const filterConditions = filterValueArray
|
|
|
|
|
.filter((f) => f)
|
|
|
|
|
.map((filterObjValue) => {
|
|
|
|
|
let [operation, value] = Object.entries(filterObjValue)[0];
|
|
|
|
|
|
2025-11-03 17:28:11 -05:00
|
|
|
// Handle null values: convert equals/notEquals to IS NULL or IS NOT NULL
|
|
|
|
|
if (value === null) {
|
|
|
|
|
if (operation === 'equals') {
|
|
|
|
|
return `"${tableName}".${key} IS NULL`;
|
|
|
|
|
}
|
|
|
|
|
if (operation === 'notEquals') {
|
|
|
|
|
return `"${tableName}".${key} IS NOT NULL`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-06 11:09:14 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 17:28:11 -05:00
|
|
|
return `"${tableName}".${key}${OPERATORS[operation]}`;
|
2025-08-06 11:09:14 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { logicalOperator } = filterArrayOrObject;
|
|
|
|
|
// Default to `or` if we don't have a logical operator
|
|
|
|
|
conditions.push(
|
|
|
|
|
parenthetical(
|
|
|
|
|
logicalOperator === 'and'
|
|
|
|
|
? and(filterConditions)
|
|
|
|
|
: or(filterConditions),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-11-03 17:28:11 -05:00
|
|
|
|
|
|
|
|
if (filters.joins?.length > 0) {
|
|
|
|
|
const tableNameIndex = {};
|
|
|
|
|
|
|
|
|
|
filters.joins.forEach((join) => {
|
|
|
|
|
const {
|
|
|
|
|
resource,
|
|
|
|
|
query,
|
|
|
|
|
joinFrom = 'id',
|
|
|
|
|
joinOn,
|
|
|
|
|
joinType = 'INNER',
|
|
|
|
|
} = join;
|
|
|
|
|
const joinTableName = underscore(resource);
|
|
|
|
|
tableNameIndex[resource] ??= 1;
|
|
|
|
|
// Alias in the possible scenario we join the same table more than once
|
|
|
|
|
const alias = `${joinTableName}${tableNameIndex[resource]}`;
|
|
|
|
|
|
|
|
|
|
joins.push({
|
|
|
|
|
type: joinType,
|
|
|
|
|
table: joinTableName,
|
|
|
|
|
alias,
|
|
|
|
|
condition: `"${tableName}".${joinFrom} = ${alias}.${joinOn}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add the conditions from the join query
|
|
|
|
|
if (query?.filters) {
|
|
|
|
|
addFilterConditions({
|
|
|
|
|
filters: query.filters,
|
|
|
|
|
parameters,
|
|
|
|
|
conditions,
|
|
|
|
|
tableName: alias,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tableNameIndex[resource]++;
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-08-06 11:09:14 -04:00
|
|
|
}
|
|
|
|
|
|
2025-08-22 11:38:12 -04:00
|
|
|
function addSearchConditions({
|
|
|
|
|
search,
|
|
|
|
|
resource,
|
|
|
|
|
tableName,
|
|
|
|
|
parameters,
|
|
|
|
|
conditions,
|
|
|
|
|
}) {
|
2025-10-31 16:58:05 -04:00
|
|
|
if (!search || !modelMapping[resource]) {
|
2025-08-06 11:09:14 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2025-11-05 17:57:45 -05:00
|
|
|
const searchSelect = search?.select ?? 'rowid';
|
|
|
|
|
const searchSql = `SELECT ${searchSelect} FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?`;
|
|
|
|
|
const getParameter = (fields, text) =>
|
|
|
|
|
fields?.length > 0
|
|
|
|
|
? or(fields.map((field) => `${field}:"${text}"*`))
|
|
|
|
|
: `"${text}"*`;
|
2025-08-06 11:09:14 -04:00
|
|
|
|
2025-10-31 16:58:05 -04:00
|
|
|
// Use the special prefix indicator "*" for full-text search
|
|
|
|
|
if (typeOf(search) === 'object') {
|
2025-11-05 17:57:45 -05:00
|
|
|
if (!search.text) {
|
2025-10-31 16:58:05 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-05 17:57:45 -05:00
|
|
|
const parameter = getParameter(search.fields, search.text);
|
|
|
|
|
parameters.push(parameter);
|
2025-10-31 16:58:05 -04:00
|
|
|
} else {
|
|
|
|
|
parameters.push(`"${search}"*`);
|
2025-08-06 11:09:14 -04:00
|
|
|
}
|
|
|
|
|
|
2025-11-05 17:57:45 -05:00
|
|
|
// If there are extra related searches on other tables, add them too
|
|
|
|
|
if (search?.relatedSearches?.length > 0) {
|
|
|
|
|
const relatedSearchQueries = [];
|
|
|
|
|
|
|
|
|
|
search.relatedSearches.forEach((relatedSearch) => {
|
|
|
|
|
const { resource: relatedResource, fields, join } = relatedSearch;
|
|
|
|
|
const relatedTableName = underscore(relatedResource);
|
|
|
|
|
|
|
|
|
|
const parameter = getParameter(fields, search.text);
|
|
|
|
|
parameters.push(parameter);
|
|
|
|
|
|
|
|
|
|
// Build the related FTS query with optional join
|
|
|
|
|
let relatedQuery = [];
|
|
|
|
|
relatedQuery.push(
|
|
|
|
|
`SELECT "${tableName}".${searchSelect} FROM ${relatedTableName}_fts`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (join) {
|
|
|
|
|
const { joinFrom = 'id', joinOn } = join;
|
|
|
|
|
relatedQuery.push(
|
|
|
|
|
`JOIN "${tableName}" ON "${tableName}".${joinFrom} = ${relatedResource}_fts.${joinOn}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
relatedQuery.push(`WHERE ${relatedTableName}_fts MATCH ?`);
|
|
|
|
|
|
|
|
|
|
relatedSearchQueries.push(relatedQuery.join(' '));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add the original search first since we've already added the original parameter first
|
|
|
|
|
const conditionStatement = [searchSql, ...relatedSearchQueries].join(
|
|
|
|
|
'\nUNION ',
|
|
|
|
|
);
|
|
|
|
|
conditions.push(
|
|
|
|
|
`"${tableName}".${searchSelect} IN (${conditionStatement})`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-31 16:58:05 -04:00
|
|
|
// 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.
|
2025-11-05 17:57:45 -05:00
|
|
|
conditions.push(`"${tableName}".${searchSelect} IN (${searchSql})`);
|
2025-10-31 16:58:05 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function constructSelectClause(select = [{ field: '*' }], tableName) {
|
|
|
|
|
const distinctColumns = select.filter(({ isDistinct }) => isDistinct);
|
|
|
|
|
const nonDistinctColumns = select.filter(({ isDistinct }) => !isDistinct);
|
|
|
|
|
|
|
|
|
|
const buildColumnExpression = ({ field, isCount, alias, isDistinct }) => {
|
2025-11-03 17:28:11 -05:00
|
|
|
let column = field === '*' ? field : `"${tableName}".${field}`;
|
2025-10-31 16:58:05 -04:00
|
|
|
|
|
|
|
|
// If there's just distinct with nothing else, this will be handled separately
|
|
|
|
|
// and we will just return the column back as is
|
|
|
|
|
if (isCount && isDistinct) {
|
|
|
|
|
column = `count(DISTINCT ${column})`;
|
|
|
|
|
} else if (isCount) {
|
|
|
|
|
column = `count(${column})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (alias) {
|
|
|
|
|
column = `${column} as ${alias}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return column;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let selectColumns;
|
|
|
|
|
|
|
|
|
|
if (distinctColumns.length > 0) {
|
|
|
|
|
// Check if any columns also have COUNT (or other aggregate function in the future)
|
|
|
|
|
const hasAggregateDistinct = distinctColumns.some((col) => col.isCount);
|
|
|
|
|
|
|
|
|
|
if (hasAggregateDistinct) {
|
|
|
|
|
selectColumns = distinctColumns.map(buildColumnExpression).join(', ');
|
|
|
|
|
|
|
|
|
|
// Add back in the non distinct columns
|
|
|
|
|
if (nonDistinctColumns.length > 0) {
|
|
|
|
|
const regularPart = nonDistinctColumns
|
|
|
|
|
.map(buildColumnExpression)
|
|
|
|
|
.join(', ');
|
|
|
|
|
selectColumns = `${selectColumns}, ${regularPart}`;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
assert(
|
|
|
|
|
'Can not combine non-distincts with multi column distincts',
|
|
|
|
|
nonDistinctColumns.length === 0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Only do multi column DISTINCT with no aggregate functions
|
|
|
|
|
selectColumns = `DISTINCT ${distinctColumns.map(buildColumnExpression).join(', ')}`;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
selectColumns = select.map(buildColumnExpression).join(', ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `SELECT ${selectColumns} FROM "${tableName}"`;
|
2025-08-06 11:09:14 -04:00
|
|
|
}
|
|
|
|
|
|
2025-11-03 17:28:11 -05:00
|
|
|
function constructJoinClause(joins) {
|
|
|
|
|
if (!joins || joins.length === 0) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return joins
|
|
|
|
|
.map(
|
|
|
|
|
({ type, table, alias, condition }) =>
|
|
|
|
|
`${type} JOIN "${table}" ${alias} ON ${condition}`,
|
|
|
|
|
)
|
|
|
|
|
.join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function constructOrderByClause(resource, tableName, sort) {
|
|
|
|
|
const defaultOrderByClause = `ORDER BY "${tableName}".created_time DESC`;
|
2025-08-18 13:39:43 -07:00
|
|
|
|
|
|
|
|
const { attributes, customSort, direction, isCoalesced } = sort;
|
|
|
|
|
const sortDirection = direction === 'desc' ? 'DESC' : 'ASC';
|
2025-11-03 17:28:11 -05:00
|
|
|
const attributesWithTableName = attributes?.map(
|
|
|
|
|
(attribute) => `"${tableName}".${attribute}`,
|
|
|
|
|
);
|
2025-08-18 13:39:43 -07:00
|
|
|
|
|
|
|
|
// 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]}' `;
|
|
|
|
|
},
|
|
|
|
|
'',
|
|
|
|
|
);
|
2025-11-03 17:28:11 -05:00
|
|
|
return `ORDER BY CASE ${attributesWithTableName.join(', ')} ${whenClauses}END ${sortDirection}`;
|
2025-08-18 13:39:43 -07:00
|
|
|
} else if (attributes?.length > 0) {
|
2025-11-03 17:28:11 -05:00
|
|
|
const commaSeparatedVals = attributesWithTableName.join(', ');
|
2025-08-18 13:39:43 -07:00
|
|
|
|
|
|
|
|
// 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) {
|
2025-11-03 17:28:11 -05:00
|
|
|
return `ORDER BY COALESCE(${commaSeparatedVals}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
|
2025-08-18 13:39:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const attributesWithNoCollate = attributes
|
2025-11-03 17:28:11 -05:00
|
|
|
.map((attr) => `"${tableName}".${attr} COLLATE NOCASE ${sortDirection}`)
|
2025-08-18 13:39:43 -07:00
|
|
|
.join(', ');
|
|
|
|
|
const attributesWithDirection = attributes
|
2025-11-03 17:28:11 -05:00
|
|
|
.map((attr) => `"${tableName}".${attr} ${sortDirection}`)
|
2025-08-18 13:39:43 -07:00
|
|
|
.join(', ');
|
|
|
|
|
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
|
|
|
|
|
} else if (modelMapping[resource]?.created_time) {
|
|
|
|
|
return defaultOrderByClause;
|
|
|
|
|
} else {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-06 11:09:14 -04:00
|
|
|
// Comparison Operators
|
|
|
|
|
const OPERATORS = {
|
|
|
|
|
equals: ' = ?',
|
|
|
|
|
notEquals: ' != ?',
|
|
|
|
|
gt: ' > ?',
|
|
|
|
|
gte: ' >= ?',
|
|
|
|
|
lt: ' < ?',
|
|
|
|
|
lte: ' <= ?',
|
|
|
|
|
contains: ' LIKE ?',
|
2025-09-17 15:32:39 -07:00
|
|
|
in: (values) => ` IN (${values.map(() => '?').join(', ')})`,
|
|
|
|
|
notIn: (values) => ` NOT IN (${values.map(() => '?').join(', ')})`,
|
2025-08-06 11:09:14 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Logical Operators
|
|
|
|
|
const and = (clauses) => clauses.join(' AND ');
|
|
|
|
|
const or = (clauses) => clauses.join(' OR ');
|
|
|
|
|
const parenthetical = (expression) => (expression ? `(${expression})` : '');
|