@metaobjectsdev/codegen-ts 0.16.0 → 0.17.0
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/dist/column-mapper.d.ts +10 -0
- package/dist/column-mapper.d.ts.map +1 -1
- package/dist/column-mapper.js +21 -2
- package/dist/column-mapper.js.map +1 -1
- package/dist/naming.d.ts +4 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +6 -0
- package/dist/naming.js.map +1 -1
- package/dist/projection/build-projection-views.d.ts +5 -1
- package/dist/projection/build-projection-views.d.ts.map +1 -1
- package/dist/projection/build-projection-views.js +165 -47
- package/dist/projection/build-projection-views.js.map +1 -1
- package/dist/projection/extract-view-spec.d.ts +8 -1
- package/dist/projection/extract-view-spec.d.ts.map +1 -1
- package/dist/projection/extract-view-spec.js +430 -29
- package/dist/projection/extract-view-spec.js.map +1 -1
- package/dist/projection/view-ddl-emit.d.ts.map +1 -1
- package/dist/projection/view-ddl-emit.js +142 -25
- package/dist/projection/view-ddl-emit.js.map +1 -1
- package/dist/projection/view-spec.d.ts +101 -3
- package/dist/projection/view-spec.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.js +9 -0
- package/dist/templates/drizzle-schema.js.map +1 -1
- package/dist/templates/entity-file.d.ts.map +1 -1
- package/dist/templates/entity-file.js +39 -3
- package/dist/templates/entity-file.js.map +1 -1
- package/dist/templates/inferred-types.d.ts +1 -1
- package/dist/templates/inferred-types.d.ts.map +1 -1
- package/dist/templates/inferred-types.js +13 -1
- package/dist/templates/inferred-types.js.map +1 -1
- package/dist/templates/projection-decl.d.ts.map +1 -1
- package/dist/templates/projection-decl.js +9 -70
- package/dist/templates/projection-decl.js.map +1 -1
- package/dist/templates/queries-file.d.ts.map +1 -1
- package/dist/templates/queries-file.js +117 -43
- package/dist/templates/queries-file.js.map +1 -1
- package/dist/templates/queries.d.ts +21 -4
- package/dist/templates/queries.d.ts.map +1 -1
- package/dist/templates/queries.js +48 -12
- package/dist/templates/queries.js.map +1 -1
- package/dist/templates/view-decl.d.ts +28 -0
- package/dist/templates/view-decl.d.ts.map +1 -0
- package/dist/templates/view-decl.js +107 -0
- package/dist/templates/view-decl.js.map +1 -0
- package/dist/templates/zod-validators.d.ts +6 -0
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +53 -7
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/column-mapper.ts +26 -1
- package/src/naming.ts +7 -0
- package/src/projection/build-projection-views.ts +211 -50
- package/src/projection/extract-view-spec.ts +468 -29
- package/src/projection/view-ddl-emit.ts +158 -24
- package/src/projection/view-spec.ts +104 -3
- package/src/reference/entity.ts +11 -1
- package/src/reference/queries.ts +4 -1
- package/src/templates/drizzle-schema.ts +7 -0
- package/src/templates/entity-file.ts +46 -3
- package/src/templates/inferred-types.ts +18 -1
- package/src/templates/projection-decl.ts +8 -74
- package/src/templates/queries-file.ts +133 -48
- package/src/templates/queries.ts +50 -11
- package/src/templates/view-decl.ts +128 -0
- package/src/templates/zod-validators.ts +54 -9
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
JoinNode, ViewSpec, ViewFilterClause, ViewExprNode, ViewOrderKey, SelectColumn,
|
|
3
|
+
} from "./view-spec.js";
|
|
2
4
|
|
|
3
5
|
// Quote an identifier only when it isn't a plain lowercase snake identifier —
|
|
4
6
|
// exactly postgres's own rule. This keeps snake_case output unquoted (the common
|
|
@@ -52,36 +54,158 @@ function renderFilterCond(clause: ViewFilterClause, dialect: EmitOptions["dialec
|
|
|
52
54
|
const joined = clause.clauses.map((c) => renderFilterCond(c, dialect)).join(clause.kind === "and" ? " AND " : " OR ");
|
|
53
55
|
return `(${joined})`;
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
// The left-hand side is a bare `alias.column` for a `cmp`, or a parenthesized
|
|
58
|
+
// inlined computed expression for #207's `exprCmp` (a projection @filter ref to
|
|
59
|
+
// an origin.computed field). The op handling below is identical for both.
|
|
60
|
+
const lhs = clause.kind === "exprCmp" ? `(${renderExpr(clause.expr, dialect)})` : quoteRef(clause.ref);
|
|
61
|
+
if (clause.op === "isNull") return clause.value === false ? `${lhs} IS NOT NULL` : `${lhs} IS NULL`;
|
|
57
62
|
if (clause.op === "in") {
|
|
58
63
|
const vals = (Array.isArray(clause.value) ? clause.value : [clause.value]).map((v) => sqlLiteral(v, dialect));
|
|
59
|
-
return `${
|
|
64
|
+
return `${lhs} IN (${vals.join(", ")})`;
|
|
60
65
|
}
|
|
61
66
|
const op = FILTER_OP_SQL[clause.op];
|
|
62
|
-
if (!op) throw new Error(`view-ddl-emit: unsupported
|
|
63
|
-
return `${
|
|
67
|
+
if (!op) throw new Error(`view-ddl-emit: unsupported filter operator "${clause.op}".`);
|
|
68
|
+
return `${lhs} ${op} ${sqlLiteral(clause.value, dialect)}`;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Render resolved ordering keys, applying the #195 nulls-last pin (`NULLS LAST` in
|
|
73
|
+
* both directions — PG + SQLite ≥ 3.30). `alias` qualifies each key's column.
|
|
74
|
+
*/
|
|
75
|
+
function renderOrderKeys(keys: readonly ViewOrderKey[], alias: string): string {
|
|
76
|
+
return keys
|
|
77
|
+
.map((k) => `${alias}.${quoteIfNeeded(k.column)} ${k.dir.toUpperCase()} NULLS LAST`)
|
|
78
|
+
.join(", ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Lower a resolved computed expression tree (#195 origin.computed) to a SQL scalar expression. */
|
|
82
|
+
function renderExpr(node: ViewExprNode, dialect: EmitOptions["dialect"]): string {
|
|
83
|
+
switch (node.kind) {
|
|
84
|
+
case "col":
|
|
85
|
+
return quoteRef(node.ref);
|
|
86
|
+
case "lit":
|
|
87
|
+
return sqlLiteral(node.value, dialect);
|
|
88
|
+
case "cmp": {
|
|
89
|
+
const op = FILTER_OP_SQL[node.op];
|
|
90
|
+
if (!op) throw new Error(`view-ddl-emit: unsupported computed comparison operator "${node.op}".`);
|
|
91
|
+
return `${renderExpr(node.left, dialect)} ${op} ${renderExpr(node.right, dialect)}`;
|
|
92
|
+
}
|
|
93
|
+
case "nullTest":
|
|
94
|
+
return `${renderExpr(node.arg, dialect)} IS ${node.negated ? "NOT " : ""}NULL`;
|
|
95
|
+
case "not":
|
|
96
|
+
return `NOT (${renderExpr(node.arg, dialect)})`;
|
|
97
|
+
case "logic": {
|
|
98
|
+
const joiner = node.op === "and" ? " AND " : " OR ";
|
|
99
|
+
return `(${node.args.map((a) => renderExpr(a, dialect)).join(joiner)})`;
|
|
100
|
+
}
|
|
101
|
+
case "coalesce":
|
|
102
|
+
return `COALESCE(${node.args.map((a) => renderExpr(a, dialect)).join(", ")})`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Lower an `origin.first` column (#195) to a correlated scalar subquery. The subquery
|
|
108
|
+
* keys on the OUTER base alias (`baseAlias`), so it composes with the view's GROUP BY
|
|
109
|
+
* (the base PK is always a grouped passthrough column). The child PK ascending is
|
|
110
|
+
* ALWAYS appended as the final tie-breaker so equal-order rows stay byte-deterministic.
|
|
111
|
+
*/
|
|
112
|
+
function renderFirst(
|
|
113
|
+
c: Extract<SelectColumn, { kind: "first" }>,
|
|
114
|
+
options: EmitOptions,
|
|
115
|
+
baseAlias: string,
|
|
116
|
+
): string {
|
|
117
|
+
const childTable = options.joinTables[c.childEntity];
|
|
118
|
+
if (!childTable) {
|
|
119
|
+
throw new Error(`view-ddl-emit: no table name registered for origin.first child entity "${c.childEntity}".`);
|
|
120
|
+
}
|
|
121
|
+
const of = `${c.childAlias}.${quoteIfNeeded(c.sourceColumn)}`;
|
|
122
|
+
const fk = quoteIfNeeded(c.fkColumn);
|
|
123
|
+
const pk = quoteIfNeeded(c.pkColumn);
|
|
124
|
+
// Mirror renderJoin's ON clause, with the child in the subquery and the base outside.
|
|
125
|
+
// referenceHolder "source" → FK on base: child.pk = base.fk (belongs-to)
|
|
126
|
+
// referenceHolder "target" → FK on child: child.fk = base.pk (has-many)
|
|
127
|
+
const correlation = c.referenceHolder === "source"
|
|
128
|
+
? `${c.childAlias}.${pk} = ${baseAlias}.${fk}`
|
|
129
|
+
: `${c.childAlias}.${fk} = ${baseAlias}.${pk}`;
|
|
130
|
+
const filterClause = c.filter ? ` AND ${renderFilterCond(c.filter, options.dialect)}` : "";
|
|
131
|
+
// The @orderBy keys carry the nulls-last pin; the appended PK tie-breaker never does
|
|
132
|
+
// (a primary key is non-null, so NULLS LAST would be noise). Its ASC direction makes
|
|
133
|
+
// equal-order rows byte-deterministic.
|
|
134
|
+
const tieBreak = `${c.childAlias}.${quoteIfNeeded(c.childPkColumn)} ASC`;
|
|
135
|
+
const orderKeys = c.orderBy.length > 0
|
|
136
|
+
? `${renderOrderKeys(c.orderBy, c.childAlias)}, ${tieBreak}`
|
|
137
|
+
: tieBreak;
|
|
138
|
+
return (
|
|
139
|
+
`(SELECT ${of} FROM ${quoteIfNeeded(childTable)} ${c.childAlias}` +
|
|
140
|
+
` WHERE ${correlation}${filterClause} ORDER BY ${orderKeys} LIMIT 1) AS ${quoteIfNeeded(c.dbColAlias)}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderColumn(c: SelectColumn, options: EmitOptions, baseAlias: string): string {
|
|
145
|
+
const dialect = options.dialect;
|
|
68
146
|
const alias = quoteIfNeeded(c.dbColAlias);
|
|
147
|
+
|
|
69
148
|
if (c.kind === "passthrough") {
|
|
70
|
-
return `${
|
|
149
|
+
return `${c.sourceAlias}.${quoteIfNeeded(c.sourceColumn)} AS ${alias}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (c.kind === "aggregate") {
|
|
153
|
+
const src = `${c.sourceAlias}.${quoteIfNeeded(c.sourceColumn)}`;
|
|
154
|
+
// aggregate — use DISTINCT for count() over joined PKs to avoid join inflation.
|
|
155
|
+
// A scoping @filter renders as postgres `FILTER (WHERE …)`; sqlite (no aggregate
|
|
156
|
+
// FILTER pre-3.30) uses the portable `CASE WHEN … END` argument form.
|
|
157
|
+
const cond = c.filter ? renderFilterCond(c.filter, dialect) : undefined;
|
|
158
|
+
if (c.agg === "count") {
|
|
159
|
+
if (cond && dialect === "sqlite") return `COUNT(DISTINCT CASE WHEN ${cond} THEN ${src} END) AS ${alias}`;
|
|
160
|
+
if (cond) return `COUNT(DISTINCT ${src}) FILTER (WHERE ${cond}) AS ${alias}`;
|
|
161
|
+
return `COUNT(DISTINCT ${src}) AS ${alias}`;
|
|
162
|
+
}
|
|
163
|
+
const fn = c.agg.toUpperCase();
|
|
164
|
+
if (cond && dialect === "sqlite") return `${fn}(CASE WHEN ${cond} THEN ${src} END) AS ${alias}`;
|
|
165
|
+
if (cond) return `${fn}(${src}) FILTER (WHERE ${cond}) AS ${alias}`;
|
|
166
|
+
return `${fn}(${src}) AS ${alias}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (c.kind === "predicateAgg") {
|
|
170
|
+
// #195 any/all. The phantom-row guard (joined.pk IS NOT NULL) EXCLUDES null-extended
|
|
171
|
+
// LEFT-JOIN non-matches, so the empty-related-set pins hold: any=false / all=true.
|
|
172
|
+
const pred = renderFilterCond(c.pred, dialect);
|
|
173
|
+
const guard = `${c.sourceAlias}.${quoteIfNeeded(c.joinedPkColumn)} IS NOT NULL`;
|
|
174
|
+
if (dialect === "sqlite") {
|
|
175
|
+
// No boolean aggregates: MAX≡bool_or, MIN≡bool_and over 1/0. The outer CASE yields
|
|
176
|
+
// NULL (not 0) for phantom rows so MIN/MAX ignore them — the COALESCE default then
|
|
177
|
+
// supplies the empty-set pin (0 for any, 1 for all).
|
|
178
|
+
const fn = c.quant === "any" ? "MAX" : "MIN";
|
|
179
|
+
const empty = c.quant === "any" ? "0" : "1";
|
|
180
|
+
return `COALESCE(${fn}(CASE WHEN ${guard} THEN (CASE WHEN ${pred} THEN 1 ELSE 0 END) END), ${empty}) AS ${alias}`;
|
|
181
|
+
}
|
|
182
|
+
const fn = c.quant === "any" ? "bool_or" : "bool_and";
|
|
183
|
+
const empty = c.quant === "any" ? "FALSE" : "TRUE";
|
|
184
|
+
return `COALESCE(${fn}(${pred}) FILTER (WHERE ${guard}), ${empty}) AS ${alias}`;
|
|
71
185
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
186
|
+
|
|
187
|
+
if (c.kind === "collectAgg") {
|
|
188
|
+
const src = `${c.sourceAlias}.${quoteIfNeeded(c.sourceColumn)}`;
|
|
189
|
+
const guard = `${c.sourceAlias}.${quoteIfNeeded(c.joinedPkColumn)} IS NOT NULL`;
|
|
190
|
+
const distinctKw = c.distinct ? "DISTINCT " : "";
|
|
191
|
+
// Element order: @distinct always orders by the value (PG's array_agg(DISTINCT x
|
|
192
|
+
// ORDER BY x) co-occurrence rule); otherwise an explicit @orderBy, else the
|
|
193
|
+
// value-ascending default (both for conformance byte-stability).
|
|
194
|
+
const orderClause = !c.distinct && c.orderBy.length > 0
|
|
195
|
+
? `ORDER BY ${renderOrderKeys(c.orderBy, c.sourceAlias)}`
|
|
196
|
+
: `ORDER BY ${src} ASC`;
|
|
197
|
+
if (dialect === "sqlite") {
|
|
198
|
+
return `COALESCE(json_group_array(${distinctKw}${src} ${orderClause}) FILTER (WHERE ${guard}), json_array()) AS ${alias}`;
|
|
199
|
+
}
|
|
200
|
+
return `COALESCE(array_agg(${distinctKw}${src} ${orderClause}) FILTER (WHERE ${guard}), '{}') AS ${alias}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (c.kind === "computed") {
|
|
204
|
+
return `${renderExpr(c.expr, dialect)} AS ${alias}`;
|
|
80
205
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return `${fn}(${src}) AS ${alias}`;
|
|
206
|
+
|
|
207
|
+
// first — a correlated scalar subquery keyed on the base alias.
|
|
208
|
+
return renderFirst(c, options, baseAlias);
|
|
85
209
|
}
|
|
86
210
|
|
|
87
211
|
function renderJoin(
|
|
@@ -106,7 +230,10 @@ function renderJoin(
|
|
|
106
230
|
const onClause = node.referenceHolder === "source"
|
|
107
231
|
? `${childAlias}.${pkCol} = ${parentAlias}.${fkCol}`
|
|
108
232
|
: `${childAlias}.${fkCol} = ${parentAlias}.${pkCol}`;
|
|
109
|
-
|
|
233
|
+
// #209 — join type derived from FK optionality (extract-view-spec): a required
|
|
234
|
+
// belongs-to FK → INNER (matches the hand-written INNER-join view); else LEFT OUTER.
|
|
235
|
+
const joinKw = node.joinType === "inner" ? "INNER JOIN" : "LEFT OUTER JOIN";
|
|
236
|
+
let sql = ` ${joinKw} ${quoteIfNeeded(table)} ${childAlias} ON ${onClause}`;
|
|
110
237
|
for (const childJoin of node.children) {
|
|
111
238
|
sql += "\n" + renderJoin(childJoin, childAlias, options);
|
|
112
239
|
}
|
|
@@ -115,12 +242,19 @@ function renderJoin(
|
|
|
115
242
|
|
|
116
243
|
export function emitViewDdl(spec: ViewSpec, options: EmitOptions): string {
|
|
117
244
|
const cols = spec.selectSpec.columns
|
|
118
|
-
.map((c) => " " + renderColumn(c, options.
|
|
245
|
+
.map((c) => " " + renderColumn(c, options, spec.joinTree.baseAlias))
|
|
119
246
|
.join(",\n");
|
|
120
247
|
const fromClause = ` FROM ${quoteIfNeeded(options.baseTableName)} ${spec.joinTree.baseAlias}`;
|
|
121
248
|
const joinsClause = spec.joinTree.joins
|
|
122
249
|
.map((j) => renderJoin(j, spec.joinTree.baseAlias, options))
|
|
123
250
|
.join("\n");
|
|
251
|
+
// #207 — a projection-level row @filter is an outer WHERE that scopes which base
|
|
252
|
+
// rows the view returns. It renders AFTER the joins and BEFORE any GROUP BY (it
|
|
253
|
+
// filters rows, not aggregate groups — a post-aggregate HAVING is a separate concern).
|
|
254
|
+
const whereClause =
|
|
255
|
+
spec.where !== undefined
|
|
256
|
+
? `\n WHERE ${renderFilterCond(spec.where, options.dialect)}`
|
|
257
|
+
: "";
|
|
124
258
|
const groupByClause =
|
|
125
259
|
spec.groupBy.length > 0
|
|
126
260
|
? `\n GROUP BY ${spec.groupBy.map(quoteRef).join(", ")}`
|
|
@@ -128,7 +262,7 @@ export function emitViewDdl(spec: ViewSpec, options: EmitOptions): string {
|
|
|
128
262
|
|
|
129
263
|
const body = ` SELECT
|
|
130
264
|
${cols}
|
|
131
|
-
${fromClause}${joinsClause ? "\n" + joinsClause : ""}${groupByClause}`;
|
|
265
|
+
${fromClause}${joinsClause ? "\n" + joinsClause : ""}${whereClause}${groupByClause}`;
|
|
132
266
|
|
|
133
267
|
if (options.bodyOnly) return body;
|
|
134
268
|
return `CREATE VIEW ${quoteIfNeeded(spec.viewName)} AS
|
|
@@ -16,6 +16,12 @@ export interface JoinNode {
|
|
|
16
16
|
readonly pkColumn: string;
|
|
17
17
|
/** Which side of this hop physically holds the FK: the parent (source) or the child (target). */
|
|
18
18
|
readonly referenceHolder: "source" | "target";
|
|
19
|
+
/** #209 — `inner` when this is a belongs-to hop whose FK is NOT NULL (required):
|
|
20
|
+
* the join can neither drop nor NULL-fill a base row, so it is semantically INNER
|
|
21
|
+
* and matches the hand-written INNER-join view it stands in for. `left` otherwise —
|
|
22
|
+
* a nullable belongs-to FK, or ANY has-many (inverse-FK) hop, where a base row with
|
|
23
|
+
* no match must survive (aggregates COALESCE to 0, not drop the row). */
|
|
24
|
+
readonly joinType: "inner" | "left";
|
|
19
25
|
/** Child joins. */
|
|
20
26
|
readonly children: readonly JoinNode[];
|
|
21
27
|
}
|
|
@@ -31,15 +37,46 @@ export interface JoinTree {
|
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
|
-
* A resolved filter clause
|
|
35
|
-
*
|
|
36
|
-
*
|
|
40
|
+
* A resolved filter clause. Column refs are already resolved to `alias.column`
|
|
41
|
+
* (naming-strategy applied), so the emitter is a pure renderer. Mirrors the
|
|
42
|
+
* canonical attr.filter shape. Used both for an aggregate's scoping `@filter`
|
|
43
|
+
* (which only ever produces `cmp`/`and`/`or`) and for #207's projection-level
|
|
44
|
+
* row `@filter` (which may additionally compare an inlined computed expression —
|
|
45
|
+
* the `exprCmp` node — when a filter ref names an `origin.computed` field).
|
|
37
46
|
*/
|
|
38
47
|
export type ViewFilterClause =
|
|
39
48
|
| { readonly kind: "cmp"; readonly ref: string; readonly op: string; readonly value: unknown }
|
|
49
|
+
// #207 — a comparison whose left-hand side is an inlined computed expression
|
|
50
|
+
// (a resolved `origin.computed` field referenced by a projection-level @filter),
|
|
51
|
+
// rather than a bare `alias.column`. Only the view-level WHERE resolver produces
|
|
52
|
+
// it; aggregate `@filter` resolution never does.
|
|
53
|
+
| { readonly kind: "exprCmp"; readonly expr: ViewExprNode; readonly op: string; readonly value: unknown }
|
|
40
54
|
| { readonly kind: "and"; readonly clauses: readonly ViewFilterClause[] }
|
|
41
55
|
| { readonly kind: "or"; readonly clauses: readonly ViewFilterClause[] };
|
|
42
56
|
|
|
57
|
+
/** A scalar literal in a resolved computed expression (mirrors attr.expression's ExprLiteral). */
|
|
58
|
+
export type ViewExprLiteral = string | number | boolean | null;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A resolved node of an `origin.computed` expression (#195). Field refs are already
|
|
62
|
+
* lowered to a physical `alias.column` (base alias + naming strategy applied), so the
|
|
63
|
+
* emitter is a pure tree-walk. Mirrors the closed `attr.expression` node grammar.
|
|
64
|
+
*/
|
|
65
|
+
export type ViewExprNode =
|
|
66
|
+
| { readonly kind: "col"; readonly ref: string } // resolved base column `alias.column`
|
|
67
|
+
| { readonly kind: "lit"; readonly value: ViewExprLiteral } // scalar literal
|
|
68
|
+
| { readonly kind: "cmp"; readonly op: string; readonly left: ViewExprNode; readonly right: ViewExprNode } // eq/ne/gt/gte/lt/lte
|
|
69
|
+
| { readonly kind: "nullTest"; readonly negated: boolean; readonly arg: ViewExprNode } // isNull / isNotNull
|
|
70
|
+
| { readonly kind: "not"; readonly arg: ViewExprNode }
|
|
71
|
+
| { readonly kind: "logic"; readonly op: "and" | "or"; readonly args: readonly ViewExprNode[] }
|
|
72
|
+
| { readonly kind: "coalesce"; readonly args: readonly ViewExprNode[] };
|
|
73
|
+
|
|
74
|
+
/** A resolved ordering key: a physical column (naming-strategy applied) + direction. */
|
|
75
|
+
export interface ViewOrderKey {
|
|
76
|
+
readonly column: string;
|
|
77
|
+
readonly dir: "asc" | "desc";
|
|
78
|
+
}
|
|
79
|
+
|
|
43
80
|
/** One column of the SELECT list. */
|
|
44
81
|
export type SelectColumn =
|
|
45
82
|
| {
|
|
@@ -58,6 +95,62 @@ export type SelectColumn =
|
|
|
58
95
|
readonly sourceColumn: string;
|
|
59
96
|
/** Optional scoping filter (origin.aggregate @filter) → SQL aggregate FILTER (WHERE …). */
|
|
60
97
|
readonly filter?: ViewFilterClause;
|
|
98
|
+
}
|
|
99
|
+
| {
|
|
100
|
+
// #195 — origin.aggregate @agg:any|all — a predicate quantifier over the related
|
|
101
|
+
// row-set. Lowered to COALESCE(bool_or/bool_and(pred) FILTER (WHERE joined.pk IS
|
|
102
|
+
// NOT NULL), FALSE/TRUE) on PG; MAX/MIN(CASE …) on SQLite. Empty set → false (any)
|
|
103
|
+
// / true (all). Inflation-immune; never null.
|
|
104
|
+
readonly kind: "predicateAgg";
|
|
105
|
+
readonly fieldName: string;
|
|
106
|
+
readonly dbColAlias: string;
|
|
107
|
+
readonly quant: "any" | "all";
|
|
108
|
+
readonly sourceAlias: string; // alias of the related (aggregated) entity
|
|
109
|
+
readonly joinedPkColumn: string; // related entity's PK column — the LEFT-JOIN phantom guard
|
|
110
|
+
readonly pred: ViewFilterClause; // the quantified predicate (origin.aggregate @filter — required)
|
|
111
|
+
}
|
|
112
|
+
| {
|
|
113
|
+
// #195 — origin.aggregate @agg:collect — array rollup of @of across the related set.
|
|
114
|
+
// Lowered to COALESCE(array_agg(<of> [DISTINCT] ORDER BY …) FILTER (WHERE joined.pk
|
|
115
|
+
// IS NOT NULL), '{}') on PG; json_group_array on SQLite. Empty set → []. Default
|
|
116
|
+
// element order = value ascending (byte-stability); @distinct dedupes.
|
|
117
|
+
readonly kind: "collectAgg";
|
|
118
|
+
readonly fieldName: string;
|
|
119
|
+
readonly dbColAlias: string;
|
|
120
|
+
readonly sourceAlias: string;
|
|
121
|
+
readonly sourceColumn: string; // the @of column (collected value)
|
|
122
|
+
readonly joinedPkColumn: string; // related entity's PK column — the LEFT-JOIN phantom guard
|
|
123
|
+
readonly distinct: boolean;
|
|
124
|
+
/** Element ordering over the @of entity's columns; empty ⇒ value-ascending default. */
|
|
125
|
+
readonly orderBy: readonly ViewOrderKey[];
|
|
126
|
+
}
|
|
127
|
+
| {
|
|
128
|
+
// #195 — origin.computed — a row-level value from the base entity's own fields via
|
|
129
|
+
// a structured @expr tree (no related rows). Lowered by a tree-walk to a SQL scalar
|
|
130
|
+
// expression over the base alias's columns.
|
|
131
|
+
readonly kind: "computed";
|
|
132
|
+
readonly fieldName: string;
|
|
133
|
+
readonly dbColAlias: string;
|
|
134
|
+
readonly expr: ViewExprNode;
|
|
135
|
+
}
|
|
136
|
+
| {
|
|
137
|
+
// #195 — origin.first — argmax-then-project: the single related row selected by
|
|
138
|
+
// @orderBy along @via, projecting @of. Lowered to a CORRELATED scalar subquery
|
|
139
|
+
// keyed on the base alias (coexists with the outer GROUP BY). Empty set → null.
|
|
140
|
+
readonly kind: "first";
|
|
141
|
+
readonly fieldName: string;
|
|
142
|
+
readonly dbColAlias: string;
|
|
143
|
+
readonly childEntity: string; // entity name of the related rows (for table lookup)
|
|
144
|
+
readonly childAlias: string; // FRESH subquery alias (never a JOIN-tree alias)
|
|
145
|
+
readonly sourceColumn: string; // the @of column projected from the selected row
|
|
146
|
+
/** Correlation direction (mirrors JoinNode.referenceHolder). */
|
|
147
|
+
readonly referenceHolder: "source" | "target";
|
|
148
|
+
readonly fkColumn: string; // FK column (resolved) for the base↔child correlation
|
|
149
|
+
readonly pkColumn: string; // PK column (resolved) for the base↔child correlation
|
|
150
|
+
readonly childPkColumn: string; // child's own PK column — the determinism tie-breaker
|
|
151
|
+
readonly orderBy: readonly ViewOrderKey[]; // row-selection ordering over the child entity
|
|
152
|
+
/** Optional scoping filter over the child entity (refs use `childAlias`). */
|
|
153
|
+
readonly filter?: ViewFilterClause;
|
|
61
154
|
};
|
|
62
155
|
|
|
63
156
|
export interface SelectSpec {
|
|
@@ -71,4 +164,12 @@ export interface ViewSpec {
|
|
|
71
164
|
readonly selectSpec: SelectSpec;
|
|
72
165
|
/** non-aggregate column SQL fragments to put in GROUP BY (empty if no aggregates). */
|
|
73
166
|
readonly groupBy: readonly string[];
|
|
167
|
+
/**
|
|
168
|
+
* #207 — a projection-level row `@filter` (view-level WHERE): a resolved predicate
|
|
169
|
+
* over the projection's OWN fields (each ref already lowered to `alias.column`),
|
|
170
|
+
* rendered as an outer `WHERE` BEFORE any `GROUP BY` — it scopes which base rows the
|
|
171
|
+
* view returns (soft-delete / status / type views). Distinct from an aggregate's
|
|
172
|
+
* `@filter`, which scopes the rows a single aggregate spans. Undefined = no filter.
|
|
173
|
+
*/
|
|
174
|
+
readonly where?: ViewFilterClause;
|
|
74
175
|
}
|
package/src/reference/entity.ts
CHANGED
|
@@ -42,8 +42,11 @@ import {
|
|
|
42
42
|
SHARED_ENUMS_BASENAME,
|
|
43
43
|
// predicates + helpers:
|
|
44
44
|
isProjection,
|
|
45
|
+
isWriteThrough,
|
|
45
46
|
isAbstract,
|
|
46
47
|
hasWritableRdbSource,
|
|
48
|
+
// engine composer — used for the delegated write-through variant:
|
|
49
|
+
renderEntityFile,
|
|
47
50
|
// engine plumbing:
|
|
48
51
|
formatTs,
|
|
49
52
|
entityOutputPath,
|
|
@@ -79,8 +82,15 @@ function renderEntity(entity: MetaObject, ctx: RenderContext, opts?: RenderEntit
|
|
|
79
82
|
if (!runtime || !hasWritableRdbSource(entity)) {
|
|
80
83
|
return renderValueObjectFile(entity, ctx.apiPrefix, ctx);
|
|
81
84
|
}
|
|
85
|
+
// #214 — a write-through entity read-view (writable table + a read-only replica view +
|
|
86
|
+
// derived origin.* fields) needs the hybrid file (table + `.existing()` view decl + a
|
|
87
|
+
// z.infer read type carrying the derived fields). Delegate to the engine composer rather
|
|
88
|
+
// than duplicate that branch here (mirrors the projection delegation above).
|
|
89
|
+
if (isWriteThrough(entity)) {
|
|
90
|
+
return renderEntityFile(entity, ctx, { allowlists });
|
|
91
|
+
}
|
|
82
92
|
|
|
83
|
-
//
|
|
93
|
+
// Vanilla entity → the full Drizzle table file. Reorder/drop sections freely.
|
|
84
94
|
const enumAliases = renderEnumTypeAliases(entity, ctx);
|
|
85
95
|
const tphBlock = renderTphDiscriminatorUnion(entity, ctx.loadedRoot);
|
|
86
96
|
const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot);
|
package/src/reference/queries.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
reverseFksFor,
|
|
32
32
|
isTphDiscriminatorBase,
|
|
33
33
|
isProjection,
|
|
34
|
+
isWriteThrough,
|
|
34
35
|
isTphSubtype,
|
|
35
36
|
renderQueriesFile, // engine composer — used for the delegated variants
|
|
36
37
|
formatTs,
|
|
@@ -41,7 +42,9 @@ import {
|
|
|
41
42
|
// --- composition (OWNED for the common case) ---
|
|
42
43
|
function renderQueries(obj: MetaObject, ctx: RenderContext): string {
|
|
43
44
|
// Advanced variants delegate to the engine (byte-identical). Own them by copying their source.
|
|
44
|
-
|
|
45
|
+
// #214 — a write-through entity read-view (reads → replica view, writes → table) delegates
|
|
46
|
+
// too; owning it inline would duplicate the hybrid read/write routing.
|
|
47
|
+
if (isTphDiscriminatorBase(obj, ctx.loadedRoot) || isProjection(obj) || isWriteThrough(obj)) {
|
|
45
48
|
return renderQueriesFile(obj, ctx);
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -66,6 +66,11 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
|
|
|
66
66
|
// Collect CHECK constraints for enum columns; emitted as table-level check() callbacks.
|
|
67
67
|
const checkConstraints: Array<{ name: string; expr: string }> = [];
|
|
68
68
|
for (const child of obj.fields()) {
|
|
69
|
+
// #213 — a derived (origin-bearing) field is read-only, materialized on the
|
|
70
|
+
// read (view) side, NOT a column on the entity's write table (FR-024 §7).
|
|
71
|
+
// Emitting it here would declare a Drizzle column for a table column migrate
|
|
72
|
+
// no longer creates.
|
|
73
|
+
if (child.isDerived()) continue;
|
|
69
74
|
const isPk = pkFieldNames.has(child.name);
|
|
70
75
|
const isUnique = uniqueFieldNames.has(child.name) && !isPk;
|
|
71
76
|
const fkInfo = fkMap.get(child.name);
|
|
@@ -90,6 +95,8 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
|
|
|
90
95
|
// stamp onto other-subtype inserts), regardless of the field's @required.
|
|
91
96
|
// Subtype entities emit no table of their own (the value-object path).
|
|
92
97
|
for (const child of collectTphSubtypeFields(obj, ctx.loadedRoot)) {
|
|
98
|
+
// #213 — a TPH subtype's derived field is read-only too; never a table column.
|
|
99
|
+
if (child.isDerived()) continue;
|
|
93
100
|
const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
|
|
94
101
|
const fieldDocs = renderDocsFor(child);
|
|
95
102
|
const columnLine = renderColumn(
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// !hasWritableRdbSource(entity) → renderValueObjectFile (in-memory / transit shape: interface + Zod schema)
|
|
7
7
|
// vanilla / write-through entity → Drizzle table path
|
|
8
8
|
|
|
9
|
-
import { joinCode, type Code } from "ts-poet";
|
|
9
|
+
import { code, imp, joinCode, type Code } from "ts-poet";
|
|
10
10
|
import type { MetaObject } from "@metaobjectsdev/metadata";
|
|
11
11
|
import type { RenderContext } from "../render-context.js";
|
|
12
12
|
import { renderDrizzleSchema } from "./drizzle-schema.js";
|
|
@@ -17,8 +17,13 @@ import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.j
|
|
|
17
17
|
import { renderFilterType } from "./filter-type.js";
|
|
18
18
|
import { renderTphDiscriminatorUnion, isTphDiscriminatorBase } from "./tph-discriminator.js";
|
|
19
19
|
import { GENERATED_HEADER } from "../constants.js";
|
|
20
|
-
import { isProjection } from "../projection/projection-detector.js";
|
|
20
|
+
import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
|
|
21
21
|
import { renderProjectionDecl } from "./projection-decl.js";
|
|
22
|
+
import { projectionViewName } from "../projection/extract-view-spec.js";
|
|
23
|
+
import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js";
|
|
24
|
+
import { renderDocsFor } from "./jsdoc.js";
|
|
25
|
+
import { valueObjectModuleSpecifier } from "../import-path.js";
|
|
26
|
+
import { stripPackage } from "@metaobjectsdev/metadata";
|
|
22
27
|
import { hasWritableRdbSource } from "../source-detect.js";
|
|
23
28
|
import { renderValueObjectFile } from "./value-object-file.js";
|
|
24
29
|
import { isAbstract } from "../instance-artifacts.js";
|
|
@@ -108,9 +113,47 @@ export function renderEntityFile(
|
|
|
108
113
|
// bare `<Base>` type — so the inferred Drizzle row type is emitted as
|
|
109
114
|
// `<Base>Row` to avoid a duplicate `export type <Base>`.
|
|
110
115
|
const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot);
|
|
116
|
+
|
|
117
|
+
// #214 — a write-through entity read-view (FR-024 §7): reads route to the replica
|
|
118
|
+
// VIEW, so the entity file additionally declares the `.existing()` view (carrying the
|
|
119
|
+
// derived fields the write table omits, #213) and a read schema `<Entity>Schema`
|
|
120
|
+
// whose `z.infer` IS the read type <Entity> (dialect-agnostic — a Drizzle view is not
|
|
121
|
+
// a Table, so InferSelectModel/`$inferSelect` don't uniformly apply; the Zod schema's
|
|
122
|
+
// nullability mirrors the view columns exactly like a projection). The write table +
|
|
123
|
+
// Insert/Update stay derived-free. `.existing()` is a runtime-target Drizzle binding,
|
|
124
|
+
// and this path only runs in a runtime target (contract-only/non-writable returned above).
|
|
125
|
+
// A TPH discriminator base owns `export type <Base>` via its discriminated-union block,
|
|
126
|
+
// so it must NOT also emit the view-schema read type (that would be a duplicate-identifier
|
|
127
|
+
// compile error). A base+write-through combo keeps the TPH polymorphic read path (reads
|
|
128
|
+
// the base table); routing its reads through a replica view is a documented non-goal.
|
|
129
|
+
const writeThrough = isWriteThrough(entity) && !tphBase;
|
|
130
|
+
const viewSections: Code[] = [];
|
|
131
|
+
if (writeThrough) {
|
|
132
|
+
const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
|
|
133
|
+
const fields = entity.fields();
|
|
134
|
+
const voModule = (refBase: string): string =>
|
|
135
|
+
valueObjectModuleSpecifier(stripPackage(refBase), ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle);
|
|
136
|
+
const viewOpts = {
|
|
137
|
+
dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voModule,
|
|
138
|
+
};
|
|
139
|
+
const z = imp("z@zod");
|
|
140
|
+
const docs = renderDocsFor(entity);
|
|
141
|
+
const docsPrefix = docs ? `${docs}\n` : "";
|
|
142
|
+
viewSections.push(
|
|
143
|
+
renderExistingViewDecl(fields, projectionViewName(entity, ctx.columnNamingStrategy), `${camel}View`, viewOpts),
|
|
144
|
+
code`
|
|
145
|
+
export const ${entity.name}Schema = ${renderViewReadZodObject(fields, viewOpts)};
|
|
146
|
+
`,
|
|
147
|
+
code`
|
|
148
|
+
${docsPrefix}export type ${entity.name} = ${z}.infer<typeof ${entity.name}Schema>;
|
|
149
|
+
`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
111
153
|
const sections: Code[] = [
|
|
112
154
|
renderDrizzleSchema(entity, ctx),
|
|
113
|
-
|
|
155
|
+
...viewSections,
|
|
156
|
+
renderInferredTypes(entity, tphBase, ctx, writeThrough /* skipRow — read type is the view schema */),
|
|
114
157
|
...(enumAliases !== null ? [enumAliases] : []),
|
|
115
158
|
renderZodValidators(entity, ctx),
|
|
116
159
|
renderEntityConstants(entity, ctx.apiPrefix),
|
|
@@ -51,7 +51,12 @@ import type { RenderContext } from "../render-context.js";
|
|
|
51
51
|
* to avoid a duplicate `export type <Base>`. Insert/Update keep their names
|
|
52
52
|
* (no collision); they describe the physical TPH table row shape.
|
|
53
53
|
*/
|
|
54
|
-
export function renderInferredTypes(
|
|
54
|
+
export function renderInferredTypes(
|
|
55
|
+
entity: MetaObject,
|
|
56
|
+
tphBase = false,
|
|
57
|
+
ctx?: RenderContext,
|
|
58
|
+
skipRow = false,
|
|
59
|
+
): Code {
|
|
55
60
|
// The inferred Row/Insert types reference the Drizzle table var, so they must
|
|
56
61
|
// resolve to the SAME (possibly overridden) collection name the schema emits.
|
|
57
62
|
// ctx is optional for bare unit-test calls — those fall back to the default
|
|
@@ -64,6 +69,18 @@ export function renderInferredTypes(entity: MetaObject, tphBase = false, ctx?: R
|
|
|
64
69
|
const docs = renderDocsFor(entity);
|
|
65
70
|
const docsPrefix = docs ? `${docs}\n` : "";
|
|
66
71
|
const rowName = tphBase ? `${entity.name}Row` : entity.name;
|
|
72
|
+
// #214 — a write-through entity read-view routes READS to the replica view, so the
|
|
73
|
+
// read row type <Entity> is emitted from the VIEW's read schema (z.infer, carrying
|
|
74
|
+
// the derived fields) by the entity-file composer, NOT here. `skipRow` suppresses
|
|
75
|
+
// the table-inferred Row so there is no duplicate `export type <Entity>` (selectSym
|
|
76
|
+
// then goes unreferenced and ts-poet drops its import). Insert/Update always stay
|
|
77
|
+
// inferred from the write TABLE (derived-free, #213).
|
|
78
|
+
if (skipRow) {
|
|
79
|
+
return code`
|
|
80
|
+
export type ${entity.name}Insert = ${insertSym}<typeof ${varName}>;
|
|
81
|
+
export type ${entity.name}Update = Partial<${entity.name}Insert>;
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
67
84
|
return code`
|
|
68
85
|
${docsPrefix}export type ${rowName} = ${selectSym}<typeof ${varName}>;
|
|
69
86
|
export type ${entity.name}Insert = ${insertSym}<typeof ${varName}>;
|