This commit has a few notable changes: 1. A simple attempt at establishing a pattern for using SELECT statements. This will be useful in the future for joins/subquery support. 2. Expanded usage of searching to be able to pass instead of just a string, an object that lets a consumer choose which columns to search on. 3. Decouple our session recording filters from being loaded from the route model hook which is a departure from our convention. This has the benefit of actually letting the page first load and having the filters load async so the page doesn't wait for the filters to finish. This is also much faster to query for each individual filter option from SQLite rather than returning all data to filter in application code.
524 lines
15 KiB
JavaScript
524 lines
15 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 (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 created_time DESC`.removeExtraWhiteSpace(),
|
|
);
|
|
assert.deepEqual(parameters, []);
|
|
});
|
|
|
|
test.each(
|
|
'it generates select clause with various options',
|
|
{
|
|
'single field': {
|
|
select: [{ field: 'name' }],
|
|
expectedSelect: 'name',
|
|
},
|
|
'multiple fields': {
|
|
select: [{ field: 'name' }, { field: 'type' }, { field: 'id' }],
|
|
expectedSelect: 'name, type, id',
|
|
},
|
|
'field with alias': {
|
|
select: [{ field: 'name', alias: 'resource_name' }],
|
|
expectedSelect: 'name as resource_name',
|
|
},
|
|
'multiple fields with aliases': {
|
|
select: [
|
|
{ field: 'name', alias: 'resource_name' },
|
|
{ field: 'type', alias: 'resource_type' },
|
|
],
|
|
expectedSelect: 'name as resource_name, type as resource_type',
|
|
},
|
|
'count with alias': {
|
|
select: [{ field: 'id', isCount: true, alias: 'total_count' }],
|
|
expectedSelect: 'count(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 status)',
|
|
},
|
|
'count distinct with alias': {
|
|
select: [
|
|
{
|
|
field: 'type',
|
|
isCount: true,
|
|
isDistinct: true,
|
|
alias: 'unique_types',
|
|
},
|
|
],
|
|
expectedSelect: 'count(DISTINCT type) as unique_types',
|
|
},
|
|
'mixed regular and count fields': {
|
|
select: [
|
|
{ field: 'name' },
|
|
{ field: 'id', isCount: true, alias: 'count' },
|
|
],
|
|
expectedSelect: 'name, count(id) as count',
|
|
},
|
|
'field with distinct': {
|
|
select: [{ field: 'type', isDistinct: true }],
|
|
expectedSelect: 'DISTINCT type',
|
|
},
|
|
'multiple distinct fields': {
|
|
select: [
|
|
{ field: 'type', isDistinct: true },
|
|
{ field: 'status', isDistinct: true },
|
|
],
|
|
expectedSelect: 'DISTINCT type, status',
|
|
},
|
|
'multiple distinct with count fields': {
|
|
select: [
|
|
{ field: 'type', isDistinct: true, isCount: true },
|
|
{ field: 'status', isDistinct: true, isCount: true },
|
|
],
|
|
expectedSelect: 'count(DISTINCT type), count(DISTINCT status)',
|
|
},
|
|
'distinct with alias': {
|
|
select: [{ field: 'type', isDistinct: true, alias: 'unique_type' }],
|
|
expectedSelect: 'DISTINCT 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 type as unique_type, 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 type) as type_count, count(DISTINCT status) as status_count',
|
|
},
|
|
},
|
|
function (assert, { select, expectedSelect }) {
|
|
const { sql, parameters } = generateSQLExpressions(
|
|
'target',
|
|
{},
|
|
{ select },
|
|
);
|
|
|
|
assert.strictEqual(
|
|
sql,
|
|
`
|
|
SELECT ${expectedSelect} FROM "target"
|
|
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
|
);
|
|
assert.deepEqual(parameters, []);
|
|
},
|
|
);
|
|
|
|
test.each(
|
|
'it generates filters correctly',
|
|
{
|
|
equals: {
|
|
query: {
|
|
filters: {
|
|
type: [{ equals: 'ssh' }],
|
|
},
|
|
},
|
|
expectedWhereClause: 'WHERE (type = ?)',
|
|
expectedParams: ['ssh'],
|
|
},
|
|
notEquals: {
|
|
query: {
|
|
filters: {
|
|
type: [{ notEquals: 'ssh' }],
|
|
},
|
|
},
|
|
expectedWhereClause: 'WHERE (type != ?)',
|
|
expectedParams: ['ssh'],
|
|
},
|
|
contains: {
|
|
query: {
|
|
filters: {
|
|
type: [{ contains: 'ssh' }],
|
|
},
|
|
},
|
|
expectedWhereClause: 'WHERE (type LIKE ?)',
|
|
expectedParams: ['%ssh%'],
|
|
},
|
|
greaterThan: {
|
|
query: {
|
|
filters: {
|
|
created_time: [{ gt: date }],
|
|
numberField: [{ gt: 10 }],
|
|
},
|
|
},
|
|
expectedWhereClause: 'WHERE (created_time > ?) AND (numberField > ?)',
|
|
expectedParams: [isoDateString, 10],
|
|
},
|
|
lessThan: {
|
|
query: {
|
|
filters: {
|
|
created_time: [{ lt: date }],
|
|
numberField: [{ lt: 10 }],
|
|
},
|
|
},
|
|
expectedWhereClause: 'WHERE (created_time < ?) AND (numberField < ?)',
|
|
expectedParams: [isoDateString, 10],
|
|
},
|
|
greaterThanOrEqual: {
|
|
query: {
|
|
filters: {
|
|
created_time: [{ gte: date }],
|
|
numberField: [{ gte: 10 }],
|
|
},
|
|
},
|
|
expectedWhereClause: 'WHERE (created_time >= ?) AND (numberField >= ?)',
|
|
expectedParams: [isoDateString, 10],
|
|
},
|
|
lessThanOrEqual: {
|
|
query: {
|
|
filters: {
|
|
created_time: [{ lte: date }],
|
|
numberField: [{ lte: 10 }],
|
|
},
|
|
},
|
|
expectedWhereClause: 'WHERE (created_time <= ?) AND (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 (id NOT IN (?, ?)) AND (status IN (?, ?)) AND (type = ?)',
|
|
expectedParams: ['id1', 'id2', 'active', 'pending', 'ssh'],
|
|
},
|
|
},
|
|
|
|
function (assert, { query, expectedWhereClause, expectedParams }) {
|
|
const { sql, parameters } = generateSQLExpressions('target', query);
|
|
assert.strictEqual(
|
|
sql,
|
|
`
|
|
SELECT * FROM "target"
|
|
${expectedWhereClause}
|
|
ORDER BY 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 name COLLATE NOCASE ${expectedDirection}, 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(name, id) COLLATE NOCASE DESC, COALESCE(name, id) DESC`,
|
|
},
|
|
'sort on multiple attributes': {
|
|
sort: {
|
|
attributes: ['name', 'id'],
|
|
},
|
|
expectedOrderByClause: `ORDER BY name COLLATE NOCASE DESC, id COLLATE NOCASE DESC, name DESC, id DESC`,
|
|
},
|
|
'sort on mapped attributes': {
|
|
sort: {
|
|
attributes: ['type'],
|
|
customSort: { attributeMap: { ssh: 'SSH', tcp: 'Generic TCP' } },
|
|
},
|
|
expectedOrderByClause: `ORDER BY CASE 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 created_time DESC
|
|
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
|
|
);
|
|
assert.deepEqual(parameters, expectedParams);
|
|
},
|
|
);
|
|
|
|
test('it generates FTS5 search with filters', function (assert) {
|
|
const query = {
|
|
search: 'favorite',
|
|
filters: {
|
|
id: {
|
|
logicalOperator: 'and',
|
|
values: [{ notEquals: 'id1' }, { notEquals: 'id2' }],
|
|
},
|
|
status: [{ equals: 'active' }, { equals: 'pending' }],
|
|
},
|
|
};
|
|
|
|
const { sql, parameters } = generateSQLExpressions('target', query);
|
|
assert.strictEqual(
|
|
sql,
|
|
`
|
|
SELECT * FROM "target"
|
|
WHERE (id NOT IN (?, ?)) AND (status IN (?, ?)) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
|
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
|
);
|
|
assert.deepEqual(parameters, [
|
|
'id1',
|
|
'id2',
|
|
'active',
|
|
'pending',
|
|
'"favorite"*',
|
|
]);
|
|
});
|
|
|
|
test('it generates FTS5 search with object parameter', function (assert) {
|
|
const query = {
|
|
search: {
|
|
text: 'favorite',
|
|
fields: ['name', 'description'],
|
|
},
|
|
filters: {
|
|
type: [{ equals: 'ssh' }],
|
|
},
|
|
};
|
|
|
|
const { sql, parameters } = generateSQLExpressions('target', query);
|
|
assert.strictEqual(
|
|
sql,
|
|
`
|
|
SELECT * FROM "target"
|
|
WHERE (type = ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
|
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
|
);
|
|
assert.deepEqual(parameters, [
|
|
'ssh',
|
|
'name:"favorite"* OR description:"favorite"*',
|
|
]);
|
|
});
|
|
|
|
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 data FROM "target"
|
|
WHERE (type = ?) AND (status IN (?, ?)) AND (created_time >= ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
|
ORDER BY name COLLATE NOCASE DESC, 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 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, []);
|
|
},
|
|
);
|
|
});
|