* feat: 🎸 Add sqlite handler and parser ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17410
163 lines
4.6 KiB
JavaScript
163 lines
4.6 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';
|
|
|
|
/**
|
|
* 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 = [];
|
|
|
|
addFilterConditions(filters, parameters, conditions);
|
|
addSearchConditions(search, resource, parameters, conditions);
|
|
|
|
const { attribute, direction } = sort;
|
|
let orderByClause = '';
|
|
if (attribute) {
|
|
// We have to check if the attribute is valid for the resource
|
|
// as we can't use parameterized queries for ORDER BY
|
|
const validAttributes = Object.keys(modelMapping[resource]);
|
|
if (validAttributes.includes(attribute)) {
|
|
orderByClause = `ORDER BY ${attribute} ${direction === 'desc' ? 'DESC' : 'ASC'}`;
|
|
}
|
|
} else if (modelMapping[resource]?.created_time) {
|
|
orderByClause = `ORDER BY created_time DESC`;
|
|
}
|
|
|
|
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 ${resource}`;
|
|
|
|
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;
|
|
}
|
|
|
|
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, 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 ${resource}_fts WHERE ${resource}_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);
|
|
}
|
|
|
|
// Comparison Operators
|
|
const OPERATORS = {
|
|
equals: ' = ?',
|
|
notEquals: ' != ?',
|
|
gt: ' > ?',
|
|
gte: ' >= ?',
|
|
lt: ' < ?',
|
|
lte: ' <= ?',
|
|
contains: ' LIKE ?',
|
|
};
|
|
|
|
// Logical Operators
|
|
const and = (clauses) => clauses.join(' AND ');
|
|
const or = (clauses) => clauses.join(' OR ');
|
|
const parenthetical = (expression) => (expression ? `(${expression})` : '');
|