@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
@@ -1,9 +1,29 @@
1
- import { TYPE_FIELD, TYPE_IDENTITY, TYPE_ORIGIN, TYPE_RELATIONSHIP, MetaSource, ORIGIN_SUBTYPE_PASSTHROUGH, ORIGIN_SUBTYPE_AGGREGATE, ORIGIN_PASSTHROUGH_ATTR_FROM, ORIGIN_PASSTHROUGH_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_AGG, ORIGIN_AGGREGATE_ATTR_OF, ORIGIN_AGGREGATE_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_FILTER, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_CARDINALITY, CARDINALITY_ONE, IDENTITY_SUBTYPE_REFERENCE, IDENTITY_REFERENCE_ATTR_REFERENCES, FIELD_ATTR_COLUMN, findReferenceBetween, stripPackage, } from "@metaobjectsdev/metadata";
1
+ import { TYPE_FIELD, TYPE_IDENTITY, TYPE_ORIGIN, TYPE_RELATIONSHIP, MetaSource, ORIGIN_SUBTYPE_PASSTHROUGH, ORIGIN_SUBTYPE_AGGREGATE, ORIGIN_SUBTYPE_COMPUTED, ORIGIN_SUBTYPE_FIRST, ORIGIN_PASSTHROUGH_ATTR_FROM, ORIGIN_PASSTHROUGH_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_AGG, ORIGIN_AGGREGATE_ATTR_OF, ORIGIN_AGGREGATE_ATTR_VIA, ORIGIN_AGGREGATE_ATTR_FILTER, ORIGIN_ATTR_DISTINCT, ORIGIN_ATTR_ORDER_BY, ORIGIN_COMPUTED_ATTR_EXPR, ORIGIN_FIRST_ATTR_OF, ORIGIN_FIRST_ATTR_VIA, ORIGIN_FIRST_ATTR_FILTER, AGG_ANY, AGG_ALL, AGG_COLLECT, AGGREGATE_FUNCTIONS, FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IS_NULL, FILTER_COMPOSE_AND, FILTER_COMPOSE_OR, SORT_ORDER_DESC, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_CARDINALITY, CARDINALITY_ONE, IDENTITY_SUBTYPE_REFERENCE, IDENTITY_REFERENCE_ATTR_REFERENCES, FIELD_ATTR_COLUMN, OBJECT_PROJECTION_ATTR_FILTER, findReferenceBetween, stripPackage, } from "@metaobjectsdev/metadata";
2
2
  import { MetaObject } from "@metaobjectsdev/metadata";
3
3
  import { columnNameFromField, viewNameFromProjection, } from "../naming.js";
4
+ // #213 — a write-through ENTITY (FR-024 §7 read-view) hosts its own view; the base
5
+ // is the entity itself, not an extends-anchored projection.
6
+ import { isWriteThrough } from "./projection-detector.js";
7
+ // #209 — the SAME NOT-NULL predicate that drives a column's `.notNull()` decides
8
+ // whether a belongs-to join is INNER (required FK) vs LEFT OUTER (nullable FK).
9
+ import { isRequired } from "../column-mapper.js";
4
10
  /** Compose keys in the attr.filter shape. */
