@metaobjectsdev/codegen-ts 0.16.0 → 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
@@ -6,35 +6,81 @@ import {
6
6
  MetaSource,
7
7
  ORIGIN_SUBTYPE_PASSTHROUGH,
8
8
  ORIGIN_SUBTYPE_AGGREGATE,
9
+ ORIGIN_SUBTYPE_COMPUTED,
10
+ ORIGIN_SUBTYPE_FIRST,
9
11
  ORIGIN_PASSTHROUGH_ATTR_FROM,
10
12
  ORIGIN_PASSTHROUGH_ATTR_VIA,
11
13
  ORIGIN_AGGREGATE_ATTR_AGG,
12
14
  ORIGIN_AGGREGATE_ATTR_OF,
13
15
  ORIGIN_AGGREGATE_ATTR_VIA,
14
16
  ORIGIN_AGGREGATE_ATTR_FILTER,
17
+ ORIGIN_ATTR_DISTINCT,
18
+ ORIGIN_ATTR_ORDER_BY,
19
+ ORIGIN_COMPUTED_ATTR_EXPR,
20
+ ORIGIN_FIRST_ATTR_OF,
21
+ ORIGIN_FIRST_ATTR_VIA,
22
+ ORIGIN_FIRST_ATTR_FILTER,
23
+ AGG_ANY,
24
+ AGG_ALL,
25
+ AGG_COLLECT,
26
+ AGGREGATE_FUNCTIONS,
27
+ FILTER_OP_EQ,
28
+ FILTER_OP_NE,
29
+ FILTER_OP_GT,
30
+ FILTER_OP_GTE,
31
+ FILTER_OP_LT,
32
+ FILTER_OP_LTE,
33
+ FILTER_OP_IS_NULL,
34
+ FILTER_COMPOSE_AND,
35
+ FILTER_COMPOSE_OR,
36
+ SORT_ORDER_DESC,
15
37
  RELATIONSHIP_ATTR_OBJECT_REF,
16
38
  RELATIONSHIP_ATTR_CARDINALITY,
17
39
  CARDINALITY_ONE,
18
40
  IDENTITY_SUBTYPE_REFERENCE,
19
41
  IDENTITY_REFERENCE_ATTR_REFERENCES,
20
42
  FIELD_ATTR_COLUMN,
43
+ OBJECT_PROJECTION_ATTR_FILTER,
21
44
  findReferenceBetween,
22
45
  stripPackage,
23
46
  type AggregateFunction,
24
47
  } from "@metaobjectsdev/metadata";
