feat(base): schema-qualified query builder for single-instance duckdb

This commit is contained in:
Philipinho
2026-04-23 16:19:47 +01:00
parent f12a0675ea
commit 568d94be1f
2 changed files with 29 additions and 2 deletions
@@ -2,6 +2,8 @@ import { buildColumnSpecs } from './column-types';
import { buildDuckDbListQuery } from './duckdb-query-builder'; import { buildDuckDbListQuery } from './duckdb-query-builder';
import { BasePropertyType } from '../base.schemas'; import { BasePropertyType } from '../base.schemas';
const SCHEMA = 'b_019c69a3dd4770148b87ec8f1675aaaa';
const numericProp = { const numericProp = {
id: '00000000-0000-0000-0000-000000000001', id: '00000000-0000-0000-0000-000000000001',
type: BasePropertyType.NUMBER, type: BasePropertyType.NUMBER,
@@ -18,10 +20,11 @@ const columns = buildColumnSpecs([numericProp, textProp]);
describe('buildDuckDbListQuery', () => { describe('buildDuckDbListQuery', () => {
it('renders no-filter, no-sort, no-search as live-rows-paginated-by-position', () => { it('renders no-filter, no-sort, no-search as live-rows-paginated-by-position', () => {
const { sql, params } = buildDuckDbListQuery({ const { sql, params } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
pagination: { limit: 100 }, pagination: { limit: 100 },
}); });
expect(sql).toMatch(/FROM rows/); expect(sql).toContain(`FROM ${SCHEMA}.rows`);
expect(sql).toMatch(/deleted_at IS NULL/); expect(sql).toMatch(/deleted_at IS NULL/);
expect(sql).toMatch(/ORDER BY position ASC, id ASC/); expect(sql).toMatch(/ORDER BY position ASC, id ASC/);
expect(sql).toMatch(/LIMIT 101/); expect(sql).toMatch(/LIMIT 101/);
@@ -30,6 +33,7 @@ describe('buildDuckDbListQuery', () => {
it('renders numeric gt filter with parameterized value', () => { it('renders numeric gt filter with parameterized value', () => {
const { sql, params } = buildDuckDbListQuery({ const { sql, params } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
filter: { filter: {
op: 'and', op: 'and',
@@ -43,6 +47,7 @@ describe('buildDuckDbListQuery', () => {
it('renders text contains with ILIKE and escaped wildcards', () => { it('renders text contains with ILIKE and escaped wildcards', () => {
const { sql, params } = buildDuckDbListQuery({ const { sql, params } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
filter: { filter: {
op: 'and', op: 'and',
@@ -56,6 +61,7 @@ describe('buildDuckDbListQuery', () => {
it('renders sort with sentinel wrapping and cursor keyset', () => { it('renders sort with sentinel wrapping and cursor keyset', () => {
const { sql } = buildDuckDbListQuery({ const { sql } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
sorts: [{ propertyId: numericProp.id, direction: 'asc' }], sorts: [{ propertyId: numericProp.id, direction: 'asc' }],
pagination: { pagination: {
@@ -71,6 +77,7 @@ describe('buildDuckDbListQuery', () => {
it('renders search in trgm mode as ILIKE on search_text', () => { it('renders search in trgm mode as ILIKE on search_text', () => {
const { sql, params } = buildDuckDbListQuery({ const { sql, params } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
search: { mode: 'trgm', query: 'hello' }, search: { mode: 'trgm', query: 'hello' },
pagination: { limit: 10 }, pagination: { limit: 10 },
@@ -89,6 +96,7 @@ describe('buildDuckDbListQuery', () => {
const choiceA = 'choice-uuid-aaa'; const choiceA = 'choice-uuid-aaa';
const choiceB = 'choice-uuid-bbb'; const choiceB = 'choice-uuid-bbb';
const { sql, params } = buildDuckDbListQuery({ const { sql, params } = buildDuckDbListQuery({
schema: SCHEMA,
columns: cols, columns: cols,
filter: { filter: {
op: 'and', op: 'and',
@@ -104,6 +112,7 @@ describe('buildDuckDbListQuery', () => {
it('renders nested AND/OR groups with correct parentheses', () => { it('renders nested AND/OR groups with correct parentheses', () => {
const { sql } = buildDuckDbListQuery({ const { sql } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
filter: { filter: {
op: 'or', op: 'or',
@@ -119,6 +128,7 @@ describe('buildDuckDbListQuery', () => {
it('handles empty filter group without emitting WHERE on it', () => { it('handles empty filter group without emitting WHERE on it', () => {
const { sql, params } = buildDuckDbListQuery({ const { sql, params } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
filter: { op: 'and', children: [] }, filter: { op: 'and', children: [] },
pagination: { limit: 100 }, pagination: { limit: 100 },
@@ -130,6 +140,7 @@ describe('buildDuckDbListQuery', () => {
it('renders multi-sort keyset with s0, s1, position, id chain', () => { it('renders multi-sort keyset with s0, s1, position, id chain', () => {
const { sql } = buildDuckDbListQuery({ const { sql } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
sorts: [ sorts: [
{ propertyId: numericProp.id, direction: 'asc' }, { propertyId: numericProp.id, direction: 'asc' },
@@ -149,6 +160,7 @@ describe('buildDuckDbListQuery', () => {
it('renders text isEmpty as IS NULL OR = empty-string', () => { it('renders text isEmpty as IS NULL OR = empty-string', () => {
const { sql } = buildDuckDbListQuery({ const { sql } = buildDuckDbListQuery({
schema: SCHEMA,
columns, columns,
filter: { filter: {
op: 'and', op: 'and',
@@ -158,4 +170,14 @@ describe('buildDuckDbListQuery', () => {
}); });
expect(sql).toMatch(new RegExp(`"${textProp.id}" IS NULL`)); expect(sql).toMatch(new RegExp(`"${textProp.id}" IS NULL`));
}); });
it('rejects invalid schema name', () => {
expect(() =>
buildDuckDbListQuery({
schema: 'bad name',
columns: [],
pagination: { limit: 10 },
}),
).toThrow(/invalid schema/i);
});
}); });
@@ -12,6 +12,7 @@ import { ColumnSpec } from './query-cache.types';
export type AfterKeys = Record<string, unknown>; export type AfterKeys = Record<string, unknown>;
export type DuckDbListQueryOpts = { export type DuckDbListQueryOpts = {
schema: string;
columns: ColumnSpec[]; columns: ColumnSpec[];
filter?: FilterNode; filter?: FilterNode;
sorts?: SortSpec[]; sorts?: SortSpec[];
@@ -54,6 +55,10 @@ const SYSTEM_COLUMN_DUCK: Record<string, 'created_at' | 'updated_at' | 'last_upd
export function buildDuckDbListQuery( export function buildDuckDbListQuery(
opts: DuckDbListQueryOpts, opts: DuckDbListQueryOpts,
): DuckDbListQuery { ): DuckDbListQuery {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(opts.schema)) {
throw new Error(`Invalid schema name "${opts.schema}"`);
}
const rowsTable = `${opts.schema}.rows`;
const index = indexColumns(opts.columns); const index = indexColumns(opts.columns);
const params: unknown[] = []; const params: unknown[] = [];
@@ -86,7 +91,7 @@ export function buildDuckDbListQuery(
const sql = const sql =
`SELECT ${selectParts.join(', ')}` + `SELECT ${selectParts.join(', ')}` +
` FROM rows` + ` FROM ${rowsTable}` +
` WHERE ${whereClauses.join(' AND ')}` + ` WHERE ${whereClauses.join(' AND ')}` +
` ORDER BY ${orderByParts.join(', ')}` + ` ORDER BY ${orderByParts.join(', ')}` +
` LIMIT ${opts.pagination.limit + 1}`; ` LIMIT ${opts.pagination.limit + 1}`;