diff --git a/.nm-triage-nonprod b/.nm-triage-nonprod new file mode 100644 index 0000000000..5409416478 --- /dev/null +++ b/.nm-triage-nonprod @@ -0,0 +1,11 @@ +# Paths that no-mistakes triage counts as non-production (one glob per line). +# Conformance corpora and other test fixtures: data, gated by every port's runner. +fixtures/** +# The metamodel spec and the copies generated from it into each port. +spec/metamodel/*.json +server/csharp/MetaObjects/SpecMetamodel/*.json +server/python/src/metaobjects/spec_metamodel/*.json +server/typescript/packages/metadata/src/**/*.embedded.ts +# The generated reference site and showcase payload. +site-reference/** +examples/showcase/site-payload.json diff --git a/AGENTS.md b/AGENTS.md index 7b73c724fe..3d772c9692 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (458 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (459 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0a6175ac..5e2d310b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ here.** ## [Unreleased] +### Fixed + +- **A projection view keeps the columns a reverse `@via` hop feeds.** An origin `@via` that walks + an `identity.reference` backwards (`Project.timeEntries`, #560) loaded, but `meta migrate` left + its `origin.aggregate` / `origin.first` columns out of `CREATE VIEW` without a word. The view + emitter now resolves the hop the way the loader does and joins from the holder of the + reference. Gated by a Postgres test that creates the view and reads it. +- **`origin.aggregate` `sum`/`avg`/`min`/`max` over a value-object field is refused at load.** + `@of: Invoice.total` on a flattened Money field loaded and emitted `SUM(i.total)`, a column that + does not exist. Every port now refuses it with `ERR_INVALID_ORIGIN` and says to aggregate a + scalar field; gated by the `error-origin-aggregate-value-object` conformance fixture. Refusing, + rather than summing the flattened amount column, keeps a sum from adding amounts in different + currencies. `count` is unaffected. + +### Changed + +- **Agent context routes report work to the reporting vocabulary.** The `meta init` agent guide + and the `metaobjects-authoring` skill now send grouped totals, dashboard numbers and read-only + report views to `object.report` (`references/declaring-reports.md`) before the `@sql` escape, + which they describe as the last resort for bodies the vocabulary cannot express. + ## [1.3.0] — 2026-11-11 _npm `1.3.0` (full lockstep across all 14 `@metaobjectsdev/*` publish candidates)._ diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index de11bce2cc..459023b776 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md b/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e8..99422ce11f 100644 --- a/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/agent-context/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/agent-context/skills/metaobjects-authoring/references/sources.md b/agent-context/skills/metaobjects-authoring/references/sources.md index 5f7f94c031..bff437f410 100644 --- a/agent-context/skills/metaobjects-authoring/references/sources.md +++ b/agent-context/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index cc0003da65..cc1aaaf1bf 100644 --- a/agent-context/templates/always-on.md.mustache +++ b/agent-context/templates/always-on.md.mustache @@ -50,6 +50,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 4fac400837..24b413c16c 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 458 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 459 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 45 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -354,7 +354,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (458) +### `fixtures/conformance/` — metamodel loader + canonical serializer (459) | Fixture prefix | Feature doc | |---|---| @@ -716,7 +716,7 @@ JSON and verify payload feeds it unchanged. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 458 + yaml 16 + verify 45 +The fixtures in the nine corpora mapped above (metamodel 459 + yaml 16 + verify 45 + render 18 + persistence 62 + api-contract 123 + source-resolution 25 + scope 10 + dependency 47) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the `cube-model` corpus and `explorer-conformance`, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/docs/features/source-kinds.md b/docs/features/source-kinds.md index 6840c71a10..3fe738009d 100644 --- a/docs/features/source-kinds.md +++ b/docs/features/source-kinds.md @@ -182,6 +182,12 @@ max over only the active rows — the filtered-scalar shape a plain aggregate ca ]}} ``` +**`sum` / `avg` / `min` / `max` need a scalar `@of`.** A value-object field (`field.object`, +flattened or jsonb) is several columns or a document, so there is no one column to reduce, and a +Money-shaped sum would add amounts in different currencies. The loader refuses it in every port +(`ERR_INVALID_ORIGIN`); model the amount as its own scalar field and group by its currency. +`count` still counts rows. + **More `origin.aggregate` reductions (#195).** Beyond the scalar reduces, `@agg` also takes `any` / `all` (predicate quantifiers over a `@filter` — `@of` forbidden; empty set → `any=false`, `all=true`) and `collect` (an array rollup of `@of` into an `isArray` field, diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 78478a6803..c12759239a 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.3" }, "counts": { - "fixtures": 458, + "fixtures": 459, "corpora": 38, "baseTypes": 17 }, diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2cc..459023b776 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e8..99422ce11f 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c031..bff437f410 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md index cc76b4b873..a3f4014b0b 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/AGENTS.md @@ -68,6 +68,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md index cc76b4b873..a3f4014b0b 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/csharp/expected/.metaobjects/CLAUDE.md @@ -68,6 +68,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2cc..459023b776 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e8..99422ce11f 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c031..bff437f410 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md index b700a9d00e..6c4200f7c4 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md @@ -87,6 +87,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md index b700a9d00e..6c4200f7c4 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -87,6 +87,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2cc..459023b776 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e8..99422ce11f 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c031..bff437f410 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md index 26abb81c10..074f87fdd1 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md @@ -73,6 +73,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md index 26abb81c10..074f87fdd1 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md @@ -73,6 +73,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2cc..459023b776 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e8..99422ce11f 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c031..bff437f410 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index 8f925a267a..4ac33cdba1 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md @@ -69,6 +69,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md index 8f925a267a..4ac33cdba1 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md @@ -69,6 +69,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index de11bce2cc..459023b776 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -31,10 +31,10 @@ This file covers what almost every model needs. The topics below live in | `references/identities-and-indexes.md` | declaring a primary, secondary or reference identity, an FK, or a non-unique `index.lookup` | | `references/sources.md` | the `source.rdb` shape and `@kind`; a store MetaObjects does not manage; a derived or computed column (reach for an entity read-view first) | | `references/abstracts-and-overlay.md` | a shared `abstract` base, `extends`, or a layered `overlay` file | -| `references/declaring-reports.md` | declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | +| `references/declaring-reports.md` | **open first** for a report, dashboard, totals, `GROUP BY`, "per client / per month" or a read-only summary view: declaring `dimension.*`, `measure.*`, `segment.filter` or an `object.report` | | `references/model-first-rationale.md` | someone argues that hand-writing a layer is cheaper this once | | `references/relationships.md` | a many-to-many link or self-join, or a "rows that reference this one" query | -| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | +| `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view (a grouped or aggregate view is `declaring-reports.md` first) | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | | `references/reporting.md` | what a declared report gives you: what columns a report gets, time grains, null rules, rows with no facts (`@spine`), defaults (`@default`), engine differences | diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md index 06d2ccf2e8..99422ce11f 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/read-views-and-projections.md @@ -1,6 +1,6 @@ # Projections, `origin.*`, and managed views -> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. +> Part of the `metaobjects-authoring` skill. The skill covers the entity read-view, which is the right answer for most grids. Read this when you need an independent exposure (`object.projection`), the `origin.*` vocabulary, `@filter` / `@expr` shapes, or an `@sql` / `@unmanaged` view. A grouped total or report (per client, per month) is an `object.report`: `declaring-reports.md`. ## Contents - Read-view or projection? @@ -125,8 +125,9 @@ non-aggregate-derived** projection fields: naming an undeclared field fails the **The `CREATE VIEW` body is generated from those `origin.*` children by the Node `meta migrate` — never hand-author view SQL for a shape origins can express** (an unmodeled -view is *unmanaged*, so `meta verify --db` can't even catch the drift). For a genuinely -irreducible body (recursive CTE, window function, set operation) that origins can't express, +view is *unmanaged*, so `meta verify --db` can't even catch the drift). **An `@sql` body that +`GROUP BY`s: check `declaring-reports.md` first** (`object.report`, `@spine`, `@default`). +For a genuinely irreducible body (recursive CTE, window function, set operation) that origins can't express, carry it in the `source.rdb` **`@sql`** escape (#208, ADR-0043) — a hand-written body the tool registers, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`) — rather than a hand-edited migration file where it goes diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md index 5f7f94c031..bff437f410 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/sources.md @@ -94,4 +94,6 @@ column because the filter tier runs against the view. **When you need an independent exposure** — a subset, renamed columns, a row-filtered view, a versioned or external shape — that is an `object.projection`. It, the `origin.*` vocabulary, the structured `@filter` / `@expr` shapes and the `@sql` / `@unmanaged` escapes -are in `references/read-views-and-projections.md`. +are in `references/read-views-and-projections.md`. A grouped or aggregate view (totals per client +or per month) is an `object.report` (`references/declaring-reports.md`); `@sql` is for what neither +can express (a recursive CTE, a window function, a set operation). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md index f71ff6288e..32ec038942 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md @@ -86,6 +86,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md index f71ff6288e..32ec038942 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -86,6 +86,9 @@ Commit the metadata, the generated code, `.metaobjects/migrations/` and - Once the project declares a `template.prompt`, every LLM prompt is one: the text in a template, the data in a payload, a structured reply read through its generated extractor (`metaobjects-prompts`). +- A grouped total, dashboard number or read-only report view (per client, per month) is an + `object.report` over `dimension.*` / `measure.*` (`metaobjects-authoring`, + `references/declaring-reports.md`). A hand-written `@sql` view body is the last resort. - Never invent metadata vocabulary. Search it: `npx meta types `, and `npx meta types . --detail` for one construct's valid attributes. - Metadata nodes are fused-key maps, `{"field.string": {"name": "email"}}`. Attribute names are diff --git a/fixtures/conformance/error-origin-aggregate-value-object/expected-errors.json b/fixtures/conformance/error-origin-aggregate-value-object/expected-errors.json new file mode 100644 index 0000000000..3290a6cb63 --- /dev/null +++ b/fixtures/conformance/error-origin-aggregate-value-object/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_ORIGIN", + "source": { + "format": "resolved", + "files": [ + "meta.billing.json" + ], + "jsonPath": "$['metadata.root'].children[3]['object.projection'].children[2]['field.decimal'].children[0]['origin.aggregate']", + "referrer": "ProjectInvoicing::invoicedTotal", + "target": "Invoice.total" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-origin-aggregate-value-object/input/meta.billing.json b/fixtures/conformance/error-origin-aggregate-value-object/input/meta.billing.json new file mode 100644 index 0000000000..4f13778018 --- /dev/null +++ b/fixtures/conformance/error-origin-aggregate-value-object/input/meta.billing.json @@ -0,0 +1,58 @@ +{ + "metadata.root": { + "package": "acme::billing", + "children": [ + { + "object.value": { + "name": "Money", + "children": [ + { "field.decimal": { "name": "amount", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "currency", "@maxLength": 3 } } + ] + } + }, + { + "object.entity": { + "name": "Project", + "children": [ + { "source.rdb": { "@table": "projects" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "relationship.association": { "name": "invoices", "@objectRef": "Invoice", "@cardinality": "many" } } + ] + } + }, + { + "object.entity": { + "name": "Invoice", + "children": [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "projectId" } }, + { "field.object": { "name": "total", "@objectRef": "Money", "@storage": "flattened" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "project", "@fields": "projectId", "@references": "Project" } } + ] + } + }, + { + "object.projection": { + "name": "ProjectInvoicing", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_project_invoicing" } }, + { "field.long": { "name": "id", "extends": "Project.id" } }, + { + "field.decimal": { + "name": "invoicedTotal", + "children": [ + { "origin.aggregate": { "@agg": "sum", "@of": "Invoice.total", "@via": "Project.invoices" } } + ] + } + }, + { "identity.primary": { "name": "id", "extends": "Project.id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-origin-aggregate-value-object/providers.json b/fixtures/conformance/error-origin-aggregate-value-object/providers.json new file mode 100644 index 0000000000..f9bb435a37 --- /dev/null +++ b/fixtures/conformance/error-origin-aggregate-value-object/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 04612bb0c5..c9fe25c3db 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -703,6 +703,20 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) // NOTE (FR-024 B6): NO extends/origin agreement on aggregates. var ofTarget = ValidateFromPath(of, root, obj, field.Name, errors, "origin.aggregate.@of", src); + // A sum/avg/min/max reduces ONE column. A value-object field is several columns + // (flattened) or a document (jsonb), so there is no column to reduce, and summing a + // Money-shaped value would add amounts in different currencies. + // ADR-0039: resolving — @objectRef may be inherited via extends. + if (ofTarget is ResolvedFromTarget valueTarget + && agg is not null && Array.IndexOf(COLUMN_VALUE_AGGREGATES, agg) >= 0 + && (valueTarget.Field.SubType == FIELD_SUBTYPE_OBJECT + || valueTarget.Field.Attr(FIELD_ATTR_OBJECT_REF) is not null)) + errors.Add(new MetaError( + $"origin.aggregate @agg:{agg} on {obj.Name}.{field.Name}: @of \"{of}\" is a value-object " + + $"field (field.{valueTarget.Field.SubType}), which has no single column to aggregate. " + + "Aggregate a scalar field instead: model the amount as its own field (and group by its currency).", + ErrorCode.ERR_INVALID_ORIGIN, + Envelope: ResolvedSource.From(src, $"{obj.Fqn()}::{field.Name}", of))); // #195 — collect preserves the element type: the array field's own // subType must equal the @of column's subType (the #185 doctrine). if (isCollect && ofTarget is ResolvedFromTarget collectTarget diff --git a/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs b/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs index 9d57eb3d71..42bb13f82a 100644 --- a/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs +++ b/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs @@ -105,6 +105,11 @@ public static class OriginConstants public const string AGG_ALL = "all"; public const string AGG_COLLECT = "collect"; + /// The @agg values that reduce the VALUES of the @of column (count only counts + /// non-null rows). Mirrors the TS COLUMN_VALUE_AGGREGATES. + public static readonly string[] COLUMN_VALUE_AGGREGATES = + [AGGREGATE_FN_SUM, AGGREGATE_FN_AVG, AGGREGATE_FN_MIN, AGGREGATE_FN_MAX]; + /// The full @agg attribute vocabulary (matches origin.json allowedValues). public static readonly string[] ORIGIN_AGG_VALUES = [AGGREGATE_FN_COUNT, AGGREGATE_FN_SUM, AGGREGATE_FN_AVG, AGGREGATE_FN_MIN, AGGREGATE_FN_MAX, diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index b26e8eeed1..cd3f2fd32c 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -3114,6 +3114,23 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, } OriginTarget ofTarget = validateFromOrOfPath(of, root, obj, field.getName(), "origin.aggregate.@of", src); + // A sum/avg/min/max reduces ONE column. A value-object field is several columns + // (flattened) or a document (jsonb), so there is no column to reduce, and summing a + // Money-shaped value would add amounts in different currencies. + // ADR-0039: resolving (hasMetaAttr defaults to includeParentData) — @objectRef may be + // inherited via extends. + if (agg != null && MetaOrigin.COLUMN_VALUE_AGGREGATES.contains(agg) + && (ObjectField.SUBTYPE_OBJECT.equals(ofTarget.field.getSubType()) + || ofTarget.field.hasMetaAttr(ObjectField.ATTR_OBJECTREF))) { + throw new MetaDataException( + ErrorMessageConstants.ERR_INVALID_ORIGIN + + ": origin.aggregate @agg:" + agg + " on " + obj.getName() + "." + field.getName() + + ": @of \"" + of + "\" is a value-object field (field." + ofTarget.field.getSubType() + + "), which has no single column to aggregate. Aggregate a scalar field instead:" + + " model the amount as its own field (and group by its currency).", + ErrorCode.ERR_INVALID_ORIGIN, + ResolvedSource.from(src, obj.getShortName() + "::" + field.getName(), of)); + } // #195 — collect preserves the element type: the array field's own subType // must equal the @of column's subType (the #185 doctrine on the element). if (isCollect && !field.getSubType().equals(ofTarget.field.getSubType())) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java b/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java index c0d9a5771f..ab238a77e5 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java +++ b/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java @@ -124,6 +124,11 @@ public abstract class MetaOrigin extends MetaData { public static final String AGG_ALL = "all"; public static final String AGG_COLLECT = "collect"; + /** The {@code @agg} values that reduce the VALUES of the {@code @of} column (count only counts + * non-null rows). Mirrors the TS COLUMN_VALUE_AGGREGATES. */ + public static final java.util.List COLUMN_VALUE_AGGREGATES = + java.util.List.of(AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX); + /** * Closed set of valid {@code @agg} values. Used by * {@link com.metaobjects.loader.ValidationPhase} for enum-membership checks. diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index d3c566cb96..54280e7b85 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -105,6 +105,7 @@ AGG_ANY, AGG_COLLECT, ASSEMBLY_ORIGIN_SUBTYPES, + COLUMN_VALUE_AGGREGATES, ORIGIN_ATTR_AGG, ORIGIN_ATTR_CONVERT, ORIGIN_ATTR_DISTINCT, @@ -2500,6 +2501,23 @@ def _validate_origin_paths( of_target = _validate_entity_field_ref( of_ref, ORIGIN_ATTR_OF, ctx, root, host_pkg, errors, origin, referrer ) + # A sum/avg/min/max reduces ONE column. A value-object field is several + # columns (flattened) or a document (jsonb), so there is no column to reduce, + # and summing a Money-shaped value would add amounts in different currencies. + # Resolving read: @objectRef may be inherited via extends (ADR-0039). + if ( + of_target is not None + and agg in COLUMN_VALUE_AGGREGATES + and (of_target[1].sub_type == FIELD_SUBTYPE_OBJECT + or of_target[1].get_meta_attr(FIELD_ATTR_OBJECT_REF) is not None) + ): + errors.append(MetaError( + f"origin.aggregate @agg:{agg} on {obj.name}.{node.name}: @of \"{of_ref}\" is a " + f"value-object field (field.{of_target[1].sub_type}), which has no single column " + f"to aggregate. Aggregate a scalar field instead: model the amount as its own " + f"field (and group by its currency).", + ErrorCode.ERR_INVALID_ORIGIN, + envelope=resolved_source(origin.source, referrer, of_ref))) # #195 — collect preserves the element type: the array field's own subType # must equal the @of column's subType (the #185 doctrine on the element). if is_collect and of_target is not None and node.sub_type != of_target[1].sub_type: diff --git a/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py b/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py index 6097df59c5..04d95b23f7 100644 --- a/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py +++ b/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py @@ -51,6 +51,9 @@ AGG_ANY = "any" AGG_ALL = "all" AGG_COLLECT = "collect" +# The @agg values that reduce the VALUES of the @of column (count only counts +# non-null rows). Mirrors the TS COLUMN_VALUE_AGGREGATES. +COLUMN_VALUE_AGGREGATES = ("sum", "avg", "min", "max") ORIGIN_ATTR_OF = "of" # @filter (attr.filter object): an optional PORTABLE structured predicate scoping the # aggregated rows (same shape as a preset filter). On @agg:any|all it is the QUANTIFIED diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index 8fd32e963d..639c83b7ee 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -54,6 +54,7 @@ import { FIELD_ATTR_COLUMN, OBJECT_PROJECTION_ATTR_FILTER, findReferencesBetween, + findReverseReferenceHops, resolveRelationshipReference, resolveObjectRef, refTarget, @@ -353,11 +354,16 @@ function findReferenceHop(obj: MetaData, name: string): MetaData | undefined { } /** Resolve a `@via` hop (relationship OR reference) to its target + cardinality. - * A reference hop is a to-one forward FK; its target is `@references`. */ + * A reference hop is a to-one forward FK; its target is `@references`. A hop naming + * neither may walk an identity.reference BACKWARDS (#560): `Project.timeEntries` is the + * reference TimeEntry holds onto Project, named by TimeEntry's collection name — a to-many + * hop whose target is the holder (`reverse`). It resolves only when exactly one reference + * answers the name; the loader refuses an ambiguous one (ERR_AMBIGUOUS_PATH). */ function resolveHop( obj: MetaData, name: string, -): { hop: MetaData; target: MetaObject | undefined; cardinality: "one" | "many" } | undefined { + root: MetaRoot, +): { hop: MetaData; target: MetaObject | undefined; cardinality: "one" | "many"; reverse?: true } | undefined { // ADR-0059 R3/R7 — a hop's target is the object its reference attribute binds to, resolved // in the package of the node that declares that attribute, never the walked entity's. const rel = findRelationship(obj, name); @@ -377,7 +383,11 @@ function resolveHop( const target = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); return { hop: ref, target: isMetaObject(target) ? target : undefined, cardinality: "one" }; } - return undefined; + if (!isMetaObject(obj)) return undefined; + const reverse = findReverseReferenceHops(obj, name, root.objects()); + if (reverse.length !== 1) return undefined; + const { holder, referenceIdentity } = reverse[0]!; + return { hop: referenceIdentity, target: holder, cardinality: "many", reverse: true }; } /** @@ -404,7 +414,10 @@ function resolveHopReference( hop: MetaData, hopName: string, target: MetaObject, + reverse = false, ): ReferenceLookup | ReferenceLookup[] { + // #560 — a reverse hop walks the reference `target` holds onto `holder`. + if (reverse) return { holder: target, other: holder, referenceIdentity: hop as unknown as MetaReferenceIdentity }; if (hop.type === TYPE_IDENTITY && hop.subType === IDENTITY_SUBTYPE_REFERENCE) { return { holder, other: target, referenceIdentity: hop as unknown as MetaReferenceIdentity }; } @@ -424,11 +437,11 @@ function resolveHopReference( * reads it for the reference's full `@fields` list, where a view join reads the first. */ export function hopReferenceIdentity(holder: MetaObject, hopName: string, root: MetaRoot): MetaReferenceIdentity | undefined { - const resolved = resolveHop(holder, hopName); + const resolved = resolveHop(holder, hopName, root); if (!resolved) return undefined; const target = resolved.target; if (!target) return undefined; - const ref = resolveHopReference(holder, resolved.hop, hopName, target); + const ref = resolveHopReference(holder, resolved.hop, hopName, target, resolved.reverse); if (Array.isArray(ref)) return ref.length === 1 ? ref[0]!.referenceIdentity : undefined; return ref.referenceIdentity; } @@ -705,7 +718,7 @@ function viaTerminalEntity(via: string, root: MetaRoot, referrerPkg: string): Me let currentObj = resolveEntityRef(root, rawEntity, referrerPkg); if (!currentObj) return undefined; for (const relName of segments.slice(1)) { - const resolved = resolveHop(currentObj, relName); + const resolved = resolveHop(currentObj, relName, root); if (!resolved) return undefined; const target = resolved.target; if (!target) return undefined; @@ -858,9 +871,9 @@ export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ct // FR-024: a hop may name a relationship OR a reference-only FK // (identity.reference — a to-one forward-FK edge). ADR-0039: resolving — // a traversed relationship/reference may inherit its target via extends. - const resolved = resolveHop(currentObj, relName); + const resolved = resolveHop(currentObj, relName, root); if (!resolved) break; - const { hop, target, cardinality } = resolved; + const { hop, target, cardinality, reverse } = resolved; if (!target) break; // #368: two identity.reference declarations onto the same target are legal @@ -869,7 +882,7 @@ export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ct // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly. // Only a relationship hop that even the ladder cannot choose reaches the throw. - const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target); + const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target, reverse); let ref: ReferenceLookup | undefined; if (Array.isArray(resolvedRef)) { if (resolvedRef.length > 1) { @@ -919,8 +932,9 @@ export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ct const resolvedPkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id"; + // A reverse hop's reference is always on the far side, even on a self-reference. const referenceHolder: "source" | "target" = - ref.holder.name === currentObj.name ? "source" : "target"; + !reverse && ref.holder.name === currentObj.name ? "source" : "target"; // FK lives on the holder; PK on the entity it references. Resolve both to // physical columns now so the ON clause is naming-strategy correct. diff --git a/server/typescript/packages/integration-tests/test/origin-reverse-hop-view-pg.test.ts b/server/typescript/packages/integration-tests/test/origin-reverse-hop-view-pg.test.ts new file mode 100644 index 0000000000..ddc738b7f7 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/origin-reverse-hop-view-pg.test.ts @@ -0,0 +1,99 @@ +/** + * Postgres: a projection view whose origin `@via` walks an identity.reference BACKWARDS (#560). + * + * `Project.timeEntries` names no relationship: it walks TimeEntry's `project` reference from the + * entity it targets back to the entity holding it. The loader accepted that hop, but the view + * emitter resolved hops only through relationships and forward references, so it silently left + * the aggregate and first columns out of CREATE VIEW. This test migrates an empty database, puts + * rows in, and reads the view: a missing column, or a join pointed the wrong way, fails it. + */ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { buildExpectedSchema, collectUnmanagedNames, diff, emit, introspectPostgres, splitSqlStatements } from "@metaobjectsdev/migrate-ts"; +import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { Pool } from "pg"; +import { startPostgres, type RunningPg } from "../src/postgres-container.ts"; + +const META = { + "metadata.root": { + package: "acme::studio", + children: [ + { "object.entity": { name: "Project", children: [ + { "source.rdb": { "@table": "projects" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "name" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ] } }, + { "object.entity": { name: "TimeEntry", children: [ + { "source.rdb": { "@table": "time_entries" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "projectId" } }, + { "field.decimal": { name: "hours", "@precision": 6, "@scale": 2 } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { "identity.reference": { name: "project", "@fields": "projectId", "@references": "Project" } }, + ] } }, + { "object.projection": { name: "ProjectHours", children: [ + { "source.rdb": { "@kind": "view", "@view": "v_project_hours" } }, + { "field.long": { name: "id", extends: "Project.id" } }, + { "field.string": { name: "name", extends: "Project.name" } }, + { "field.decimal": { name: "totalHours", "@precision": 10, "@scale": 2, children: [ + { "origin.aggregate": { "@agg": "sum", "@of": "TimeEntry.hours", "@via": "Project.timeEntries" } } ] } }, + { "field.int": { name: "entryCount", children: [ + { "origin.aggregate": { "@agg": "count", "@of": "TimeEntry.id", "@via": "Project.timeEntries" } } ] } }, + { "field.decimal": { name: "latestHours", "@precision": 6, "@scale": 2, children: [ + { "origin.first": { "@of": "TimeEntry.hours", "@via": "Project.timeEntries", "@orderBy": ["id:desc"] } } ] } }, + { "identity.primary": { name: "id", extends: "Project.id" } }, + ] } }, + ], + }, +}; + +let pg: RunningPg; +let k: Kysely>; +let root: MetaRoot; + +beforeAll(async () => { + pg = await startPostgres(); + k = new Kysely>({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: pg.connectionUri, max: 1 }) }), + }); + const loaded = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(META))]); + expect(loaded.errors).toEqual([]); + root = loaded.root; +}, 120_000); + +afterAll(async () => { + await k.destroy(); + await pg.stop(); +}, 60_000); + +describe("origin @via over a reverse identity.reference hop (postgres)", () => { + test("the view carries the aggregate and first columns, joined from the holder", async () => { + const views = buildProjectionViews(root, { dialect: "postgres" }); + const result = await diff({ + expected: buildExpectedSchema(root, { views }), + actual: await introspectPostgres(k), + dialect: "postgres", + unmanagedNames: collectUnmanagedNames(root), + }); + expect(result.blocked).toEqual([]); + for (const stmt of splitSqlStatements(emit(result.changes, { dialect: "postgres" }).up)) { + await sql.raw(stmt).execute(k); + } + + await sql.raw(`INSERT INTO projects (id, name) VALUES (1, 'Alpha'), (2, 'Idle')`).execute(k); + await sql.raw(`INSERT INTO time_entries (id, project_id, hours) VALUES (10, 1, 2.50), (11, 1, 4.00)`).execute(k); + + const rows = await sql>`SELECT * FROM v_project_hours ORDER BY id`.execute(k); + // Columns follow the default snake_case naming; pg returns numeric and bigint as strings. + const num = (v: unknown) => (v === null ? null : Number(v)); + expect(rows.rows.map((r) => ({ + id: Number(r.id), name: r.name, totalHours: num(r.total_hours), + entryCount: num(r.entry_count), latestHours: num(r.latest_hours), + }))).toEqual([ + { id: 1, name: "Alpha", totalHours: 6.5, entryCount: 2, latestHours: 4 }, + { id: 2, name: "Idle", totalHours: null, entryCount: 0, latestHours: null }, + ]); + }, 60_000); +}); diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index fe976c190f..ac5b819ef0 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -132,6 +132,7 @@ import { AGG_ALL, AGG_COLLECT, ASSEMBLY_ORIGIN_SUBTYPES, + COLUMN_VALUE_AGGREGATES, } from "../persistence/origin/origin-constants.js"; import { inferExprType, @@ -1543,6 +1544,21 @@ export function validateOriginPaths(root: MetaData): ParseError[] { // an aggregate computes something new (count/sum/…); spec §4 defines // agreement for passthrough only. const ofTarget = _validateFromPath(of_, root, obj, field.name, src, errors, "origin.aggregate.@of"); + // A sum/avg/min/max reduces ONE column. A value-object field is several columns + // (flattened) or a document (jsonb), so there is no column to reduce, and summing + // a Money-shaped value across rows would add amounts in different currencies. + if ( + ofTarget !== undefined && + (COLUMN_VALUE_AGGREGATES as readonly unknown[]).includes(agg) && + // ADR-0039: resolving — @objectRef may be inherited via extends. + (ofTarget.field.subType === FIELD_SUBTYPE_OBJECT || ofTarget.field.attr(FIELD_ATTR_OBJECT_REF) !== undefined) + ) { + errors.push(new ParseError( + `origin.aggregate @agg:${String(agg)} on ${obj.name}.${field.name}: @of "${of_}" is a value-object ` + + `field (field.${ofTarget.field.subType}), which has no single column to aggregate. Aggregate a ` + + `scalar field instead: model the amount as its own field (and group by its currency).`, + { code: "ERR_INVALID_ORIGIN", source: resolvedSource(src, `${obj.fqn()}::${field.name}`, of_) })); + } // #195 — collect preserves the element type: the array field's own subType // must equal the @of column's subType (the #185 doctrine on the element). if (isCollect && ofTarget !== undefined && field.subType !== ofTarget.field.subType) { diff --git a/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts b/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts index f2a5ee3aaf..47f994b0d1 100644 --- a/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts +++ b/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts @@ -91,6 +91,9 @@ export const ORIGIN_FIRST_ATTR_FILTER = "filter"; export const AGGREGATE_FUNCTIONS = ["count", "sum", "avg", "min", "max"] as const; export type AggregateFunction = (typeof AGGREGATE_FUNCTIONS)[number]; +/** The @agg values that reduce the VALUES of the @of column (count only counts non-null rows). */ +export const COLUMN_VALUE_AGGREGATES: readonly AggregateFunction[] = ["sum", "avg", "min", "max"]; + // #195 — the predicate-quantifier + array-rollup @agg values. export const AGG_ANY = "any"; export const AGG_ALL = "all";