mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
fix(server): use DuckDB json_contains for multi-select filters and expand builder coverage
This commit is contained in:
@@ -78,4 +78,84 @@ describe('buildDuckDbListQuery', () => {
|
|||||||
expect(sql).toMatch(/search_text ILIKE \?/);
|
expect(sql).toMatch(/search_text ILIKE \?/);
|
||||||
expect(params).toContain('%hello%');
|
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`));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -387,21 +387,21 @@ function arrayOfIdsCondition(
|
|||||||
case 'any': {
|
case 'any': {
|
||||||
const arr = asStringArray(val);
|
const arr = asStringArray(val);
|
||||||
if (arr.length === 0) return 'FALSE';
|
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);
|
for (const v of arr) params.push(v);
|
||||||
return `(${legs.join(' OR ')})`;
|
return `(${legs.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
case 'all': {
|
case 'all': {
|
||||||
const arr = asStringArray(val);
|
const arr = asStringArray(val);
|
||||||
if (arr.length === 0) return 'TRUE';
|
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);
|
for (const v of arr) params.push(v);
|
||||||
return `(${legs.join(' AND ')})`;
|
return `(${legs.join(' AND ')})`;
|
||||||
}
|
}
|
||||||
case 'none': {
|
case 'none': {
|
||||||
const arr = asStringArray(val);
|
const arr = asStringArray(val);
|
||||||
if (arr.length === 0) return 'TRUE';
|
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);
|
for (const v of arr) params.push(v);
|
||||||
return `(${colRef} IS NULL OR NOT (${legs.join(' OR ')}))`;
|
return `(${colRef} IS NULL OR NOT (${legs.join(' OR ')}))`;
|
||||||
}
|
}
|
||||||
@@ -613,6 +613,10 @@ function quoteIdent(name: string): string {
|
|||||||
return `"${name.replace(/"/g, '""')}"`;
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonArrayContains(colRef: string, paramPlaceholder: string): string {
|
||||||
|
return `json_contains(${colRef}, to_json(${paramPlaceholder}))`;
|
||||||
|
}
|
||||||
|
|
||||||
function asStringArray(val: unknown): string[] {
|
function asStringArray(val: unknown): string[] {
|
||||||
if (val == null) return [];
|
if (val == null) return [];
|
||||||
if (Array.isArray(val)) return val.filter((v) => v != null).map(String);
|
if (Array.isArray(val)) return val.filter((v) => v != null).map(String);
|
||||||
|
|||||||
Reference in New Issue
Block a user