25
- import { type MetaData, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
48
+ import { type MetaData, type MetaField, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
26
49
  import {
27
50
  columnNameFromField,
28
51
  viewNameFromProjection,
29
52
  } from "../naming.js";
53
+ // #213 — a write-through ENTITY (FR-024 §7 read-view) hosts its own view; the base
54
+ // is the entity itself, not an extends-anchored projection.
55
+ import { isWriteThrough } from "./projection-detector.js";
56
+ // #209 — the SAME NOT-NULL predicate that drives a column's `.notNull()` decides
57
+ // whether a belongs-to join is INNER (required FK) vs LEFT OUTER (nullable FK).
58
+ import { isRequired } from "../column-mapper.js";
30
59
  import type { ColumnNamingStrategy } from "../metaobjects-config.js";
31
60
  import type {
32
61
  JoinNode, JoinTree, SelectColumn, SelectSpec, ViewSpec, ViewFilterClause,
62
+ ViewExprNode, ViewExprLiteral, ViewOrderKey,
33
63
  } from "./view-spec.js";
34
64
 
35
65
  /** Compose keys in the attr.filter shape. */
36
- const FILTER_AND = "and";
37
- const FILTER_OR = "or";
66
+ const FILTER_AND = FILTER_COMPOSE_AND;
67
+ const FILTER_OR = FILTER_COMPOSE_OR;
68
+
69
+ // #195 — the three expression-only op/fn names of the attr.expression grammar. They
70
+ // mirror EXPR_OP_IS_NOT_NULL / EXPR_OP_NOT / EXPR_FN_COALESCE in the metadata package's
71
+ // meta-attr-expression module, which is not re-exported through the public barrel; the
72
+ // remaining node ops (eq/ne/gt/… , isNull, and/or) are the shared FILTER_* vocabulary
73
+ // imported above.
74
+ const EXPR_OP_IS_NOT_NULL = "isNotNull";
75
+ const EXPR_OP_NOT = "not";
76
+ const EXPR_FN_COALESCE = "coalesce";
77
+
78
+ /** The scalar-reduce @agg values that map to the plain `aggregate` SelectColumn kind. */
79
+ const SCALAR_AGG_FUNCTIONS: ReadonlySet<string> = new Set(AGGREGATE_FUNCTIONS);
80
+ /** Expression comparison ops (share the filter vocabulary + per-subtype legality bands). */
81
+ const EXPR_COMPARISON_OPS: ReadonlySet<string> = new Set([
82
+ FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE,
83
+ ]);
38
84
 
39
85
  /**
40
86
  * Desugar a single field clause to the canonical `{ op: value }` form (scalar→eq,
@@ -86,6 +132,67 @@ function resolveAggregateFilter(
86
132
  return clauses.length === 1 ? clauses[0]! : { kind: "and", clauses };
87
133
  }
88
134
 
135
+ /**
136
+ * #207 — resolve a projection's row-scope `@filter` (the desugared canonical
137
+ * `{ field: { op: value }, and?, or? }`) into a {@link ViewFilterClause} whose
138
+ * refs are resolved against the projection's OWN declared columns (its SelectSpec),
139
+ * NOT against a single aggregated entity+alias (that is `resolveAggregateFilter`).
140
+ * Each field key names a declared projection field; it resolves by SelectColumn kind:
141
+ * - passthrough (base OR joined) → `sourceAlias.sourceColumn` — the machinery that
142
+ * makes `WHERE joined.status IS NULL OR joined.status = 1` work.
143
+ * - computed (origin.computed) → the inlined resolved expression (`exprCmp`).
144
+ * - aggregate-derived (aggregate/predicateAgg/collectAgg/first) → THROW (a WHERE
145
+ * cannot see aggregates; HAVING is a separate later extension). The loader already
146
+ * fail-closes this (ERR_BAD_ATTR_FILTER) — this throw is a codegen belt-and-suspenders.
147
+ * - a ref naming no declared field → THROW (dangling; also loader-rejected).
148
+ * Multiple fields at one level compose with AND (mirrors resolveAggregateFilter).
149
+ */
150
+ function resolveViewFilter(
151
+ filter: unknown,
152
+ columnsByField: ReadonlyMap<string, SelectColumn>,
153
+ projectionName: string,
154
+ ): ViewFilterClause | undefined {
155
+ if (typeof filter !== "object" || filter === null || Array.isArray(filter)) return undefined;
156
+ const clauses: ViewFilterClause[] = [];
157
+ for (const [key, val] of Object.entries(filter as Record<string, unknown>)) {
158
+ if (key === FILTER_AND || key === FILTER_OR) {
159
+ const subs = (Array.isArray(val) ? val : [])
160
+ .map((s) => resolveViewFilter(s, columnsByField, projectionName))
161
+ .filter((c): c is ViewFilterClause => c !== undefined);
162
+ if (subs.length > 0) clauses.push({ kind: key === FILTER_AND ? "and" : "or", clauses: subs });
163
+ continue;
164
+ }
165
+ const col = columnsByField.get(key);
166
+ if (!col) {
167
+ // The loader (validateProjectionFilter) already fail-closes a dangling or
168
+ // aggregate-derived ref, so by codegen time a missing column means a DECLARED,
169
+ // addressable field that buildSelectSpec could not resolve to a SELECT column
170
+ // (an internal inconsistency) — report it as such, not as "dangling".
171
+ throw new Error(
172
+ `Projection ${projectionName}: view @filter field "${key}" did not resolve to a view column.`,
173
+ );
174
+ }
175
+ // A field clause may carry MULTIPLE ops (a range like { gte: 100, lte: 500 }); each
176
+ // becomes its own comparison, AND-composed (dropping all-but-the-first would silently
177
+ // widen the exposed row set). The loader has already validated every op for this
178
+ // field's subtype.
179
+ for (const [op, value] of Object.entries(desugarClause(val))) {
180
+ if (col.kind === "passthrough") {
181
+ clauses.push({ kind: "cmp", ref: `${col.sourceAlias}.${col.sourceColumn}`, op, value });
182
+ } else if (col.kind === "computed") {
183
+ clauses.push({ kind: "exprCmp", expr: col.expr, op, value });
184
+ } else {
185
+ throw new Error(
186
+ `Projection ${projectionName}: view @filter references "${key}", an aggregate-derived ` +
187
+ `field — a WHERE cannot see aggregates. Filter on a passthrough or computed field instead.`,
188
+ );
189
+ }
190
+ }
191
+ }
192
+ if (clauses.length === 0) return undefined;
193
+ return clauses.length === 1 ? clauses[0]! : { kind: "and", clauses };
194
+ }
195
+
89
196
  // ---------------------------------------------------------------------------
90
197
  // Public context type
91
198
  // ---------------------------------------------------------------------------
@@ -180,7 +287,7 @@ export function projectionViewName(
180
287
  * loader's `_refNamedOwner`: the ref names the anchor, never the physical
181
288
  * declaring ancestor of an inherited child.
182
289
  */
183
- function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined {
290
+ export function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined {
184
291
  const ref = (node as { superRef?: string }).superRef;
185
292
  if (ref === undefined) return undefined;
186
293
  const lastSep = ref.lastIndexOf("::");
@@ -250,6 +357,122 @@ function joinColumnFor(entity: MetaObject, fieldName: string, ctx: ExtractContex
250
357
  return f ? sourceColumnNameFor(f, ctx) : columnNameFromField(fieldName, ctx.columnNamingStrategy);
251
358
  }
252
359
 
360
+ /** The physical column of an entity's primary-key field — the LEFT-JOIN phantom guard
361
+ * (#195) tests `<joined>.<pk> IS NOT NULL`, and it is origin.first's tie-breaker. */
362
+ function primaryKeyColumn(entity: MetaObject, ctx: ExtractContext): string | undefined {
363
+ // ADR-0039: resolving — a projection base may inherit its primary identity via extends.
364
+ const pkField = entity.primaryIdentity()?.fields[0];
365
+ return pkField ? joinColumnFor(entity, pkField, ctx) : undefined;
366
+ }
367
+
368
+ /**
369
+ * Walk a `@via` dotted path to its terminal (related) entity name. any/all carry no
370
+ * `@of` to name the aggregated entity, so it is derived from the last `@via` hop.
371
+ * Returns undefined if any hop fails to resolve (a prior loader error already fired).
372
+ */
373
+ function viaTerminalEntity(via: string, root: MetaRoot): string | undefined {
374
+ const segments = via.split(".");
375
+ const rawEntity = segments[0];
376
+ if (!rawEntity) return undefined;
377
+ let currentObj = root.findObject(stripPackage(rawEntity));
378
+ if (!currentObj) return undefined;
379
+ let terminal = currentObj.name;
380
+ for (const relName of segments.slice(1)) {
381
+ const resolved = resolveHop(currentObj, relName);
382
+ if (!resolved) return undefined;
383
+ const target = root.findObject(stripPackage(resolved.targetName));
384
+ if (!target) return undefined;
385
+ currentObj = target;
386
+ terminal = target.name;
387
+ }
388
+ return terminal;
389
+ }
390
+
391
+ /**
392
+ * Resolve `@orderBy` keys ('field[:asc|desc]') against `entity`'s effective fields into
393
+ * physical {column, dir} pairs (naming strategy applied). Default direction is asc; nulls
394
+ * placement is pinned (nulls-last) at emit, not spelled here. Unknown keys are skipped
395
+ * (the loader validates key existence — `_validateOrderByKeys`).
396
+ */
397
+ function resolveOrderByKeys(
398
+ orderBy: unknown,
399
+ entity: MetaObject,
400
+ ctx: ExtractContext,
401
+ ): ViewOrderKey[] {
402
+ if (!Array.isArray(orderBy)) return [];
403
+ const keys: ViewOrderKey[] = [];
404
+ for (const raw of orderBy) {
405
+ if (typeof raw !== "string") continue;
406
+ const colonIdx = raw.indexOf(":");
407
+ const name = colonIdx === -1 ? raw : raw.slice(0, colonIdx);
408
+ const dir = colonIdx === -1 ? undefined : raw.slice(colonIdx + 1);
409
+ const field = entity.fields().find((f) => f.name === name);
410
+ if (!field) continue;
411
+ keys.push({ column: sourceColumnNameFor(field, ctx), dir: dir === SORT_ORDER_DESC ? "desc" : "asc" });
412
+ }
413
+ return keys;
414
+ }
415
+
416
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
417
+ return typeof v === "object" && v !== null && !Array.isArray(v);
418
+ }
419
+
420
+ /**
421
+ * Resolve a raw `attr.expression` node (from origin.computed `@expr`) into a {@link
422
+ * ViewExprNode} whose `{field}` refs are lowered to the base alias's physical columns
423
+ * (naming strategy applied). The emitter then walks the resolved tree. Returns undefined
424
+ * if any node is structurally malformed or references a non-base field — the loader's
425
+ * origin.computed validation already rejects those, so this is defensive only.
426
+ */
427
+ function resolveExprNode(
428
+ raw: unknown,
429
+ base: MetaObject,
430
+ baseAlias: string,
431
+ ctx: ExtractContext,
432
+ ): ViewExprNode | undefined {
433
+ if (!isPlainObject(raw)) return undefined;
434
+
435
+ if ("field" in raw) {
436
+ const name = raw.field;
437
+ if (typeof name !== "string") return undefined;
438
+ const field = base.fields().find((f) => f.name === name);
439
+ if (!field) return undefined;
440
+ return { kind: "col", ref: `${baseAlias}.${sourceColumnNameFor(field, ctx)}` };
441
+ }
442
+ if ("value" in raw) {
443
+ return { kind: "lit", value: raw.value as ViewExprLiteral };
444
+ }
445
+ if ("fn" in raw) {
446
+ if (raw.fn !== EXPR_FN_COALESCE || !Array.isArray(raw.args)) return undefined;
447
+ const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
448
+ if (args.some((a) => a === undefined)) return undefined;
449
+ return { kind: "coalesce", args: args as ViewExprNode[] };
450
+ }
451
+ if ("op" in raw && typeof raw.op === "string") {
452
+ const op = raw.op;
453
+ if (EXPR_COMPARISON_OPS.has(op)) {
454
+ const left = resolveExprNode(raw.left, base, baseAlias, ctx);
455
+ const right = resolveExprNode(raw.right, base, baseAlias, ctx);
456
+ return left && right ? { kind: "cmp", op, left, right } : undefined;
457
+ }
458
+ if (op === FILTER_OP_IS_NULL || op === EXPR_OP_IS_NOT_NULL) {
459
+ const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
460
+ return arg ? { kind: "nullTest", negated: op === EXPR_OP_IS_NOT_NULL, arg } : undefined;
461
+ }
462
+ if (op === EXPR_OP_NOT) {
463
+ const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
464
+ return arg ? { kind: "not", arg } : undefined;
465
+ }
466
+ if (op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) {
467
+ if (!Array.isArray(raw.args)) return undefined;
468
+ const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
469
+ if (args.some((a) => a === undefined)) return undefined;
470
+ return { kind: "logic", op: op === FILTER_COMPOSE_AND ? "and" : "or", args: args as ViewExprNode[] };
471
+ }
472
+ }
473
+ return undefined;
474
+ }
475
+
253
476
  function shortAliasFor(entityName: string, used: Set<string>): string {
254
477
  const base = (entityName[0] ?? "x").toLowerCase();
255
478
  if (!used.has(base)) { used.add(base); return base; }
@@ -273,6 +496,8 @@ interface PathStep {
273
496
  fkColumn: string;
274
497
  pkColumn: string;
275
498
  referenceHolder: "source" | "target";
499
+ /** #209 — derived join type: `inner` for a required belongs-to FK, else `left`. */
500
+ joinType: "inner" | "left";
276
501
  targetEntity: string;
277
502
  }
278
503
 
@@ -302,9 +527,19 @@ function buildJoinTree(
302
527
  for (const origin of field.ownChildren()) {
303
528
  if (origin.type !== TYPE_ORIGIN) continue;
304
529
  // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
305
- const viaAttr = origin.subType === ORIGIN_SUBTYPE_AGGREGATE
306
- ? (origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined)
307
- : (origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA) as string | undefined);
530
+ // Only aggregate (count/sum/avg/min/max/any/all/collect) and passthrough origins
531
+ // contribute a LEFT-JOIN branch. origin.computed is row-level (no @via); origin.first
532
+ // (#195) lowers to a CORRELATED subquery, NOT a join — its @via must NOT enter the
533
+ // join tree (both @from-via and @first-via share the physical name "via", so an
534
+ // unguarded read would wrongly join a `first`).
535
+ let viaAttr: string | undefined;
536
+ if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
537
+ viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined;
538
+ } else if (origin.subType === ORIGIN_SUBTYPE_PASSTHROUGH) {
539
+ viaAttr = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA) as string | undefined;
540
+ } else {
541
+ continue;
542
+ }
308
543
  if (!viaAttr) continue;
309
544
 
310
545
  const segments = viaAttr.split(".");
@@ -348,6 +583,23 @@ function buildJoinTree(
348
583
  const fkHolder = referenceHolder === "source" ? currentObj : target;
349
584
  const pkHolder = referenceHolder === "source" ? target : currentObj;
350
585
 
586
+ // #209 — a belongs-to hop (FK on the parent) whose FK is NOT NULL is
587
+ // semantically INNER: every base row has a match, so INNER and LEFT OUTER
588
+ // return the same set, and INNER matches the hand-written view it stands in
589
+ // for (and keeps `verify --db` fingerprints aligned). A nullable belongs-to
590
+ // FK, or ANY has-many hop (FK on the child — a base row may have zero
591
+ // children), stays LEFT OUTER so no base row is dropped.
592
+ const fkFieldObj = (fkHolder as MetaObject).findField(fkField);
593
+ const selfInner =
594
+ referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj);
595
+ // Nested-chain safety: joins render flat + left-associative, so an INNER hop
596
+ // BELOW any LEFT ancestor drops the base row (its ON references a column the
597
+ // LEFT ancestor NULLed). An INNER only survives when the ENTIRE ancestor chain
598
+ // is INNER; otherwise demote to LEFT (lossless — under a LEFT ancestor, LEFT is
599
+ // the correct type). `path` holds this chain's ancestor hops accumulated so far.
600
+ const joinType: "inner" | "left" =
601
+ selfInner && path.every((prior) => prior.joinType === "inner") ? "inner" : "left";
602
+
351
603
  path.push({
352
604
  entity: currentObj,
353
605
  relationship: relName,
@@ -355,6 +607,7 @@ function buildJoinTree(
355
607
  fkColumn: joinColumnFor(fkHolder, fkField, ctx),
356
608
  pkColumn: joinColumnFor(pkHolder, resolvedPkField, ctx),
357
609
  referenceHolder,
610
+ joinType,
358
611
  targetEntity: stripPackage(targetName),
359
612
  });
360
613
  currentObj = target;
@@ -387,6 +640,7 @@ function buildJoinTree(
387
640
  fkColumn: step.fkColumn,
388
641
  pkColumn: step.pkColumn,
389
642
  referenceHolder: step.referenceHolder,
643
+ joinType: step.joinType,
390
644
  children: Array.from(node.children.values()).map(toJoinNode),
391
645
  };
392
646
  }
@@ -426,6 +680,7 @@ function buildSelectSpec(
426
680
  joinTree: JoinTree,
427
681
  root: MetaRoot,
428
682
  ctx: ExtractContext,
683
+ usedAliases: Set<string>,
429
684
  ): SelectSpec {
430
685
  const columns: SelectColumn[] = [];
431
686
 
@@ -435,12 +690,17 @@ function buildSelectSpec(
435
690
  // is removed with the B4b cutover: base columns are declared explicitly as
436
691
  // extends-bound fields (`{ field.int: { name: id, extends: "Program.id" } }`).
437
692
 
438
- // Fields explicitly declared on the projection.
439
- // ADR-0039: own the projection's DECLARED field set IS the exposure
440
- // (FR-024/ADR-0028); iterate own fields + each field's own origin. origin.*
441
- // NEVER inherits (ADR-0029), so the origin attr reads below are own (category 4).
442
- for (const field of projection.ownChildren()) {
443
- if (field.type !== TYPE_FIELD) continue;
693
+ // #213 an entity read-view HOST (base === projection, FR-024 §7) exposes its
694
+ // EFFECTIVE field set: the `o.*` includes fields inherited via extends (a
695
+ // BaseEntity id/createdAt). A plain projection exposes only its DECLARED (own)
696
+ // fields the declared set IS the exposure (FR-024/ADR-0028). Either way, each
697
+ // field's own origin decides passthrough-from-base vs derived-from-join. origin.*
698
+ // NEVER inherits (ADR-0029), so the origin reads below are own (category 4).
699
+ const declaredFields: MetaField[] =
700
+ base === projection
701
+ ? base.fields()
702
+ : projection.ownChildren().filter((c): c is MetaField => c.type === TYPE_FIELD);
703
+ for (const field of declaredFields) {
444
704
  const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
445
705
  const dbCol = sourceColumnNameFor(field, ctx);
446
706
 
@@ -482,9 +742,41 @@ function buildSelectSpec(
482
742
  });
483
743
  } else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
484
744
  // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
485
- const agg = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_AGG) as AggregateFunction;
486
- const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF) as string;
487
- if (!agg || !of_) continue;
745
+ const agg = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_AGG) as string | undefined;
746
+ if (!agg) continue;
747
+ const filterAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_FILTER);
748
+
749
+ if (agg === AGG_ANY || agg === AGG_ALL) {
750
+ // #195 predicate quantifier — no @of; the related entity is @via's terminal
751
+ // hop, and @filter is the (required) quantified predicate.
752
+ const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined;
753
+ if (!via) continue;
754
+ const relatedName = viaTerminalEntity(via, root);
755
+ if (!relatedName) continue;
756
+ const relatedEntity = root.findObject(relatedName);
757
+ const sourceAlias = findAliasInTree(joinTree, relatedName);
758
+ if (!relatedEntity || sourceAlias === undefined) continue;
759
+ const joinedPk = primaryKeyColumn(relatedEntity, ctx);
760
+ if (joinedPk === undefined) continue;
761
+ const pred = filterAttr !== undefined
762
+ ? resolveAggregateFilter(filterAttr, relatedEntity, sourceAlias, ctx)
763
+ : undefined;
764
+ if (pred === undefined) continue; // loader requires @filter on any/all
765
+ columns.push({
766
+ kind: "predicateAgg",
767
+ fieldName: field.name,
768
+ dbColAlias: dbCol,
769
+ quant: agg === AGG_ANY ? "any" : "all",
770
+ sourceAlias,
771
+ joinedPkColumn: joinedPk,
772
+ pred,
773
+ });
774
+ continue;
775
+ }
776
+
777
+ // collect + the scalar reduces (count/sum/avg/min/max) all name @of.
778
+ const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF) as string | undefined;
779
+ if (!of_) continue;
488
780
  const dotIdx = of_.indexOf(".");
489
781
  if (dotIdx < 1) continue;
490
782
  const entityName = stripPackage(of_.slice(0, dotIdx));
@@ -494,20 +786,97 @@ function buildSelectSpec(
494
786
  if (!targetEntity || sourceAlias === undefined) continue;
495
787
  const targetField = targetEntity.fields().find((f) => f.name === fieldName);
496
788
  if (!targetField) continue;
497
- // Optional scoping filter — resolved against the aggregated entity (the @of
498
- // entity, reached at `sourceAlias`), e.g. max(version) over only active rows.
499
- // ADR-0039: own (category 4) origin.* never inherits (ADR-0029).
500
- const filterAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_FILTER);
789
+
790
+ if (agg === AGG_COLLECT) {
791
+ // #195 array rollupcollect @of across the related set. @distinct = set
792
+ // semantics; @orderBy (non-distinct) sets element order, else value-ascending.
793
+ const joinedPk = primaryKeyColumn(targetEntity, ctx);
794
+ if (joinedPk === undefined) continue;
795
+ const distinct = origin.ownAttr(ORIGIN_ATTR_DISTINCT) === true;
796
+ const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), targetEntity, ctx);
797
+ columns.push({
798
+ kind: "collectAgg",
799
+ fieldName: field.name,
800
+ dbColAlias: dbCol,
801
+ sourceAlias,
802
+ sourceColumn: sourceColumnNameFor(targetField, ctx),
803
+ joinedPkColumn: joinedPk,
804
+ distinct,
805
+ orderBy,
806
+ });
807
+ continue;
808
+ }
809
+
810
+ if (SCALAR_AGG_FUNCTIONS.has(agg)) {
811
+ // Optional scoping filter — resolved against the aggregated entity (the @of
812
+ // entity, reached at `sourceAlias`), e.g. max(version) over only active rows.
813
+ const filter = filterAttr !== undefined
814
+ ? resolveAggregateFilter(filterAttr, targetEntity, sourceAlias, ctx)
815
+ : undefined;
816
+ columns.push({
817
+ kind: "aggregate",
818
+ fieldName: field.name,
819
+ dbColAlias: dbCol,
820
+ agg: agg as AggregateFunction,
821
+ sourceAlias,
822
+ sourceColumn: sourceColumnNameFor(targetField, ctx),
823
+ ...(filter !== undefined ? { filter } : {}),
824
+ });
825
+ }
826
+ } else if (origin.subType === ORIGIN_SUBTYPE_COMPUTED) {
827
+ // #195 row-level computed value from the base entity's own fields (@expr tree,
828
+ // no @via). ADR-0039: own — origin.* never inherits (ADR-0029).
829
+ const rawExpr = origin.ownAttr(ORIGIN_COMPUTED_ATTR_EXPR);
830
+ const expr = resolveExprNode(rawExpr, base, joinTree.baseAlias, ctx);
831
+ if (expr === undefined) continue;
832
+ columns.push({ kind: "computed", fieldName: field.name, dbColAlias: dbCol, expr });
833
+ } else if (origin.subType === ORIGIN_SUBTYPE_FIRST) {
834
+ // #195 correlated latest-row — argmax-then-project. Resolves to a correlated
835
+ // subquery (NOT a join). ADR-0039: own — origin.* never inherits (ADR-0029).
836
+ const of_ = origin.ownAttr(ORIGIN_FIRST_ATTR_OF) as string | undefined;
837
+ if (!of_) continue;
838
+ const dotIdx = of_.indexOf(".");
839
+ if (dotIdx < 1) continue;
840
+ const childName = stripPackage(of_.slice(0, dotIdx));
841
+ const ofFieldName = of_.slice(dotIdx + 1);
842
+ const childEntity = root.findObject(childName);
843
+ if (!childEntity) continue;
844
+ const ofField = childEntity.fields().find((f) => f.name === ofFieldName);
845
+ if (!ofField) continue;
846
+ // The base↔child correlation FK — resolved exactly as buildJoinTree resolves a
847
+ // single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
848
+ // a multi-hop @via on origin.first is not lowered here (rare, and validated away).
849
+ const ref = findReferenceBetween(base, childEntity);
850
+ if (!ref) continue;
851
+ const fkField = ref.referenceIdentity.fields[0];
852
+ if (!fkField) continue;
853
+ const pkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id";
854
+ const referenceHolder: "source" | "target" =
855
+ ref.holder.name === base.name ? "source" : "target";
856
+ const fkHolder = referenceHolder === "source" ? base : childEntity;
857
+ const pkHolder = referenceHolder === "source" ? childEntity : base;
858
+ const childPk = primaryKeyColumn(childEntity, ctx);
859
+ if (childPk === undefined) continue;
860
+ // A FRESH alias — the subquery is an independent correlated scope, never the
861
+ // JOIN-tree alias (reserved against usedAliases so it can never collide).
862
+ const childAlias = shortAliasFor(childEntity.name, usedAliases);
863
+ const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), childEntity, ctx);
864
+ const filterAttr = origin.ownAttr(ORIGIN_FIRST_ATTR_FILTER);
501
865
  const filter = filterAttr !== undefined
