mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(base): pure SQL builder for pg-extension loader
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user