5
- const FILTER_AND = "and";
6
- const FILTER_OR = "or";
11
+ const FILTER_AND = FILTER_COMPOSE_AND;
12
+ const FILTER_OR = FILTER_COMPOSE_OR;
13
+ // #195 — the three expression-only op/fn names of the attr.expression grammar. They
14
+ // mirror EXPR_OP_IS_NOT_NULL / EXPR_OP_NOT / EXPR_FN_COALESCE in the metadata package's
15
+ // meta-attr-expression module, which is not re-exported through the public barrel; the
16
+ // remaining node ops (eq/ne/gt/… , isNull, and/or) are the shared FILTER_* vocabulary
17
+ // imported above.
18
+ const EXPR_OP_IS_NOT_NULL = "isNotNull";
19
+ const EXPR_OP_NOT = "not";
20
+ const EXPR_FN_COALESCE = "coalesce";
21
+ /** The scalar-reduce @agg values that map to the plain `aggregate` SelectColumn kind. */
22
+ const SCALAR_AGG_FUNCTIONS = new Set(AGGREGATE_FUNCTIONS);
23
+ /** Expression comparison ops (share the filter vocabulary + per-subtype legality bands). */
24
+ const EXPR_COMPARISON_OPS = new Set([
25
+ FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE,
26
+ ]);
7
27
  /**
8
28
  * Desugar a single field clause to the canonical `{ op: value }` form (scalar→eq,
9
29
  * array→in, null→isNull, object→as-is). Mirrors metadata's attr.filter desugar so an
@@ -55,6 +75,63 @@ function resolveAggregateFilter(filter, entity, alias, ctx) {
55
75
  return undefined;
56
76
  return clauses.length === 1 ? clauses[0] : { kind: "and", clauses };
57
77
  }
78
+ /**
79
+ * #207 — resolve a projection's row-scope `@filter` (the desugared canonical
80
+ * `{ field: { op: value }, and?, or? }`) into a {@link ViewFilterClause} whose
81
+ * refs are resolved against the projection's OWN declared columns (its SelectSpec),
82
+ * NOT against a single aggregated entity+alias (that is `resolveAggregateFilter`).
83
+ * Each field key names a declared projection field; it resolves by SelectColumn kind:
84
+ * - passthrough (base OR joined) → `sourceAlias.sourceColumn` — the machinery that
85
+ * makes `WHERE joined.status IS NULL OR joined.status = 1` work.
86
+ * - computed (origin.computed) → the inlined resolved expression (`exprCmp`).
87
+ * - aggregate-derived (aggregate/predicateAgg/collectAgg/first) → THROW (a WHERE
88
+ * cannot see aggregates; HAVING is a separate later extension). The loader already
89
+ * fail-closes this (ERR_BAD_ATTR_FILTER) — this throw is a codegen belt-and-suspenders.
90
+ * - a ref naming no declared field → THROW (dangling; also loader-rejected).
91
+ * Multiple fields at one level compose with AND (mirrors resolveAggregateFilter).
92
+ */
93
+ function resolveViewFilter(filter, columnsByField, projectionName) {
94
+ if (typeof filter !== "object" || filter === null || Array.isArray(filter))
95
+ return undefined;
96
+ const clauses = [];
97
+ for (const [key, val] of Object.entries(filter)) {
98
+ if (key === FILTER_AND || key === FILTER_OR) {
99
+ const subs = (Array.isArray(val) ? val : [])
100
+ .map((s) => resolveViewFilter(s, columnsByField, projectionName))
101
+ .filter((c) => c !== undefined);
102
+ if (subs.length > 0)
103
+ clauses.push({ kind: key === FILTER_AND ? "and" : "or", clauses: subs });
104
+ continue;
105
+ }
106
+ const col = columnsByField.get(key);
107
+ if (!col) {
108
+ // The loader (validateProjectionFilter) already fail-closes a dangling or
109
+ // aggregate-derived ref, so by codegen time a missing column means a DECLARED,
110
+ // addressable field that buildSelectSpec could not resolve to a SELECT column
111
+ // (an internal inconsistency) — report it as such, not as "dangling".
112
+ throw new Error(`Projection ${projectionName}: view @filter field "${key}" did not resolve to a view column.`);
113
+ }
114
+ // A field clause may carry MULTIPLE ops (a range like { gte: 100, lte: 500 }); each
115
+ // becomes its own comparison, AND-composed (dropping all-but-the-first would silently
116
+ // widen the exposed row set). The loader has already validated every op for this
117
+ // field's subtype.
118
+ for (const [op, value] of Object.entries(desugarClause(val))) {
119
+ if (col.kind === "passthrough") {
120
+ clauses.push({ kind: "cmp", ref: `${col.sourceAlias}.${col.sourceColumn}`, op, value });
121
+ }
122
+ else if (col.kind === "computed") {
123
+ clauses.push({ kind: "exprCmp", expr: col.expr, op, value });
124
+ }
125
+ else {
126
+ throw new Error(`Projection ${projectionName}: view @filter references "${key}", an aggregate-derived ` +
127
+ `field — a WHERE cannot see aggregates. Filter on a passthrough or computed field instead.`);
128
+ }
129
+ }
130
+ }
131
+ if (clauses.length === 0)
132
+ return undefined;
133
+ return clauses.length === 1 ? clauses[0] : { kind: "and", clauses };
134
+ }
58
135
  // ---------------------------------------------------------------------------