502
- ? resolveAggregateFilter(filterAttr, targetEntity, sourceAlias, ctx)
866
+ ? resolveAggregateFilter(filterAttr, childEntity, childAlias, ctx)
503
867
  : undefined;
504
868
  columns.push({
505
- kind: "aggregate",
869
+ kind: "first",
506
870
  fieldName: field.name,
507
871
  dbColAlias: dbCol,
508
- agg,
509
- sourceAlias,
510
- sourceColumn: sourceColumnNameFor(targetField, ctx),
872
+ childEntity: childEntity.name,
873
+ childAlias,
874
+ sourceColumn: sourceColumnNameFor(ofField, ctx),
875
+ referenceHolder,
876
+ fkColumn: joinColumnFor(fkHolder, fkField, ctx),
877
+ pkColumn: joinColumnFor(pkHolder, pkField, ctx),
878
+ childPkColumn: childPk,
879
+ orderBy,
511
880
  ...(filter !== undefined ? { filter } : {}),
512
881
  });
513
882
  }
@@ -516,8 +885,29 @@ function buildSelectSpec(
516
885
  return { columns };
517
886
  }
518
887
 
888
+ /** The inflation-sensitive aggregate kinds: a non-distinct row multiplication corrupts
889
+ * their value (sum/avg double-count; a non-distinct collect duplicates elements).
890
+ * count is DISTINCT-guarded; min/max and any/all are inflation-immune. */
891
+ function isInflationSensitive(c: SelectColumn): boolean {
892
+ if (c.kind === "aggregate") return c.agg === "sum" || c.agg === "avg";
893
+ if (c.kind === "collectAgg") return !c.distinct;
894
+ return false;
895
+ }
896
+
897
+ /** Count top-level join branches that traverse at least one to-many hop. Two or more
898
+ * independent many-branches multiply (cartesian) and inflate sensitive aggregates. */
899
+ function countManyBranches(joinTree: JoinTree): number {
900
+ const hasMany = (node: JoinNode): boolean =>
901
+ node.cardinality === "many" || node.children.some(hasMany);
902
+ return joinTree.joins.filter(hasMany).length;
903
+ }
904
+
519
905
  function buildGroupBy(spec: SelectSpec): string[] {
520
- const hasAgg = spec.columns.some((c) => c.kind === "aggregate");
906
+ // predicateAgg (bool_or/bool_and) and collectAgg (array_agg) are real aggregates and
907
+ // force GROUP BY too; computed/first are scalar-per-row and never grouped.
908
+ const hasAgg = spec.columns.some(
909
+ (c) => c.kind === "aggregate" || c.kind === "predicateAgg" || c.kind === "collectAgg",
910
+ );
521
911
  if (!hasAgg) return [];
522
912
  return spec.columns
523
913
  .filter((c) => c.kind === "passthrough")
@@ -543,17 +933,66 @@ export function extractViewSpec(
543
933
  root: MetaRoot,
544
934
  ctx: ExtractContext,
545
935
  ): ViewSpec {
546
- const base = baseEntityFor(projection, root);
936
+ // #213 a write-through entity (FR-024 §7) IS its own base: stored fields SELECT
937
+ // from the base alias (o.*), derived (origin.*) fields from the joins. A plain
938
+ // projection anchors its base via an extends binding (baseEntityFor).
939
+ const writeThrough = isWriteThrough(projection);
940
+ const base = writeThrough ? projection : baseEntityFor(projection, root);
547
941
  const usedAliases = new Set<string>();
548
942
  const baseAlias = shortAliasFor(base.name, usedAliases);
549
943
  const joinTree = buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx);
550
- const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx);
944
+ const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx, usedAliases);
551
945
  const groupBy = buildGroupBy(selectSpec);
