|
| 1 | +--- |
| 2 | +name: Bug report |
| 3 | +about: Report a defect |
| 4 | +title: "Projection origin.aggregate columns dropped from generated view DDL (inverse-FK @via + aggregate filter)" |
| 5 | +labels: bug |
| 6 | +--- |
| 7 | + |
| 8 | +**Affected port(s):** TypeScript (codegen-ts view-SQL builder) |
| 9 | +**Package + version:** `@metaobjectsdev/codegen-ts` 0.12.5 (`projection/extract-view-spec.ts`), surfaced via `@metaobjectsdev/migrate-ts` 0.12.0 + `@metaobjectsdev/cli` 0.12.5, dialect postgres. |
| 10 | + |
| 11 | +**What happened** |
| 12 | + |
| 13 | +A `object.projection` with an `origin.aggregate` column over a **one-to-many (inverse-FK)** relationship has that column **silently dropped** from the generated `CREATE VIEW` DDL. The column IS present in the generated TS contract (Zod + types), so the contract and the actual DB view disagree — querying the view fails (the column doesn't exist) while the types claim it does. |
| 14 | + |
| 15 | +Two distinct gaps, both in `codegen-ts` `extract-view-spec.ts` → `view-ddl-emit.ts`: |
| 16 | + |
| 17 | +1. **Inverse-FK `@via` not resolved into the join tree.** When `origin.aggregate @via` points at a `relationship.composition` whose FK lives on the *target* side (the 1→many inverse — parent has many children, child holds the FK), the join-tree builder doesn't add the child table, so `findAliasInTree(joinTree, entityName)` returns `undefined` and the aggregate branch hits `continue` — the column is dropped entirely. (`extract-view-spec.ts`, the `else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE)` block.) This drops *every* aggregate over an inverse relationship, including a plain `count`. |
| 18 | + |
| 19 | +2. **Aggregate `filter` not rendered.** `origin.aggregate` accepts a `filter` (an `attr.filter` desugar — e.g. `filter: { status: { eq: active } }`) and the loader accepts it without error, but `view-ddl-emit.ts` emits `AGG(src) AS alias` with **no `FILTER (WHERE …)`** clause. So even when the join resolves, a filtered aggregate (`max(version) where status='active'`) computes over *all* rows, returning the wrong value. (`meta types origin.aggregate` also doesn't list `filter` as an attr, though the loader honors it.) |
| 20 | + |
| 21 | +**What you expected** |
| 22 | + |
| 23 | +The generated view SQL should include the aggregate column with the join and the filter, e.g.: |
| 24 | + |
| 25 | +```sql |
| 26 | +CREATE VIEW v_behavior_definition AS |
| 27 | +SELECT |
| 28 | + b.id, b.name, ..., |
| 29 | + COUNT(DISTINCT v.id) AS version_count, |
| 30 | + MAX(v.version) FILTER (WHERE v.status = 'active') AS active_version |
| 31 | +FROM behavior_definitions b |
| 32 | +LEFT JOIN behavior_versions v ON v.behavior_id = b.id |
| 33 | +GROUP BY b.id, b.name, ...; |
| 34 | +``` |
| 35 | + |
| 36 | +**Reproduction** |
| 37 | + |
| 38 | +Minimal metadata: a parent entity, a child entity holding the FK back to the parent, an inverse `relationship.composition` on the parent, and a projection that aggregates over it. |
| 39 | + |
| 40 | +```yaml |
| 41 | +- object.entity: |
| 42 | + name: BehaviorDefinition |
| 43 | + children: |
| 44 | + - source.rdb: { table: behavior_definitions } |
| 45 | + - field.string: { name: name } |
| 46 | + # inverse of behavior_versions.behavior_id (FK is on the child): |
| 47 | + - relationship.composition: { name: versions, objectRef: BehaviorVersion, cardinality: many } |
| 48 | + extends: AuditedEntity |
| 49 | +- object.entity: |
| 50 | + name: BehaviorVersion |
| 51 | + children: |
| 52 | + - source.rdb: { table: behavior_versions } |
| 53 | + - field.uuid: { name: behaviorId, column: behavior_id } |
| 54 | + - field.int: { name: version } |
| 55 | + - field.string: { name: status } |
| 56 | + - identity.reference: { name: fk_b, fields: [behaviorId], references: BehaviorDefinition } |
| 57 | +- object.projection: |
| 58 | + name: BehaviorDefinitionView |
| 59 | + children: |
| 60 | + - source.rdb: { kind: view, view: v_behavior_definition } |
| 61 | + - field.uuid: { name: id, extends: BehaviorDefinition.id } |
| 62 | + - field.string: { name: name, children: [ origin.passthrough: { from: BehaviorDefinition.name } ] } |
| 63 | + - field.int: { name: version_count, |
| 64 | + children: [ origin.aggregate: { agg: count, of: BehaviorVersion.id, via: BehaviorDefinition.versions } ] } |
| 65 | + - field.int: { name: active_version, |
| 66 | + children: [ origin.aggregate: { agg: max, of: BehaviorVersion.version, |
| 67 | + via: BehaviorDefinition.versions, |
| 68 | + filter: { status: { eq: active } } } ] } |
| 69 | + - identity.primary: { extends: BehaviorDefinition.pk } |
| 70 | +``` |
| 71 | +
|
| 72 | +Steps: `meta gen` (the TS contract gets `version_count` + `active_version` — correct), then `meta migrate --dialect postgres` → the generated `CREATE VIEW` has **only the passthrough columns**; both aggregate columns are absent (no `LEFT JOIN`, no `GROUP BY`). |
| 73 | + |
| 74 | +**Root-cause pointers** |
| 75 | +- `codegen-ts/src/projection/extract-view-spec.ts`: the aggregate branch `continue`s when `findAliasInTree` can't find the target — the join-tree builder needs to resolve an inverse (target-side-FK, cardinality `many`) composition into a `LEFT JOIN child ON child.fk = parent.pk`. |
| 76 | +- `codegen-ts/src/projection/view-ddl-emit.ts`: the aggregate emitter needs a postgres `FILTER (WHERE …)` (or per-dialect equivalent) when the `origin.aggregate` carries a `filter`. |
| 77 | +- A conformance fixture exercising an inverse-FK aggregate + a filtered aggregate would pin both. |
| 78 | + |
| 79 | +**Environment** |
| 80 | +Linux, Node 24, pnpm 10. Found while migrating a downstream consumer's admin UI to a generated projection view; worked around by hand-authoring the view SQL body. |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +## Resolution (2026-06-28) |
| 85 | + |
| 86 | +**Gap 1 (inverse-FK join) was already fixed on `main`** — the `ProgramSummary` test |
| 87 | +(`extract-view-spec.test.ts`) covers an aggregate `count` over an inverse-FK `weeks` |
| 88 | +relationship and passes. The reporter hit it only on the published 0.12.0 the CLI |
| 89 | +pins; latest `main` resolves the inverse join. No code change needed. |
| 90 | + |
| 91 | +**Gap 2 (aggregate filter) is fixed here** (codegen-ts only — no cross-port metamodel |
| 92 | +change, see scoping note): |
| 93 | +- `projection/view-spec.ts` — new `ViewFilterClause` type; `aggregate` SelectColumn |
| 94 | + gains an optional `filter`. |
| 95 | +- `projection/extract-view-spec.ts` — reads the aggregate's `@filter` attr, **desugars** |
| 96 | + it (scalar→eq, array→in, null→isNull, and/or recurse), and resolves each field to |
| 97 | + `<sourceAlias>.<column>` on the aggregated entity. |
| 98 | +- `projection/view-ddl-emit.ts` — renders postgres `AGG(src) FILTER (WHERE …)` / |
| 99 | + `COUNT(DISTINCT src) FILTER (WHERE …)`, and the portable `AGG(CASE WHEN … THEN src END)` |
| 100 | + form for sqlite (no aggregate FILTER pre-3.30). |
| 101 | +- Tests: `build-projection-views.test.ts` — postgres FILTER, scalar-shorthand desugar, |
| 102 | + and sqlite CASE WHEN. Full codegen-ts suite green (863). |
| 103 | + |
| 104 | +**Scoping note:** the `@filter` attr is read codegen-locally (a const in |
| 105 | +extract-view-spec) rather than declared in the `origin.aggregate` metamodel |
| 106 | +(`spec/metamodel/origin.json`). Declaring it there is the "proper" home but ripples to |
| 107 | +all five ports' registry-conformance + the embedded/manifest/docs drift gates — a |
| 108 | +separate cross-port change. The loader already stores the undeclared attr; codegen |
| 109 | +desugars it, so the feature works today without the metamodel churn. Follow-up: declare |
| 110 | +`@filter` on `origin.aggregate` across all ports when convenient (then codegen can drop |
| 111 | +its local desugar and rely on the framework's). |
0 commit comments