@rindle/query-compiler 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +42 -0
- package/dist/ast.d.ts +2 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -0
- package/dist/catalog.d.ts +51 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +7 -0
- package/dist/catalog.js.map +1 -0
- package/dist/complete-ordering.d.ts +3 -0
- package/dist/complete-ordering.d.ts.map +1 -0
- package/dist/complete-ordering.js +44 -0
- package/dist/complete-ordering.js.map +1 -0
- package/dist/dialect.d.ts +49 -0
- package/dist/dialect.d.ts.map +1 -0
- package/dist/dialect.js +35 -0
- package/dist/dialect.js.map +1 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/pg-types.d.ts +20 -0
- package/dist/pg-types.d.ts.map +1 -0
- package/dist/pg-types.js +107 -0
- package/dist/pg-types.js.map +1 -0
- package/dist/postgres.d.ts +7 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +134 -0
- package/dist/postgres.js.map +1 -0
- package/dist/reject.d.ts +3 -0
- package/dist/reject.d.ts.map +1 -0
- package/dist/reject.js +74 -0
- package/dist/reject.js.map +1 -0
- package/dist/walker.d.ts +11 -0
- package/dist/walker.d.ts.map +1 -0
- package/dist/walker.js +350 -0
- package/dist/walker.js.map +1 -0
- package/package.json +39 -0
- package/src/ast.ts +18 -0
- package/src/catalog.ts +59 -0
- package/src/complete-ordering.ts +48 -0
- package/src/dialect.ts +85 -0
- package/src/errors.ts +30 -0
- package/src/index.ts +74 -0
- package/src/pg-types.ts +119 -0
- package/src/postgres.ts +152 -0
- package/src/reject.ts +71 -0
- package/src/walker.ts +418 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Why a query could not be compiled. Mirrors `rindle-d2s`'s `CompileError` (lib.rs:133) so
|
|
2
|
+
// the two compilers reject the same shapes with the same reasons.
|
|
3
|
+
|
|
4
|
+
export class CompileError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "CompileError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A referenced table is not in the catalog. */
|
|
12
|
+
export function unknownTable(table: string): CompileError {
|
|
13
|
+
return new CompileError(`unknown table \`${table}\``);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A `related`/EXISTS relationship's cardinality is not declared in the catalog. */
|
|
17
|
+
export function unknownRelationship(table: string, rel: string): CompileError {
|
|
18
|
+
return new CompileError(`unknown relationship \`${rel}\` on table \`${table}\``);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A `related` subquery is missing an alias (every child must be named). */
|
|
22
|
+
export function missingAlias(table: string): CompileError {
|
|
23
|
+
return new CompileError(`related subquery on \`${table}\` has no alias`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An AST feature the compiler does not lower in the position it appears — rejected loudly
|
|
27
|
+
* rather than emitting silently-wrong SQL (e.g. a `start` paging bound inside EXISTS). */
|
|
28
|
+
export function unsupported(what: string): CompileError {
|
|
29
|
+
return new CompileError(`unsupported AST feature: ${what}`);
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @rindle/query-compiler — compile a Rindle query `Ast` into one SQL `SELECT` whose single
|
|
2
|
+
// column `"rindle_result"` is the entire nested result tree as JSON, run on a live SQL
|
|
3
|
+
// backend inside the caller's open transaction (POSTGRES-READ-COMPILER-DESIGN.md §4).
|
|
4
|
+
//
|
|
5
|
+
// Dialect-parametric, with BOTH dialects product targets:
|
|
6
|
+
// - `postgres` — the BYO-Postgres server mutator read, with the §6.2 `::text::<type>` cast
|
|
7
|
+
// discipline (PG is a second type system whose driver parsing must be pinned + reconciled).
|
|
8
|
+
// - `sqlite` — the daemon-backend mutator read, ridden through an interactive mutation
|
|
9
|
+
// session (DAEMON-INTERACTIVE-TXN-DESIGN.md §5.4). NO casts, by design: SQLite IS the
|
|
10
|
+
// canonical store and results return as raw storage classes over Rindle's own value
|
|
11
|
+
// encoding, so there is no second representation to reconcile — every value binds as a
|
|
12
|
+
// native parameter (`rindle-d2s`, the Rust twin, states the same principle: "Values
|
|
13
|
+
// (minimal — no casting)"). It doubles as the offline differential oracle (§9, §11),
|
|
14
|
+
// which is what proves the shared walker end-to-end against `node:sqlite`.
|
|
15
|
+
|
|
16
|
+
export type {
|
|
17
|
+
Aggregate,
|
|
18
|
+
Ast,
|
|
19
|
+
Bound,
|
|
20
|
+
Condition,
|
|
21
|
+
CorrelatedSubquery,
|
|
22
|
+
Correlation,
|
|
23
|
+
Dir,
|
|
24
|
+
ExistsOp,
|
|
25
|
+
LitValue,
|
|
26
|
+
OrderPart,
|
|
27
|
+
SimpleOp,
|
|
28
|
+
ValuePosition,
|
|
29
|
+
} from "./ast.ts";
|
|
30
|
+
export type { Cardinality, Catalog, ColumnType, TableSchema } from "./catalog.ts";
|
|
31
|
+
export type { Dialect } from "./dialect.ts";
|
|
32
|
+
export { sqliteDialect } from "./dialect.ts";
|
|
33
|
+
export { postgresDialect } from "./postgres.ts";
|
|
34
|
+
|
|
35
|
+
import type { Ast } from "./ast.ts";
|
|
36
|
+
import type { Catalog } from "./catalog.ts";
|
|
37
|
+
import { sqliteDialect } from "./dialect.ts";
|
|
38
|
+
import { postgresDialect } from "./postgres.ts";
|
|
39
|
+
import { compileWith } from "./walker.ts";
|
|
40
|
+
|
|
41
|
+
/** Which SQL dialect to emit. Both are product targets: `postgres` for the BYO-PG backend,
|
|
42
|
+
* `sqlite` for the daemon backend's session reads (and the offline oracle, §9). */
|
|
43
|
+
export type DialectName = "postgres" | "sqlite";
|
|
44
|
+
|
|
45
|
+
export interface CompileOptions {
|
|
46
|
+
dialect: DialectName;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A compiled query: one SQL `SELECT` plus its positional bound parameters. `sql` returns a
|
|
51
|
+
* single row with a single column `"rindle_result"` — the whole nested result as JSON
|
|
52
|
+
* (`jsonb` on Postgres, `json` text on SQLite). No runtime value is ever interpolated into
|
|
53
|
+
* `sql`; every filter/paging value is a bound parameter (Invariant 4).
|
|
54
|
+
*/
|
|
55
|
+
export interface CompiledQuery {
|
|
56
|
+
sql: string;
|
|
57
|
+
/** In order: `$1..$n` for Postgres, `?` for SQLite. */
|
|
58
|
+
params: unknown[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compile `ast` against `catalog` into `{ sql, params }` (§4). A pure function of its
|
|
63
|
+
* inputs — no database access (Invariant 2). The root is **singular** when `ast.one` is set
|
|
64
|
+
* (a single JSON object or `null`), else **plural** (a JSON array).
|
|
65
|
+
*/
|
|
66
|
+
export function compile(ast: Ast, catalog: Catalog, opts: CompileOptions): CompiledQuery {
|
|
67
|
+
const singular = ast.one === true;
|
|
68
|
+
switch (opts.dialect) {
|
|
69
|
+
case "sqlite":
|
|
70
|
+
return compileWith(ast, catalog, sqliteDialect, singular);
|
|
71
|
+
case "postgres":
|
|
72
|
+
return compileWith(ast, catalog, postgresDialect, singular);
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/pg-types.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Postgres type classification — the predicates that decide which `::text::<type>` cast a
|
|
2
|
+
// value gets (§6.2). Ported verbatim from mono's `zero-cache/src/types/pg-data-type.ts` (the
|
|
3
|
+
// maps + `formatTypeForLookup`), plus the temporal helpers the projection reconciliation needs.
|
|
4
|
+
// Keeping these faithful to z2s is what keeps the filter-side casts bug-for-bug aligned.
|
|
5
|
+
|
|
6
|
+
// The map *values* are irrelevant to the predicates (they test key membership); kept as in
|
|
7
|
+
// mono for fidelity and in case the value-type mapping is needed later.
|
|
8
|
+
const pgToZqlNumericTypeMap: Record<string, string> = Object.freeze({
|
|
9
|
+
smallint: "number",
|
|
10
|
+
integer: "number",
|
|
11
|
+
int: "number",
|
|
12
|
+
int2: "number",
|
|
13
|
+
int4: "number",
|
|
14
|
+
int8: "number",
|
|
15
|
+
bigint: "number",
|
|
16
|
+
smallserial: "number",
|
|
17
|
+
serial: "number",
|
|
18
|
+
serial2: "number",
|
|
19
|
+
serial4: "number",
|
|
20
|
+
serial8: "number",
|
|
21
|
+
bigserial: "number",
|
|
22
|
+
decimal: "number",
|
|
23
|
+
numeric: "number",
|
|
24
|
+
real: "number",
|
|
25
|
+
"double precision": "number",
|
|
26
|
+
float: "number",
|
|
27
|
+
float4: "number",
|
|
28
|
+
float8: "number",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const pgToZqlNativeStringTypeMap: Record<string, string> = Object.freeze({
|
|
32
|
+
bpchar: "string",
|
|
33
|
+
character: "string",
|
|
34
|
+
"character varying": "string",
|
|
35
|
+
text: "string",
|
|
36
|
+
varchar: "string",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const pgToZqlTextRepresentedTypeMap: Record<string, string> = Object.freeze({
|
|
40
|
+
cidr: "string",
|
|
41
|
+
ean13: "string",
|
|
42
|
+
inet: "string",
|
|
43
|
+
isbn: "string",
|
|
44
|
+
isbn13: "string",
|
|
45
|
+
ismn: "string",
|
|
46
|
+
ismn13: "string",
|
|
47
|
+
issn: "string",
|
|
48
|
+
issn13: "string",
|
|
49
|
+
macaddr: "string",
|
|
50
|
+
macaddr8: "string",
|
|
51
|
+
pg_lsn: "string",
|
|
52
|
+
upc: "string",
|
|
53
|
+
uuid: "string",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const pgToZqlStringTypeMap: Record<string, string> = Object.freeze({
|
|
57
|
+
...pgToZqlNativeStringTypeMap,
|
|
58
|
+
...pgToZqlTextRepresentedTypeMap,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Strips args (the `(32)` in `char(32)`) and lowercases — mono's `formatTypeForLookup`.
|
|
62
|
+
* Every predicate normalizes through this, so `VARCHAR(255)` / `UUID` classify correctly. */
|
|
63
|
+
export function formatTypeForLookup(pgType: string): string {
|
|
64
|
+
const startOfArgs = pgType.indexOf("(");
|
|
65
|
+
if (startOfArgs === -1) return pgType.toLowerCase();
|
|
66
|
+
return pgType.toLowerCase().substring(0, startOfArgs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isPgNumberType(pgType: string): boolean {
|
|
70
|
+
return Object.hasOwn(pgToZqlNumericTypeMap, formatTypeForLookup(pgType));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isPgNativeStringType(pgType: string): boolean {
|
|
74
|
+
return Object.hasOwn(pgToZqlNativeStringTypeMap, formatTypeForLookup(pgType));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isPgTextRepresentedType(pgType: string): boolean {
|
|
78
|
+
return Object.hasOwn(pgToZqlTextRepresentedTypeMap, formatTypeForLookup(pgType));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isPgStringType(pgType: string): boolean {
|
|
82
|
+
return Object.hasOwn(pgToZqlStringTypeMap, formatTypeForLookup(pgType));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── temporal helpers (projection reconciliation, §6.2) ──────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const TEMPORAL_TYPES = new Set([
|
|
88
|
+
"date",
|
|
89
|
+
"time",
|
|
90
|
+
"time without time zone",
|
|
91
|
+
"time with time zone",
|
|
92
|
+
"timetz",
|
|
93
|
+
"timestamp",
|
|
94
|
+
"timestamp without time zone",
|
|
95
|
+
"timestamp with time zone",
|
|
96
|
+
"timestamptz",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/** A temporal type projected as epoch-ms (outbound) and reconciled via `to_timestamp`/interval
|
|
100
|
+
* (inbound). Everything else projects raw. */
|
|
101
|
+
export function isTemporalType(pgType: string): boolean {
|
|
102
|
+
return TEMPORAL_TYPES.has(formatTypeForLookup(pgType));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Offset-bearing time-of-day types: `EXTRACT(EPOCH FROM …)` can go negative (e.g. 01:00+02 =
|
|
106
|
+
// 23:00 UTC prev day = -3600s), so the outbound projection normalizes into 0..86_400_000 ms.
|
|
107
|
+
const TIME_OF_DAY_TZ_TYPES = new Set(["timetz", "time with time zone"]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether an outbound temporal projection needs the `mod 1 day` normalization. **Deliberately
|
|
111
|
+
* narrower than z2s's `selectIdent`**, which (via a switch fall-through) also normalizes
|
|
112
|
+
* `timestamptz` — that would collapse a full timestamp to time-of-day. The design (§6.2) is
|
|
113
|
+
* explicit that `timestamptz` projects as plain epoch-ms and only the offset-bearing
|
|
114
|
+
* *time-of-day* types are modular-normalized; the §9 parity harness against the engine's value
|
|
115
|
+
* model is the check that keeps this honest.
|
|
116
|
+
*/
|
|
117
|
+
export function needsTimeOfDayNormalization(pgType: string): boolean {
|
|
118
|
+
return TIME_OF_DAY_TZ_TYPES.has(formatTypeForLookup(pgType));
|
|
119
|
+
}
|
package/src/postgres.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The Postgres dialect leaf (§6.1/§6.2) — the product target. The walker is shared with the
|
|
2
|
+
// SQLite oracle; this file supplies only the Postgres-specific leaves:
|
|
3
|
+
//
|
|
4
|
+
// - JSON assembly via `jsonb_build_object` / `jsonb_agg` / `'[]'::jsonb` (the design's §6.1
|
|
5
|
+
// choice — the direct analogue of `rindle-d2s`'s `json_object`, NOT z2s's `row_to_json`);
|
|
6
|
+
// - explicit `ASC NULLS FIRST` / `DESC NULLS LAST` (§8 — Postgres's default is the opposite
|
|
7
|
+
// of the engine's null-low order);
|
|
8
|
+
// - the outbound projection reconciliation (temporal → epoch-ms);
|
|
9
|
+
// - the inbound filter/paging cast strategy `$N::text::<type>`, ported verbatim from z2s's
|
|
10
|
+
// `sql.ts` — the `::text` intermediate pins the driver's parameter description to text, and
|
|
11
|
+
// the `::<type>` half is value-model↔native reconciliation (§6.2). Values are parameterized.
|
|
12
|
+
//
|
|
13
|
+
// NOTE: `$N` numbering is per-occurrence (no de-duplication of repeated values). z2s reuses a
|
|
14
|
+
// placeholder for a repeated (value,type); that is a plan-cache optimization, not correctness —
|
|
15
|
+
// deferred (203 §7 posture).
|
|
16
|
+
|
|
17
|
+
import type { Dir } from "./ast.ts";
|
|
18
|
+
import type { ColumnType } from "./catalog.ts";
|
|
19
|
+
import type { Dialect, LitScalar } from "./dialect.ts";
|
|
20
|
+
import {
|
|
21
|
+
formatTypeForLookup,
|
|
22
|
+
isPgNativeStringType,
|
|
23
|
+
isPgNumberType,
|
|
24
|
+
isPgStringType,
|
|
25
|
+
isPgTextRepresentedType,
|
|
26
|
+
isTemporalType,
|
|
27
|
+
needsTimeOfDayNormalization,
|
|
28
|
+
} from "./pg-types.ts";
|
|
29
|
+
|
|
30
|
+
function quoteIdent(name: string): string {
|
|
31
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The Postgres cast for a literal whose type is inferred from its JS value (no column). */
|
|
35
|
+
function pgTypeForLiteralType(litType: "boolean" | "number" | "string"): string {
|
|
36
|
+
// `double precision` is IEEE-754 like a JS number, so it round-trips any zql number exactly.
|
|
37
|
+
return litType === "boolean" ? "boolean" : litType === "number" ? "double precision" : "text";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The inbound value↔native cast for a filter/paging value against a known column type — the
|
|
42
|
+
* verbatim port of z2s's `formatCommonToSingularAndPlural` (`sql.ts:194`). `vp` is the value
|
|
43
|
+
* placeholder (`$N`, or `value` inside an array unnest). `isComparison` widens strings to bare
|
|
44
|
+
* `text` and numbers to `double precision` so neither is forced to the column's width/precision.
|
|
45
|
+
*/
|
|
46
|
+
function pgCast(vp: string, type: string, isEnum: boolean, isComparison: boolean): string {
|
|
47
|
+
const t = formatTypeForLookup(type);
|
|
48
|
+
// Temporal: epoch-ms ↔ native. The zone-naive spellings get `AT TIME ZONE 'UTC'`.
|
|
49
|
+
if (t === "timestamptz" || t === "timestamp with time zone") {
|
|
50
|
+
return `to_timestamp(${vp}::text::numeric / 1000.0)`;
|
|
51
|
+
}
|
|
52
|
+
if (t === "date" || t === "timestamp" || t === "timestamp without time zone") {
|
|
53
|
+
return `to_timestamp(${vp}::text::numeric / 1000.0) AT TIME ZONE 'UTC'`;
|
|
54
|
+
}
|
|
55
|
+
if (t === "timetz" || t === "time with time zone") {
|
|
56
|
+
return `(${vp}::text::int * interval'1ms')::time`;
|
|
57
|
+
}
|
|
58
|
+
if (t === "time" || t === "time without time zone") {
|
|
59
|
+
return `(${vp}::text::int * interval'1ms')::time AT TIME ZONE 'UTC'`;
|
|
60
|
+
}
|
|
61
|
+
if (t === "uuid") return `${vp}::text::uuid`;
|
|
62
|
+
if (isEnum) return `${vp}::text::"${type}"`;
|
|
63
|
+
if (isPgNativeStringType(type)) return isComparison ? `${vp}::text` : `${vp}::text::${type}`;
|
|
64
|
+
if (isPgTextRepresentedType(type)) return `${vp}::text::${type}`;
|
|
65
|
+
if (isPgNumberType(type)) {
|
|
66
|
+
return isComparison ? `${vp}::text::double precision` : `${vp}::text::${type}`;
|
|
67
|
+
}
|
|
68
|
+
return `${vp}::text::${type}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The text-pinned serialization of a bound value — z2s's `stringify` (`sql.ts:120`). Strings
|
|
72
|
+
* and enum/string columns bind their raw text; everything else binds its JSON text form so the
|
|
73
|
+
* `$N::text::…` cast re-parses it authoritatively. */
|
|
74
|
+
function stringifyForColumn(value: LitScalar, col: ColumnType): string | null {
|
|
75
|
+
if (value === null) return null;
|
|
76
|
+
if (col.isArray) return JSON.stringify(value);
|
|
77
|
+
if (col.isEnum || isPgStringType(col.type)) return String(value);
|
|
78
|
+
return JSON.stringify(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bindValue(
|
|
82
|
+
params: unknown[],
|
|
83
|
+
value: LitScalar,
|
|
84
|
+
col: ColumnType | null,
|
|
85
|
+
isComparison: boolean,
|
|
86
|
+
): string {
|
|
87
|
+
const idx = params.length + 1;
|
|
88
|
+
|
|
89
|
+
// Free literal (no column context): infer the cast from the JS type.
|
|
90
|
+
if (col === null) {
|
|
91
|
+
if (value === null) {
|
|
92
|
+
params.push(null);
|
|
93
|
+
return `$${idx}`;
|
|
94
|
+
}
|
|
95
|
+
const litType = typeof value as "boolean" | "number" | "string";
|
|
96
|
+
params.push(litType === "string" ? value : JSON.stringify(value));
|
|
97
|
+
return `$${idx}::text::${pgTypeForLiteralType(litType)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
params.push(stringifyForColumn(value, col));
|
|
101
|
+
if (value === null && col.isArray) return `$${idx}`;
|
|
102
|
+
if (col.isArray) {
|
|
103
|
+
// Unnest a JSON array param, casting each element (z2s's `formatPlural`, `sql.ts:260`).
|
|
104
|
+
return `ARRAY(SELECT ${pgCast("value", col.type, col.isEnum, isComparison)} FROM jsonb_array_elements_text($${idx}::text::jsonb))`;
|
|
105
|
+
}
|
|
106
|
+
return pgCast(`$${idx}`, col.type, col.isEnum, isComparison);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Project a stored column into the value model's representation (§6.2 outbound half). Temporal
|
|
111
|
+
* columns become **integer epoch-ms** — the value model always snaps to whole milliseconds, so
|
|
112
|
+
* `::bigint` rounds off the native µs resolution (unlike z2s, which projects the non-normalized
|
|
113
|
+
* path as an unsnapped float). Time-of-day-with-offset types additionally wrap `mod 1 day`.
|
|
114
|
+
* Enums (their label), uuid, numeric, json, and the scalar types project raw. See
|
|
115
|
+
* `needsTimeOfDayNormalization` for the deliberate divergence from z2s on `timestamptz`.
|
|
116
|
+
*
|
|
117
|
+
* The rounding mode (`::bigint` = round-half-to-even) must match pg-source's projector for exact
|
|
118
|
+
* parity — it is the one shared value-model contract (§10), not an independent choice here.
|
|
119
|
+
*/
|
|
120
|
+
function projectColumn(colRef: string, col: ColumnType | null): string {
|
|
121
|
+
if (!col || col.isEnum || !isTemporalType(col.type)) return colRef;
|
|
122
|
+
const normalize = needsTimeOfDayNormalization(col.type);
|
|
123
|
+
const toMs = (epoch: string): string =>
|
|
124
|
+
normalize ? `((${epoch})::bigint + 86400000) % 86400000` : `(${epoch})::bigint`;
|
|
125
|
+
if (col.isArray) {
|
|
126
|
+
return `CASE WHEN ${colRef} IS NULL THEN NULL ELSE ARRAY(SELECT ${toMs(`EXTRACT(EPOCH FROM unnest(${colRef})) * 1000`)}) END`;
|
|
127
|
+
}
|
|
128
|
+
return toMs(`EXTRACT(EPOCH FROM ${colRef}) * 1000`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function orderDir(dir: Dir): string {
|
|
132
|
+
// Engine order is null-low; Postgres's default is the opposite, so make it explicit (§8).
|
|
133
|
+
return dir === "asc" ? "ASC NULLS FIRST" : "DESC NULLS LAST";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The Postgres dialect — the BYO-Postgres server-mutator read target. Emits `(sql, params)`
|
|
138
|
+
* with `$N::text::<type>` parameters and never imports a driver (Invariant 7).
|
|
139
|
+
*/
|
|
140
|
+
export const postgresDialect: Dialect = {
|
|
141
|
+
name: "postgres",
|
|
142
|
+
objectBuild: (parts) => `jsonb_build_object(${parts.join(", ")})`,
|
|
143
|
+
groupArray: (elem, orderSql) => `jsonb_agg(${elem}${orderSql})`,
|
|
144
|
+
emptyArray: "'[]'::jsonb",
|
|
145
|
+
reassertJson: (x) => x, // jsonb is a real type; no re-assert needed
|
|
146
|
+
trueLit: "TRUE",
|
|
147
|
+
falseLit: "FALSE",
|
|
148
|
+
orderDir,
|
|
149
|
+
projectColumn,
|
|
150
|
+
bindValue,
|
|
151
|
+
quoteIdent,
|
|
152
|
+
};
|
package/src/reject.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Reject AST features the compiler does not lower, anywhere in the tree — so an unhandled
|
|
2
|
+
// field fails loudly rather than producing silently-wrong SQL. Faithful port of
|
|
3
|
+
// `rindle-d2s`'s `reject_unsupported` family (lib.rs:194).
|
|
4
|
+
//
|
|
5
|
+
// `start` (a paging bound) is lowered as a keyset predicate, but only on the **materialized
|
|
6
|
+
// collections** the compiler emits (the root and each `related` child). It is *not* honored
|
|
7
|
+
// inside an EXISTS/NOT EXISTS subquery, nor inside a `count(child)` aggregate — a `start`
|
|
8
|
+
// there is rejected here rather than silently dropped.
|
|
9
|
+
|
|
10
|
+
import type { Ast, Condition } from "./ast.ts";
|
|
11
|
+
import { unsupported } from "./errors.ts";
|
|
12
|
+
|
|
13
|
+
export function rejectUnsupported(ast: Ast): void {
|
|
14
|
+
for (const rel of ast.related ?? []) {
|
|
15
|
+
if (rel.subquery.aggregate) {
|
|
16
|
+
// A `count(child)` aggregate reduces its rows away: a `start`/`limit`/nested `related`
|
|
17
|
+
// would change the IVM count but `count_expr` ignores them — reject rather than diverge.
|
|
18
|
+
rejectAggregateSubquery(rel.subquery);
|
|
19
|
+
} else {
|
|
20
|
+
rejectUnsupported(rel.subquery); // collection: start honored, recurse
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (ast.where) {
|
|
24
|
+
rejectUnsupportedCond(ast.where);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rejectAggregateSubquery(ast: Ast): void {
|
|
29
|
+
if (ast.start) throw unsupported("start inside a count aggregate");
|
|
30
|
+
if (ast.limit !== undefined) throw unsupported("limit inside a count aggregate");
|
|
31
|
+
if (ast.related && ast.related.length > 0) {
|
|
32
|
+
throw unsupported("nested related inside a count aggregate");
|
|
33
|
+
}
|
|
34
|
+
if (ast.where) rejectUnsupportedCond(ast.where);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rejectUnsupportedCond(cond: Condition): void {
|
|
38
|
+
switch (cond.type) {
|
|
39
|
+
case "simple":
|
|
40
|
+
return;
|
|
41
|
+
case "correlatedSubquery":
|
|
42
|
+
rejectStartAnywhere(cond.related.subquery); // EXISTS: its `start` is not honored
|
|
43
|
+
return;
|
|
44
|
+
case "and":
|
|
45
|
+
case "or":
|
|
46
|
+
for (const c of cond.conditions) rejectUnsupportedCond(c);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reject a `start` bound *anywhere* in `ast` (the EXISTS subtrees the compiler does not
|
|
52
|
+
* page). Conservative: rejects even positions a clever lowering might honor. */
|
|
53
|
+
function rejectStartAnywhere(ast: Ast): void {
|
|
54
|
+
if (ast.start) throw unsupported("start (paging) inside an EXISTS subquery");
|
|
55
|
+
for (const rel of ast.related ?? []) rejectStartAnywhere(rel.subquery);
|
|
56
|
+
if (ast.where) rejectStartAnywhereCond(ast.where);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rejectStartAnywhereCond(cond: Condition): void {
|
|
60
|
+
switch (cond.type) {
|
|
61
|
+
case "simple":
|
|
62
|
+
return;
|
|
63
|
+
case "correlatedSubquery":
|
|
64
|
+
rejectStartAnywhere(cond.related.subquery);
|
|
65
|
+
return;
|
|
66
|
+
case "and":
|
|
67
|
+
case "or":
|
|
68
|
+
for (const c of cond.conditions) rejectStartAnywhereCond(c);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|