552
946
 
947
+ const view = viewName(projection, ctx);
948
+ warnOnJoinInflation(projection, view, joinTree, selectSpec);
949
+
950
+ // #207 — a projection's OWN row-scope @filter lowers to the view's outer WHERE.
951
+ // Gated to projections only (v1 scope): a write-through entity read-view is
952
+ // NEVER filtered — a filtered replica breaks read-your-writes totality, and the
953
+ // @filter attr is registered on object.projection (not object.entity), so a
954
+ // write-through entity cannot carry one anyway. The stored value is already the
955
+ // desugared canonical { field: { op: value } } form (FilterAttr.desugar at parse).
956
+ let where: ViewFilterClause | undefined;
957
+ if (!writeThrough) {
958
+ const rawFilter = projection.ownAttr(OBJECT_PROJECTION_ATTR_FILTER);
959
+ if (rawFilter !== undefined) {
960
+ const columnsByField = new Map<string, SelectColumn>(
961
+ selectSpec.columns.map((c) => [c.fieldName, c] as const),
962
+ );
963
+ where = resolveViewFilter(rawFilter, columnsByField, projection.name);
964
+ }
965
+ }
966
+
553
967
  return {
554
- viewName: viewName(projection, ctx),
968
+ viewName: view,
555
969
  joinTree,
556
970
  selectSpec,
557
971
  groupBy,
972
+ ...(where !== undefined ? { where } : {}),
558
973
  };
