From 6544ff6d383c3a7b19c6c7ad7caaa420148b4fb2 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:31:00 +0100 Subject: [PATCH] feat(base): pure SQL builder for pg-extension loader --- .../core/base/query-cache/loader-sql.spec.ts | 108 ++++++++++++++++++ .../src/core/base/query-cache/loader-sql.ts | 100 ++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 apps/server/src/core/base/query-cache/loader-sql.spec.ts create mode 100644 apps/server/src/core/base/query-cache/loader-sql.ts diff --git a/apps/server/src/core/base/query-cache/loader-sql.spec.ts b/apps/server/src/core/base/query-cache/loader-sql.spec.ts new file mode 100644 index 00000000..b4040432 --- /dev/null +++ b/apps/server/src/core/base/query-cache/loader-sql.spec.ts @@ -0,0 +1,108 @@ +import { buildLoaderSql } from './loader-sql'; +import { ColumnSpec } from './query-cache.types'; +import { BasePropertyType } from '../base.schemas'; + +const sys: ColumnSpec[] = [ + { column: 'id', ddlType: 'VARCHAR', indexable: false }, + { column: 'base_id', ddlType: 'VARCHAR', indexable: false }, + { column: 'workspace_id', ddlType: 'VARCHAR', indexable: false }, + { column: 'creator_id', ddlType: 'VARCHAR', indexable: false }, + { column: 'position', ddlType: 'VARCHAR', indexable: true }, + { column: 'created_at', ddlType: 'TIMESTAMPTZ', indexable: true }, + { column: 'updated_at', ddlType: 'TIMESTAMPTZ', indexable: true }, + { column: 'last_updated_by_id', ddlType: 'VARCHAR', indexable: true }, + { column: 'deleted_at', ddlType: 'TIMESTAMPTZ', indexable: false }, + { column: 'search_text', ddlType: 'VARCHAR', indexable: false }, +]; + +const makeProp = ( + id: string, + type: (typeof BasePropertyType)[keyof typeof BasePropertyType], +): ColumnSpec['property'] => ({ id, type, typeOptions: null } as any); + +describe('buildLoaderSql', () => { + it('projects system columns verbatim from pg.base_rows', () => { + const sql = buildLoaderSql(sys); + expect(sql).toContain('CREATE TABLE rows AS'); + expect(sql).toContain('id::text AS id'); + expect(sql).toContain('base_id::text AS base_id'); + expect(sql).toContain('position'); + expect(sql).toContain('created_at'); + expect(sql).toContain("''::VARCHAR AS search_text"); + expect(sql).toContain('FROM pg.base_rows'); + expect(sql).toContain( + 'WHERE base_id = $1::uuid AND workspace_id = $2::uuid AND deleted_at IS NULL', + ); + }); + + it('maps TEXT -> base_cell_text', () => { + const prop = makeProp('019c69a3-dd47-7014-8b87-ec8f167577aa', BasePropertyType.TEXT); + const sql = buildLoaderSql([ + ...sys, + { column: prop!.id, ddlType: 'VARCHAR', indexable: true, property: prop }, + ]); + expect(sql).toContain( + `base_cell_text(cells, '019c69a3-dd47-7014-8b87-ec8f167577aa'::uuid) AS "019c69a3-dd47-7014-8b87-ec8f167577aa"`, + ); + }); + + it('maps NUMBER -> base_cell_numeric', () => { + const prop = makeProp('019c69a3-dd47-7014-8b87-ec8f167577bb', BasePropertyType.NUMBER); + const sql = buildLoaderSql([ + ...sys, + { column: prop!.id, ddlType: 'DOUBLE', indexable: true, property: prop }, + ]); + expect(sql).toContain(`base_cell_numeric(cells, '019c69a3-dd47-7014-8b87-ec8f167577bb'::uuid)`); + }); + + it('maps DATE -> base_cell_timestamptz', () => { + const prop = makeProp('019c69a3-dd47-7014-8b87-ec8f167577cc', BasePropertyType.DATE); + const sql = buildLoaderSql([ + ...sys, + { column: prop!.id, ddlType: 'TIMESTAMPTZ', indexable: true, property: prop }, + ]); + expect(sql).toContain(`base_cell_timestamptz(cells, '019c69a3-dd47-7014-8b87-ec8f167577cc'::uuid)`); + }); + + it('maps CHECKBOX -> base_cell_bool', () => { + const prop = makeProp('019c69a3-dd47-7014-8b87-ec8f167577dd', BasePropertyType.CHECKBOX); + const sql = buildLoaderSql([ + ...sys, + { column: prop!.id, ddlType: 'BOOLEAN', indexable: true, property: prop }, + ]); + expect(sql).toContain(`base_cell_bool(cells, '019c69a3-dd47-7014-8b87-ec8f167577dd'::uuid)`); + }); + + it('maps MULTI_SELECT (JSON) -> raw jsonb cast to text', () => { + const prop = makeProp('019c69a3-dd47-7014-8b87-ec8f167577ee', BasePropertyType.MULTI_SELECT); + const sql = buildLoaderSql([ + ...sys, + { column: prop!.id, ddlType: 'JSON', indexable: false, property: prop }, + ]); + expect(sql).toContain(`(cells -> '019c69a3-dd47-7014-8b87-ec8f167577ee')::text`); + }); + + it('rejects invalid column names (defense-in-depth against quoting bugs)', () => { + const bad: ColumnSpec = { + column: 'pwned"; DROP TABLE rows; --', + ddlType: 'VARCHAR', + indexable: false, + }; + expect(() => buildLoaderSql([bad])).toThrow(/invalid column name/i); + }); + + it('rejects non-UUID property ids', () => { + const badProp = { id: 'not-a-uuid', type: BasePropertyType.TEXT, typeOptions: null } as any; + expect(() => + buildLoaderSql([ + { column: 'some-uuid-col', ddlType: 'VARCHAR', indexable: true, property: badProp }, + ]), + ).toThrow(/invalid property uuid/i); + }); + + it('produces deterministic column order across invocations', () => { + const a = buildLoaderSql(sys); + const b = buildLoaderSql(sys); + expect(a).toEqual(b); + }); +}); diff --git a/apps/server/src/core/base/query-cache/loader-sql.ts b/apps/server/src/core/base/query-cache/loader-sql.ts new file mode 100644 index 00000000..f8c99030 --- /dev/null +++ b/apps/server/src/core/base/query-cache/loader-sql.ts @@ -0,0 +1,100 @@ +import { ColumnSpec } from './query-cache.types'; + +/* + * Pure SQL builder for the cold-load query executed by DuckDB's postgres + * extension against `pg.base_rows`. Parameterized by + * $1 = baseId (uuid), $2 = workspaceId (uuid) + * Callers bind via prepared statements. + * + * Design notes: + * + * - Every SYSTEM_COLUMN maps directly onto a column in `base_rows`. + * UUID columns cast to text so they land in DuckDB's VARCHAR column. + * + * - User columns delegate to the Postgres helper functions defined in + * migration 20260417T120000 (`base_cell_text`, `base_cell_numeric`, + * `base_cell_timestamptz`, `base_cell_bool`). These run on the + * Postgres side — DuckDB ships the full SELECT through the extension; + * JSONB extraction never touches DuckDB. + * + * - JSON columns (multi-select, file, multi-person) are passed as raw JSON + * text (`(cells -> 'uuid')::text`). DuckDB's JSON column accepts that. + * + * - Identifiers are validated before interpolation. `ColumnSpec.column` is + * always a UUID or snake_case system name; the regex catches any + * programming mistake that would otherwise break SQL quoting. + */ +export function buildLoaderSql(specs: ColumnSpec[]): string { + const projections = specs.map((spec) => projectionFor(spec)); + return [ + 'CREATE TABLE rows AS', + 'SELECT', + ' ' + projections.join(',\n '), + 'FROM pg.base_rows', + 'WHERE base_id = $1::uuid AND workspace_id = $2::uuid AND deleted_at IS NULL', + ].join('\n'); +} + +function projectionFor(spec: ColumnSpec): string { + validateColumnName(spec.column); + const qid = `"${spec.column}"`; + + // System columns — fixed mapping onto base_rows. + switch (spec.column) { + case 'id': return 'id::text AS id'; + case 'base_id': return 'base_id::text AS base_id'; + case 'workspace_id': return 'workspace_id::text AS workspace_id'; + case 'creator_id': return 'creator_id::text AS creator_id'; + case 'position': return 'position'; + case 'created_at': return 'created_at'; + case 'updated_at': return 'updated_at'; + case 'last_updated_by_id': return 'last_updated_by_id::text AS last_updated_by_id'; + case 'deleted_at': return 'deleted_at'; + case 'search_text': return "''::VARCHAR AS search_text"; + } + + // User columns. + const prop = spec.property; + if (!prop) { + throw new Error( + `ColumnSpec for "${spec.column}" has no property; cannot project`, + ); + } + + const id = prop.id; + validateUuid(id); + + switch (spec.ddlType) { + case 'VARCHAR': + // TEXT, URL, EMAIL, SELECT, STATUS, single-PERSON all map to VARCHAR. + return `base_cell_text(cells, '${id}'::uuid) AS ${qid}`; + case 'DOUBLE': + return `base_cell_numeric(cells, '${id}'::uuid) AS ${qid}`; + case 'TIMESTAMPTZ': + return `base_cell_timestamptz(cells, '${id}'::uuid) AS ${qid}`; + case 'BOOLEAN': + return `base_cell_bool(cells, '${id}'::uuid) AS ${qid}`; + case 'JSON': + // MULTI_SELECT / FILE / multi-PERSON. + return `(cells -> '${id}')::text AS ${qid}`; + default: { + const _never: never = spec.ddlType; + throw new Error(`Unknown DuckDbDdlType: ${_never}`); + } + } +} + +const VALID_COL = /^[a-zA-Z0-9_\-]+$/; +function validateColumnName(name: string): void { + if (!VALID_COL.test(name)) { + throw new Error(`Invalid column name "${name}"`); + } +} + +const UUID = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; +function validateUuid(s: string): void { + if (!UUID.test(s)) { + throw new Error(`Invalid property UUID "${s}"`); + } +}