59
136
  // Private helpers
60
137
  // ---------------------------------------------------------------------------
@@ -125,7 +202,7 @@ export function projectionViewName(projection, columnNamingStrategy) {
125
202
  * loader's `_refNamedOwner`: the ref names the anchor, never the physical
126
203
  * declaring ancestor of an inherited child.
127
204
  */
128
- function refNamedOwner(node, root) {
205
+ export function refNamedOwner(node, root) {
129
206
  const ref = node.superRef;
130
207
  if (ref === undefined)
131
208
  return undefined;
@@ -189,6 +266,121 @@ function joinColumnFor(entity, fieldName, ctx) {
189
266
  const f = entity.fields().find((x) => x.name === fieldName);
190
267
  return f ? sourceColumnNameFor(f, ctx) : columnNameFromField(fieldName, ctx.columnNamingStrategy);
191
268
  }
269
+ /** The physical column of an entity's primary-key field — the LEFT-JOIN phantom guard
270
+ * (#195) tests `<joined>.<pk> IS NOT NULL`, and it is origin.first's tie-breaker. */
271
+ function primaryKeyColumn(entity, ctx) {
272
+ // ADR-0039: resolving — a projection base may inherit its primary identity via extends.
273
+ const pkField = entity.primaryIdentity()?.fields[0];
274
+ return pkField ? joinColumnFor(entity, pkField, ctx) : undefined;
275
+ }
276
+ /**
277
+ * Walk a `@via` dotted path to its terminal (related) entity name. any/all carry no
278
+ * `@of` to name the aggregated entity, so it is derived from the last `@via` hop.
279
+ * Returns undefined if any hop fails to resolve (a prior loader error already fired).
280
+ */
281
+ function viaTerminalEntity(via, root) {
282
+ const segments = via.split(".");
283
+ const rawEntity = segments[0];
284
+ if (!rawEntity)
285
+ return undefined;
286
+ let currentObj = root.findObject(stripPackage(rawEntity));
287
+ if (!currentObj)
288
+ return undefined;
289
+ let terminal = currentObj.name;
290
+ for (const relName of segments.slice(1)) {
291
+ const resolved = resolveHop(currentObj, relName);
292
+ if (!resolved)
293
+ return undefined;
294
+ const target = root.findObject(stripPackage(resolved.targetName));
295
+ if (!target)
296
+ return undefined;
297
+ currentObj = target;
298
+ terminal = target.name;
299
+ }
300
+ return terminal;
301
+ }
302
+ /**
303
+ * Resolve `@orderBy` keys ('field[:asc|desc]') against `entity`'s effective fields into
304
+ * physical {column, dir} pairs (naming strategy applied). Default direction is asc; nulls
305
+ * placement is pinned (nulls-last) at emit, not spelled here. Unknown keys are skipped
306
+ * (the loader validates key existence — `_validateOrderByKeys`).
307
+ */
308
+ function resolveOrderByKeys(orderBy, entity, ctx) {
309
+ if (!Array.isArray(orderBy))
310
+ return [];
311
+ const keys = [];
312
+ for (const raw of orderBy) {
313
+ if (typeof raw !== "string")
314
+ continue;
315
+ const colonIdx = raw.indexOf(":");
316
+ const name = colonIdx === -1 ? raw : raw.slice(0, colonIdx);
317
+ const dir = colonIdx === -1 ? undefined : raw.slice(colonIdx + 1);
318
+ const field = entity.fields().find((f) => f.name === name);
319
+ if (!field)
320
+ continue;
321
+ keys.push({ column: sourceColumnNameFor(field, ctx), dir: dir === SORT_ORDER_DESC ? "desc" : "asc" });
322
+ }
323
+ return keys;
324
+ }
325
+ function isPlainObject(v) {
326
+ return typeof v === "object" && v !== null && !Array.isArray(v);
327
+ }
328
+ /**
329
+ * Resolve a raw `attr.expression` node (from origin.computed `@expr`) into a {@link
330
+ * ViewExprNode} whose `{field}` refs are lowered to the base alias's physical columns
331
+ * (naming strategy applied). The emitter then walks the resolved tree. Returns undefined
332
+ * if any node is structurally malformed or references a non-base field — the loader's
333
+ * origin.computed validation already rejects those, so this is defensive only.
334
+ */
335
+ function resolveExprNode(raw, base, baseAlias, ctx) {
336
+ if (!isPlainObject(raw))
337
+ return undefined;
338
+ if ("field" in raw) {
339
+ const name = raw.field;
340
+ if (typeof name !== "string")
341
+ return undefined;
342
+ const field = base.fields().find((f) => f.name === name);
343
+ if (!field)
344
+ return undefined;
345
+ return { kind: "col", ref: `${baseAlias}.${sourceColumnNameFor(field, ctx)}` };
346
+ }
347
+ if ("value" in raw) {
348
+ return { kind: "lit", value: raw.value };
349
+ }
350
+ if ("fn" in raw) {
351
+ if (raw.fn !== EXPR_FN_COALESCE || !Array.isArray(raw.args))
352
+ return undefined;
353
+ const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
354
+ if (args.some((a) => a === undefined))
355
+ return undefined;
356
+ return { kind: "coalesce", args: args };
357
+ }
358
+ if ("op" in raw && typeof raw.op === "string") {
359
+ const op = raw.op;
360
+ if (EXPR_COMPARISON_OPS.has(op)) {
361
+ const left = resolveExprNode(raw.left, base, baseAlias, ctx);
362
+ const right = resolveExprNode(raw.right, base, baseAlias, ctx);
363
+ return left && right ? { kind: "cmp", op, left, right } : undefined;
364
+ }
365
+ if (op === FILTER_OP_IS_NULL || op === EXPR_OP_IS_NOT_NULL) {
366
+ const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
367
+ return arg ? { kind: "nullTest", negated: op === EXPR_OP_IS_NOT_NULL, arg } : undefined;
368
+ }
369
+ if (op === EXPR_OP_NOT) {
370
+ const arg = resolveExprNode(raw.arg, base, baseAlias, ctx);
371
+ return arg ? { kind: "not", arg } : undefined;
372
+ }
373
+ if (op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) {
374
+ if (!Array.isArray(raw.args))
375
+ return undefined;
376
+ const args = raw.args.map((a) => resolveExprNode(a, base, baseAlias, ctx));
377
+ if (args.some((a) => a === undefined))
378
+ return undefined;
379
+ return { kind: "logic", op: op === FILTER_COMPOSE_AND ? "and" : "or", args: args };
380
+ }
381
+ }
382
+ return undefined;
383
+ }
192
384
  function shortAliasFor(entityName, used) {
193
385
  const base = (entityName[0] ?? "x").toLowerCase();
194
386
  if (!used.has(base)) {
@@ -217,9 +409,21 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
217
409
  if (origin.type !== TYPE_ORIGIN)
218
410
  continue;
219
411
  // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
220
- const viaAttr = origin.subType === ORIGIN_SUBTYPE_AGGREGATE
221
- ? origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA)
222
- : origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
412
+ // Only aggregate (count/sum/avg/min/max/any/all/collect) and passthrough origins
413
+ // contribute a LEFT-JOIN branch. origin.computed is row-level (no @via); origin.first
414
+ // (#195) lowers to a CORRELATED subquery, NOT a join — its @via must NOT enter the
415
+ // join tree (both @from-via and @first-via share the physical name "via", so an
416
+ // unguarded read would wrongly join a `first`).
417
+ let viaAttr;
418
+ if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
419
+ viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
420
+ }
421
+ else if (origin.subType === ORIGIN_SUBTYPE_PASSTHROUGH) {
422
+ viaAttr = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
423
+ }
424
+ else {
425
+ continue;
426
+ }
223
427
  if (!viaAttr)
224
428
  continue;
225
429
  const segments = viaAttr.split(".");
@@ -261,6 +465,20 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
261
465
  // physical columns now so the ON clause is naming-strategy correct.
262
466
  const fkHolder = referenceHolder === "source" ? currentObj : target;
263
467
  const pkHolder = referenceHolder === "source" ? target : currentObj;
468
+ // #209 — a belongs-to hop (FK on the parent) whose FK is NOT NULL is
469
+ // semantically INNER: every base row has a match, so INNER and LEFT OUTER
470
+ // return the same set, and INNER matches the hand-written view it stands in
471
+ // for (and keeps `verify --db` fingerprints aligned). A nullable belongs-to
472
+ // FK, or ANY has-many hop (FK on the child — a base row may have zero
473
+ // children), stays LEFT OUTER so no base row is dropped.
474
+ const fkFieldObj = fkHolder.findField(fkField);
475
+ const selfInner = referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj);
476
+ // Nested-chain safety: joins render flat + left-associative, so an INNER hop
477
+ // BELOW any LEFT ancestor drops the base row (its ON references a column the
478
+ // LEFT ancestor NULLed). An INNER only survives when the ENTIRE ancestor chain
479
+ // is INNER; otherwise demote to LEFT (lossless — under a LEFT ancestor, LEFT is
480
+ // the correct type). `path` holds this chain's ancestor hops accumulated so far.
481
+ const joinType = selfInner && path.every((prior) => prior.joinType === "inner") ? "inner" : "left";
264
482
  path.push({
265
483
  entity: currentObj,
266
484
  relationship: relName,
@@ -268,6 +486,7 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
268
486
  fkColumn: joinColumnFor(fkHolder, fkField, ctx),
269
487
  pkColumn: joinColumnFor(pkHolder, resolvedPkField, ctx),
270
488
  referenceHolder,
489
+ joinType,
271
490
  targetEntity: stripPackage(targetName),
272
491
  });
273
492
  currentObj = target;
@@ -299,6 +518,7 @@ function buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx) {
299
518
  fkColumn: step.fkColumn,
300
519
  pkColumn: step.pkColumn,
301
520
  referenceHolder: step.referenceHolder,
521
+ joinType: step.joinType,
302
522
  children: Array.from(node.children.values()).map(toJoinNode),
303
523
  };
304
524
  }
