Files
threshold-ui/addons/api/tests/unit/utils/sqlite-query-test.js
T
Zhihe Li fda1f56ad1 feat: 🎸 Add support for joins with filters (#3010)
This commit adds support for being able to join to other tables when filtering.
Support was also added for being able to find null values.
2025-11-03 17:28:11 -05:00

626 lines
19 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { generateSQLExpressions } from 'api/utils/sqlite-query';
import { module, test } from 'qunit';
module('Unit | Utility | sqlite-query', function (hooks) {
const date = new Date(2025, 1, 1);
const isoDateString = date.toISOString();
hooks.beforeEach(function () {
String.prototype.removeExtraWhiteSpace = function () {
return this.replace(/^\s+/gm, '').trim();
};
});
hooks.afterEach(function () {
delete String.prototype.removeExtraWhiteSpace;
});
test('it grabs resources not in the model mapping', function (assert) {
const query = {
filters: { id: [{ equals: 'tokenKey' }] },
};
const { sql, parameters } = generateSQLExpressions('token', query);
assert.strictEqual(
sql,
`
SELECT * FROM "token"
WHERE ("token".id = ?)`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, ['tokenKey']);
});
test('it executes count queries correctly', function (assert) {
const select = {
select: [{ field: '*', isCount: true, alias: 'total' }],
};
const { sql, parameters } = generateSQLExpressions('target', {}, select);
assert.strictEqual(
sql,
`
SELECT count(*) as total FROM "target"
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
});
test.each(
'it generates select clause with various options',
{
'single field': {
select: [{ field: 'name' }],
expectedSelect: '"target".name',
},
'multiple fields': {
select: [{ field: 'name' }, { field: 'type' }, { field: 'id' }],
expectedSelect: '"target".name, "target".type, "target".id',
},
'field with alias': {
select: [{ field: 'name', alias: 'resource_name' }],
expectedSelect: '"target".name as resource_name',
},
'multiple fields with aliases': {
select: [
{ field: 'name', alias: 'resource_name' },
{ field: 'type', alias: 'resource_type' },
],
expectedSelect: '"target".name as resource_name, "target".type as resource_type',
},
'count with alias': {
select: [{ field: 'id', isCount: true, alias: 'total_count' }],
expectedSelect: 'count("target".id) as total_count',
},
'count without alias': {
select: [{ field: '*', isCount: true }],
expectedSelect: 'count(*)',
},
'count distinct': {
select: [{ field: 'status', isCount: true, isDistinct: true }],
expectedSelect: 'count(DISTINCT "target".status)',
},
'count distinct with alias': {
select: [
{
field: 'type',
isCount: true,
isDistinct: true,
alias: 'unique_types',
},
],
expectedSelect: 'count(DISTINCT "target".type) as unique_types',
},
'mixed regular and count fields': {
select: [
{ field: 'name' },
{ field: 'id', isCount: true, alias: 'count' },
],
expectedSelect: '"target".name, count("target".id) as count',
},
'field with distinct': {
select: [{ field: 'type', isDistinct: true }],
expectedSelect: 'DISTINCT "target".type',
},
'multiple distinct fields': {
select: [
{ field: 'type', isDistinct: true },
{ field: 'status', isDistinct: true },
],
expectedSelect: 'DISTINCT "target".type, "target".status',
},
'multiple distinct with count fields': {
select: [
{ field: 'type', isDistinct: true, isCount: true },
{ field: 'status', isDistinct: true, isCount: true },
],
expectedSelect: 'count(DISTINCT "target".type), count(DISTINCT "target".status)',
},
'distinct with alias': {
select: [{ field: 'type', isDistinct: true, alias: 'unique_type' }],
expectedSelect: 'DISTINCT "target".type as unique_type',
},
'multiple distinct fields with aliases': {
select: [
{ field: 'type', isDistinct: true, alias: 'unique_type' },
{ field: 'status', isDistinct: true, alias: 'unique_status' },
],
expectedSelect: 'DISTINCT "target".type as unique_type, "target".status as unique_status',
},
'count distinct multiple fields': {
select: [
{
field: 'type',
isCount: true,
isDistinct: true,
alias: 'type_count',
},
{
field: 'status',
isCount: true,
isDistinct: true,
alias: 'status_count',
},
],
expectedSelect:
'count(DISTINCT "target".type) as type_count, count(DISTINCT "target".status) as status_count',
},
},
function (assert, { select, expectedSelect }) {
const { sql, parameters } = generateSQLExpressions(
'target',
{},
{ select },
);
assert.strictEqual(
sql,
`
SELECT ${expectedSelect} FROM "target"
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
},
);
test.each(
'it generates filters correctly',
{
equals: {
query: {
filters: {
type: [{ equals: 'ssh' }],
},
},
expectedWhereClause: 'WHERE ("target".type = ?)',
expectedParams: ['ssh'],
},
notEquals: {
query: {
filters: {
type: [{ notEquals: 'ssh' }],
},
},
expectedWhereClause: 'WHERE ("target".type != ?)',
expectedParams: ['ssh'],
},
contains: {
query: {
filters: {
type: [{ contains: 'ssh' }],
},
},
expectedWhereClause: 'WHERE ("target".type LIKE ?)',
expectedParams: ['%ssh%'],
},
greaterThan: {
query: {
filters: {
created_time: [{ gt: date }],
numberField: [{ gt: 10 }],
},
},
expectedWhereClause:
'WHERE ("target".created_time > ?) AND ("target".numberField > ?)',
expectedParams: [isoDateString, 10],
},
lessThan: {
query: {
filters: {
created_time: [{ lt: date }],
numberField: [{ lt: 10 }],
},
},
expectedWhereClause:
'WHERE ("target".created_time < ?) AND ("target".numberField < ?)',
expectedParams: [isoDateString, 10],
},
greaterThanOrEqual: {
query: {
filters: {
created_time: [{ gte: date }],
numberField: [{ gte: 10 }],
},
},
expectedWhereClause:
'WHERE ("target".created_time >= ?) AND ("target".numberField >= ?)',
expectedParams: [isoDateString, 10],
},
lessThanOrEqual: {
query: {
filters: {
created_time: [{ lte: date }],
numberField: [{ lte: 10 }],
},
},
expectedWhereClause:
'WHERE ("target".created_time <= ?) AND ("target".numberField <= ?)',
expectedParams: [isoDateString, 10],
},
logicalOperators: {
query: {
filters: {
id: {
logicalOperator: 'and',
values: [{ notEquals: 'id1' }, { notEquals: 'id2' }],
},
status: {
logicalOperator: 'or',
values: [{ equals: 'active' }, { equals: 'pending' }],
},
type: [{ equals: 'ssh' }],
},
},
expectedWhereClause:
'WHERE ("target".id NOT IN (?, ?)) AND ("target".status IN (?, ?)) AND ("target".type = ?)',
expectedParams: ['id1', 'id2', 'active', 'pending', 'ssh'],
},
equalsNull: {
query: {
filters: {
description: [{ equals: null }],
},
},
expectedWhereClause: 'WHERE ("target".description IS NULL)',
expectedParams: [],
},
notEqualsNull: {
query: {
filters: {
description: [{ notEquals: null }],
},
},
expectedWhereClause: 'WHERE ("target".description IS NOT NULL)',
expectedParams: [],
},
mixedNullAndValues: {
query: {
filters: {
status: [
{ equals: 'active' },
{ equals: 'pending' },
{ equals: null },
],
type: [{ notEquals: null }, { notEquals: 'ssh' }],
},
},
expectedWhereClause:
'WHERE ("target".status IN (?, ?) OR "target".status IS NULL) AND ("target".type NOT IN (?) OR "target".type IS NOT NULL)',
expectedParams: ['active', 'pending', 'ssh'],
},
},
function (assert, { query, expectedWhereClause, expectedParams }) {
const { sql, parameters } = generateSQLExpressions('target', query);
assert.strictEqual(
sql,
`
SELECT * FROM "target"
${expectedWhereClause}
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, expectedParams);
},
);
test.each(
'it generates sort direction correctly',
[
['asc', 'ASC'],
['desc', 'DESC'],
],
function (assert, [direction, expectedDirection]) {
const query = {
sort: { attributes: ['name'], direction },
};
const { sql, parameters } = generateSQLExpressions('target', query);
assert.strictEqual(
sql,
`
SELECT * FROM "target"
ORDER BY "target".name COLLATE NOCASE ${expectedDirection}, "target".name ${expectedDirection}`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
},
);
test.each(
'it generates sort order clause correctly',
{
'sort on multiple attributes and coalesced': {
sort: {
attributes: ['name', 'id'],
isCoalesced: true,
},
expectedOrderByClause: `ORDER BY COALESCE("target".name, "target".id) COLLATE NOCASE DESC, COALESCE("target".name, "target".id) DESC`,
},
'sort on multiple attributes': {
sort: {
attributes: ['name', 'id'],
},
expectedOrderByClause: `ORDER BY "target".name COLLATE NOCASE DESC, "target".id COLLATE NOCASE DESC, "target".name DESC, "target".id DESC`,
},
'sort on mapped attributes': {
sort: {
attributes: ['type'],
customSort: { attributeMap: { ssh: 'SSH', tcp: 'Generic TCP' } },
},
expectedOrderByClause: `ORDER BY CASE "target".type WHEN 'ssh' THEN 'SSH' WHEN 'tcp' THEN 'Generic TCP' END DESC`,
},
},
function (assert, { sort, expectedOrderByClause }) {
const query = {
sort: { ...sort, direction: 'desc' },
};
const { sql, parameters } = generateSQLExpressions('target', query);
assert.strictEqual(
sql,
`
SELECT * FROM "target"
${expectedOrderByClause}`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
},
);
test.each(
'it generates pagination clause',
[
{ page: 1, pageSize: 10, expectedParams: [10, 0] },
{ page: 3, pageSize: 10, expectedParams: [10, 20] },
{ page: 7, pageSize: 50, expectedParams: [50, 300] },
],
function (assert, { page, pageSize, expectedParams }) {
const { sql, parameters } = generateSQLExpressions(
'target',
{},
{ page, pageSize },
);
assert.strictEqual(
sql,
`
SELECT * FROM "target"
ORDER BY "target".created_time DESC
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, expectedParams);
},
);
test.each(
'it generates FTS5 search correctly',
{
'string search with filters': {
query: {
search: 'favorite',
},
expectedSql: `
SELECT * FROM "target"
WHERE "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
ORDER BY "target".created_time DESC`,
expectedParams: ['"favorite"*'],
},
'object search with field-specific searches': {
query: {
search: {
text: 'favorite',
fields: ['name', 'description'],
},
},
expectedSql: `
SELECT * FROM "target"
WHERE "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
ORDER BY "target".created_time DESC`,
expectedParams: ['name:"favorite"* OR description:"favorite"*'],
},
},
function (assert, { query, expectedSql, expectedParams }) {
const { sql, parameters } = generateSQLExpressions('target', query);
assert.strictEqual(sql, expectedSql.removeExtraWhiteSpace());
assert.deepEqual(parameters, expectedParams);
},
);
test.each(
'it generates joins with filters',
{
'basic joins with filters': {
query: {
filters: {
type: [{ equals: 'ssh' }],
joins: [
{
resource: 'session',
query: {
filters: {
status: [{ equals: 'active' }],
},
},
joinOn: 'target_id',
joinType: 'INNER',
},
{
resource: 'session-recording',
query: {
filters: {
state: [{ equals: 'available' }],
},
},
joinFrom: 'idx',
joinOn: 'target_id',
joinType: 'LEFT',
},
],
},
},
select: [{ field: 'data' }],
expectedSql: `
SELECT "target".data FROM "target"
INNER JOIN "session" session1 ON "target".id = session1.target_id LEFT JOIN "session_recording" session_recording1 ON "target".idx = session_recording1.target_id
WHERE ("target".type = ?) AND ("session1".status = ?) AND ("session_recording1".state = ?)
ORDER BY "target".created_time DESC`,
expectedParams: ['ssh', 'active', 'available'],
},
'multiple joins same table': {
query: {
filters: {
joins: [
{
resource: 'session',
query: {
filters: {
status: [{ equals: 'active' }],
},
},
joinOn: 'target_id',
joinType: 'INNER',
},
{
resource: 'session',
query: {
filters: {
status: [{ equals: 'pending' }],
},
},
joinOn: 'host_id',
joinType: 'LEFT',
},
],
},
},
select: [{ field: 'id' }],
expectedSql: `
SELECT "target".id FROM "target"
INNER JOIN "session" session1 ON "target".id = session1.target_id LEFT JOIN "session" session2 ON "target".id = session2.host_id
WHERE ("session1".status = ?) AND ("session2".status = ?)
ORDER BY "target".created_time DESC`,
expectedParams: ['active', 'pending'],
},
},
function (assert, { query, select, expectedSql, expectedParams }) {
const { sql, parameters } = generateSQLExpressions('target', query, {
select,
});
assert.strictEqual(sql, expectedSql.removeExtraWhiteSpace());
assert.deepEqual(parameters, expectedParams);
},
);
test('it generates SQL with all clauses combined', function (assert) {
const query = {
search: 'favorite',
filters: {
type: [{ equals: 'ssh' }],
status: {
logicalOperator: 'or',
values: [{ equals: 'active' }, { equals: 'pending' }],
},
created_time: [{ gte: date }],
},
sort: { attributes: ['name'], direction: 'desc' },
};
const { sql, parameters } = generateSQLExpressions('target', query, {
page: 2,
pageSize: 15,
select: [{ field: 'data' }],
});
assert.strictEqual(
sql,
`
SELECT "target".data FROM "target"
WHERE ("target".type = ?) AND ("target".status IN (?, ?)) AND ("target".created_time >= ?) AND "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
ORDER BY "target".name COLLATE NOCASE DESC, "target".name DESC
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, [
'ssh',
'active',
'pending',
isoDateString,
'"favorite"*',
15,
15,
]);
});
test.each(
'it throws assertion error for invalid select combinations',
{
'mixing distinct with non-distinct columns': {
select: [
{ field: 'type', isDistinct: true },
{ field: 'status', isDistinct: true },
{ field: 'name' },
],
expectedError:
/Can not combine non-distincts with multi column distincts/,
},
'mixing distinct with aggregate non-distinct columns': {
select: [
{ field: 'type', isDistinct: true },
{ field: 'status', isDistinct: true },
{ field: 'name', isCount: true },
],
expectedError:
/Can not combine non-distincts with multi column distincts/,
},
},
function (assert, { select, expectedError }) {
assert.throws(() => {
generateSQLExpressions('target', {}, { select });
}, expectedError);
},
);
test.each(
'it handles empty and invalid fields',
{
emptyObject: {},
emptySearch: { search: '' },
emptyFilter: { filters: {} },
emptyFilterAttributes: { filters: { type: [] } },
invalidSortAttribute: {
sort: { attributes: ['test'], direction: 'asc' },
},
invalidCoalescedSortAttribute: {
sort: {
attributes: ['id', 'test'],
direction: 'asc',
isCoalesced: true,
},
},
onlyPage: { page: 10 },
onlyPageSize: { pageSize: 5 },
},
function (assert, query) {
const { sql: targetSql, parameters: targetParameters } =
generateSQLExpressions('target', query);
assert.strictEqual(
targetSql,
`
SELECT * FROM "target"
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(targetParameters, []);
const { sql: resourceSql, parameters: resourceParameters } =
generateSQLExpressions('resource', query);
assert.strictEqual(
resourceSql,
`
SELECT * FROM "resource"`.removeExtraWhiteSpace(),
);
assert.deepEqual(resourceParameters, []);
},
);
});