mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(server): add DuckDB SQL builder for base list queries
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
import { buildColumnSpecs } from './column-types';
|
||||||
|
import { buildDuckDbListQuery } from './duckdb-query-builder';
|
||||||
|
import { BasePropertyType } from '../base.schemas';
|
||||||
|
|
||||||
|
const numericProp = {
|
||||||
|
id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
type: BasePropertyType.NUMBER,
|
||||||
|
typeOptions: {},
|
||||||
|
} as any;
|
||||||
|
const textProp = {
|
||||||
|
id: '00000000-0000-0000-0000-000000000002',
|
||||||
|
type: BasePropertyType.TEXT,
|
||||||
|
typeOptions: {},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const columns = buildColumnSpecs([numericProp, textProp]);
|
||||||
|
|
||||||
|
describe('buildDuckDbListQuery', () => {
|
||||||
|
it('renders no-filter, no-sort, no-search as live-rows-paginated-by-position', () => {
|
||||||
|
const { sql, params } = buildDuckDbListQuery({
|
||||||
|
columns,
|
||||||
|
pagination: { limit: 100 },
|
||||||
|
});
|
||||||
|
expect(sql).toMatch(/FROM rows/);
|
||||||
|
expect(sql).toMatch(/deleted_at IS NULL/);
|
||||||
|
expect(sql).toMatch(/ORDER BY position ASC, id ASC/);
|
||||||
|
expect(sql).toMatch(/LIMIT 101/);
|
||||||
|
expect(params).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders numeric gt filter with parameterized value', () => {
|
||||||
|
const { sql, params } = buildDuckDbListQuery({
|
||||||
|
columns,
|
||||||
|
filter: {
|
||||||
|
op: 'and',
|
||||||
|
children: [{ propertyId: numericProp.id, op: 'gt', value: 42 }],
|
||||||
|
},
|
||||||
|
pagination: { limit: 100 },
|
||||||
|
});
|
||||||
|
expect(sql).toMatch(new RegExp(`"${numericProp.id}" > \\?`));
|
||||||
|
expect(params).toContain(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders text contains with ILIKE and escaped wildcards', () => {
|
||||||
|
const { sql, params } = buildDuckDbListQuery({
|
||||||
|
columns,
|
||||||
|
filter: {
|
||||||
|
op: 'and',
|
||||||
|
children: [{ propertyId: textProp.id, op: 'contains', value: 'a_b%c' }],
|
||||||
|
},
|
||||||
|
pagination: { limit: 100 },
|
||||||
|
});
|
||||||
|
expect(sql).toMatch(/ILIKE \?/);
|
||||||
|
expect(params).toContain('%a\\_b\\%c%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sort with sentinel wrapping and cursor keyset', () => {
|
||||||
|
const { sql } = buildDuckDbListQuery({
|
||||||
|
columns,
|
||||||
|
sorts: [{ propertyId: numericProp.id, direction: 'asc' }],
|
||||||
|
pagination: {
|
||||||
|
limit: 50,
|
||||||
|
afterKeys: { s0: 10, position: 'A0', id: '00000000-0000-0000-0000-0000000000aa' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(sql).toMatch(/COALESCE\("[0-9a-f-]+", '?[Ii]nfinity'?::[A-Z]+\) AS s0/);
|
||||||
|
expect(sql).toMatch(/ORDER BY s0 ASC, position ASC, id ASC/);
|
||||||
|
// keyset OR-chain
|
||||||
|
expect(sql).toMatch(/s0 > \?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders search in trgm mode as ILIKE on search_text', () => {
|
||||||
|
const { sql, params } = buildDuckDbListQuery({
|
||||||
|
columns,
|
||||||
|
search: { mode: 'trgm', query: 'hello' },
|
||||||
|
pagination: { limit: 10 },
|
||||||
|
});
|
||||||
|
expect(sql).toMatch(/search_text ILIKE \?/);
|
||||||
|
expect(params).toContain('%hello%');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
import { BasePropertyType } from '../base.schemas';
|
||||||
|
import {
|
||||||
|
Condition,
|
||||||
|
FilterNode,
|
||||||
|
SearchSpec,
|
||||||
|
SortSpec,
|
||||||
|
} from '../engine/schema.zod';
|
||||||
|
import { escapeIlike } from '../engine/extractors';
|
||||||
|
import { PropertyKind, propertyKind } from '../engine/kinds';
|
||||||
|
import { ColumnSpec } from './query-cache.types';
|
||||||
|
|
||||||
|
export type AfterKeys = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type DuckDbListQueryOpts = {
|
||||||
|
columns: ColumnSpec[];
|
||||||
|
filter?: FilterNode;
|
||||||
|
sorts?: SortSpec[];
|
||||||
|
search?: SearchSpec;
|
||||||
|
pagination: { limit: number; afterKeys?: AfterKeys };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DuckDbListQuery = {
|
||||||
|
sql: string;
|
||||||
|
params: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FtsNotSupportedInCache extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('FTS search mode is not supported in the DuckDB query cache');
|
||||||
|
this.name = 'FtsNotSupportedInCache';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnIndex = {
|
||||||
|
byId: Map<string, ColumnSpec>;
|
||||||
|
userColumns: ColumnSpec[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SortBuild = {
|
||||||
|
key: string;
|
||||||
|
expression: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
|
||||||
|
// System property type → DuckDB system column name. Mirrors
|
||||||
|
// engine/kinds.SYSTEM_COLUMN but in snake_case (DuckDB table uses
|
||||||
|
// snake_case columns; the engine relies on Kysely's camel-case plugin).
|
||||||
|
const SYSTEM_COLUMN_DUCK: Record<string, 'created_at' | 'updated_at' | 'last_updated_by_id'> = {
|
||||||
|
[BasePropertyType.CREATED_AT]: 'created_at',
|
||||||
|
[BasePropertyType.LAST_EDITED_AT]: 'updated_at',
|
||||||
|
[BasePropertyType.LAST_EDITED_BY]: 'last_updated_by_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDuckDbListQuery(
|
||||||
|
opts: DuckDbListQueryOpts,
|
||||||
|
): DuckDbListQuery {
|
||||||
|
const index = indexColumns(opts.columns);
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
const whereClauses: string[] = ['deleted_at IS NULL'];
|
||||||
|
|
||||||
|
if (opts.search) {
|
||||||
|
whereClauses.push(buildSearch(opts.search, params));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.filter) {
|
||||||
|
const filterSql = buildFilter(opts.filter, index, params);
|
||||||
|
if (filterSql) whereClauses.push(filterSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBuilds = buildSorts(opts.sorts ?? [], index);
|
||||||
|
|
||||||
|
const selectParts: string[] = buildSelect(index, sortBuilds);
|
||||||
|
|
||||||
|
if (opts.pagination.afterKeys) {
|
||||||
|
whereClauses.push(
|
||||||
|
buildKeyset(opts.pagination.afterKeys, sortBuilds, params),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderByParts: string[] = [
|
||||||
|
...sortBuilds.map((s) => `${s.key} ${s.direction.toUpperCase()}`),
|
||||||
|
'position ASC',
|
||||||
|
'id ASC',
|
||||||
|
];
|
||||||
|
|
||||||
|
const sql =
|
||||||
|
`SELECT ${selectParts.join(', ')}` +
|
||||||
|
` FROM rows` +
|
||||||
|
` WHERE ${whereClauses.join(' AND ')}` +
|
||||||
|
` ORDER BY ${orderByParts.join(', ')}` +
|
||||||
|
` LIMIT ${opts.pagination.limit + 1}`;
|
||||||
|
|
||||||
|
return { sql, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- select projection -------------------------------------------------
|
||||||
|
|
||||||
|
function buildSelect(index: ColumnIndex, sortBuilds: SortBuild[]): string[] {
|
||||||
|
const cellsJson = buildCellsJson(index.userColumns);
|
||||||
|
const parts: string[] = [
|
||||||
|
'id',
|
||||||
|
'base_id',
|
||||||
|
`${cellsJson} AS cells`,
|
||||||
|
'position',
|
||||||
|
'creator_id',
|
||||||
|
'last_updated_by_id',
|
||||||
|
'workspace_id',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'deleted_at',
|
||||||
|
];
|
||||||
|
for (const sb of sortBuilds) {
|
||||||
|
parts.push(`${sb.expression} AS ${sb.key}`);
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCellsJson(userColumns: ColumnSpec[]): string {
|
||||||
|
if (userColumns.length === 0) return `'{}'::JSON`;
|
||||||
|
const entries: string[] = [];
|
||||||
|
for (const col of userColumns) {
|
||||||
|
entries.push(`'${col.column}'`);
|
||||||
|
entries.push(quoteIdent(col.column));
|
||||||
|
}
|
||||||
|
return `json_object(${entries.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- filter ------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildFilter(
|
||||||
|
node: FilterNode,
|
||||||
|
index: ColumnIndex,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
if ('children' in node) {
|
||||||
|
if (node.children.length === 0) return 'TRUE';
|
||||||
|
const built = node.children
|
||||||
|
.map((c) => buildFilter(c, index, params))
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
if (built.length === 0) return 'TRUE';
|
||||||
|
const joiner = node.op === 'and' ? ' AND ' : ' OR ';
|
||||||
|
return `(${built.join(joiner)})`;
|
||||||
|
}
|
||||||
|
return buildCondition(node, index, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCondition(
|
||||||
|
cond: Condition,
|
||||||
|
index: ColumnIndex,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const col = index.byId.get(cond.propertyId);
|
||||||
|
if (!col) return 'FALSE';
|
||||||
|
|
||||||
|
const propType = col.property?.type;
|
||||||
|
if (propType && SYSTEM_COLUMN_DUCK[propType]) {
|
||||||
|
return systemCondition(SYSTEM_COLUMN_DUCK[propType], cond, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = propType ? propertyKind(propType) : null;
|
||||||
|
if (!kind) return 'FALSE';
|
||||||
|
|
||||||
|
const colRef = quoteIdent(col.column);
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case PropertyKind.TEXT:
|
||||||
|
return textCondition(colRef, cond, params);
|
||||||
|
case PropertyKind.NUMERIC:
|
||||||
|
return numericCondition(colRef, cond, params);
|
||||||
|
case PropertyKind.DATE:
|
||||||
|
return dateCondition(colRef, cond, params);
|
||||||
|
case PropertyKind.BOOL:
|
||||||
|
return boolCondition(colRef, cond, params);
|
||||||
|
case PropertyKind.SELECT:
|
||||||
|
return selectCondition(colRef, cond, params);
|
||||||
|
case PropertyKind.MULTI:
|
||||||
|
return arrayOfIdsCondition(colRef, cond, params);
|
||||||
|
case PropertyKind.PERSON: {
|
||||||
|
const allowMultiple = !!(col.property?.typeOptions as any)?.allowMultiple;
|
||||||
|
return allowMultiple
|
||||||
|
? arrayOfIdsCondition(colRef, cond, params)
|
||||||
|
: selectCondition(colRef, cond, params);
|
||||||
|
}
|
||||||
|
case PropertyKind.FILE:
|
||||||
|
return arrayOfIdsCondition(colRef, cond, params);
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textCondition(
|
||||||
|
colRef: string,
|
||||||
|
cond: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const val = cond.value;
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} = '')`;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return `(${colRef} IS NOT NULL AND ${colRef} != '')`;
|
||||||
|
case 'eq':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${colRef} = ?`;
|
||||||
|
case 'neq':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} != ?)`;
|
||||||
|
case 'contains':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(`%${escapeIlike(String(val))}%`);
|
||||||
|
return `${colRef} ILIKE ?`;
|
||||||
|
case 'ncontains':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(`%${escapeIlike(String(val))}%`);
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} NOT ILIKE ?)`;
|
||||||
|
case 'startsWith':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(`${escapeIlike(String(val))}%`);
|
||||||
|
return `${colRef} ILIKE ?`;
|
||||||
|
case 'endsWith':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(`%${escapeIlike(String(val))}`);
|
||||||
|
return `${colRef} ILIKE ?`;
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericCondition(
|
||||||
|
colRef: string,
|
||||||
|
cond: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const raw = cond.value;
|
||||||
|
const num = raw == null ? null : Number(raw);
|
||||||
|
const bad = num == null || Number.isNaN(num);
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return `${colRef} IS NULL`;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return `${colRef} IS NOT NULL`;
|
||||||
|
case 'eq':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(num);
|
||||||
|
return `${colRef} = ?`;
|
||||||
|
case 'neq':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(num);
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} != ?)`;
|
||||||
|
case 'gt':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(num);
|
||||||
|
return `${colRef} > ?`;
|
||||||
|
case 'gte':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(num);
|
||||||
|
return `${colRef} >= ?`;
|
||||||
|
case 'lt':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(num);
|
||||||
|
return `${colRef} < ?`;
|
||||||
|
case 'lte':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(num);
|
||||||
|
return `${colRef} <= ?`;
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateCondition(
|
||||||
|
colRef: string,
|
||||||
|
cond: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const raw = cond.value;
|
||||||
|
const bad = raw == null || raw === '';
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return `${colRef} IS NULL`;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return `${colRef} IS NOT NULL`;
|
||||||
|
case 'eq':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(raw));
|
||||||
|
return `${colRef} = ?`;
|
||||||
|
case 'neq':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(raw));
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} != ?)`;
|
||||||
|
case 'before':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(raw));
|
||||||
|
return `${colRef} < ?`;
|
||||||
|
case 'after':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(raw));
|
||||||
|
return `${colRef} > ?`;
|
||||||
|
case 'onOrBefore':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(raw));
|
||||||
|
return `${colRef} <= ?`;
|
||||||
|
case 'onOrAfter':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(raw));
|
||||||
|
return `${colRef} >= ?`;
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolCondition(
|
||||||
|
colRef: string,
|
||||||
|
cond: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return `${colRef} IS NULL`;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return `${colRef} IS NOT NULL`;
|
||||||
|
case 'eq':
|
||||||
|
if (cond.value == null) return 'FALSE';
|
||||||
|
params.push(Boolean(cond.value));
|
||||||
|
return `${colRef} = ?`;
|
||||||
|
case 'neq':
|
||||||
|
if (cond.value == null) return 'FALSE';
|
||||||
|
params.push(Boolean(cond.value));
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} != ?)`;
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCondition(
|
||||||
|
colRef: string,
|
||||||
|
cond: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const val = cond.value;
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} = '')`;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return `(${colRef} IS NOT NULL AND ${colRef} != '')`;
|
||||||
|
case 'eq':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${colRef} = ?`;
|
||||||
|
case 'neq':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} != ?)`;
|
||||||
|
case 'any': {
|
||||||
|
const arr = asStringArray(val);
|
||||||
|
if (arr.length === 0) return 'FALSE';
|
||||||
|
const placeholders = arr.map(() => '?').join(', ');
|
||||||
|
for (const v of arr) params.push(v);
|
||||||
|
return `${colRef} IN (${placeholders})`;
|
||||||
|
}
|
||||||
|
case 'none': {
|
||||||
|
const arr = asStringArray(val);
|
||||||
|
if (arr.length === 0) return 'TRUE';
|
||||||
|
const placeholders = arr.map(() => '?').join(', ');
|
||||||
|
for (const v of arr) params.push(v);
|
||||||
|
return `(${colRef} IS NULL OR ${colRef} NOT IN (${placeholders}))`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayOfIdsCondition(
|
||||||
|
colRef: string,
|
||||||
|
cond: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const val = cond.value;
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return `(${colRef} IS NULL OR json_array_length(${colRef}) = 0)`;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return `(${colRef} IS NOT NULL AND json_array_length(${colRef}) > 0)`;
|
||||||
|
case 'any': {
|
||||||
|
const arr = asStringArray(val);
|
||||||
|
if (arr.length === 0) return 'FALSE';
|
||||||
|
const legs = arr.map(() => `json_array_contains(${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}, ?)`);
|
||||||
|
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}, ?)`);
|
||||||
|
for (const v of arr) params.push(v);
|
||||||
|
return `(${colRef} IS NULL OR NOT (${legs.join(' OR ')}))`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemCondition(
|
||||||
|
column: 'created_at' | 'updated_at' | 'last_updated_by_id',
|
||||||
|
cond: Condition,
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
const val = cond.value;
|
||||||
|
|
||||||
|
if (column === 'last_updated_by_id') {
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return `${column} IS NULL`;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return `${column} IS NOT NULL`;
|
||||||
|
case 'eq':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${column} = ?`;
|
||||||
|
case 'neq':
|
||||||
|
if (val == null) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `(${column} IS NULL OR ${column} != ?)`;
|
||||||
|
case 'any': {
|
||||||
|
const arr = asStringArray(val);
|
||||||
|
if (arr.length === 0) return 'FALSE';
|
||||||
|
const placeholders = arr.map(() => '?').join(', ');
|
||||||
|
for (const v of arr) params.push(v);
|
||||||
|
return `${column} IN (${placeholders})`;
|
||||||
|
}
|
||||||
|
case 'none': {
|
||||||
|
const arr = asStringArray(val);
|
||||||
|
if (arr.length === 0) return 'TRUE';
|
||||||
|
const placeholders = arr.map(() => '?').join(', ');
|
||||||
|
for (const v of arr) params.push(v);
|
||||||
|
return `(${column} IS NULL OR ${column} NOT IN (${placeholders}))`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = val == null || val === '';
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return 'FALSE';
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return 'TRUE';
|
||||||
|
case 'eq':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${column} = ?`;
|
||||||
|
case 'neq':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${column} != ?`;
|
||||||
|
case 'before':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${column} < ?`;
|
||||||
|
case 'after':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${column} > ?`;
|
||||||
|
case 'onOrBefore':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${column} <= ?`;
|
||||||
|
case 'onOrAfter':
|
||||||
|
if (bad) return 'FALSE';
|
||||||
|
params.push(String(val));
|
||||||
|
return `${column} >= ?`;
|
||||||
|
default:
|
||||||
|
return 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sort --------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSorts(sorts: SortSpec[], index: ColumnIndex): SortBuild[] {
|
||||||
|
const out: SortBuild[] = [];
|
||||||
|
for (let i = 0; i < sorts.length; i++) {
|
||||||
|
const s = sorts[i];
|
||||||
|
const col = index.byId.get(s.propertyId);
|
||||||
|
if (!col) continue;
|
||||||
|
const key = `s${i}`;
|
||||||
|
|
||||||
|
const propType = col.property?.type;
|
||||||
|
const sys = propType ? SYSTEM_COLUMN_DUCK[propType] : undefined;
|
||||||
|
if (sys) {
|
||||||
|
out.push({ key, expression: sys, direction: s.direction });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = propType ? propertyKind(propType) : null;
|
||||||
|
if (!kind) continue;
|
||||||
|
|
||||||
|
out.push(wrapWithSentinel(col.column, kind, s.direction, key));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapWithSentinel(
|
||||||
|
column: string,
|
||||||
|
kind: ReturnType<typeof propertyKind>,
|
||||||
|
direction: 'asc' | 'desc',
|
||||||
|
key: string,
|
||||||
|
): SortBuild {
|
||||||
|
const colRef = quoteIdent(column);
|
||||||
|
let sentinel: string;
|
||||||
|
if (kind === PropertyKind.NUMERIC) {
|
||||||
|
sentinel = direction === 'asc' ? `'Infinity'::DOUBLE` : `'-Infinity'::DOUBLE`;
|
||||||
|
} else if (kind === PropertyKind.DATE) {
|
||||||
|
sentinel =
|
||||||
|
direction === 'asc'
|
||||||
|
? `'9999-12-31 23:59:59+00'::TIMESTAMPTZ`
|
||||||
|
: `'0001-01-01 00:00:00+00'::TIMESTAMPTZ`;
|
||||||
|
} else if (kind === PropertyKind.BOOL) {
|
||||||
|
sentinel = direction === 'asc' ? 'TRUE' : 'FALSE';
|
||||||
|
} else {
|
||||||
|
// TEXT / SELECT / MULTI / PERSON / FILE — sort by the column's raw text
|
||||||
|
// representation; JSON-typed list columns will stringify in DuckDB
|
||||||
|
// lexicographically, matching the Postgres engine's text extractor.
|
||||||
|
sentinel = direction === 'asc' ? 'CHR(1114111)' : `''`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
expression: `COALESCE(${colRef}, ${sentinel})`,
|
||||||
|
direction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- search ------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSearch(spec: SearchSpec, params: unknown[]): string {
|
||||||
|
const q = spec.query.trim();
|
||||||
|
if (!q) return 'TRUE';
|
||||||
|
if (spec.mode === 'fts') {
|
||||||
|
throw new FtsNotSupportedInCache();
|
||||||
|
}
|
||||||
|
params.push(`%${escapeIlike(q)}%`);
|
||||||
|
return `search_text ILIKE ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- keyset ------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildKeyset(
|
||||||
|
afterKeys: AfterKeys,
|
||||||
|
sortBuilds: SortBuild[],
|
||||||
|
params: unknown[],
|
||||||
|
): string {
|
||||||
|
// Keys in the same order as ORDER BY: s0..sN, then position, then id.
|
||||||
|
// Mirrors cursor-pagination.ts `applyCursor`: builds the lexicographic
|
||||||
|
// OR-chain from tail to head, wrapping each step as
|
||||||
|
// `(fi > v) OR (fi = v AND <tail>)`.
|
||||||
|
type Leg = { key: string; expression: string; direction: 'asc' | 'desc' };
|
||||||
|
const legs: Leg[] = [
|
||||||
|
...sortBuilds.map((s) => ({
|
||||||
|
key: s.key,
|
||||||
|
expression: s.key,
|
||||||
|
direction: s.direction,
|
||||||
|
})),
|
||||||
|
{ key: 'position', expression: 'position', direction: 'asc' },
|
||||||
|
{ key: 'id', expression: 'id', direction: 'asc' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let expr = '';
|
||||||
|
for (let i = legs.length - 1; i >= 0; i--) {
|
||||||
|
const leg = legs[i];
|
||||||
|
if (!(leg.key in afterKeys)) continue;
|
||||||
|
const value = afterKeys[leg.key];
|
||||||
|
const cmp = leg.direction === 'asc' ? '>' : '<';
|
||||||
|
|
||||||
|
params.push(value);
|
||||||
|
const head = `${leg.expression} ${cmp} ?`;
|
||||||
|
|
||||||
|
if (!expr) {
|
||||||
|
expr = head;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params.push(value);
|
||||||
|
const tie = `${leg.expression} = ?`;
|
||||||
|
expr = `(${head} OR (${tie} AND ${expr}))`;
|
||||||
|
}
|
||||||
|
return expr || 'TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- utilities ---------------------------------------------------------
|
||||||
|
|
||||||
|
function indexColumns(columns: ColumnSpec[]): ColumnIndex {
|
||||||
|
const byId = new Map<string, ColumnSpec>();
|
||||||
|
const userColumns: ColumnSpec[] = [];
|
||||||
|
for (const c of columns) {
|
||||||
|
if (c.property) {
|
||||||
|
byId.set(c.property.id, c);
|
||||||
|
userColumns.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byId, userColumns };
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIdent(name: string): string {
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringArray(val: unknown): string[] {
|
||||||
|
if (val == null) return [];
|
||||||
|
if (Array.isArray(val)) return val.filter((v) => v != null).map(String);
|
||||||
|
return [String(val)];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user