@@ -326,20 +546,23 @@ function findAliasInTree(joinTree, entityName) {
326
546
  }
327
547
  return recurse(joinTree.joins);
328
548
  }
329
- function buildSelectSpec(projection, base, joinTree, root, ctx) {
549
+ function buildSelectSpec(projection, base, joinTree, root, ctx, usedAliases) {
330
550
  const columns = [];
331
551
  // FR-024 (ADR-0028): the projection's DECLARED field set IS the exposure —
332
552
  // the inclusive list, fail-closed by construction. The pre-FR-024 loop that
333
553
  // emitted every base-entity field as an implicit passthrough (the firehose)
334
554
  // is removed with the B4b cutover: base columns are declared explicitly as
335
555
  // extends-bound fields (`{ field.int: { name: id, extends: "Program.id" } }`).
336
- // Fields explicitly declared on the projection.
337
- // ADR-0039: own the projection's DECLARED field set IS the exposure
338
- // (FR-024/ADR-0028); iterate own fields + each field's own origin. origin.*
339
- // NEVER inherits (ADR-0029), so the origin attr reads below are own (category 4).
340
- for (const field of projection.ownChildren()) {
341
- if (field.type !== TYPE_FIELD)
342
- continue;
556
+ // #213 an entity read-view HOST (base === projection, FR-024 §7) exposes its
557
+ // EFFECTIVE field set: the `o.*` includes fields inherited via extends (a
558
+ // BaseEntity id/createdAt). A plain projection exposes only its DECLARED (own)
559
+ // fields the declared set IS the exposure (FR-024/ADR-0028). Either way, each
560
+ // field's own origin decides passthrough-from-base vs derived-from-join. origin.*
561
+ // NEVER inherits (ADR-0029), so the origin reads below are own (category 4).
562
+ const declaredFields = base === projection
563
+ ? base.fields()
564
+ : projection.ownChildren().filter((c) => c.type === TYPE_FIELD);
565
+ for (const field of declaredFields) {
343
566
  const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
344
567
  const dbCol = sourceColumnNameFor(field, ctx);
345
568
  if (!origin) {
@@ -384,8 +607,44 @@ function buildSelectSpec(projection, base, joinTree, root, ctx) {
384
607
  else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
385
608
  // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
386
609
  const agg = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_AGG);
610
+ if (!agg)
611
+ continue;
612
+ const filterAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_FILTER);
613
+ if (agg === AGG_ANY || agg === AGG_ALL) {
614
+ // #195 predicate quantifier — no @of; the related entity is @via's terminal
615
+ // hop, and @filter is the (required) quantified predicate.
616
+ const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
617
+ if (!via)
618
+ continue;
619
+ const relatedName = viaTerminalEntity(via, root);
620
+ if (!relatedName)
621
+ continue;
622
+ const relatedEntity = root.findObject(relatedName);
623
+ const sourceAlias = findAliasInTree(joinTree, relatedName);
624
+ if (!relatedEntity || sourceAlias === undefined)
625
+ continue;
626
+ const joinedPk = primaryKeyColumn(relatedEntity, ctx);
627
+ if (joinedPk === undefined)
628
+ continue;
629
+ const pred = filterAttr !== undefined
630
+ ? resolveAggregateFilter(filterAttr, relatedEntity, sourceAlias, ctx)
631
+ : undefined;
632
+ if (pred === undefined)
633
+ continue; // loader requires @filter on any/all
634
+ columns.push({
635
+ kind: "predicateAgg",
636
+ fieldName: field.name,
637
+ dbColAlias: dbCol,
638
+ quant: agg === AGG_ANY ? "any" : "all",
639
+ sourceAlias,
640
+ joinedPkColumn: joinedPk,
641
+ pred,
642
+ });
643
+ continue;
644
+ }
645
+ // collect + the scalar reduces (count/sum/avg/min/max) all name @of.
387
646
  const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
388
- if (!agg || !of_)
647
+ if (!of_)
389
648
  continue;
390
649
  const dotIdx = of_.indexOf(".");
391
650
  if (dotIdx < 1)
@@ -399,28 +658,131 @@ function buildSelectSpec(projection, base, joinTree, root, ctx) {
399
658
  const targetField = targetEntity.fields().find((f) => f.name === fieldName);
400
659
  if (!targetField)
401
660
  continue;
402
- // Optional scoping filter — resolved against the aggregated entity (the @of
403
- // entity, reached at `sourceAlias`), e.g. max(version) over only active rows.
404
- // ADR-0039: own (category 4) origin.* never inherits (ADR-0029).
405
- const filterAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_FILTER);
661
+ if (agg === AGG_COLLECT) {
662
+ // #195 array rollup collect @of across the related set. @distinct = set
663
+ // semantics; @orderBy (non-distinct) sets element order, else value-ascending.
664
+ const joinedPk = primaryKeyColumn(targetEntity, ctx);
665
+ if (joinedPk === undefined)
666
+ continue;
667
+ const distinct = origin.ownAttr(ORIGIN_ATTR_DISTINCT) === true;
668
+ const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), targetEntity, ctx);
669
+ columns.push({
670
+ kind: "collectAgg",
671
+ fieldName: field.name,
672
+ dbColAlias: dbCol,
673
+ sourceAlias,
674
+ sourceColumn: sourceColumnNameFor(targetField, ctx),
675
+ joinedPkColumn: joinedPk,
676
+ distinct,
677
+ orderBy,
678
+ });
679
+ continue;
680
+ }
681
+ if (SCALAR_AGG_FUNCTIONS.has(agg)) {
682
+ // Optional scoping filter — resolved against the aggregated entity (the @of
683
+ // entity, reached at `sourceAlias`), e.g. max(version) over only active rows.
684
+ const filter = filterAttr !== undefined
685
+ ? resolveAggregateFilter(filterAttr, targetEntity, sourceAlias, ctx)
686
+ : undefined;
687
+ columns.push({
688
+ kind: "aggregate",
689
+ fieldName: field.name,
690
+ dbColAlias: dbCol,
691
+ agg: agg,
692
+ sourceAlias,
693
+ sourceColumn: sourceColumnNameFor(targetField, ctx),
694
+ ...(filter !== undefined ? { filter } : {}),
695
+ });
696
+ }
697
+ }
698
+ else if (origin.subType === ORIGIN_SUBTYPE_COMPUTED) {
699
+ // #195 row-level computed value from the base entity's own fields (@expr tree,
700
+ // no @via). ADR-0039: own — origin.* never inherits (ADR-0029).
701
+ const rawExpr = origin.ownAttr(ORIGIN_COMPUTED_ATTR_EXPR);
702
+ const expr = resolveExprNode(rawExpr, base, joinTree.baseAlias, ctx);
703
+ if (expr === undefined)
704
+ continue;
705
+ columns.push({ kind: "computed", fieldName: field.name, dbColAlias: dbCol, expr });
706
+ }
707
+ else if (origin.subType === ORIGIN_SUBTYPE_FIRST) {
708
+ // #195 correlated latest-row — argmax-then-project. Resolves to a correlated
709
+ // subquery (NOT a join). ADR-0039: own — origin.* never inherits (ADR-0029).
710
+ const of_ = origin.ownAttr(ORIGIN_FIRST_ATTR_OF);
711
+ if (!of_)
712
+ continue;
713
+ const dotIdx = of_.indexOf(".");
714
+ if (dotIdx < 1)
715
+ continue;
716
+ const childName = stripPackage(of_.slice(0, dotIdx));
717
+ const ofFieldName = of_.slice(dotIdx + 1);
718
+ const childEntity = root.findObject(childName);
719
+ if (!childEntity)
720
+ continue;
721
+ const ofField = childEntity.fields().find((f) => f.name === ofFieldName);
722
+ if (!ofField)
723
+ continue;
724
+ // The base↔child correlation FK — resolved exactly as buildJoinTree resolves a
725
+ // single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
726
+ // a multi-hop @via on origin.first is not lowered here (rare, and validated away).
727
+ const ref = findReferenceBetween(base, childEntity);
728
+ if (!ref)
729
+ continue;
730
+ const fkField = ref.referenceIdentity.fields[0];
731
+ if (!fkField)
732
+ continue;
733
+ const pkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id";
734
+ const referenceHolder = ref.holder.name === base.name ? "source" : "target";
735
+ const fkHolder = referenceHolder === "source" ? base : childEntity;
736
+ const pkHolder = referenceHolder === "source" ? childEntity : base;
737
+ const childPk = primaryKeyColumn(childEntity, ctx);
738
+ if (childPk === undefined)
739
+ continue;
740
+ // A FRESH alias — the subquery is an independent correlated scope, never the
741
+ // JOIN-tree alias (reserved against usedAliases so it can never collide).
742
+ const childAlias = shortAliasFor(childEntity.name, usedAliases);
743
+ const orderBy = resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), childEntity, ctx);
744
+ const filterAttr = origin.ownAttr(ORIGIN_FIRST_ATTR_FILTER);
406
745
  const filter = filterAttr !== undefined
