@metaobjectsdev/codegen-ts 0.16.0-rc.1 → 0.17.0-rc.1

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.
Files changed (66) hide show
  1. package/dist/column-mapper.d.ts +10 -0
  2. package/dist/column-mapper.d.ts.map +1 -1
  3. package/dist/column-mapper.js +21 -2
  4. package/dist/column-mapper.js.map +1 -1
  5. package/dist/naming.d.ts +4 -0
  6. package/dist/naming.d.ts.map +1 -1
  7. package/dist/naming.js +6 -0
  8. package/dist/naming.js.map +1 -1
  9. package/dist/projection/build-projection-views.d.ts +5 -1
  10. package/dist/projection/build-projection-views.d.ts.map +1 -1
  11. package/dist/projection/build-projection-views.js +165 -47
  12. package/dist/projection/build-projection-views.js.map +1 -1
  13. package/dist/projection/extract-view-spec.d.ts +8 -1
  14. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  15. package/dist/projection/extract-view-spec.js +430 -29
  16. package/dist/projection/extract-view-spec.js.map +1 -1
  17. package/dist/projection/view-ddl-emit.d.ts.map +1 -1
  18. package/dist/projection/view-ddl-emit.js +142 -25
  19. package/dist/projection/view-ddl-emit.js.map +1 -1
  20. package/dist/projection/view-spec.d.ts +101 -3
  21. package/dist/projection/view-spec.d.ts.map +1 -1
  22. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  23. package/dist/templates/drizzle-schema.js +9 -0
  24. package/dist/templates/drizzle-schema.js.map +1 -1
  25. package/dist/templates/entity-file.d.ts.map +1 -1
  26. package/dist/templates/entity-file.js +39 -3
  27. package/dist/templates/entity-file.js.map +1 -1
  28. package/dist/templates/inferred-types.d.ts +1 -1
  29. package/dist/templates/inferred-types.d.ts.map +1 -1
  30. package/dist/templates/inferred-types.js +13 -1
  31. package/dist/templates/inferred-types.js.map +1 -1
  32. package/dist/templates/projection-decl.d.ts.map +1 -1
  33. package/dist/templates/projection-decl.js +9 -70
  34. package/dist/templates/projection-decl.js.map +1 -1
  35. package/dist/templates/queries-file.d.ts.map +1 -1
  36. package/dist/templates/queries-file.js +117 -43
  37. package/dist/templates/queries-file.js.map +1 -1
  38. package/dist/templates/queries.d.ts +21 -4
  39. package/dist/templates/queries.d.ts.map +1 -1
  40. package/dist/templates/queries.js +48 -12
  41. package/dist/templates/queries.js.map +1 -1
  42. package/dist/templates/view-decl.d.ts +28 -0
  43. package/dist/templates/view-decl.d.ts.map +1 -0
  44. package/dist/templates/view-decl.js +107 -0
  45. package/dist/templates/view-decl.js.map +1 -0
  46. package/dist/templates/zod-validators.d.ts +6 -0
  47. package/dist/templates/zod-validators.d.ts.map +1 -1
  48. package/dist/templates/zod-validators.js +53 -7
  49. package/dist/templates/zod-validators.js.map +1 -1
  50. package/package.json +6 -6
  51. package/src/column-mapper.ts +26 -1
  52. package/src/naming.ts +7 -0
  53. package/src/projection/build-projection-views.ts +211 -50
  54. package/src/projection/extract-view-spec.ts +468 -29
  55. package/src/projection/view-ddl-emit.ts +158 -24
  56. package/src/projection/view-spec.ts +104 -3
  57. package/src/reference/entity.ts +11 -1
  58. package/src/reference/queries.ts +4 -1
  59. package/src/templates/drizzle-schema.ts +7 -0
  60. package/src/templates/entity-file.ts +46 -3
  61. package/src/templates/inferred-types.ts +18 -1
  62. package/src/templates/projection-decl.ts +8 -74
  63. package/src/templates/queries-file.ts +133 -48
  64. package/src/templates/queries.ts +50 -11
  65. package/src/templates/view-decl.ts +128 -0
  66. package/src/templates/zod-validators.ts +54 -9
@@ -1,4 +1,6 @@
1
- import type { JoinNode, ViewSpec, ViewFilterClause } from "./view-spec.js";
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
- const ref = quoteRef(clause.ref);
56
- if (clause.op === "isNull") return clause.value === false ? `${ref} IS NOT NULL` : `${ref} IS NULL`;
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 `${ref} IN (${vals.join(", ")})`;
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 aggregate filter operator "${clause.op}".`);
63
- return `${ref} ${op} ${sqlLiteral(clause.value, dialect)}`;
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
- function renderColumn(c: import("./view-spec.js").SelectColumn, dialect: EmitOptions["dialect"]): string {
67
- const src = `${c.sourceAlias}.${quoteIfNeeded(c.sourceColumn)}`;
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 `${src} AS ${alias}`;
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
- // aggregate — use DISTINCT for count() over joined PKs to avoid join inflation.
73
- // A scoping @filter renders as postgres `FILTER (WHERE …)`; sqlite (no aggregate
74
- // FILTER pre-3.30) uses the portable `CASE WHEN … END` argument form.
75
- const cond = c.filter ? renderFilterCond(c.filter, dialect) : undefined;
76
- if (c.agg === "count") {
77
- if (cond && dialect === "sqlite") return `COUNT(DISTINCT CASE WHEN ${cond} THEN ${src} END) AS ${alias}`;
78
- if (cond) return `COUNT(DISTINCT ${src}) FILTER (WHERE ${cond}) AS ${alias}`;
79
- return `COUNT(DISTINCT ${src}) AS ${alias}`;
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
- const fn = c.agg.toUpperCase();
82
- if (cond && dialect === "sqlite") return `${fn}(CASE WHEN ${cond} THEN ${src} END) AS ${alias}`;
83
- if (cond) return `${fn}(${src}) FILTER (WHERE ${cond}) AS ${alias}`;
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
- let sql = ` LEFT OUTER JOIN ${quoteIfNeeded(table)} ${childAlias} ON ${onClause}`;
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.dialect))
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 scoping an aggregate to a subset of related rows. Column
35
- * refs are already resolved to `alias.column` (naming-strategy applied), so the
36
- * emitter is a pure renderer. Mirrors the canonical attr.filter shape.
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
  }
@@ -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
- // Write-through entity → the full Drizzle table file. Reorder/drop sections freely.
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);
@@ -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
- if (isTphDiscriminatorBase(obj, ctx.loadedRoot) || isProjection(obj)) {
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
- renderInferredTypes(entity, tphBase, ctx),
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(entity: MetaObject, tphBase = false, ctx?: RenderContext): Code {
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}>;