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.
This commit is contained in:
Zhihe Li authored and GitHub committed 2025-11-03 17:28:11 -05:00
1 parent fa18bfff01
commit fda1f56ad1
3 files changed
+348 -132

No files matched your search

+186 -84
View File
@@ -30,7 +30,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
sql,
`
SELECT * FROM "token"
WHERE (id = ?)`.removeExtraWhiteSpace(),
WHERE ("token".id = ?)`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, ['tokenKey']);
});
@@ -45,7 +45,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
sql,
`
SELECT count(*) as total FROM "target"
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
});
@@ -55,26 +55,26 @@ module('Unit | Utility | sqlite-query', function (hooks) {
{
'single field': {
select: [{ field: 'name' }],
expectedSelect: 'name',
expectedSelect: '"target".name',
},
'multiple fields': {
select: [{ field: 'name' }, { field: 'type' }, { field: 'id' }],
expectedSelect: 'name, type, id',
expectedSelect: '"target".name, "target".type, "target".id',
},
'field with alias': {
select: [{ field: 'name', alias: 'resource_name' }],
expectedSelect: 'name as resource_name',
expectedSelect: '"target".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',
expectedSelect: '"target".name as resource_name, "target".type as resource_type',
},
'count with alias': {
select: [{ field: 'id', isCount: true, alias: 'total_count' }],
expectedSelect: 'count(id) as total_count',
expectedSelect: 'count("target".id) as total_count',
},
'count without alias': {
select: [{ field: '*', isCount: true }],
@@ -82,7 +82,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
},
'count distinct': {
select: [{ field: 'status', isCount: true, isDistinct: true }],
expectedSelect: 'count(DISTINCT status)',
expectedSelect: 'count(DISTINCT "target".status)',
},
'count distinct with alias': {
select: [
@@ -93,43 +93,43 @@ module('Unit | Utility | sqlite-query', function (hooks) {
alias: 'unique_types',
},
],
expectedSelect: 'count(DISTINCT type) as 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: 'name, count(id) as count',
expectedSelect: '"target".name, count("target".id) as count',
},
'field with distinct': {
select: [{ field: 'type', isDistinct: true }],
expectedSelect: 'DISTINCT type',
expectedSelect: 'DISTINCT "target".type',
},
'multiple distinct fields': {
select: [
{ field: 'type', isDistinct: true },
{ field: 'status', isDistinct: true },
],
expectedSelect: 'DISTINCT type, status',
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 type), count(DISTINCT status)',
expectedSelect: 'count(DISTINCT "target".type), count(DISTINCT "target".status)',
},
'distinct with alias': {
select: [{ field: 'type', isDistinct: true, alias: 'unique_type' }],
expectedSelect: 'DISTINCT type as 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 type as unique_type, status as unique_status',
expectedSelect: 'DISTINCT "target".type as unique_type, "target".status as unique_status',
},
'count distinct multiple fields': {
select: [
@@ -147,7 +147,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
},
],
expectedSelect:
'count(DISTINCT type) as type_count, count(DISTINCT status) as status_count',
'count(DISTINCT "target".type) as type_count, count(DISTINCT "target".status) as status_count',
},
},
function (assert, { select, expectedSelect }) {
@@ -161,7 +161,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
sql,
`
SELECT ${expectedSelect} FROM "target"
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
},
@@ -176,7 +176,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
type: [{ equals: 'ssh' }],
},
},
expectedWhereClause: 'WHERE (type = ?)',
expectedWhereClause: 'WHERE ("target".type = ?)',
expectedParams: ['ssh'],
},
notEquals: {
@@ -185,7 +185,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
type: [{ notEquals: 'ssh' }],
},
},
expectedWhereClause: 'WHERE (type != ?)',
expectedWhereClause: 'WHERE ("target".type != ?)',
expectedParams: ['ssh'],
},
contains: {
@@ -194,7 +194,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
type: [{ contains: 'ssh' }],
},
},
expectedWhereClause: 'WHERE (type LIKE ?)',
expectedWhereClause: 'WHERE ("target".type LIKE ?)',
expectedParams: ['%ssh%'],
},
greaterThan: {
@@ -204,7 +204,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
numberField: [{ gt: 10 }],
},
},
expectedWhereClause: 'WHERE (created_time > ?) AND (numberField > ?)',
expectedWhereClause:
'WHERE ("target".created_time > ?) AND ("target".numberField > ?)',
expectedParams: [isoDateString, 10],
},
lessThan: {
@@ -214,7 +215,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
numberField: [{ lt: 10 }],
},
},
expectedWhereClause: 'WHERE (created_time < ?) AND (numberField < ?)',
expectedWhereClause:
'WHERE ("target".created_time < ?) AND ("target".numberField < ?)',
expectedParams: [isoDateString, 10],
},
greaterThanOrEqual: {
@@ -224,7 +226,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
numberField: [{ gte: 10 }],
},
},
expectedWhereClause: 'WHERE (created_time >= ?) AND (numberField >= ?)',
expectedWhereClause:
'WHERE ("target".created_time >= ?) AND ("target".numberField >= ?)',
expectedParams: [isoDateString, 10],
},
lessThanOrEqual: {
@@ -234,7 +237,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
numberField: [{ lte: 10 }],
},
},
expectedWhereClause: 'WHERE (created_time <= ?) AND (numberField <= ?)',
expectedWhereClause:
'WHERE ("target".created_time <= ?) AND ("target".numberField <= ?)',
expectedParams: [isoDateString, 10],
},
logicalOperators: {
@@ -252,9 +256,42 @@ module('Unit | Utility | sqlite-query', function (hooks) {
},
},
expectedWhereClause:
'WHERE (id NOT IN (?, ?)) AND (status IN (?, ?)) AND (type = ?)',
'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 }) {
@@ -264,7 +301,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
`
SELECT * FROM "target"
${expectedWhereClause}
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, expectedParams);
},
@@ -286,7 +323,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
sql,
`
SELECT * FROM "target"
ORDER BY name COLLATE NOCASE ${expectedDirection}, name ${expectedDirection}`.removeExtraWhiteSpace(),
ORDER BY "target".name COLLATE NOCASE ${expectedDirection}, "target".name ${expectedDirection}`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
},
@@ -300,20 +337,20 @@ module('Unit | Utility | sqlite-query', function (hooks) {
attributes: ['name', 'id'],
isCoalesced: true,
},
expectedOrderByClause: `ORDER BY COALESCE(name, id) COLLATE NOCASE DESC, COALESCE(name, id) DESC`,
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 name COLLATE NOCASE DESC, id COLLATE NOCASE DESC, name DESC, id DESC`,
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 type WHEN 'ssh' THEN 'SSH' WHEN 'tcp' THEN 'Generic TCP' END DESC`,
expectedOrderByClause: `ORDER BY CASE "target".type WHEN 'ssh' THEN 'SSH' WHEN 'tcp' THEN 'Generic TCP' END DESC`,
},
},
function (assert, { sort, expectedOrderByClause }) {
@@ -349,66 +386,131 @@ module('Unit | Utility | sqlite-query', function (hooks) {
sql,
`
SELECT * FROM "target"
ORDER BY created_time DESC
ORDER BY "target".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' }],
test.each(
'it generates FTS5 search correctly',
{
'string search with filters': {
query: {
search: 'favorite',
},
status: [{ equals: 'active' }, { equals: 'pending' }],
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"*'],
},
};
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'],
'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"*'],
},
filters: {
type: [{ equals: 'ssh' }],
},
};
},
function (assert, { query, expectedSql, expectedParams }) {
const { sql, parameters } = generateSQLExpressions('target', query);
assert.strictEqual(sql, expectedSql.removeExtraWhiteSpace());
assert.deepEqual(parameters, expectedParams);
},
);
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.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 = {
@@ -433,9 +535,9 @@ module('Unit | Utility | sqlite-query', function (hooks) {
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
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(),
);
@@ -506,7 +608,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
targetSql,
`
SELECT * FROM "target"
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(targetParameters, []);