407
- ? resolveAggregateFilter(filterAttr, targetEntity, sourceAlias, ctx)
746
+ ? resolveAggregateFilter(filterAttr, childEntity, childAlias, ctx)
408
747
  : undefined;
409
748
  columns.push({
410
- kind: "aggregate",
749
+ kind: "first",
411
750
  fieldName: field.name,
412
751
  dbColAlias: dbCol,
413
- agg,
414
- sourceAlias,
415
- sourceColumn: sourceColumnNameFor(targetField, ctx),
752
+ childEntity: childEntity.name,
753
+ childAlias,
754
+ sourceColumn: sourceColumnNameFor(ofField, ctx),
755
+ referenceHolder,
756
+ fkColumn: joinColumnFor(fkHolder, fkField, ctx),
757
+ pkColumn: joinColumnFor(pkHolder, pkField, ctx),
758
+ childPkColumn: childPk,
759
+ orderBy,
416
760
  ...(filter !== undefined ? { filter } : {}),
417
761
  });
418
762
  }
419
763
  }
420
764
  return { columns };
421
765
  }
766
+ /** The inflation-sensitive aggregate kinds: a non-distinct row multiplication corrupts
767
+ * their value (sum/avg double-count; a non-distinct collect duplicates elements).
768
+ * count is DISTINCT-guarded; min/max and any/all are inflation-immune. */
769
+ function isInflationSensitive(c) {
770
+ if (c.kind === "aggregate")
771
+ return c.agg === "sum" || c.agg === "avg";
772
+ if (c.kind === "collectAgg")
773
+ return !c.distinct;
774
+ return false;
775
+ }
776
+ /** Count top-level join branches that traverse at least one to-many hop. Two or more
777
+ * independent many-branches multiply (cartesian) and inflate sensitive aggregates. */
778
+ function countManyBranches(joinTree) {
779
+ const hasMany = (node) => node.cardinality === "many" || node.children.some(hasMany);
780
+ return joinTree.joins.filter(hasMany).length;
781
+ }
422
782
  function buildGroupBy(spec) {
423
- const hasAgg = spec.columns.some((c) => c.kind === "aggregate");
783
+ // predicateAgg (bool_or/bool_and) and collectAgg (array_agg) are real aggregates and
784
+ // force GROUP BY too; computed/first are scalar-per-row and never grouped.
785
+ const hasAgg = spec.columns.some((c) => c.kind === "aggregate" || c.kind === "predicateAgg" || c.kind === "collectAgg");
424
786
  if (!hasAgg)
425
787
  return [];
426
788
  return spec.columns
@@ -441,17 +803,56 @@ function buildGroupBy(spec) {
441
803
  * @param ctx Column naming strategy for SQL identifiers.
442
804
  */
443
805
  export function extractViewSpec(projection, root, ctx) {
444
- const base = baseEntityFor(projection, root);
806
+ // #213 a write-through entity (FR-024 §7) IS its own base: stored fields SELECT
807
+ // from the base alias (o.*), derived (origin.*) fields from the joins. A plain
808
+ // projection anchors its base via an extends binding (baseEntityFor).
809
+ const writeThrough = isWriteThrough(projection);
810
+ const base = writeThrough ? projection : baseEntityFor(projection, root);
445
811
  const usedAliases = new Set();
446
812
  const baseAlias = shortAliasFor(base.name, usedAliases);
447
813
  const joinTree = buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx);
448
- const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx);
814
+ const selectSpec = buildSelectSpec(projection, base, joinTree, root, ctx, usedAliases);
449
815
  const groupBy = buildGroupBy(selectSpec);
816
+ const view = viewName(projection, ctx);
817
+ warnOnJoinInflation(projection, view, joinTree, selectSpec);
818
+ // #207 — a projection's OWN row-scope @filter lowers to the view's outer WHERE.
819
+ // Gated to projections only (v1 scope): a write-through entity read-view is
820
+ // NEVER filtered — a filtered replica breaks read-your-writes totality, and the
821
+ // @filter attr is registered on object.projection (not object.entity), so a
822
+ // write-through entity cannot carry one anyway. The stored value is already the
823
+ // desugared canonical { field: { op: value } } form (FilterAttr.desugar at parse).
824
+ let where;
825
+ if (!writeThrough) {
826
+ const rawFilter = projection.ownAttr(OBJECT_PROJECTION_ATTR_FILTER);
827
+ if (rawFilter !== undefined) {
828
+ const columnsByField = new Map(selectSpec.columns.map((c) => [c.fieldName, c]));
829
+ where = resolveViewFilter(rawFilter, columnsByField, projection.name);
830
+ }
831
+ }
450
832
  return {
451
- viewName: viewName(projection, ctx),
833
+ viewName: view,
452
834
  joinTree,
453
835
  selectSpec,
454
836
  groupBy,
837
+ ...(where !== undefined ? { where } : {}),
455
838
  };
456
839
  }
