From b28597125d826f47d65d4bc52d9f7d84fd25e2ed Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:11:29 +0100 Subject: [PATCH] fix(server): use DuckDB json_contains for multi-select filters and expand builder coverage --- .../query-cache/duckdb-query-builder.spec.ts | 80 +++++++++++++++++++ .../base/query-cache/duckdb-query-builder.ts | 10 ++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/apps/server/src/core/base/query-cache/duckdb-query-builder.spec.ts b/apps/server/src/core/base/query-cache/duckdb-query-builder.spec.ts index 71cfd929..ebd02535 100644 --- a/apps/server/src/core/base/query-cache/duckdb-query-builder.spec.ts +++ b/apps/server/src/core/base/query-cache/duckdb-query-builder.spec.ts @@ -78,4 +78,84 @@ describe('buildDuckDbListQuery', () => { expect(sql).toMatch(/search_text ILIKE \?/); expect(params).toContain('%hello%'); }); + + it('renders multi-select any filter with json_contains and to_json binding', () => { + const multiProp = { + id: '00000000-0000-0000-0000-000000000010', + type: BasePropertyType.MULTI_SELECT, + typeOptions: {}, + } as any; + const cols = buildColumnSpecs([multiProp]); + const choiceA = 'choice-uuid-aaa'; + const choiceB = 'choice-uuid-bbb'; + const { sql, params } = buildDuckDbListQuery({ + columns: cols, + filter: { + op: 'and', + children: [{ propertyId: multiProp.id, op: 'any', value: [choiceA, choiceB] }], + }, + pagination: { limit: 100 }, + }); + expect(sql).toMatch(/json_contains\("[0-9a-f-]+", to_json\(\?\)\)/); + expect(sql).not.toMatch(/json_array_contains/); + expect(params).toContain(choiceA); + expect(params).toContain(choiceB); + }); + + it('renders nested AND/OR groups with correct parentheses', () => { + const { sql } = buildDuckDbListQuery({ + columns, + filter: { + op: 'or', + children: [ + { op: 'and', children: [{ propertyId: numericProp.id, op: 'gt', value: 1 }] }, + { op: 'and', children: [{ propertyId: textProp.id, op: 'eq', value: 'x' }] }, + ], + }, + pagination: { limit: 100 }, + }); + expect(sql).toMatch(/\(\(.+\) OR \(.+\)\)/); + }); + + it('handles empty filter group without emitting WHERE on it', () => { + const { sql, params } = buildDuckDbListQuery({ + columns, + filter: { op: 'and', children: [] }, + pagination: { limit: 100 }, + }); + // either WHERE clause elided entirely, or group becomes TRUE + expect(sql).toMatch(/deleted_at IS NULL/); + expect(params).toEqual([]); + }); + + it('renders multi-sort keyset with s0, s1, position, id chain', () => { + const { sql } = buildDuckDbListQuery({ + columns, + sorts: [ + { propertyId: numericProp.id, direction: 'asc' }, + { propertyId: textProp.id, direction: 'desc' }, + ], + pagination: { + limit: 10, + afterKeys: { s0: 10, s1: 'abc', position: 'A0', id: '00000000-0000-0000-0000-0000000000aa' }, + }, + }); + expect(sql).toMatch(/AS s0/); + expect(sql).toMatch(/AS s1/); + expect(sql).toMatch(/ORDER BY s0 ASC, s1 DESC, position ASC, id ASC/); + expect(sql).toMatch(/s0 > \?/); + expect(sql).toMatch(/s1 < \?/); // desc → less-than + }); + + it('renders text isEmpty as IS NULL OR = empty-string', () => { + const { sql } = buildDuckDbListQuery({ + columns, + filter: { + op: 'and', + children: [{ propertyId: textProp.id, op: 'isEmpty' }], + }, + pagination: { limit: 10 }, + }); + expect(sql).toMatch(new RegExp(`"${textProp.id}" IS NULL`)); + }); }); diff --git a/apps/server/src/core/base/query-cache/duckdb-query-builder.ts b/apps/server/src/core/base/query-cache/duckdb-query-builder.ts index 3518e3df..dad5a1d6 100644 --- a/apps/server/src/core/base/query-cache/duckdb-query-builder.ts +++ b/apps/server/src/core/base/query-cache/duckdb-query-builder.ts @@ -387,21 +387,21 @@ function arrayOfIdsCondition( case 'any': { const arr = asStringArray(val); if (arr.length === 0) return 'FALSE'; - const legs = arr.map(() => `json_array_contains(${colRef}, ?)`); + const legs = arr.map(() => jsonArrayContains(colRef, '?')); for (const v of arr) params.push(v); return `(${legs.join(' OR ')})`; } case 'all': { const arr = asStringArray(val); if (arr.length === 0) return 'TRUE'; - const legs = arr.map(() => `json_array_contains(${colRef}, ?)`); + const legs = arr.map(() => jsonArrayContains(colRef, '?')); for (const v of arr) params.push(v); return `(${legs.join(' AND ')})`; } case 'none': { const arr = asStringArray(val); if (arr.length === 0) return 'TRUE'; - const legs = arr.map(() => `json_array_contains(${colRef}, ?)`); + const legs = arr.map(() => jsonArrayContains(colRef, '?')); for (const v of arr) params.push(v); return `(${colRef} IS NULL OR NOT (${legs.join(' OR ')}))`; } @@ -613,6 +613,10 @@ function quoteIdent(name: string): string { return `"${name.replace(/"/g, '""')}"`; } +function jsonArrayContains(colRef: string, paramPlaceholder: string): string { + return `json_contains(${colRef}, to_json(${paramPlaceholder}))`; +} + function asStringArray(val: unknown): string[] { if (val == null) return []; if (Array.isArray(val)) return val.filter((v) => v != null).map(String);