559
974
  }
975
+
976
+ /**
977
+ * #195 (spec §6, risk 2) — emit a load-time WARN when an inflation-sensitive aggregate
978
+ * (`sum`/`avg`/non-distinct `collect`) coexists with ≥2 independent to-many join
979
+ * branches in one view. Two many-branches multiply (cartesian), silently double-counting
980
+ * — a latent bug for `sum`/`avg` today, now surfaced. count is DISTINCT-guarded and
981
+ * min/max/any/all are inflation-immune, so they never trip it.
982
+ */
983
+ function warnOnJoinInflation(
984
+ projection: MetaObject,
985
+ viewName: string,
986
+ joinTree: JoinTree,
987
+ spec: SelectSpec,
988
+ ): void {
989
+ if (countManyBranches(joinTree) < 2) return;
990
+ const sensitive = spec.columns.filter(isInflationSensitive).map((c) => c.fieldName);
991
+ if (sensitive.length === 0) return;
992
+ console.warn(
993
+ `[codegen-ts] projection "${projection.name}" (view ${viewName}): inflation-sensitive ` +
994
+ `aggregate field(s) [${sensitive.join(", ")}] coexist with ${countManyBranches(joinTree)} ` +
995
+ `independent to-many join branches — the joins multiply, so these values may double-count. ` +
996
+ `Split them into separate projections, or scope with @filter/@distinct.`,
997
+ );
998
+ }