840
+ /**
841
+ * #195 (spec §6, risk 2) — emit a load-time WARN when an inflation-sensitive aggregate
842
+ * (`sum`/`avg`/non-distinct `collect`) coexists with ≥2 independent to-many join
843
+ * branches in one view. Two many-branches multiply (cartesian), silently double-counting
844
+ * — a latent bug for `sum`/`avg` today, now surfaced. count is DISTINCT-guarded and
845
+ * min/max/any/all are inflation-immune, so they never trip it.
846
+ */
847
+ function warnOnJoinInflation(projection, viewName, joinTree, spec) {
848
+ if (countManyBranches(joinTree) < 2)
849
+ return;
850
+ const sensitive = spec.columns.filter(isInflationSensitive).map((c) => c.fieldName);
851
+ if (sensitive.length === 0)
852
+ return;
853
+ console.warn(`[codegen-ts] projection "${projection.name}" (view ${viewName}): inflation-sensitive ` +
854
+ `aggregate field(s) [${sensitive.join(", ")}] coexist with ${countManyBranches(joinTree)} ` +
855
+ `independent to-many join branches — the joins multiply, so these values may double-count. ` +
856
+ `Split them into separate projections, or scope with @filter/@distinct.`);
857
+ }
457
858
  //# sourceMappingURL=extract-view-spec.js.map