diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md index 9b1b4a379..ae51970c5 100644 --- a/.claude/rules/cross-language-porting.md +++ b/.claude/rules/cross-language-porting.md @@ -17,7 +17,7 @@ Preserve the following contracts exactly across all language ports: **Metamodel subtype vocabularies (must be identical across languages):** the `registry-conformance` gate (`fixtures/registry-conformance/`) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to `expected-registry.json`. **All five ports (TS / C# / Java / Kotlin / Python) are live + green** (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See `fixtures/registry-conformance/README.md`. - Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull` -- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) +- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and **inert in every generator and in `meta migrate`**: an `object.report` emits nothing until its lowering plans land, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. See [docs/features/reporting.md](docs/features/reporting.md). - Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`. - Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210). - Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N / **junction-unpairable** → `ERR_INVALID_RELATIONSHIP`. **Unpairable means the junction declares its two references but neither resolves to the navigating entity, or neither to the `@objectRef` target** — declaring two references is NOT enough, and the loader now checks WHAT they point at (owner ruling 2026-09-20). It does so by running the real FK derivation and converting its failure, never a parallel re-implementation, so the loader and the derivation cannot drift; scope mirrors codegen's own iteration exactly — every CONCRETE, non-projection object crossed with its EFFECTIVE relationships, NOT deduped by declaration, because pairing is a property of the navigating entity and an inherited M:N can pair from one subtype and not another. Before the ruling this loaded clean and then diverged: TS and C# warned and emitted no traversal route (a silent 404), while Java, Kotlin and Python failed the build. Gated by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`. diff --git a/AGENTS.md b/AGENTS.md index dea015517..88d233c83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,9 +47,10 @@ number. Both are the convergent-publishing rule working as intended. **1.0 is CU 1.0.5's `rc.1` through `rc.11`, 1.0.7's and 1.0.8's `rc.1`, and 1.0.9's `rc.1` through `rc.6` are superseded, and npm's `next` tag is REPOINTED onto each release — not deleted, because `dist-tag rm` 403s for every token we hold (`docs/RELEASING.md` §4). -`metamodelVersion` reads **`1.0`**, frozen — C4 landed and G4 shipped it, and no 1.0.x patch -has moved it (1.0.4 relaxed `@sourceRefField` onto `@cardinality: one` with a prose-only -manifest footprint, and was ruled a hold). Per-release detail lives in **`CHANGELOG.md`** — it is the log, and this file does +`metamodelVersion` read **`1.0`** through 1.0.13 — C4 landed and G4 shipped it, and no 1.0.x patch +moved it (1.0.4 relaxed `@sourceRefField` onto `@cardinality: one` with a prose-only +manifest footprint, and was ruled a hold). `main` now carries **`1.1`** for the FR-044 reporting +vocabulary, so 1.0.x PATCH releases are held from `main` until 1.1 ships. Per-release detail lives in **`CHANGELOG.md`** — it is the log, and this file does not duplicate it. The npm surface is **14 `@metaobjectsdev/*` packages in full lockstep**; the two `angular` @@ -74,14 +75,14 @@ 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/` (334 fixtures; 24 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/` (361 fixtures; 25 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 **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. Two sub-corpora run the **generated lane only, on all five ports** — `write-through/` and `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`). 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. - YAML / verify corpora green across the ports that ship those layers. - **Codegen-compile gate** (all five ports; a GATE, not a corpus — it has no fixtures of its own and no row in the matrix). Every corpus above gates BEHAVIOUR; none asks whether the emitted code BUILDS, which is how four "generated code does not compile" defects shipped in 1.0.4 with the whole matrix green — `gen` exits 0 in all four cases and the adopter's build is the first thing that disagrees. Each port generates from `fixtures/persistence-conformance/canonical/meta.fitness.json` (reused deliberately: a second kitchen sink would drift from the one the other corpora already maintain) and compiles the emitted tree with its real compiler — `ts.createProgram` / Roslyn / `javac` / `KotlinCompilation` / (Python, having no static compiler) importing the generated package plus `ruff` F821. **Every port excludes its framework-bound route tier** (TS `routesFile`, C# `RoutesGenerator`, Java `SpringControllerGenerator`, Kotlin `KotlinSpringControllerGenerator`): those imports are not on an in-memory compile's classpath and stubbing them drowns the signal, so that tier is proven by the api-contract integration lane instead. One cross-port rule, not four local concessions. Found 5 further real defects on first run. Boundary detail: `docs/CONFORMANCE.md` → "Split coverage". -**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen). +**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen); FR-044 Plan 1 (the reporting vocabulary — `dimension.attribute`/`dimension.time`, `measure.aggregate`/`measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value — registered and loader-validated in all five ports behind 27 conformance fixtures + the `codegen-noop` corpus; **reports generate nothing yet**, see [docs/features/reporting.md](docs/features/reporting.md)). **Latest release: 1.0.13** (2026-10-03) — npm `1.0.13`, PyPI `1.0.13`, NuGet `1.0.13`, Maven Central `8.0.13`. A PATCH: an already-plural entity name (`Stats`, `Settings`) no longer doubles in API-surface names (REST paths, hooks, finders, DbSets) in any port, while default physical table names stay frozen on the old rule; two entities that would share one API name are now a generation error; two Kotlin controller compile fixes (`field.inet` filter ops, `@dbColumnType: uuid` on a string field). Gated by a private `1.0.13-rc.1` build on the adopter estate (`rc-gate.sh` 7/7) and a full `--strict-toolchains` local CI run. The previous release, 1.0.12 (2026-10-02), added `fmt` in every CLI, a deprecated-reference `verify` advisory, the `onLocate` extract hook, and an Exposed 1.x Kotlin output mode. diff --git a/CHANGELOG.md b/CHANGELOG.md index 97437061e..0ea4555b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,47 @@ here.** ## [Unreleased] +_**`metamodelVersion` moves `1.0` to `1.1`.** The vocabulary is additive: a model that uses none +of the new names generates byte-for-byte what it did under 1.0. What loads changes only where the +Java and Python parser fixes under **Fixed** bring those ports into line with TypeScript, and each +entry says which models that affects. `main` carries 1.1 from here, so no 1.0.x PATCH is cut from +it until 1.1 ships._ + +### Added + +- **Metamodel 1.1: the reporting vocabulary (FR-044), loader-validated in all five ports.** + Registered: `dimension.attribute`, `dimension.time` (`@grains`: `hour, day, week, month, + quarter, year`, weeks start Monday), `measure.aggregate` (`@agg`: `count, sum, avg, min, max`), + `measure.ratio`, `segment.filter` (all children of `object.entity`) and `object.report` + (`@from`, `@dimensions`, `@measures`, `@segment`, `@filter`), plus the relative-date filter + value `{ now: "" }`, legal only in the `@filter` of a segment, a + `measure.aggregate` or an `object.report`. Four new error codes (`ERR_INVALID_DIMENSION`, + `ERR_INVALID_MEASURE`, `ERR_INVALID_REPORT`, `ERR_REPORT_FOREIGN_MEASURE`) and extended + `ERR_BAD_ATTR_FILTER` carry the load-time rules, gated by 27 new shared conformance fixtures. + `measure.derived` is not registered (it waits for FR-037 R5). **No generated output yet:** an + `object.report` emits no view DDL, route, client code or docs page, `meta migrate` proposes + nothing for it, and a model using the new names generates exactly what it did without them. + See [docs/features/reporting.md](docs/features/reporting.md). + +### Fixed + +- **Java: a bare string authored for an `isArray` attribute is now ONE item.** The Java parser + used to split it on commas, which left stray quotes in the items; TypeScript and Python + already kept it whole. All three now agree, so a Java model that relied on the split (a + comma-separated string where a list belongs) now reads one item, as the other ports always did. +- **Python: the `@filter` desugar now matches TypeScript.** It recurses into `and` / `or`; + previously it turned each list into an `in` clause on a field named `and` or `or`, so the + clauses inside a composition were never checked. A dangling field reference inside an `or` + of a projection `@filter` is therefore now a load error, as it is in TypeScript. An object + clause that is not an operator map is now kept as written instead of being wrapped in `eq`: + the passes that check operators (a `layout.dataGrid` preset and the reporting `@filter`s) + report its keys as bad operators, while the projection `@filter` pass, which checks field + references only, still accepts it. +- **Python: a `layout.dataGrid` preset `@filter` composed with `and` / `or` is no longer + refused.** The preset check read `and` and `or` as field names ("references field 'or' + which is not a filterable field"), so a valid composed preset failed to load. It now + recurses into each composed clause and checks it like a top-level one, as TypeScript does. + ## [1.0.13] — 2026-10-03 _npm `1.0.13` (all 14 `@metaobjectsdev/*` packages in lockstep), PyPI `1.0.13`, NuGet `1.0.13` and diff --git a/README.md b/README.md index 3865e5c97..37ae799df 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ first-week wedge plan — and `meta init` picks up from there. | Template-drift verify | Yes | Yes (`Verify.check`) | Yes (via Java) | Yes (`dotnet meta verify`) | Yes (`metaobjects.render.verify`) | | YAML authoring (sigil-free → JSON) | Yes | Yes | Yes (via Java) | Yes | Yes | | Capability requirements (`requirement.*`) | Registered + `meta verify` gate | Registered (loads + validates) | Registered (via Java) | Registered (loads + validates) | Registered (loads + validates) | +| Reporting vocabulary (`dimension` / `measure` / `segment` / `object.report`, FR-044) | Registered (loads + validates); no generated output yet | Registered (loads + validates); no generated output yet | Registered (via Java); no generated output yet | Registered (loads + validates); no generated output yet | Registered (loads + validates); no generated output yet | | Libraries (`libraries: [...]`) | Yes | Yes | Yes (via Java) | Yes | Yes | | Metadata dependencies (`dependencies`) | Yes (`meta deps sync`, `path` transport) | Phase 2 | Phase 2 | Phase 2 | Yes (loads the synced snapshot) | | Runtime metadata (ObjectManager-style) | Yes (`runtime-ts`) | Yes (OMDB) | Yes (via Java OMDB + Exposed) | Roadmap | Yes (ObjectManager) | @@ -259,8 +260,8 @@ metaobjects/ ├── README.md # you are here ├── CLAUDE.md # project instructions for Claude ├── spec/ # canonical metamodel docs, ADRs, roadmap -├── fixtures/ # 22 cross-language conformance corpora — the oracle -│ ├── conformance/ # metamodel (loader + serializer + navigation), 329 fixtures +├── fixtures/ # 25 cross-language conformance corpora — the oracle +│ ├── conformance/ # metamodel (loader + serializer + navigation), 361 fixtures │ ├── yaml-conformance/ # YAML authoring desugar │ ├── render-conformance/ # FR-004 byte-identical render oracle │ ├── verify-conformance/ # FR-004 template-drift gate diff --git a/docs/1.0-readiness.md b/docs/1.0-readiness.md index 637f65217..0efcc352d 100644 --- a/docs/1.0-readiness.md +++ b/docs/1.0-readiness.md @@ -2,7 +2,7 @@ > **CLOSED 2026-09-09 — the cut shipped. This is history, not a live gate list.** > npm / PyPI / NuGet are at `1.0.0`, Maven Central at `8.0.0`, and `metamodelVersion` is -> frozen at `1.0`. The declared blocking path this doc named in its own footer — **G3d, then +> `1.0` (it moves to `1.1` on `main` with the FR-044 reporting vocabulary). The declared blocking path this doc named in its own footer — **G3d, then > G1/G2/G4** — is closed end to end: G3d on nine adopter estates that ran the release > candidate with their own drift gates enforced (not a bare `meta verify`), and G1/G2/G4 at > the cut itself. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index c76f21a0f..97954023d 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **24 shared conformance corpora** under +The MetaObjects standard ships **25 shared conformance corpora** under [`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to it* and asserts the same expected behaviour against the same fixtures. **This page is the inverse index**: fixture → feature doc + per-port pass status, and it is the @@ -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) | 334 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 361 | ✓ | ✓ | 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/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -50,6 +50,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — | | [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | +| [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary is inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | A ✓ means the port runs the corpus green; an explicit `n / m` is used where a port carries a ledgered divergence. The two ledgered YAML fixtures are documented @@ -74,8 +75,9 @@ the corpora above do two different jobs. Only the first is a promise to adopters (the canonical serializer, surfaced per-file — ADR-0034's "canonical format" is core). A red cell here is a MetaObjects bug. - **Template quality checks — not a promise.** The generated lane of - `api-contract-conformance/`, `generator-registry-conformance/` (stable generator names) - and the codegen-compile gate. They check that the reference generators are correct + `api-contract-conformance/`, `generator-registry-conformance/` (stable generator names), + `codegen-noop/` (vocabulary with no lowering yet emits nothing) and the codegen-compile + gate. They check that the reference generators are correct starting points; an adopter's ejected copy is theirs and is not gated. The hand-rolled reference-server lane of `api-contract-conformance/` still pins the wire contract that the runtimes and the client speak, which is core. @@ -200,7 +202,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (334) +### `fixtures/conformance/` — metamodel loader + canonical serializer (361) | Fixture prefix | Feature doc | |---|---| @@ -222,6 +224,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. | `origin-*`, `error-origin-*` | [features/templates-and-payloads.md](features/templates-and-payloads.md) (payload origins) | | `projection-*`, `error-projection-*`, `field-readonly-on-view-projection` | [features/source-kinds.md](features/source-kinds.md) (projections + the object taxonomy, ADR-0028) | | `requirement-*`, `error-unknown-attr-requirement` | [features/requirements.md](features/requirements.md) (vocabulary only — the `meta verify` checks are TS-owned; see "Split coverage" above) | +| `reporting-*`, `error-dimension-*`, `error-measure-*`, `error-ratio-*`, `error-segment-*`, `error-report-*`, `error-relative-date-*` | [features/reporting.md](features/reporting.md) (the `dimension` / `measure` / `segment` / `object.report` vocabulary and its load-time rules; design in the [FR-044 spec](superpowers/specs/2026-10-02-fr-044-core-reporting-design.md)) | | `smoke-empty-metadata` | [features/entities.md](features/entities.md) | ### `fixtures/yaml-conformance/` (16) @@ -394,7 +397,7 @@ own those two functions), and ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 334 + yaml 16 + verify 31 +The fixtures in the nine corpora mapped above (metamodel 361 + yaml 16 + verify 31 + render 15 + persistence 33 + api-contract 61 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/README.md b/docs/README.md index 8d6acaf94..0268e0502 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ docs/ │ ├── metadata-sources.md # where metadata comes from: sources, scope, discovery │ ├── metadata-dependencies.md # building on a metadata model published elsewhere (FR-023) │ ├── libraries.md # reusable declared design you opt into, a layer at a time (FR-043) +│ ├── reporting.md # dimensions, measures, segments, reports: vocabulary and load rules (FR-044) │ └── own-your-codegen.md # scaffold-and-own generator ownership (ADR-0034) └── ports/ # one file per language/framework port ├── typescript.md @@ -65,6 +66,7 @@ this tree is documentation, not the source of truth. | Build on a metadata model another repository publishes (`dependencies`, `meta deps sync`, overlay/extend across the boundary) | [`features/metadata-dependencies.md`](features/metadata-dependencies.md) | | Adopt a design MetaObjects already ships — users/groups/roles, an LLM trace envelope — instead of authoring it (`libraries`, `meta eject `) | [`features/libraries.md`](features/libraries.md) | | Record what the system is supposed to do, and stop agents reviving retired features | [`features/requirements.md`](features/requirements.md) | +| Declare what a dashboard groups by and counts (`dimension`, `measure`, `segment`, `object.report`; load-time checked, no generated output yet) | [`features/reporting.md`](features/reporting.md) | | Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) | | Share a metadata shape across multiple instances (abstracts, `extends:`) | [`features/abstracts-and-inheritance.md`](features/abstracts-and-inheritance.md) | | Add a custom metamodel subtype or attribute to a downstream project | [`features/extending-with-providers.md`](features/extending-with-providers.md) + [`recipes/extending-metaobjects-with-providers.md`](recipes/extending-metaobjects-with-providers.md) | diff --git a/docs/features/reporting.md b/docs/features/reporting.md new file mode 100644 index 000000000..c350784ff --- /dev/null +++ b/docs/features/reporting.md @@ -0,0 +1,254 @@ +# Reporting vocabulary: dimensions, measures, segments and reports + +_`dimension` / `measure` / `segment` / `object.report` — declare what a dashboard groups by +and counts, as metadata, validated when the model loads._ + +**Status:** registered and loader-validated in all five ports (TypeScript, C#, Java, +Python, Kotlin through Java). Arrived with **metamodel 1.1** (FR-044). + +**Reports generate nothing yet.** This release ships the vocabulary and its load-time +rules, and no more. There is no view DDL, no `meta migrate` proposal, no REST route, no +generated client hook and no docs page for an `object.report`, and `meta docs` and the API +docs skip it. A model that declares dimensions, measures, segments and reports generates +byte-for-byte what the same model without them generates, in every port. Generated output +for reports lands in later plans of FR-044; until then the declarations are a checked +statement of intent that an agent or a person can read. + +**Entirely opt-in.** A model that declares none of this sees no change at all. + +## The problem it solves + +A dashboard needs the same few questions answered over and over: revenue per day, buyers +per program, engagement per cohort. Hand-written, each one is a `GROUP BY` query whose +column names, filters and "what counts as an active purchase" live in application code, +where nothing checks them against the model. Rename a column and the dashboard goes quietly +wrong. + +The reporting vocabulary names the pieces once, on the entity that owns the rows, and +lets a report combine them by name. The loader then refuses the combinations that cannot +mean what they appear to mean: a measure of one table in a report over another, a +`sum` of a string, an hour grain on a date. + +## What is registered + +Exactly these names, and nothing else (`measure.derived` is **not** registered; it waits for +FR-037 R5's arithmetic wave and will need its own agreement): + +| Node | Lives on | Attributes | +|---|---|---| +| `dimension.attribute` | `object.entity` | `@of` (required), `@via` | +| `dimension.time` | `object.entity` | `@of` (required), `@via`, `@grains` (required, array) | +| `measure.aggregate` | `object.entity` | `@agg` (required), `@of` (required, array), `@distinct`, `@filter`, `@segment` | +| `measure.ratio` | `object.entity` | `@numerator` (required), `@denominator` (required) | +| `segment.filter` | `object.entity` | `@filter` (required) | +| `object.report` | root | `@from` (required), `@dimensions` (array), `@measures` (required, array), `@segment`, `@filter` | + +Closed sets: + +- `@grains`: `hour, day, week, month, quarter, year`. **Weeks start on Monday (ISO-8601)** + in every lowering. +- `@agg`: `count, sum, avg, min, max`. + +Plus one new value form inside the existing portable filter grammar: the **relative date** +`{ "now": "" }` (see [Relative dates](#relative-dates-in-filters)). + +## Declaring one + +Dimensions, measures and segments are **children of the entity whose rows they describe**. +A report is a top-level object that names an entity as its `@from`: + +```jsonc +{ "metadata.root": { + "package": "acme::shop", + "children": [ + { "object.entity": { + "name": "Program", + "children": [ + { "source.rdb": { "@table": "programs" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "title" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] + }}, + { "object.entity": { + "name": "Purchase", + "children": [ + { "source.rdb": { "@table": "purchases" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "programId" } }, + { "field.string": { "name": "customerEmail" } }, + { "field.currency": { "name": "amountCents" } }, + { "field.string": { "name": "status" } }, + { "field.timestamp": { "name": "purchasedAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "relationship.association": { "name": "program", "@objectRef": "Program", + "@cardinality": "one" } }, + { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, + { "dimension.attribute": { "name": "program", "@of": "Purchase.programId" } }, + { "dimension.attribute": { "name": "programTitle", + "@of": "Program.title", "@via": "Purchase.program" } }, + { "dimension.time": { "name": "purchasedAt", "@of": "Purchase.purchasedAt", + "@grains": ["day", "week", "month"] } }, + { "measure.aggregate": { "name": "purchases", "@agg": "count", "@of": "Purchase.id", + "@segment": "active" } }, + { "measure.aggregate": { "name": "buyers", "@agg": "count", "@distinct": true, + "@of": "Purchase.customerEmail" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Purchase.amountCents", + "@segment": "active" } }, + { "measure.ratio": { "name": "revenuePerBuyer", + "@numerator": "revenue", "@denominator": "buyers" } } + ] + }}, + { "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": ["purchasedAt:day"], + "@measures": ["purchases", "revenue"], + "@filter": { "purchasedAt": { "gte": { "now": "-P90D" } } } + }}, + { "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": ["purchases", "buyers", "revenue"] + }} + ] +}} +``` + +What each piece means: + +- **`dimension.attribute`** groups by a column's value as-is. `@of` is `Entity.field`. +- **`dimension.time`** groups by a date or timestamp truncated to a grain. It declares which + grains it supports in `@grains`. +- **`measure.aggregate`** is one aggregate over the entity's own rows. `@agg: count` without + `@distinct` counts rows. With `@distinct: true` it counts distinct values of `@of`, and a + list in `@of` is a distinct count of the tuple. This deliberately differs from + `origin.aggregate`, whose `count` is always distinct as a join-inflation guard: a measure + aggregates its own entity's rows and a dimension reaches only to-one paths, so no join + inflates it. `@filter` and `@segment` scope the rows and combine by AND. +- **`measure.ratio`** is `numerator / NULLIF(denominator, 0)`, typed decimal. A zero + denominator yields null. Both operands are `measure.aggregate` siblings (a ratio of ratios + is not supported). +- **`segment.filter`** is a named, reusable `attr.filter`. Use it when the same row rule + ("active purchase") would otherwise be repeated across measures and reports. An inline + `@filter` on a measure stays legal. +- **`object.report`** is a fixed combination of dimensions and measures of ONE entity. One + row per distinct dimension tuple; **no dimensions means exactly one row** (the totals + case). Its `@filter` and `@segment` scope rows before grouping and combine by AND. + +### A report's fields are derived, not declared + +A report declares no `field.*` and no `identity.*`. Its fields come from its items, in this +order: one per dimension, then one per measure. + +| Item | Derived field name | +|---|---| +| attribute dimension `program` | `program` | +| time dimension `purchasedAt` at grain `day` | `purchasedAtDay` (``) | +| measure `revenue` | `revenue` | + +A `@dimensions` item is a dimension name, or `name:grain` for a time dimension (a single +colon, so it cannot collide with the `::` package separator). + +## The rules the loader enforces + +Every rule below fails the load with the code shown, naming the offending node. Each one has +a conformance fixture under `fixtures/conformance/`, run by every port, and a port that reads +a rule differently fails the corpus. + +### Dimensions (`ERR_INVALID_DIMENSION`) + +| Rule | Refused | +|---|---| +| **D1** | An `@of` that is not `Entity.field`, that names the wrong entity, or whose field does not exist. Without `@via`, `Entity` is the owning entity; with `@via`, it is the `@via` terminal. | +| **D2** | An `@via` that is not `Owner.hop[.hop...]` starting at the owning entity, or that crosses a hop which is not a `relationship.*` with `@cardinality: one` or an `identity.reference`, or a hop whose target resolves to no object. | +| **D3** | A `dimension.time` whose `@of` is not a `field.date` or `field.timestamp`. | +| **D4** | A `dimension.time` over a `field.date` that declares `hour` (a date has no hour). | + +For D1, D2 and M1, the `Entity` part may name the owning entity **or an entity it extends**, +so a member declared on an abstract base and written `BaseEvent.occurredAt` validates under +each concrete entity that inherits it. Members are read through the resolving accessors, so a +report over a concrete entity sees dimensions and measures inherited from its bases. + +**Why `@via` is to-one only.** Grouping by a related row's column must never multiply the +measured rows. Following a to-many relationship would repeat each fact row once per related +row, and a `SUM` over the result would double-count. A to-one hop cannot fan out. + +### Measures (`ERR_INVALID_MEASURE`) + +| Rule | Refused | +|---|---| +| **M1** | An `@of` item that is not `Entity.field`, names an entity other than the owning one (or one it extends), or names a field that does not exist. A measure aggregates its own entity's rows. | +| **M2** | More than one `@of` item without both `@agg: count` and `@distinct: true`. | +| **M3** | `@distinct: true` with any `@agg` other than `count`. | +| **M4** | `sum` or `avg` over a non-numeric field (numeric is `int, long, double, float, decimal, currency`); `min` or `max` over a `boolean`, `object` or `map` field. | +| **M5** | An `@segment` that names no `segment` child of the owning entity. | +| **M6** | A `measure.ratio` whose `@numerator` or `@denominator` names no measure of the entity, or names one that is not a `measure.aggregate`. | + +A measure that breaks several of M1 to M4 reports only the first, in that order, so one +mistake gives one error. + +### Segments and filters (`ERR_BAD_ATTR_FILTER`) + +| Rule | Refused | +|---|---| +| **S1** | A `segment`, `measure.aggregate` or `object.report` `@filter` that names a field the owning (or `@from`) entity does not have, that is malformed, or that uses an operator not legal for the field's type. | + +### Reports (`ERR_INVALID_REPORT`, `ERR_REPORT_FOREIGN_MEASURE`) + +| Rule | Code | Refused | +|---|---|---| +| **R1** | `ERR_INVALID_REPORT` | A `@from` that does not resolve, or resolves to something other than an `object.entity`. | +| **R2** | `ERR_INVALID_REPORT` | A `@dimensions` item that names no dimension of `@from`; a time dimension with no `:grain`, or a grain its `@grains` does not declare; a grain on an attribute dimension; the same item listed twice. | +| **R3** | `ERR_REPORT_FOREIGN_MEASURE` / `ERR_INVALID_REPORT` | A `@measures` item that belongs to a **different entity** than `@from` is `ERR_REPORT_FOREIGN_MEASURE`; one that resolves nowhere is `ERR_INVALID_REPORT`. | +| **R4** | `ERR_INVALID_REPORT` | A report that declares any `field.*` or `identity.*` child. | +| **R5** | `ERR_INVALID_REPORT` | A report `source.*` that is writable. A report's source, if present, must have a read-only `@kind` (`view`, `materializedView`, `storedProc`, `tableFunction`); a writable `@kind: table` is refused. | +| **R6** | `ERR_INVALID_REPORT` | Two derived field names that collide: a dimension `revenue` with a measure `revenue`, or dimension `purchasedAt` at grain `day` with a measure named `purchasedAtDay`. A measure listed twice gets the same code with its own message ("lists 'x' more than once"). A repeated dimension item is reported under R2, not R6. | +| **R7** | `ERR_INVALID_REPORT` | A report `@segment` that names no segment of `@from`. | + +`purchasedAt:day` and `purchasedAt:week` together are legal, since they derive different +fields (`purchasedAtDay`, `purchasedAtWeek`). + +**Why every measure comes from `@from`.** Combining measures of two fact tables in one +report is the "chasm trap" that every surveyed BI tool has bugs around: join the two tables +and each side's rows multiply the other's. v1 avoids it entirely. Two fact tables are two +reports. + +### Relative dates in filters + +A filter value may be `{ "now": "" }`, meaning the current time plus a +signed duration (`-P7D` is seven days ago, `-PT12H` twelve hours ago): + +```jsonc +"@filter": { "occurredAt": { "gte": { "now": "-P7D" } } } +``` + +It is evaluated when the view is **queried**, not when it is created. A view that uses it +is therefore not cacheable by query text. + +| Rule | Code | Refused | +|---|---|---| +| **F1** | `ERR_BAD_ATTR_FILTER` | A relative value anywhere except the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. In a `layout.dataGrid` preset, a projection `@filter` or an `origin.aggregate` `@filter` it fails the load, because no lowering exists for those hosts. | +| **F2** | `ERR_BAD_ATTR_FILTER` | A relative value on any field except a `field.date` or `field.timestamp`; under any operator except `gt, gte, lt, lte`; as an object that carries keys besides `now`; or with a duration that does not match the ISO-8601 pattern `^[+-]?P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$`. | + +An operator-less `{ "occurredAt": { "now": "-P7D" } }` is read as `eq`, and `eq` is not one +of the four operators a relative date may sit under, so it is refused. + +## What a green load does not prove + +The loader checks that the declarations are consistent with each other and with the model. +It does not check that a measure means what its name says, that a segment's filter selects +the rows you intend, or that the data exists. And since a report generates nothing yet, a +passing load says nothing about any query: there is no query. + +## Compatibility + +All additions are additive. `metamodelVersion` moved `1.0` to `1.1`, a MINOR on the +metamodel axis and on every registry ([compatibility-policy.md](../compatibility-policy.md)). +A model that does not use the new names generates exactly as before. What loads changes only +where the same change fixed Java and Python parsing bugs, each toward what TypeScript already +did; the [CHANGELOG](../../CHANGELOG.md) lists them and the models they affect. Until 1.1 +ships, `main` carries `metamodelVersion` 1.1, so no 1.0.x PATCH is cut from it. + +The design and its decisions are in +[`docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md`](../superpowers/specs/2026-10-02-fr-044-core-reporting-design.md). diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index f9c50bb7f..0ecc5e2d9 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -4,12 +4,12 @@ "pypi": "1.0.13", "nuget": "1.0.13", "maven": "8.0.13", - "metamodel": "1.0" + "metamodel": "1.1" }, "counts": { - "fixtures": 334, - "corpora": 24, - "baseTypes": 14 + "fixtures": 361, + "corpora": 25, + "baseTypes": 17 }, "snippets": { "showcase-model": { diff --git a/fixtures/codegen-noop/reporting/README.md b/fixtures/codegen-noop/reporting/README.md new file mode 100644 index 000000000..1f6ec4ffe --- /dev/null +++ b/fixtures/codegen-noop/reporting/README.md @@ -0,0 +1,31 @@ +# Reporting vocabulary is inert (FR-044 Plan 1) + +Two models that differ ONLY by the FR-044 reporting vocabulary: + +- `without/meta.shop.json` — three entities (`Program`, `Purchase`, `WorkoutEvent`). +- `with/meta.shop.json` — the same entities plus their `dimension.*`, `measure.*` and + `segment.filter` children and three `object.report` nodes. It is the positive + conformance fixture `fixtures/conformance/reporting-vocabulary/` with one addition: + `StoreTotals` declares a read-only `source.rdb @kind: view` (rule R5 allows one), because + a view-backed report passes every source-keyed codegen gate and is the shape most likely + to leak output. + +Until a report's lowering lands (Plan 2/3), every generator in every port must emit +byte-identical files for the two models, and TypeScript migrate must propose nothing for +the difference. The per-port tests that hold this: + +| Port | Test | +|---|---| +| TypeScript | `server/typescript/packages/cli/test/unit/reporting-inert.test.ts` (codegen + migrate) | +| C# | `server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs` | +| Java | `server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java` | +| Kotlin | `server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt` | +| Python | `server/python/tests/test_reporting_inert.py` | + +The documentation tier is held to the same rule (FR-044 Plan 1 ruling): `meta docs` (model, +agent, requirements and site pages) and every port's api-docs builder emit nothing for a +report, because its fields are derived by the lowering and a page today would show none of +them. The TypeScript, C#, Java, Kotlin and Python tests above compare that output too. + +Regenerate `without/` from `with/` by deleting every `dimension.*`, `measure.*` and +`segment.*` child and every `object.report` node — nothing else may differ. diff --git a/fixtures/codegen-noop/reporting/with/meta.shop.json b/fixtures/codegen-noop/reporting/with/meta.shop.json new file mode 100644 index 000000000..1c3b18b9a --- /dev/null +++ b/fixtures/codegen-noop/reporting/with/meta.shop.json @@ -0,0 +1,363 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ], + "children": [ + { + "source.rdb": { + "@kind": "view", + "@table": "v_store_totals" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/codegen-noop/reporting/without/meta.shop.json b/fixtures/codegen-noop/reporting/without/meta.shop.json new file mode 100644 index 000000000..512c54f97 --- /dev/null +++ b/fixtures/codegen-noop/reporting/without/meta.shop.json @@ -0,0 +1,180 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index 6a8bf8064..ea9b94e89 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -47,7 +47,7 @@ "ERR_PARTIAL_UNRESOLVED": "Build-time verify: a template partial ({{> group/source}}) does not resolve in the configured provider.", "ERR_REQUIRED_SLOT_UNUSED": "Build-time verify (warning): a template's declared @requiredSlots slot is never referenced by the template text.", "ERR_OUTPUT_TAG_MISSING": "Build-time verify: a template declares @requiredTags but its text (including provider-resolved partials) omits a required output tag's opening () form. path = the tag name.", - "ERR_BAD_ATTR_FILTER": "An attr.filter references a field that is not addressable in that context or uses a disallowed op: a dataGrid @filter over a non-filterable field or an op disallowed for the field's subtype, or a projection row-scope @filter over a non-declared (dangling) or aggregate-derived field (#207).", + "ERR_BAD_ATTR_FILTER": "An attr.filter references a field that is not addressable in that context or uses a disallowed op: a dataGrid @filter over a non-filterable field or an op disallowed for the field's subtype, or a projection row-scope @filter over a non-declared (dangling) or aggregate-derived field (#207). FR-044: a segment / measure.aggregate / object.report @filter names a field its entity (or @from) lacks or an op illegal for the field (S1); a relative-date value { \"now\": \"\" } appears outside the @filter of a segment, measure.aggregate or object.report (F1); or a relative-date value sits on a field that is not field.date/field.timestamp, under an op other than gt/gte/lt/lte, or carries a value that is not an ISO-8601 duration (F2).", "ERR_STORAGE_FLATTENED_ARRAY": "@storage \"flattened\" cannot be combined with isArray=true.", "ERR_STORAGE_WITHOUT_OBJECT_REF": "@storage was set on a field that has no @objectRef.", "ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF": "ADR-0013: a field.object declares no @objectRef. A field.object models a typed nested value and REQUIRES @objectRef. The message states the whole ladder rather than only the escape hatch: a known key set is an object.value the field @objectRefs; dynamic keys over a known value type are a field.map (@objectRef for a value object, @valueType for a scalar); only a bag no reader pins a key in is @dbColumnType: jsonb on a field.string. Naming the escape hatch alone is what taught adopters to reach for the bag by default.", @@ -82,6 +82,10 @@ "ERR_RELATIVE_REF_IN_CANONICAL": "FR-032 (ADR-0032): a ref-bearing attribute (extends/@objectRef/@references/origin @from/@of/@via/@parameterRef/@payloadRef/@responseRef) in CANONICAL JSON used a relative authoring form (leading :: or ..::). Canonical JSON is the self-contained interchange form — every reference MUST be fully-qualified. Relative navigation (root-absolute ::, parent-relative ..::, bare-current-package) is a YAML-authoring affordance the desugar expands to FQN; it must never survive into canonical JSON.", "ERR_INVALID_METAMODEL_CONSTRAINT": "FR-033: a provider set's merged metamodel constraint graph contains a contradiction surfaced by validateConstraints — one of: (1) a parents/children rule references an unregistered type.subType (dangling); (2) a required child (min>=1) whose type.subType is not admitted under that parent (unsatisfiable); (3) bad cardinality (min>max, max:0 with min>=1, or min<0); (4) closed-set clash — a child declares a parent whose OWN children is a closed set (no * wildcard) that does not admit it; (5) a required-child cycle that cannot bottom out; (6) the same attr name contributed twice (across providers or the extends chain) with a conflicting valueType/required/default. The detail names which check and the offending type(s).", "ERR_INVALID_INDEX": "An index.lookup or identity.secondary declares neither @fields nor @expr, declares BOTH (they are the two mutually exclusive ways to key an index — @expr is used INSTEAD of @fields), or names a field that does not exist on the owning entity's effective (resolved, via extends) field set.", + "ERR_INVALID_DIMENSION": "FR-044: a dimension.* is invalid: @of is not Entity.field on the owning entity (or, with @via, on the @via terminal) or names no field (D1); @via does not start at the owning entity, names an unknown hop, or crosses a hop that is not @cardinality: one or an identity.reference (D2); a dimension.time's @of is not a field.date or field.timestamp (D3); or a dimension.time over a field.date declares the 'hour' grain (D4).", + "ERR_INVALID_MEASURE": "FR-044: a measure.* is invalid: an @of item is not a field of the owning entity (M1); more than one @of item without @agg: count and @distinct: true (M2); @distinct: true with an @agg other than count (M3); sum/avg over a non-numeric field or min/max over a boolean, object or map field (M4); @segment names no segment of the owning entity (M5); or a measure.ratio @numerator/@denominator does not name a measure.aggregate of the same entity (M6).", + "ERR_INVALID_REPORT": "FR-044: an object.report is invalid: @from does not resolve to an object.entity (R1); a @dimensions item names no dimension of @from, omits the grain of a time dimension, uses a grain the dimension does not declare, gives a grain to an attribute dimension, or repeats (R2); a @measures item resolves to no measure at all (R3); the report declares a field.* or identity.* child (R4) or a writable source (R5); two items derive the same report field name (R6); or @segment names no segment of @from (R7).", + "ERR_REPORT_FOREIGN_MEASURE": "FR-044: an object.report lists a measure (bare or dotted) that belongs to an entity other than @from (R3). All measures of a report come from @from; combining two fact tables is a second report.", "ERR_COMPUTED_TYPE_MISMATCH": "#195: an origin.computed @expr tree's inferred root type does not equal the carrying field's declared field.. A computed column's type is DERIVED from its expression, never asserted (no @convert escape), so a mismatch is a hard load error (sibling of ERR_PASSTHROUGH_TYPE_MISMATCH).", "ERR_UNKNOWN_EXPR_NODE": "#195: an origin.computed @expr tree contains a node whose kind/op/fn is not in the closed expression grammar (field/value refs, comparisons sharing the filter op vocabulary, isNull/isNotNull, and/or/not, coalesce). Fail-closed per ADR-0023; the detail names the offending token.", "ERR_SQL_BODY_WITH_UNMANAGED": "#208: a source.rdb declares both @sql (author-supplied body) and @unmanaged (DDL owned elsewhere). The two markers are the mutually exclusive non-default states of one DDL-ownership axis — contradictory on a single source.", diff --git a/fixtures/conformance/error-dimension-grain-on-date/expected-errors.json b/fixtures/conformance/error-dimension-grain-on-date/expected-errors.json new file mode 100644 index 000000000..72cb1fbf3 --- /dev/null +++ b/fixtures/conformance/error-dimension-grain-on-date/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_DIMENSION", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[21]['dimension.time']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json b/fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json new file mode 100644 index 000000000..69af9ad6d --- /dev/null +++ b/fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json @@ -0,0 +1,365 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + }, + { + "dimension.time": { + "name": "purchasedOn", + "@of": "Purchase.purchasedOn", + "@grains": [ + "hour", + "day" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-dimension-grain-on-date/providers.json b/fixtures/conformance/error-dimension-grain-on-date/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-dimension-grain-on-date/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-dimension-of-unresolved/expected-errors.json b/fixtures/conformance/error-dimension-of-unresolved/expected-errors.json new file mode 100644 index 000000000..cc77e9269 --- /dev/null +++ b/fixtures/conformance/error-dimension-of-unresolved/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_DIMENSION", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[12]['dimension.attribute']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json b/fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json new file mode 100644 index 000000000..dc259d733 --- /dev/null +++ b/fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programIdd" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-dimension-of-unresolved/providers.json b/fixtures/conformance/error-dimension-of-unresolved/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-dimension-of-unresolved/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-dimension-time-not-temporal/expected-errors.json b/fixtures/conformance/error-dimension-time-not-temporal/expected-errors.json new file mode 100644 index 000000000..8d7375d4d --- /dev/null +++ b/fixtures/conformance/error-dimension-time-not-temporal/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_DIMENSION", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[14]['dimension.time']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json b/fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json new file mode 100644 index 000000000..2cfd0d84a --- /dev/null +++ b/fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.status", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-dimension-time-not-temporal/providers.json b/fixtures/conformance/error-dimension-time-not-temporal/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-dimension-time-not-temporal/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-dimension-via-to-many/expected-errors.json b/fixtures/conformance/error-dimension-via-to-many/expected-errors.json new file mode 100644 index 000000000..21a43b9c2 --- /dev/null +++ b/fixtures/conformance/error-dimension-via-to-many/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_DIMENSION", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[6]['dimension.attribute']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json b/fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json new file mode 100644 index 000000000..ccfda5cde --- /dev/null +++ b/fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json @@ -0,0 +1,362 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + }, + { + "dimension.attribute": { + "name": "buyerEmail", + "@of": "Purchase.customerEmail", + "@via": "Program.purchases" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-dimension-via-to-many/providers.json b/fixtures/conformance/error-dimension-via-to-many/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-dimension-via-to-many/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-measure-distinct-not-count/expected-errors.json b/fixtures/conformance/error-measure-distinct-not-count/expected-errors.json new file mode 100644 index 000000000..997f2195e --- /dev/null +++ b/fixtures/conformance/error-measure-distinct-not-count/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[19]['measure.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json b/fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json new file mode 100644 index 000000000..318d0aca9 --- /dev/null +++ b/fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json @@ -0,0 +1,356 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active", + "@distinct": true + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-measure-distinct-not-count/providers.json b/fixtures/conformance/error-measure-distinct-not-count/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-measure-distinct-not-count/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-measure-of-foreign/expected-errors.json b/fixtures/conformance/error-measure-of-foreign/expected-errors.json new file mode 100644 index 000000000..c17bb4274 --- /dev/null +++ b/fixtures/conformance/error-measure-of-foreign/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[18]['measure.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-measure-of-foreign/input/meta.shop.json b/fixtures/conformance/error-measure-of-foreign/input/meta.shop.json new file mode 100644 index 000000000..c8d2b8aa8 --- /dev/null +++ b/fixtures/conformance/error-measure-of-foreign/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-measure-of-foreign/providers.json b/fixtures/conformance/error-measure-of-foreign/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-measure-of-foreign/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-measure-segment-unresolved/expected-errors.json b/fixtures/conformance/error-measure-segment-unresolved/expected-errors.json new file mode 100644 index 000000000..997f2195e --- /dev/null +++ b/fixtures/conformance/error-measure-segment-unresolved/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[19]['measure.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json b/fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json new file mode 100644 index 000000000..4ad29abc6 --- /dev/null +++ b/fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "completions" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-measure-segment-unresolved/providers.json b/fixtures/conformance/error-measure-segment-unresolved/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-measure-segment-unresolved/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-measure-sum-non-numeric/expected-errors.json b/fixtures/conformance/error-measure-sum-non-numeric/expected-errors.json new file mode 100644 index 000000000..997f2195e --- /dev/null +++ b/fixtures/conformance/error-measure-sum-non-numeric/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[19]['measure.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json b/fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json new file mode 100644 index 000000000..d5d7d81af --- /dev/null +++ b/fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.status", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-measure-sum-non-numeric/providers.json b/fixtures/conformance/error-measure-sum-non-numeric/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-measure-sum-non-numeric/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-measure-tuple-without-distinct/expected-errors.json b/fixtures/conformance/error-measure-tuple-without-distinct/expected-errors.json new file mode 100644 index 000000000..7b17d3fae --- /dev/null +++ b/fixtures/conformance/error-measure-tuple-without-distinct/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[2]['object.entity'].children[11]['measure.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json b/fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json new file mode 100644 index 000000000..97a575f68 --- /dev/null +++ b/fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json @@ -0,0 +1,354 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-measure-tuple-without-distinct/providers.json b/fixtures/conformance/error-measure-tuple-without-distinct/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-measure-tuple-without-distinct/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-ratio-operand-not-aggregate/expected-errors.json b/fixtures/conformance/error-ratio-operand-not-aggregate/expected-errors.json new file mode 100644 index 000000000..1dca30cba --- /dev/null +++ b/fixtures/conformance/error-ratio-operand-not-aggregate/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[2]['object.entity'].children[15]['measure.ratio']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json b/fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json new file mode 100644 index 000000000..5ad4d31c9 --- /dev/null +++ b/fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json @@ -0,0 +1,362 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + }, + { + "measure.ratio": { + "name": "ratioOfRatio", + "@numerator": "avgDaysPerStarter", + "@denominator": "starters" + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-ratio-operand-not-aggregate/providers.json b/fixtures/conformance/error-ratio-operand-not-aggregate/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-ratio-operand-not-aggregate/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-relative-date-bad-duration/expected-errors.json b/fixtures/conformance/error-relative-date-bad-duration/expected-errors.json new file mode 100644 index 000000000..e727f27a4 --- /dev/null +++ b/fixtures/conformance/error-relative-date-bad-duration/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json b/fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json new file mode 100644 index 000000000..580cbac4a --- /dev/null +++ b/fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "90 days" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relative-date-bad-duration/providers.json b/fixtures/conformance/error-relative-date-bad-duration/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-relative-date-bad-duration/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-relative-date-datagrid-host/expected-errors.json b/fixtures/conformance/error-relative-date-datagrid-host/expected-errors.json new file mode 100644 index 000000000..4a77ff6aa --- /dev/null +++ b/fixtures/conformance/error-relative-date-datagrid-host/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[10]['layout.dataGrid']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relative-date-datagrid-host/input/meta.shop.json b/fixtures/conformance/error-relative-date-datagrid-host/input/meta.shop.json new file mode 100644 index 000000000..ac712d11e --- /dev/null +++ b/fixtures/conformance/error-relative-date-datagrid-host/input/meta.shop.json @@ -0,0 +1,380 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt", + "@filterable": true + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.secondary": { + "name": "byPurchasedAt", + "@fields": [ + "purchasedAt" + ] + } + }, + { + "layout.dataGrid": { + "name": "recent", + "@columns": [ + "id", + "purchasedAt" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P7D" + } + } + } + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relative-date-datagrid-host/providers.json b/fixtures/conformance/error-relative-date-datagrid-host/providers.json new file mode 100644 index 000000000..ba0387a54 --- /dev/null +++ b/fixtures/conformance/error-relative-date-datagrid-host/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db","metaobjects-ui"] diff --git a/fixtures/conformance/error-relative-date-non-temporal/expected-errors.json b/fixtures/conformance/error-relative-date-non-temporal/expected-errors.json new file mode 100644 index 000000000..e727f27a4 --- /dev/null +++ b/fixtures/conformance/error-relative-date-non-temporal/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json b/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json new file mode 100644 index 000000000..7d064af7d --- /dev/null +++ b/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "amountCents": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relative-date-non-temporal/providers.json b/fixtures/conformance/error-relative-date-non-temporal/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-relative-date-non-temporal/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-relative-date-origin-host/expected-errors.json b/fixtures/conformance/error-relative-date-origin-host/expected-errors.json new file mode 100644 index 000000000..180ff9f0c --- /dev/null +++ b/fixtures/conformance/error-relative-date-origin-host/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[6]['object.projection'].children[2]['field.int'].children[0]['origin.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relative-date-origin-host/input/meta.shop.json b/fixtures/conformance/error-relative-date-origin-host/input/meta.shop.json new file mode 100644 index 000000000..35708e927 --- /dev/null +++ b/fixtures/conformance/error-relative-date-origin-host/input/meta.shop.json @@ -0,0 +1,401 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + }, + { + "object.projection": { + "name": "ProgramPurchaseCounts", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "program_purchase_counts" + } + }, + { + "field.long": { + "name": "id", + "extends": "Program.id" + } + }, + { + "field.int": { + "name": "recentPurchaseCount", + "children": [ + { + "origin.aggregate": { + "@agg": "count", + "@of": "Purchase.id", + "@via": "Program.purchases", + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P7D" + } + } + } + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "Program.id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relative-date-origin-host/providers.json b/fixtures/conformance/error-relative-date-origin-host/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-relative-date-origin-host/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-relative-date-wrong-host/expected-errors.json b/fixtures/conformance/error-relative-date-wrong-host/expected-errors.json new file mode 100644 index 000000000..9a10663ea --- /dev/null +++ b/fixtures/conformance/error-relative-date-wrong-host/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[6]['object.projection']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json b/fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json new file mode 100644 index 000000000..e6bba0cfe --- /dev/null +++ b/fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json @@ -0,0 +1,393 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + }, + { + "object.projection": { + "name": "RecentPurchase", + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P7D" + } + } + }, + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "recent_purchases" + } + }, + { + "field.long": { + "name": "id", + "extends": "Purchase.id" + } + }, + { + "field.timestamp": { + "name": "purchasedAt", + "extends": "Purchase.purchasedAt" + } + }, + { + "identity.primary": { + "name": "id", + "extends": "Purchase.id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relative-date-wrong-host/providers.json b/fixtures/conformance/error-relative-date-wrong-host/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-relative-date-wrong-host/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-declares-field/expected-errors.json b/fixtures/conformance/error-report-declares-field/expected-errors.json new file mode 100644 index 000000000..0ef8381f4 --- /dev/null +++ b/fixtures/conformance/error-report-declares-field/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[5]['object.report'].children[0]['field.long']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-declares-field/input/meta.shop.json b/fixtures/conformance/error-report-declares-field/input/meta.shop.json new file mode 100644 index 000000000..9c289ba39 --- /dev/null +++ b/fixtures/conformance/error-report-declares-field/input/meta.shop.json @@ -0,0 +1,362 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ], + "children": [ + { + "field.long": { + "name": "rowCount" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-declares-field/providers.json b/fixtures/conformance/error-report-declares-field/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-declares-field/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-dimension-grain/expected-errors.json b/fixtures/conformance/error-report-dimension-grain/expected-errors.json new file mode 100644 index 000000000..c9e5884c3 --- /dev/null +++ b/fixtures/conformance/error-report-dimension-grain/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-dimension-grain/input/meta.shop.json b/fixtures/conformance/error-report-dimension-grain/input/meta.shop.json new file mode 100644 index 000000000..c04ba260f --- /dev/null +++ b/fixtures/conformance/error-report-dimension-grain/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:hour" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-dimension-grain/providers.json b/fixtures/conformance/error-report-dimension-grain/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-dimension-grain/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-field-collision/expected-errors.json b/fixtures/conformance/error-report-field-collision/expected-errors.json new file mode 100644 index 000000000..c9e5884c3 --- /dev/null +++ b/fixtures/conformance/error-report-field-collision/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-field-collision/input/meta.shop.json b/fixtures/conformance/error-report-field-collision/input/meta.shop.json new file mode 100644 index 000000000..a2a9ff847 --- /dev/null +++ b/fixtures/conformance/error-report-field-collision/input/meta.shop.json @@ -0,0 +1,361 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + }, + { + "measure.aggregate": { + "name": "purchasedAtDay", + "@agg": "count", + "@of": "Purchase.id" + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchasedAtDay" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-field-collision/providers.json b/fixtures/conformance/error-report-field-collision/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-field-collision/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-foreign-measure/expected-errors.json b/fixtures/conformance/error-report-foreign-measure/expected-errors.json new file mode 100644 index 000000000..bbab9d84e --- /dev/null +++ b/fixtures/conformance/error-report-foreign-measure/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_REPORT_FOREIGN_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-foreign-measure/input/meta.shop.json b/fixtures/conformance/error-report-foreign-measure/input/meta.shop.json new file mode 100644 index 000000000..90a4eea5a --- /dev/null +++ b/fixtures/conformance/error-report-foreign-measure/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "starters" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-foreign-measure/providers.json b/fixtures/conformance/error-report-foreign-measure/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-foreign-measure/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-from-not-entity/expected-errors.json b/fixtures/conformance/error-report-from-not-entity/expected-errors.json new file mode 100644 index 000000000..c9e5884c3 --- /dev/null +++ b/fixtures/conformance/error-report-from-not-entity/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-from-not-entity/input/meta.shop.json b/fixtures/conformance/error-report-from-not-entity/input/meta.shop.json new file mode 100644 index 000000000..93c9872e7 --- /dev/null +++ b/fixtures/conformance/error-report-from-not-entity/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "StoreTotals", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-from-not-entity/providers.json b/fixtures/conformance/error-report-from-not-entity/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-from-not-entity/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-measure-unresolved/expected-errors.json b/fixtures/conformance/error-report-measure-unresolved/expected-errors.json new file mode 100644 index 000000000..c9e5884c3 --- /dev/null +++ b/fixtures/conformance/error-report-measure-unresolved/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json b/fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json new file mode 100644 index 000000000..8d504a293 --- /dev/null +++ b/fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "refunds" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-measure-unresolved/providers.json b/fixtures/conformance/error-report-measure-unresolved/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-measure-unresolved/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-segment-unresolved/expected-errors.json b/fixtures/conformance/error-report-segment-unresolved/expected-errors.json new file mode 100644 index 000000000..75c7833ed --- /dev/null +++ b/fixtures/conformance/error-report-segment-unresolved/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[5]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json b/fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json new file mode 100644 index 000000000..aa54a59d9 --- /dev/null +++ b/fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json @@ -0,0 +1,356 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ], + "@segment": "completions" + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-segment-unresolved/providers.json b/fixtures/conformance/error-report-segment-unresolved/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-segment-unresolved/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-two-unresolved-dimensions/expected-errors.json b/fixtures/conformance/error-report-two-unresolved-dimensions/expected-errors.json new file mode 100644 index 000000000..415037787 --- /dev/null +++ b/fixtures/conformance/error-report-two-unresolved-dimensions/expected-errors.json @@ -0,0 +1,25 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + }, + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-two-unresolved-dimensions/input/meta.shop.json b/fixtures/conformance/error-report-two-unresolved-dimensions/input/meta.shop.json new file mode 100644 index 000000000..e02d4cdbe --- /dev/null +++ b/fixtures/conformance/error-report-two-unresolved-dimensions/input/meta.shop.json @@ -0,0 +1,356 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "region", + "channel" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-two-unresolved-dimensions/providers.json b/fixtures/conformance/error-report-two-unresolved-dimensions/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-two-unresolved-dimensions/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-report-writable-source/expected-errors.json b/fixtures/conformance/error-report-writable-source/expected-errors.json new file mode 100644 index 000000000..fca1642d9 --- /dev/null +++ b/fixtures/conformance/error-report-writable-source/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_REPORT", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[5]['object.report'].children[0]['source.rdb']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-report-writable-source/input/meta.shop.json b/fixtures/conformance/error-report-writable-source/input/meta.shop.json new file mode 100644 index 000000000..e2d3c5d38 --- /dev/null +++ b/fixtures/conformance/error-report-writable-source/input/meta.shop.json @@ -0,0 +1,362 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ], + "children": [ + { + "source.rdb": { + "@table": "store_totals" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-report-writable-source/providers.json b/fixtures/conformance/error-report-writable-source/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-report-writable-source/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-segment-filter-bad-field/expected-errors.json b/fixtures/conformance/error-segment-filter-bad-field/expected-errors.json new file mode 100644 index 000000000..6bf381420 --- /dev/null +++ b/fixtures/conformance/error-segment-filter-bad-field/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[20]['segment.filter']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json b/fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json new file mode 100644 index 000000000..1150ad8fb --- /dev/null +++ b/fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "state": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-segment-filter-bad-field/providers.json b/fixtures/conformance/error-segment-filter-bad-field/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-segment-filter-bad-field/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/reporting-inherited-members/expected.json b/fixtures/conformance/reporting-inherited-members/expected.json new file mode 100644 index 000000000..036da1d48 --- /dev/null +++ b/fixtures/conformance/reporting-inherited-members/expected.json @@ -0,0 +1,82 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "BaseEvent", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "dimension.time": { + "name": "occurredAt", + "@grains": [ + "day", + "week" + ], + "@of": "BaseEvent.occurredAt" + } + }, + { + "measure.aggregate": { + "name": "events", + "@agg": "count", + "@of": [ + "BaseEvent.id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "extends": "BaseEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.report": { + "name": "DailyEvents", + "@dimensions": [ + "occurredAt:day" + ], + "@from": "WorkoutEvent", + "@measures": [ + "events" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-inherited-members/input/meta.shop.json b/fixtures/conformance/reporting-inherited-members/input/meta.shop.json new file mode 100644 index 000000000..1ebad7697 --- /dev/null +++ b/fixtures/conformance/reporting-inherited-members/input/meta.shop.json @@ -0,0 +1,80 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "BaseEvent", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "dimension.time": { + "name": "occurredAt", + "@of": "BaseEvent.occurredAt", + "@grains": [ + "day", + "week" + ] + } + }, + { + "measure.aggregate": { + "name": "events", + "@agg": "count", + "@of": "BaseEvent.id" + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "extends": "BaseEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.report": { + "name": "DailyEvents", + "@from": "WorkoutEvent", + "@dimensions": [ + "occurredAt:day" + ], + "@measures": [ + "events" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-inherited-members/providers.json b/fixtures/conformance/reporting-inherited-members/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/reporting-inherited-members/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/reporting-vocabulary/expected.json b/fixtures/conformance/reporting-vocabulary/expected.json new file mode 100644 index 000000000..245dd1945 --- /dev/null +++ b/fixtures/conformance/reporting-vocabulary/expected.json @@ -0,0 +1,373 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@cardinality": "many", + "@objectRef": "Purchase" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@fields": [ + "programId" + ], + "@references": "Program" + } + }, + { + "relationship.association": { + "name": "program", + "@cardinality": "one", + "@objectRef": "Program" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "@of": "Purchase.purchasedAt" + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@grains": [ + "month", + "year" + ], + "@of": "Program.createdAt", + "@via": "Purchase.program" + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@filter": { + "refunded": { + "eq": true + } + }, + "@of": [ + "Purchase.id" + ] + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": [ + "Purchase.id" + ], + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": [ + "Purchase.customerEmail" + ], + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": [ + "Purchase.amountCents" + ], + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": { + "eq": "active" + } + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.customerEmail" + ] + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": [ + "WorkoutEvent.occurredAt" + ] + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@denominator": "starters", + "@numerator": "daysEngaged" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": { + "eq": "exercise_complete" + } + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@dimensions": [ + "program" + ], + "@from": "WorkoutEvent", + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@dimensions": [ + "purchasedAt:day" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + }, + "@from": "Purchase", + "@measures": [ + "purchases", + "revenue" + ] + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-vocabulary/input/meta.shop.json b/fixtures/conformance/reporting-vocabulary/input/meta.shop.json new file mode 100644 index 000000000..00edb4205 --- /dev/null +++ b/fixtures/conformance/reporting-vocabulary/input/meta.shop.json @@ -0,0 +1,355 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-vocabulary/providers.json b/fixtures/conformance/reporting-vocabulary/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/reporting-vocabulary/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/metamodel-docs/expected/INDEX.md b/fixtures/metamodel-docs/expected/INDEX.md index ebdc7e472..0c8db3fc7 100644 --- a/fixtures/metamodel-docs/expected/INDEX.md +++ b/fixtures/metamodel-docs/expected/INDEX.md @@ -26,6 +26,8 @@ children, and cardinality of a subtype. Universal documentation attributes | `attr.long` | A 64-bit-integer-valued metadata attribute. Coerces to and validates as a number. | [types/attr.md#attrlong](types/attr.md#attrlong) | | `attr.properties` | A key/value map attribute (a bag of arbitrary author-supplied properties). Object-shaped value; the registered escape hatch for author-supplied properties (exempt from the strict-attr check, ADR-0023). | [types/attr.md#attrproperties](types/attr.md#attrproperties) | | `attr.string` | A string-valued metadata attribute. Coerces to and validates as text; the default value-type for inline @-syntax attrs (array-of-string is the same subtype with isArray). | [types/attr.md#attrstring](types/attr.md#attrstring) | +| `dimension.attribute` | A named group-by attribute of the entity that declares it (FR-044). Groups report rows by a column value as-is. @of names Entity.field: the owning entity, or the @via terminal. @via may follow only to-one hops, so grouping by a related row's column can never multiply the measured rows. | [types/dimension.md#dimensionattribute](types/dimension.md#dimensionattribute) | +| `dimension.time` | A named time dimension (FR-044): groups report rows by a date or timestamp column truncated to a grain. @of names a field.date or field.timestamp. Weeks start on Monday (ISO-8601) in every lowering. A report names it as 'dimension:grain' and the derived report field is (e.g. purchasedAtDay). | [types/dimension.md#dimensiontime](types/dimension.md#dimensiontime) | | `field.base` | Abstract base field — the shared root subtype that concrete field subtypes specialize. Carries the attrs common to every field but binds no concrete data type of its own (falls back to string). Not authored directly: a `field.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document. | [types/field.md#fieldbase](types/field.md#fieldbase) | | `field.boolean` | True/false flag. Binds to the native boolean type; DB column is BOOLEAN. | [types/field.md#fieldboolean](types/field.md#fieldboolean) | | `field.currency` | Stores money as integer minor units (cents). Binds to long; the client formats via @currency/@locale. Float arithmetic for money is forbidden. | [types/field.md#fieldcurrency](types/field.md#fieldcurrency) | @@ -50,10 +52,13 @@ children, and cardinality of a subtype. Universal documentation attributes | `index.lookup` | A non-unique lookup index on one or more fields. Use for query-performance indexes that do NOT enforce uniqueness — declare identity.secondary for unique constraints instead. | [types/index.md#indexlookup](types/index.md#indexlookup) | | `layout.base` | Abstract base layout — the shared root subtype for object-level UI surfaces. A layout attaches a presentation concern (grids, forms, tabs, cards) to an object. The base carries no attrs of its own; concrete subtypes add their presentation attrs. Not authored directly: a `layout.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document. | [types/layout.md#layoutbase](types/layout.md#layoutbase) | | `layout.dataGrid` | A metadata-driven data grid attached to an object: declares the displayed columns, page size, default sort, and an optional preset filter the generated grid renders. | [types/layout.md#layoutdatagrid](types/layout.md#layoutdatagrid) | +| `measure.aggregate` | A named aggregate over the declaring entity's own rows (FR-044). @agg count without @distinct counts rows; with @distinct it counts distinct values of @of (a list in @of is a distinct count of the tuple). Unlike origin.aggregate, count is NOT distinct by default: a measure aggregates its own rows and dimensions reach only to-one paths, so no join inflates it. | [types/measure.md#measureaggregate](types/measure.md#measureaggregate) | +| `measure.ratio` | A named quotient of two measure.aggregate siblings (FR-044), lowered as numerator / NULLIF(denominator, 0) and typed decimal. A zero denominator yields null. | [types/measure.md#measureratio](types/measure.md#measureratio) | | `metadata.root` | Root metadata document | [types/metadata.md#metadataroot](types/metadata.md#metadataroot) | | `object.base` | Abstract object base — the shared root subtype that concrete object subtypes (entity/value/projection) specialize. Declares the structural children common to EVERY object subtype (the intersection: field/identity/validator/layout/source); subtype-specific children (relationship, template) and attrs (discriminator) ride their own subtypes. Has no runtime semantics of its own; not authored directly. A `object.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document. | [types/object.md#objectbase](types/object.md#objectbase) | | `object.entity` | An object that owns its data: own identity, writable sources, and lifecycle. The default object subtype — a bare `object:` key resolves to entity. May co-locate templates (template.prompt and friends) with the owning entity. | [types/object.md#objectentity](types/object.md#objectentity) | | `object.projection` | A derived read-only representation of entities. Its fields are extends-bound / origin-derived / self-declared-under-external-assembly, all read-only at the subtype level. Identity is optional and MUST extend an entity identity; sources are restricted to read-only @kinds. The declared field set IS the exposure (inclusive, fail-closed). | [types/object.md#objectprojection](types/object.md#objectprojection) | +| `object.report` | A declared report (FR-044): a fixed combination of dimensions and measures of ONE entity (@from), compiled to a read-only view. One row per distinct dimension tuple; no dimensions means exactly one row. Fields are DERIVED, not declared: one per dimension (a time dimension's field is , e.g. purchasedAtDay) and one per measure. Every measure must belong to @from (two fact tables are two reports). @filter and @segment scope rows before grouping and combine by AND. Read-only: no writes, no get-by-id. | [types/object.md#objectreport](types/object.md#objectreport) | | `object.value` | A value object — pure shape with NO identity and NO source, ever. Constructed (by caller / embedding), never populated from a store. May `extends` an entity's fields to reuse shape. Equality is by content. | [types/object.md#objectvalue](types/object.md#objectvalue) | | `origin.aggregate` | A value reduced from the related row-set reached along a relationship path (@via) from the base entity: count/sum/avg/min/max over a column (@of); any/all predicate quantifiers over a @filter; or collect (an array rollup — of the @of column, or of the carrying field.object's declared @objectRef value object when @of is omitted). | [types/origin.md#originaggregate](types/origin.md#originaggregate) | | `origin.base` | Abstract base origin — the shared root subtype for field-level provenance. A field carrying any origin.* is derived ⇒ read-only wherever it lives. The base carries no attrs of its own; concrete subtypes add their provenance attrs. Not authored directly: a `origin.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document. | [types/origin.md#originbase](types/origin.md#originbase) | @@ -66,6 +71,7 @@ children, and cardinality of a subtype. Universal documentation attributes | `relationship.composition` | An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade). | [types/relationship.md#relationshipcomposition](types/relationship.md#relationshipcomposition) | | `requirement.architectural` | How the system is built, applied uniformly across the model. Its check is UNIVERSALITY, the opposite polarity to a functional requirement. What `meta verify` enforces is that a live or partial policy is applied at all — one claiming nothing is an error — not that each node it claims complies. Flat by default and object-independent; it may optionally sit in a levelled tree when a quality taxonomy is being used to organise non-functional requirements. | [types/requirement.md#requirementarchitectural](types/requirement.md#requirementarchitectural) | | `requirement.functional` | What the product does for a user, stated as one violable claim. Its check is EXISTENCE, run by `meta verify`: a live or partial claim with no implementing node anywhere in its subtree is a warning, and a named node that no longer exists is an error. Hierarchy is nesting — an L1 solution contains its L2 segments, which contain L3 services, and so on down to the levels that reference the model. | [types/requirement.md#requirementfunctional](types/requirement.md#requirementfunctional) | +| `segment.filter` | A named, reusable row filter on the declaring entity (FR-044). Measures and reports reference it by name; exporters emit it as a named segment. | [types/segment.md#segmentfilter](types/segment.md#segmentfilter) | | `source.base` | Abstract base source — the shared root subtype for declaring where an object's data lives (Project E). The base carries no attrs of its own; the concrete paradigm subtype (rdb) carries the physical-storage attrs, which are contributed by the db domain provider. Not authored directly: a `source.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document. | [types/source.md#sourcebase](types/source.md#sourcebase) | | `source.rdb` | The relational-database paradigm source (ADR-0007): binds an object to a physical relational object. Its physical name is the @table attr (not the structural `name`), and read-only-ness is DERIVED from @kind — table is writable; view, materializedView, storedProc, and tableFunction are read-only. The @table/@kind/@role/@schema/@parameterRef attrs are contributed by the db domain provider, not by core-types. | [types/source.md#sourcerdb](types/source.md#sourcerdb) | | `template.base` | Abstract base template — the shared root subtype for the fourth pillar (FR-004, ADR-0011). A template is a typed payload bound to either a rendered text artifact (prompt/output) or a tool-call envelope. The base carries no attrs of its own; concrete subtypes add their reference + governance attrs. Not authored directly: a `template.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document. | [types/template.md#templatebase](types/template.md#templatebase) | diff --git a/fixtures/metamodel-docs/expected/providers.md b/fixtures/metamodel-docs/expected/providers.md index c69807040..c9e56802c 100644 --- a/fixtures/metamodel-docs/expected/providers.md +++ b/fixtures/metamodel-docs/expected/providers.md @@ -13,7 +13,7 @@ provider owns. This is the ownership lens over the same vocabulary Core metaobjects metamodel types and subtypes. -**Owns (registers):** `attr.base`, `attr.boolean`, `attr.class`, `attr.double`, `attr.expression`, `attr.filter`, `attr.int`, `attr.intMap`, `attr.long`, `attr.properties`, `attr.string`, `field.base`, `field.boolean`, `field.currency`, `field.date`, `field.decimal`, `field.double`, `field.enum`, `field.float`, `field.inet`, `field.int`, `field.long`, `field.map`, `field.object`, `field.string`, `field.time`, `field.timestamp`, `field.uri`, `field.uuid`, `identity.primary`, `identity.reference`, `identity.secondary`, `layout.base`, `layout.dataGrid`, `object.base`, `object.entity`, `object.projection`, `object.value`, `origin.aggregate`, `origin.base`, `origin.computed`, `origin.first`, `origin.passthrough`, `relationship.aggregation`, `relationship.association`, `relationship.base`, `relationship.composition`, `source.base`, `source.rdb`, `template.base`, `template.output`, `template.prompt`, `template.toolcall`, `validator.array`, `validator.atLeastOne`, `validator.base`, `validator.comparison`, `validator.length`, `validator.numeric`, `validator.presentIff`, `validator.regex`, `validator.required`, `validator.requiredWhen`, `view.base`, `view.currency` +**Owns (registers):** `attr.base`, `attr.boolean`, `attr.class`, `attr.double`, `attr.expression`, `attr.filter`, `attr.int`, `attr.intMap`, `attr.long`, `attr.properties`, `attr.string`, `field.base`, `field.boolean`, `field.currency`, `field.date`, `field.decimal`, `field.double`, `field.enum`, `field.float`, `field.inet`, `field.int`, `field.long`, `field.map`, `field.object`, `field.string`, `field.time`, `field.timestamp`, `field.uri`, `field.uuid`, `identity.primary`, `identity.reference`, `identity.secondary`, `layout.base`, `layout.dataGrid`, `object.base`, `object.entity`, `object.projection`, `object.report`, `object.value`, `origin.aggregate`, `origin.base`, `origin.computed`, `origin.first`, `origin.passthrough`, `relationship.aggregation`, `relationship.association`, `relationship.base`, `relationship.composition`, `source.base`, `source.rdb`, `template.base`, `template.output`, `template.prompt`, `template.toolcall`, `validator.array`, `validator.atLeastOne`, `validator.base`, `validator.comparison`, `validator.length`, `validator.numeric`, `validator.presentIff`, `validator.regex`, `validator.required`, `validator.requiredWhen`, `view.base`, `view.currency` **Contributes attributes:** @@ -31,6 +31,7 @@ Core metaobjects metamodel types and subtypes. - `identity.secondary`: `@fields` - `object.entity`: `@discriminator`, `@discriminatorValue` - `object.projection`: `@filter` +- `object.report`: `@dimensions`, `@filter`, `@from`, `@measures`, `@segment` - `origin.aggregate`: `@agg`, `@distinct`, `@filter`, `@of`, `@orderBy`, `@via` - `origin.computed`: `@expr` - `origin.first`: `@filter`, `@of`, `@orderBy`, `@via` diff --git a/fixtures/metamodel-docs/expected/types/dimension.md b/fixtures/metamodel-docs/expected/types/dimension.md new file mode 100644 index 000000000..ff6878571 --- /dev/null +++ b/fixtures/metamodel-docs/expected/types/dimension.md @@ -0,0 +1,47 @@ + + +# Metamodel — `dimension` types + +Each section below is one `dimension.`. The **Attributes** table lists +the subtype's own + concern-contributed attributes (provider-tagged); universal +documentation attributes are omitted here (see [providers.md](../providers.md)). +**Allowed children** lists the structural child rules with their cardinality +(`min..max`, `*` = unbounded). + +### dimension.attribute + +A named group-by attribute of the entity that declares it (FR-044). Groups report rows by a column value as-is. @of names Entity.field: the owning entity, or the @via terminal. @via may follow only to-one hops, so grouping by a related row's column can never multiply the measured rows. + +**When to use:** A column a dashboard groups by: product, status, region. Declare it once on the fact entity and reference it by name from reports. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@of` | string | yes | | | — | Dotted Entity.field reference naming the grouped column (e.g. 'Purchase.programId', or 'Program.title' with @via). | +| `@via` | string | no | | | — | Optional dotted to-one relationship path from the owning entity to the entity @of names (e.g. 'Purchase.program'). Every hop must be @cardinality: one or an identity.reference. | + +**Allowed children** + +_No structural children._ + +### dimension.time + +A named time dimension (FR-044): groups report rows by a date or timestamp column truncated to a grain. @of names a field.date or field.timestamp. Weeks start on Monday (ISO-8601) in every lowering. A report names it as 'dimension:grain' and the derived report field is (e.g. purchasedAtDay). + +**When to use:** Per-day, per-week or per-month series on a dashboard. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@grains` | string[] | yes | | `hour`, `day`, `week`, `month`, `quarter`, `year` | — | The grains this dimension supports. Weeks start Monday (ISO-8601). 'hour' is refused on a field.date. | +| `@of` | string | yes | | | — | Dotted Entity.field reference naming the date or timestamp column. | +| `@via` | string | no | | | — | Optional dotted to-one relationship path, as on dimension.attribute. | + +**Allowed children** + +_No structural children._ + diff --git a/fixtures/metamodel-docs/expected/types/measure.md b/fixtures/metamodel-docs/expected/types/measure.md new file mode 100644 index 000000000..1166f9e73 --- /dev/null +++ b/fixtures/metamodel-docs/expected/types/measure.md @@ -0,0 +1,49 @@ + + +# Metamodel — `measure` types + +Each section below is one `measure.`. The **Attributes** table lists +the subtype's own + concern-contributed attributes (provider-tagged); universal +documentation attributes are omitted here (see [providers.md](../providers.md)). +**Allowed children** lists the structural child rules with their cardinality +(`min..max`, `*` = unbounded). + +### measure.aggregate + +A named aggregate over the declaring entity's own rows (FR-044). @agg count without @distinct counts rows; with @distinct it counts distinct values of @of (a list in @of is a distinct count of the tuple). Unlike origin.aggregate, count is NOT distinct by default: a measure aggregates its own rows and dimensions reach only to-one paths, so no join inflates it. + +**When to use:** A number a dashboard shows: revenue, purchases, distinct buyers, last activity. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@agg` | string | yes | | `count`, `sum`, `avg`, `min`, `max` | — | The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields. | +| `@distinct` | boolean | no | | | — | Count distinct values. Legal only with @agg: count. | +| `@filter` | filter | no | | | — | Optional row scope (a portable attr.filter over the declaring entity's fields). May use relative-date values ({ now: "-P7D" }). Combines with @segment by AND. | +| `@of` | string[] | yes | | | — | Dotted Entity.field reference(s) on the declaring entity. A bare string is one column; more than one requires @agg: count and @distinct: true. | +| `@segment` | string | no | | | — | Optional name of a segment declared on the same entity. Combines with @filter by AND. | + +**Allowed children** + +_No structural children._ + +### measure.ratio + +A named quotient of two measure.aggregate siblings (FR-044), lowered as numerator / NULLIF(denominator, 0) and typed decimal. A zero denominator yields null. + +**When to use:** Averages per unit that are not a plain avg: average days engaged per starter. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@denominator` | string | yes | | | — | Name of a measure.aggregate on the same entity. | +| `@numerator` | string | yes | | | — | Name of a measure.aggregate on the same entity. | + +**Allowed children** + +_No structural children._ + diff --git a/fixtures/metamodel-docs/expected/types/object.md b/fixtures/metamodel-docs/expected/types/object.md index ff9e4a327..5c138b439 100644 --- a/fixtures/metamodel-docs/expected/types/object.md +++ b/fixtures/metamodel-docs/expected/types/object.md @@ -48,11 +48,14 @@ An object that owns its data: own identity, writable sources, and lifecycle. The **Allowed children** +- `dimension.*` — 0..* - `field.*` — 0..* - `identity.*` — 0..* - `index.*` — 0..* - `layout.*` — 0..* +- `measure.*` — 0..* - `relationship.*` — 0..* +- `segment.*` — 0..* - `source.*` — 0..* - `template.*` — 0..* - `validator.*` — 0..* @@ -82,6 +85,33 @@ A derived read-only representation of entities. Its fields are extends-bound / o - `source.*` — 0..* - `validator.*` — 0..* +### object.report + +A declared report (FR-044): a fixed combination of dimensions and measures of ONE entity (@from), compiled to a read-only view. One row per distinct dimension tuple; no dimensions means exactly one row. Fields are DERIVED, not declared: one per dimension (a time dimension's field is , e.g. purchasedAtDay) and one per measure. Every measure must belong to @from (two fact tables are two reports). @filter and @segment scope rows before grouping and combine by AND. Read-only: no writes, no get-by-id. + +**Owning provider:** metaobjects-core-types + +**When to use:** Dashboard totals, per-day series and per-group summaries that would otherwise be hand-written GROUP BY queries. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@dimensions` | string[] | no | | | metaobjects-core-types | Dimension names of @from; a time dimension is written 'name:grain'. Absent means one global row. | +| `@filter` | filter | no | | | metaobjects-core-types | Optional row scope over @from's fields; may use relative-date values. | +| `@from` | string | yes | | | metaobjects-core-types | The object.entity whose rows the report aggregates. | +| `@measures` | string[] | yes | | | metaobjects-core-types | Measure names of @from. | +| `@segment` | string | no | | | metaobjects-core-types | Optional segment of @from scoping the rows. | + +**Allowed children** + +- `field.*` — 0..* +- `identity.*` — 0..* +- `index.*` — 0..* +- `layout.*` — 0..* +- `source.*` — 0..* +- `validator.*` — 0..* + ### object.value A value object — pure shape with NO identity and NO source, ever. Constructed (by caller / embedding), never populated from a store. May `extends` an entity's fields to reuse shape. Equality is by content. diff --git a/fixtures/metamodel-docs/expected/types/segment.md b/fixtures/metamodel-docs/expected/types/segment.md new file mode 100644 index 000000000..8be27d1bf --- /dev/null +++ b/fixtures/metamodel-docs/expected/types/segment.md @@ -0,0 +1,28 @@ + + +# Metamodel — `segment` types + +Each section below is one `segment.`. The **Attributes** table lists +the subtype's own + concern-contributed attributes (provider-tagged); universal +documentation attributes are omitted here (see [providers.md](../providers.md)). +**Allowed children** lists the structural child rules with their cardinality +(`min..max`, `*` = unbounded). + +### segment.filter + +A named, reusable row filter on the declaring entity (FR-044). Measures and reports reference it by name; exporters emit it as a named segment. + +**When to use:** The same filter (e.g. 'active purchase') would otherwise be repeated in several measures or reports. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@filter` | filter | yes | | | — | The row scope: a portable attr.filter over the declaring entity's fields. May use relative-date values. | + +**Allowed children** + +_No structural children._ + diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 85d267ea7..2e52c10aa 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -14,7 +14,10 @@ { "relationship.composition": { "name": "weeks", "@objectRef": "Week", "@cardinality": "many" } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, { "identity.secondary": { "name": "byTitle", "@fields": "title" } }, - { "index.lookup": { "name": "idx_programs_title_status", "@fields": ["title", "status"], "@orders": ["asc", "asc"] } } + { "index.lookup": { "name": "idx_programs_title_status", "@fields": ["title", "status"], "@orders": ["asc", "asc"] } }, + { "dimension.time": { "name": "createdAt", "@of": "Program.createdAt", "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "listValue", "@agg": "sum", "@of": "Program.priceCents", "@segment": "published" } }, + { "segment.filter": { "name": "published", "@filter": { "status": "PUBLISHED" } } } ] }}, { "object.entity": { diff --git a/fixtures/registry-conformance/coverage-report.json b/fixtures/registry-conformance/coverage-report.json index e16b558ef..631dfcee5 100644 --- a/fixtures/registry-conformance/coverage-report.json +++ b/fixtures/registry-conformance/coverage-report.json @@ -1,6 +1,6 @@ { - "registeredSubTypeCount": 59, - "exercisedSubTypeCount": 47, + "registeredSubTypeCount": 65, + "exercisedSubTypeCount": 53, "untestedSubTypes": [ "attr.boolean", "attr.class", diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index 6ec5e6419..ec78892d0 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -1,5 +1,5 @@ { - "metamodelVersion": "1.0", + "metamodelVersion": "1.1", "types": [ { "type": "attr", @@ -78,6 +78,67 @@ "attrs": [], "children": [] }, + { + "type": "dimension", + "subType": "attribute", + "description": "A named group-by attribute of the entity that declares it (FR-044). Groups report rows by a column value as-is. @of names Entity.field: the owning entity, or the @via terminal. @via may follow only to-one hops, so grouping by a related row's column can never multiply the measured rows.", + "whenToUse": "A column a dashboard groups by: product, status, region. Declare it once on the fact entity and reference it by name from reports.", + "attrs": [ + { + "name": "of", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Dotted Entity.field reference naming the grouped column (e.g. 'Purchase.programId', or 'Program.title' with @via)." + }, + { + "name": "via", + "valueType": "string", + "isArray": false, + "required": false, + "description": "Optional dotted to-one relationship path from the owning entity to the entity @of names (e.g. 'Purchase.program'). Every hop must be @cardinality: one or an identity.reference." + } + ], + "children": [] + }, + { + "type": "dimension", + "subType": "time", + "description": "A named time dimension (FR-044): groups report rows by a date or timestamp column truncated to a grain. @of names a field.date or field.timestamp. Weeks start on Monday (ISO-8601) in every lowering. A report names it as 'dimension:grain' and the derived report field is (e.g. purchasedAtDay).", + "whenToUse": "Per-day, per-week or per-month series on a dashboard.", + "attrs": [ + { + "name": "grains", + "valueType": "string", + "isArray": true, + "required": true, + "allowedValues": [ + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "description": "The grains this dimension supports. Weeks start Monday (ISO-8601). 'hour' is refused on a field.date." + }, + { + "name": "of", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Dotted Entity.field reference naming the date or timestamp column." + }, + { + "name": "via", + "valueType": "string", + "isArray": false, + "required": false, + "description": "Optional dotted to-one relationship path, as on dimension.attribute." + } + ], + "children": [] + }, { "type": "field", "subType": "base", @@ -3064,6 +3125,80 @@ ], "children": [] }, + { + "type": "measure", + "subType": "aggregate", + "description": "A named aggregate over the declaring entity's own rows (FR-044). @agg count without @distinct counts rows; with @distinct it counts distinct values of @of (a list in @of is a distinct count of the tuple). Unlike origin.aggregate, count is NOT distinct by default: a measure aggregates its own rows and dimensions reach only to-one paths, so no join inflates it.", + "whenToUse": "A number a dashboard shows: revenue, purchases, distinct buyers, last activity.", + "attrs": [ + { + "name": "agg", + "valueType": "string", + "isArray": false, + "required": true, + "allowedValues": [ + "count", + "sum", + "avg", + "min", + "max" + ], + "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields." + }, + { + "name": "distinct", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "Count distinct values. Legal only with @agg: count." + }, + { + "name": "filter", + "valueType": "filter", + "isArray": false, + "required": false, + "description": "Optional row scope (a portable attr.filter over the declaring entity's fields). May use relative-date values ({ now: \"-P7D\" }). Combines with @segment by AND." + }, + { + "name": "of", + "valueType": "string", + "isArray": true, + "required": true, + "description": "Dotted Entity.field reference(s) on the declaring entity. A bare string is one column; more than one requires @agg: count and @distinct: true." + }, + { + "name": "segment", + "valueType": "string", + "isArray": false, + "required": false, + "description": "Optional name of a segment declared on the same entity. Combines with @filter by AND." + } + ], + "children": [] + }, + { + "type": "measure", + "subType": "ratio", + "description": "A named quotient of two measure.aggregate siblings (FR-044), lowered as numerator / NULLIF(denominator, 0) and typed decimal. A zero denominator yields null.", + "whenToUse": "Averages per unit that are not a plain avg: average days engaged per starter.", + "attrs": [ + { + "name": "denominator", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of a measure.aggregate on the same entity." + }, + { + "name": "numerator", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of a measure.aggregate on the same entity." + } + ], + "children": [] + }, { "type": "metadata", "subType": "root", @@ -3170,6 +3305,13 @@ } ], "children": [ + { + "childType": "dimension", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, { "childType": "field", "childSubType": "*", @@ -3198,6 +3340,13 @@ "min": 0, "max": null }, + { + "childType": "measure", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, { "childType": "relationship", "childSubType": "*", @@ -3205,6 +3354,13 @@ "min": 0, "max": null }, + { + "childType": "segment", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, { "childType": "source", "childSubType": "*", @@ -3288,6 +3444,93 @@ } ] }, + { + "type": "object", + "subType": "report", + "description": "A declared report (FR-044): a fixed combination of dimensions and measures of ONE entity (@from), compiled to a read-only view. One row per distinct dimension tuple; no dimensions means exactly one row. Fields are DERIVED, not declared: one per dimension (a time dimension's field is , e.g. purchasedAtDay) and one per measure. Every measure must belong to @from (two fact tables are two reports). @filter and @segment scope rows before grouping and combine by AND. Read-only: no writes, no get-by-id.", + "whenToUse": "Dashboard totals, per-day series and per-group summaries that would otherwise be hand-written GROUP BY queries.", + "attrs": [ + { + "name": "dimensions", + "valueType": "string", + "isArray": true, + "required": false, + "description": "Dimension names of @from; a time dimension is written 'name:grain'. Absent means one global row." + }, + { + "name": "filter", + "valueType": "filter", + "isArray": false, + "required": false, + "description": "Optional row scope over @from's fields; may use relative-date values." + }, + { + "name": "from", + "valueType": "string", + "isArray": false, + "required": true, + "description": "The object.entity whose rows the report aggregates." + }, + { + "name": "measures", + "valueType": "string", + "isArray": true, + "required": true, + "description": "Measure names of @from." + }, + { + "name": "segment", + "valueType": "string", + "isArray": false, + "required": false, + "description": "Optional segment of @from scoping the rows." + } + ], + "children": [ + { + "childType": "field", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, + { + "childType": "identity", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, + { + "childType": "index", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, + { + "childType": "layout", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, + { + "childType": "source", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, + { + "childType": "validator", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + } + ] + }, { "type": "object", "subType": "value", @@ -3962,6 +4205,22 @@ } ] }, + { + "type": "segment", + "subType": "filter", + "description": "A named, reusable row filter on the declaring entity (FR-044). Measures and reports reference it by name; exporters emit it as a named segment.", + "whenToUse": "The same filter (e.g. 'active purchase') would otherwise be repeated in several measures or reports.", + "attrs": [ + { + "name": "filter", + "valueType": "filter", + "isArray": false, + "required": true, + "description": "The row scope: a portable attr.filter over the declaring entity's fields. May use relative-date values." + } + ], + "children": [] + }, { "type": "source", "subType": "base", diff --git a/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs b/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs index b4977479b..56a08ae70 100644 --- a/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs +++ b/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (109 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into C#'s own suite and write the assertion the @@ -184,6 +184,10 @@ public void declare_objectTaxonomy_objectProjection() { /* metaobjects.declare.o [Fact(Skip = "unfilled slot — write an assertion that fails when: A view meant to show active records showing every record, because scoping was left to each query.")] public void declare_objectTaxonomy_objectProjection_filter() { /* metaobjects.declare.objectTaxonomy.objectProjection.filter */ } + // Report — A report is a fixed combination of dimensions and measures of one entity, read-only, whose fields are derived from that combination rather than declared. + [Fact(Skip = "planned — not built yet")] + public void declare_objectTaxonomy_objectReport() { /* metaobjects.declare.objectTaxonomy.objectReport */ } + // Value object — A value object is pure shape — never its own identity, never its own source — constructed by whoever embeds it rather than fetched. [Fact(Skip = "unfilled slot — write an assertion that fails when: A transport shape that has acquired a primary key, so two copies of one message are now two records.")] public void declare_objectTaxonomy_objectValue() { /* metaobjects.declare.objectTaxonomy.objectValue */ } @@ -332,6 +336,26 @@ public void declare_relationships_relationshipAssociation_through() { /* metaobj [Fact(Skip = "unfilled slot — write an assertion that fails when: An order deleted and its lines left behind, referenced by nothing and still counted by every report.")] public void declare_relationships_relationshipComposition() { /* metaobjects.declare.relationships.relationshipComposition */ } + // Attribute dimension — An attribute dimension names a column to group by, reaching a related row only along to-one paths, so grouping can never multiply the rows measured. + [Fact(Skip = "planned — not built yet")] + public void declare_reporting_dimensionAttribute() { /* metaobjects.declare.reporting.dimensionAttribute */ } + + // Time dimension — A time dimension groups a date or timestamp column by a declared grain, and a week starts on Monday in every lowering. + [Fact(Skip = "planned — not built yet")] + public void declare_reporting_dimensionTime() { /* metaobjects.declare.reporting.dimensionTime */ } + + // Aggregate measure — An aggregate measure is one declared aggregate over the declaring entity's own rows, counting rows unless it says it counts distinct values. + [Fact(Skip = "planned — not built yet")] + public void declare_reporting_measureAggregate() { /* metaobjects.declare.reporting.measureAggregate */ } + + // Ratio measure — A ratio measure is the quotient of two aggregate measures and is empty, not an error, when the denominator is zero. + [Fact(Skip = "planned — not built yet")] + public void declare_reporting_measureRatio() { /* metaobjects.declare.reporting.measureRatio */ } + + // Named filter — A named filter is declared once on an entity and referenced by name, so a measure and a report that mean the same rows cannot drift apart. + [Fact(Skip = "planned — not built yet")] + public void declare_reporting_segmentFilter() { /* metaobjects.declare.reporting.segmentFilter */ } + // Boolean — A boolean field holds exactly true or false, and never a third state standing in for absent. [Fact(Skip = "unfilled slot — write an assertion that fails when: An unanswered question stored as false, so nobody can tell it apart from an answer of no.")] public void declare_typedFieldVocabulary_fieldBoolean() { /* metaobjects.declare.typedFieldVocabulary.fieldBoolean */ } diff --git a/fixtures/requirement-harness/java/RequirementHarnessTest.java b/fixtures/requirement-harness/java/RequirementHarnessTest.java index 8b4e9d4da..f02b52d9d 100644 --- a/fixtures/requirement-harness/java/RequirementHarnessTest.java +++ b/fixtures/requirement-harness/java/RequirementHarnessTest.java @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (109 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into Java's own suite and write the assertion the @@ -227,6 +227,11 @@ void declare_objectTaxonomy_objectProjection() { /* metaobjects.declare.objectTa @Disabled("unfilled slot — write an assertion that fails when: A view meant to show active records showing every record, because scoping was left to each query.") void declare_objectTaxonomy_objectProjection_filter() { /* metaobjects.declare.objectTaxonomy.objectProjection.filter */ } + // Report — A report is a fixed combination of dimensions and measures of one entity, read-only, whose fields are derived from that combination rather than declared. + @Test + @Disabled("planned — not built yet") + void declare_objectTaxonomy_objectReport() { /* metaobjects.declare.objectTaxonomy.objectReport */ } + // Value object — A value object is pure shape — never its own identity, never its own source — constructed by whoever embeds it rather than fetched. @Test @Disabled("unfilled slot — write an assertion that fails when: A transport shape that has acquired a primary key, so two copies of one message are now two records.") @@ -412,6 +417,31 @@ void declare_relationships_relationshipAssociation_through() { /* metaobjects.de @Disabled("unfilled slot — write an assertion that fails when: An order deleted and its lines left behind, referenced by nothing and still counted by every report.") void declare_relationships_relationshipComposition() { /* metaobjects.declare.relationships.relationshipComposition */ } + // Attribute dimension — An attribute dimension names a column to group by, reaching a related row only along to-one paths, so grouping can never multiply the rows measured. + @Test + @Disabled("planned — not built yet") + void declare_reporting_dimensionAttribute() { /* metaobjects.declare.reporting.dimensionAttribute */ } + + // Time dimension — A time dimension groups a date or timestamp column by a declared grain, and a week starts on Monday in every lowering. + @Test + @Disabled("planned — not built yet") + void declare_reporting_dimensionTime() { /* metaobjects.declare.reporting.dimensionTime */ } + + // Aggregate measure — An aggregate measure is one declared aggregate over the declaring entity's own rows, counting rows unless it says it counts distinct values. + @Test + @Disabled("planned — not built yet") + void declare_reporting_measureAggregate() { /* metaobjects.declare.reporting.measureAggregate */ } + + // Ratio measure — A ratio measure is the quotient of two aggregate measures and is empty, not an error, when the denominator is zero. + @Test + @Disabled("planned — not built yet") + void declare_reporting_measureRatio() { /* metaobjects.declare.reporting.measureRatio */ } + + // Named filter — A named filter is declared once on an entity and referenced by name, so a measure and a report that mean the same rows cannot drift apart. + @Test + @Disabled("planned — not built yet") + void declare_reporting_segmentFilter() { /* metaobjects.declare.reporting.segmentFilter */ } + // Boolean — A boolean field holds exactly true or false, and never a third state standing in for absent. @Test @Disabled("unfilled slot — write an assertion that fails when: An unanswered question stored as false, so nobody can tell it apart from an answer of no.") diff --git a/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt b/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt index df5459946..38830b4bf 100644 --- a/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt +++ b/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (109 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into Kotlin's own suite and write the assertion the @@ -227,6 +227,11 @@ class RequirementHarnessTest { @Disabled("unfilled slot — write an assertion that fails when: A view meant to show active records showing every record, because scoping was left to each query.") fun declare_objectTaxonomy_objectProjection_filter() { /* metaobjects.declare.objectTaxonomy.objectProjection.filter */ } + // Report — A report is a fixed combination of dimensions and measures of one entity, read-only, whose fields are derived from that combination rather than declared. + @Test + @Disabled("planned — not built yet") + fun declare_objectTaxonomy_objectReport() { /* metaobjects.declare.objectTaxonomy.objectReport */ } + // Value object — A value object is pure shape — never its own identity, never its own source — constructed by whoever embeds it rather than fetched. @Test @Disabled("unfilled slot — write an assertion that fails when: A transport shape that has acquired a primary key, so two copies of one message are now two records.") @@ -412,6 +417,31 @@ class RequirementHarnessTest { @Disabled("unfilled slot — write an assertion that fails when: An order deleted and its lines left behind, referenced by nothing and still counted by every report.") fun declare_relationships_relationshipComposition() { /* metaobjects.declare.relationships.relationshipComposition */ } + // Attribute dimension — An attribute dimension names a column to group by, reaching a related row only along to-one paths, so grouping can never multiply the rows measured. + @Test + @Disabled("planned — not built yet") + fun declare_reporting_dimensionAttribute() { /* metaobjects.declare.reporting.dimensionAttribute */ } + + // Time dimension — A time dimension groups a date or timestamp column by a declared grain, and a week starts on Monday in every lowering. + @Test + @Disabled("planned — not built yet") + fun declare_reporting_dimensionTime() { /* metaobjects.declare.reporting.dimensionTime */ } + + // Aggregate measure — An aggregate measure is one declared aggregate over the declaring entity's own rows, counting rows unless it says it counts distinct values. + @Test + @Disabled("planned — not built yet") + fun declare_reporting_measureAggregate() { /* metaobjects.declare.reporting.measureAggregate */ } + + // Ratio measure — A ratio measure is the quotient of two aggregate measures and is empty, not an error, when the denominator is zero. + @Test + @Disabled("planned — not built yet") + fun declare_reporting_measureRatio() { /* metaobjects.declare.reporting.measureRatio */ } + + // Named filter — A named filter is declared once on an entity and referenced by name, so a measure and a report that mean the same rows cannot drift apart. + @Test + @Disabled("planned — not built yet") + fun declare_reporting_segmentFilter() { /* metaobjects.declare.reporting.segmentFilter */ } + // Boolean — A boolean field holds exactly true or false, and never a third state standing in for absent. @Test @Disabled("unfilled slot — write an assertion that fails when: An unanswered question stored as false, so nobody can tell it apart from an answer of no.") diff --git a/fixtures/requirement-harness/python/test_requirement_harness.py b/fixtures/requirement-harness/python/test_requirement_harness.py index 793aef586..0e5fdfd90 100644 --- a/fixtures/requirement-harness/python/test_requirement_harness.py +++ b/fixtures/requirement-harness/python/test_requirement_harness.py @@ -1,6 +1,6 @@ # @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. # -# One slot per authored promise in metaobjects/meta.requirements.yaml (109 of them). +# One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). # Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a # claim that nothing is verified — the conformance corpora already check many of # these. To FILL one, move it into Python's own suite and write the assertion the @@ -222,6 +222,11 @@ def test_declare_objectTaxonomy_objectProjection(): def test_declare_objectTaxonomy_objectProjection_filter(): """metaobjects.declare.objectTaxonomy.objectProjection.filter""" +# Report — A report is a fixed combination of dimensions and measures of one entity, read-only, whose fields are derived from that combination rather than declared. +@pytest.mark.skip(reason="planned — not built yet") +def test_declare_objectTaxonomy_objectReport(): + """metaobjects.declare.objectTaxonomy.objectReport""" + # Value object — A value object is pure shape — never its own identity, never its own source — constructed by whoever embeds it rather than fetched. @pytest.mark.skip(reason="unfilled slot — write an assertion that fails when: A transport shape that has acquired a primary key, so two copies of one message are now two records.") def test_declare_objectTaxonomy_objectValue(): @@ -407,6 +412,31 @@ def test_declare_relationships_relationshipAssociation_through(): def test_declare_relationships_relationshipComposition(): """metaobjects.declare.relationships.relationshipComposition""" +# Attribute dimension — An attribute dimension names a column to group by, reaching a related row only along to-one paths, so grouping can never multiply the rows measured. +@pytest.mark.skip(reason="planned — not built yet") +def test_declare_reporting_dimensionAttribute(): + """metaobjects.declare.reporting.dimensionAttribute""" + +# Time dimension — A time dimension groups a date or timestamp column by a declared grain, and a week starts on Monday in every lowering. +@pytest.mark.skip(reason="planned — not built yet") +def test_declare_reporting_dimensionTime(): + """metaobjects.declare.reporting.dimensionTime""" + +# Aggregate measure — An aggregate measure is one declared aggregate over the declaring entity's own rows, counting rows unless it says it counts distinct values. +@pytest.mark.skip(reason="planned — not built yet") +def test_declare_reporting_measureAggregate(): + """metaobjects.declare.reporting.measureAggregate""" + +# Ratio measure — A ratio measure is the quotient of two aggregate measures and is empty, not an error, when the denominator is zero. +@pytest.mark.skip(reason="planned — not built yet") +def test_declare_reporting_measureRatio(): + """metaobjects.declare.reporting.measureRatio""" + +# Named filter — A named filter is declared once on an entity and referenced by name, so a measure and a report that mean the same rows cannot drift apart. +@pytest.mark.skip(reason="planned — not built yet") +def test_declare_reporting_segmentFilter(): + """metaobjects.declare.reporting.segmentFilter""" + # Boolean — A boolean field holds exactly true or false, and never a third state standing in for absent. @pytest.mark.skip(reason="unfilled slot — write an assertion that fails when: An unanswered question stored as false, so nobody can tell it apart from an answer of no.") def test_declare_typedFieldVocabulary_fieldBoolean(): diff --git a/fixtures/requirement-harness/typescript/requirement-harness.test.ts b/fixtures/requirement-harness/typescript/requirement-harness.test.ts index 78499ba16..487abe99d 100644 --- a/fixtures/requirement-harness/typescript/requirement-harness.test.ts +++ b/fixtures/requirement-harness/typescript/requirement-harness.test.ts @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (109 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into TypeScript's own suite and write the assertion the @@ -222,6 +222,11 @@ test.skip("metaobjects.declare.objectTaxonomy.objectProjection.filter", () => { // unfilled slot — write an assertion that fails when: A view meant to show active records showing every record, because scoping was left to each query. }); +// Report — A report is a fixed combination of dimensions and measures of one entity, read-only, whose fields are derived from that combination rather than declared. +test.skip("metaobjects.declare.objectTaxonomy.objectReport", () => { + // planned — not built yet +}); + // Value object — A value object is pure shape — never its own identity, never its own source — constructed by whoever embeds it rather than fetched. test.skip("metaobjects.declare.objectTaxonomy.objectValue", () => { // unfilled slot — write an assertion that fails when: A transport shape that has acquired a primary key, so two copies of one message are now two records. @@ -407,6 +412,31 @@ test.skip("metaobjects.declare.relationships.relationshipComposition", () => { // unfilled slot — write an assertion that fails when: An order deleted and its lines left behind, referenced by nothing and still counted by every report. }); +// Attribute dimension — An attribute dimension names a column to group by, reaching a related row only along to-one paths, so grouping can never multiply the rows measured. +test.skip("metaobjects.declare.reporting.dimensionAttribute", () => { + // planned — not built yet +}); + +// Time dimension — A time dimension groups a date or timestamp column by a declared grain, and a week starts on Monday in every lowering. +test.skip("metaobjects.declare.reporting.dimensionTime", () => { + // planned — not built yet +}); + +// Aggregate measure — An aggregate measure is one declared aggregate over the declaring entity's own rows, counting rows unless it says it counts distinct values. +test.skip("metaobjects.declare.reporting.measureAggregate", () => { + // planned — not built yet +}); + +// Ratio measure — A ratio measure is the quotient of two aggregate measures and is empty, not an error, when the denominator is zero. +test.skip("metaobjects.declare.reporting.measureRatio", () => { + // planned — not built yet +}); + +// Named filter — A named filter is declared once on an entity and referenced by name, so a measure and a report that mean the same rows cannot drift apart. +test.skip("metaobjects.declare.reporting.segmentFilter", () => { + // planned — not built yet +}); + // Boolean — A boolean field holds exactly true or false, and never a third state standing in for absent. test.skip("metaobjects.declare.typedFieldVocabulary.fieldBoolean", () => { // unfilled slot — write an assertion that fails when: An unanswered question stored as false, so nobody can tell it apart from an answer of no. diff --git a/metaobjects/meta.requirements.yaml b/metaobjects/meta.requirements.yaml index 1683af616..dcd229a30 100644 --- a/metaobjects/meta.requirements.yaml +++ b/metaobjects/meta.requirements.yaml @@ -521,6 +521,19 @@ metadata.root: A view meant to show active records showing every record, because scoping was left to each query. + - requirement.functional: + name: objectReport + title: Report + level: 4 + status: planned + statement: >- + A report is a fixed combination of dimensions and + measures of one entity, read-only, whose fields are + derived from that combination rather than declared. + counterexample: >- + A dashboard query that groups by a column the + report's author never named, because its fields + were typed by hand beside the grouping. - requirement.functional: name: attributeValueTypes @@ -1136,6 +1149,87 @@ metadata.root: A latest value that is whichever row the database happened to return. + - requirement.functional: + name: reporting + title: Reporting + level: 3 + status: planned + statement: >- + The numbers a dashboard shows are declared once, as named + measures grouped by named dimensions, so that no hand-written + query holds the only definition of what a total means. + counterexample: >- + A revenue figure computed in three places with three different + row scopes, each one correct for the screen it was written for. + description: >- + Covers declaring the dimensions, measures and segments of an + entity and the report that combines them. How a report is + lowered to a view is derivation, and how it is served is the + API surface. + children: + - requirement.functional: + name: dimensionAttribute + title: Attribute dimension + level: 4 + status: planned + statement: >- + An attribute dimension names a column to group by, + reaching a related row only along to-one paths, so + grouping can never multiply the rows measured. + counterexample: >- + A total that doubles because the dimension joined + through a one-to-many relationship. + - requirement.functional: + name: dimensionTime + title: Time dimension + level: 4 + status: planned + statement: >- + A time dimension groups a date or timestamp column by + a declared grain, and a week starts on Monday in + every lowering. + counterexample: >- + A weekly series whose weeks start on Sunday in one + language and Monday in another. + - requirement.functional: + name: measureAggregate + title: Aggregate measure + level: 4 + status: planned + statement: >- + An aggregate measure is one declared aggregate over + the declaring entity's own rows, counting rows unless + it says it counts distinct values. + counterexample: >- + A purchase count that silently counts distinct + buyers, because the default was borrowed from a + different kind of aggregate. + - requirement.functional: + name: measureRatio + title: Ratio measure + level: 4 + status: planned + statement: >- + A ratio measure is the quotient of two aggregate + measures and is empty, not an error, when the + denominator is zero. + counterexample: >- + A dashboard average that fails the whole report for + the one day nothing was started. + - requirement.functional: + name: segmentFilter + title: Named filter + level: 4 + status: planned + statement: >- + A named filter is declared once on an entity and + referenced by name, so a measure and a report that + mean the same rows cannot drift apart. + counterexample: >- + An active-purchase definition copied into four + measures, three of which are updated when the + definition changes. + - requirement.functional: name: constraints title: Constraints diff --git a/scripts/generate-embedded-metamodel.ts b/scripts/generate-embedded-metamodel.ts index c7a581867..2a439c316 100644 --- a/scripts/generate-embedded-metamodel.ts +++ b/scripts/generate-embedded-metamodel.ts @@ -84,6 +84,8 @@ const CONCEPT_DIRS: Record = { ui: "presentation/ui", "ui-web": "presentation/ui-web", requirement: "core/requirement", + // FR-044: dimension / measure / segment (the object.report vocabulary) + reporting: "core/reporting", // depth-1 template: "template", // FR-033 S1.5: the prompt concern provider lives in template/ (templateProvider diff --git a/scripts/site/reference.test.ts b/scripts/site/reference.test.ts index e8f2b7c14..c9c0ef2dd 100644 --- a/scripts/site/reference.test.ts +++ b/scripts/site/reference.test.ts @@ -50,7 +50,7 @@ describe("renderReference", () => { test("renders every metamodel markdown page", () => { expect(existsSync(MD_DIR)).toBe(true); const p = pages(); - expect(Object.keys(p).length).toBe(16); + expect(Object.keys(p).length).toBe(19); expect(Object.keys(p)).toContain("index.html"); expect(Object.keys(p)).toContain("types/field.html"); }); @@ -153,7 +153,7 @@ describe("build-site-reference --check", () => { test("a freshly rendered tree checks clean", () => { const r = run("--check", "--out", rendered()); - expect(r.out).toContain("is fresh (16 page(s))"); + expect(r.out).toContain("is fresh (19 page(s))"); expect(r.code).toBe(0); }); diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs new file mode 100644 index 000000000..2eee34142 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs @@ -0,0 +1,161 @@ +// FR-044 Plan 1 — the reporting vocabulary is INERT in every C# generator. +// +// Plan 1 registers `dimension.*`, `measure.*`, `segment.*` and `object.report` and +// validates them at load, but gives none of them output: a report's lowering lands in +// Plan 2/3. Until then a model that USES the vocabulary must generate exactly what the +// same model without it generates, byte for byte, through every registered generator. +// +// The model pair is fixtures/codegen-noop/reporting/{with,without}, shared with the other +// four ports' copies of this test. `with/` carries a report that declares a read-only +// `source.rdb @kind: view` (R5 allows one) — the case that used to leak here as an empty +// entity class, a filter allowlist, a GET-only route and a keyless DbSet with ToView. +// +// Runs through CodegenRunner.Run — the path `dotnet meta gen` takes — not a hand-built +// GenContext, because the skip lives at the runner's entity-set choke point. + +using MetaObjects.Codegen; +using MetaObjects.Codegen.ApiDocs; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class ReportingInertTests +{ + private static string Model(string variant) => + Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "codegen-noop", "reporting", variant, "meta.shop.json"); + + private static MetaRoot Load(string variant) + { + var result = new MetaDataLoader().Load([new FileSource(Model(variant))]); + Assert.True(result.Errors.Count == 0, + $"{Model(variant)} did not load:\n" + string.Join("\n", result.Errors.Select(e => $" {e.Code}: {e.Message}"))); + return result.Root; + } + + /// + /// Run a generator suite into a fresh directory and read back every file it wrote: + /// relative path → contents. A throw is recorded as the single entry "<threw>", so a + /// generator that cannot run from a bare model must at least fail identically. + /// + private static SortedDictionary Emit(MetaRoot root, IReadOnlyList generators) + { + var outDir = Path.Combine(Path.GetTempPath(), "reporting-inert-" + Guid.NewGuid().ToString("N")); + var files = new SortedDictionary(StringComparer.Ordinal); + try + { + var config = new GenConfig + { + OutDir = outDir, + Namespace = "MetaObjects.ReportingInert.Generated", + ColumnNamingStrategy = ColumnNamingStrategy.SnakeCase, + IncludeNames = true, + }; + CodegenRunner.Run(config, root, generators); + if (Directory.Exists(outDir)) + foreach (var path in Directory.EnumerateFiles(outDir, "*", SearchOption.AllDirectories)) + files[Path.GetRelativePath(outDir, path)] = File.ReadAllText(path); + } + catch (Exception e) + { + files.Clear(); + files[""] = e.Message; + } + finally + { + if (Directory.Exists(outDir)) Directory.Delete(outDir, recursive: true); + } + return files; + } + + private static IGenerator Build(GeneratorRegistryEntry entry) => + entry.Factory(new GeneratorBuildContext(CorpusPaths.FitnessTemplateRoot)); + + public static TheoryData GeneratorNames() + { + var data = new TheoryData(); + foreach (var name in GeneratorRegistry.Entries.Keys) data.Add(name); + return data; + } + + private static void AssertSame(SortedDictionary expected, SortedDictionary actual) + { + Assert.Equal(expected.Keys.ToList(), actual.Keys.ToList()); + foreach (var (path, content) in expected) + Assert.True(content == actual[path], $"{path} differs once reporting nodes are declared"); + } + + [Fact] + public void The_with_model_really_carries_the_vocabulary() + { + // Else every comparison below is vacuously green. + var reports = Load("with").Objects().Where(o => o.IsReport()).Select(o => o.Name).OrderBy(n => n).ToList(); + Assert.Equal(["DailyRevenue", "ProgramEngagement", "StoreTotals"], reports); + Assert.DoesNotContain(Load("without").Objects(), o => o.IsReport()); + } + + [Theory] + [MemberData(nameof(GeneratorNames))] + public void Generator_emits_the_same_files_with_and_without_reporting_nodes(string name) + { + var entry = GeneratorRegistry.Entries[name]; + var expected = Emit(Load("without"), [Build(entry)]); + var actual = Emit(Load("with"), [Build(entry)]); + AssertSame(expected, actual); + } + + [Fact] + public void Exactly_these_generators_cannot_run_from_a_bare_model() + { + // Each is compared above on its error message alone, which proves nothing about its + // output. Pinned by name so a generator that starts throwing cannot drop out + // silently; the list may only shrink. + var threw = GeneratorRegistry.Entries + .Where(e => Emit(Load("without"), [Build(e.Value)]).ContainsKey("")) + .Select(e => e.Key) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + Assert.Equal(new List(), threw); + } + + [Fact] + public void Every_runnable_generator_in_one_run_emits_the_same_files() + { + var runnable = GeneratorRegistry.Entries.Values + .Where(e => !Emit(Load("without"), [Build(e)]).ContainsKey("")) + .ToList(); + var expected = Emit(Load("without"), runnable.Select(Build).ToList()); + var actual = Emit(Load("with"), runnable.Select(Build).ToList()); + Assert.False(expected.ContainsKey(""), expected.GetValueOrDefault("")); + Assert.True(expected.Count > 10, $"only {expected.Count} files — the suite barely ran"); + AssertSame(expected, actual); + } + + /// + /// The api docs surface (`dotnet meta docs`): every unit page, the index and the agent + /// page, rendered exactly as DocsCommand renders them. A report has no generated API to + /// document, and its derived fields do not exist until its lowering lands. + /// + private static SortedDictionary ApiDocs(MetaRoot root) + { + var config = new GenConfig { OutDir = "/unused", Namespace = "Shop" }; + var model = new CSharpApiModelBuilder(config).Build(root, "shop"); + var renderer = new CSharpApiDocsRenderer(); + var pages = new SortedDictionary(StringComparer.Ordinal); + foreach (var unit in model.Units) + pages[DocsPaths.DocPageOutputPath(DocsPaths.Layout.Package, unit.Package, unit.Node)] = + renderer.RenderUnitPage(unit, null); + pages["README.md"] = renderer.RenderIndex(model, DocsPaths.Layout.Package); + pages["AGENT-API.md"] = renderer.RenderAgentApi(model); + return pages; + } + + [Fact] + public void Api_docs_are_the_same_with_and_without_reporting_nodes() + { + var expected = ApiDocs(Load("without")); + Assert.True(expected.Count > 3, $"only {expected.Count} pages — the docs barely ran"); + AssertSame(expected, ApiDocs(Load("with"))); + } +} diff --git a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs index 7551911e7..a9c2208ac 100644 --- a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs +++ b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs @@ -47,6 +47,9 @@ public CSharpApiModel Build(MetaRoot root, string project) // Objects: one unit per concrete object.entity / object.value. foreach (var obj in root.Objects()) { + // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). + // It has no generated API to document, and its derived fields do not exist yet. + if (obj.IsReport()) continue; var unit = BuildObjectUnit(obj, root); if (unit is not null) units.Add(unit); } diff --git a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs index 41a616762..794a35b4d 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs @@ -29,7 +29,11 @@ public static RunResult Run(GenConfig config, MetaRoot root, IReadOnlyList(); var ctx = new GenContext { - Entities = root.Objects(), + // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). + // Dropped here, at the entity set every generator reads, and not per generator: + // a report may declare a read-only `source.rdb @kind: view` (R5), which would + // otherwise pass every source-keyed gate and emit an empty projection tier. + Entities = root.Objects().Where(o => !o.IsReport()).ToList(), Root = root, Config = config, Warn = warnings.Add, diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs new file mode 100644 index 000000000..c0d32100f --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs @@ -0,0 +1,131 @@ +// ReportingAccessorsTests — FR-044 report / measure / dimension accessor behaviour. +// +// C# parity port of the "report accessors" block of the TS reference suite +// server/typescript/packages/metadata/test/reporting-validation.test.ts +// plus the ISO-8601 duration pattern case. The model is the shared positive fixture +// fixtures/conformance/reporting-vocabulary (one source for the clean model). + +using System.IO; +using System.Linq; +using MetaObjects.Core.Reporting; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; +using static MetaObjects.Core.Reporting.ReportingConstants; + +namespace MetaObjects.Conformance.Tests; + +public class ReportingAccessorsTests +{ + private static LoadResult LoadPositiveFixture() + { + string path = Path.Combine(CorpusRoot.Path, "reporting-vocabulary", "input", "meta.shop.json"); + var result = new MetaDataLoader().Load([new InMemoryStringSource(File.ReadAllText(path), id: "meta.shop.json")]); + Assert.Empty(result.Errors); + return result; + } + + private static MetaData Root(LoadResult r, string name) => r.Root.Children().Single(c => c.Name == name); + + [Fact] + public void ReportDimensionItems_parses_name_and_name_colon_grain() + { + var r = LoadPositiveFixture(); + Assert.Equal( + [new ReportDimensionItem("purchasedAt", "day")], + ReportAccessors.ReportDimensionItems(Root(r, "DailyRevenue"))); + Assert.Equal( + [new ReportDimensionItem("program")], + ReportAccessors.ReportDimensionItems(Root(r, "ProgramEngagement"))); + // No @dimensions at all is one global row: an empty list, never null. + Assert.Empty(ReportAccessors.ReportDimensionItems(Root(r, "StoreTotals"))); + } + + [Fact] + public void ReportFrom_and_ReportMeasureNames_read_the_report_attrs() + { + var r = LoadPositiveFixture(); + var report = Root(r, "DailyRevenue"); + Assert.Equal("Purchase", ReportAccessors.ReportFrom(report)); + Assert.Equal(["purchases", "revenue"], ReportAccessors.ReportMeasureNames(report)); + } + + [Fact] + public void ReportDerivedFieldName_suffixes_the_capitalized_grain_and_a_bare_name_stays_bare() + { + Assert.Equal("purchasedAtDay", ReportAccessors.ReportDerivedFieldName(new ReportDimensionItem("purchasedAt", "day"))); + Assert.Equal("purchasedAtQuarter", ReportAccessors.ReportDerivedFieldName(new ReportDimensionItem("purchasedAt", "quarter"))); + Assert.Equal("program", ReportAccessors.ReportDerivedFieldName(new ReportDimensionItem("program"))); + // An empty grain (`name:`) derives the bare name, as in TS. + Assert.Equal("program", ReportAccessors.ReportDerivedFieldName(new ReportDimensionItem("program", ""))); + } + + [Fact] + public void MeasureOfColumns_bare_string_is_a_one_element_list_and_a_list_is_the_tuple() + { + var r = LoadPositiveFixture(); + var revenue = Assert.IsType(Root(r, "Purchase").Children().Single(c => c.Name == "revenue")); + Assert.Equal(["Purchase.amountCents"], revenue.OfColumns()); + Assert.Equal(AGG_SUM, revenue.Agg()); + Assert.Equal("active", revenue.SegmentName()); + + var days = Assert.IsType(Root(r, "WorkoutEvent").Children().Single(c => c.Name == "daysEngaged")); + Assert.Equal( + ["WorkoutEvent.programId", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber"], + days.OfColumns()); + Assert.True(days.Distinct()); + + var ratio = Assert.IsType(Root(r, "WorkoutEvent").Children().Single(c => c.Name == "avgDaysPerStarter")); + Assert.True(ratio.IsRatio()); + Assert.Equal("daysEngaged", ratio.Numerator()); + Assert.Equal("starters", ratio.Denominator()); + } + + [Fact] + public void Same_named_dimension_and_relationship_are_distinct_nodes() + { + // Purchase declares relationship.association 'program' AND dimension.attribute + // 'program': children are keyed by (type, name), never name alone. + var r = LoadPositiveFixture(); + var purchase = Root(r, "Purchase"); + var dim = Assert.IsType(purchase.ChildByTypeAndName(TYPE_DIMENSION, "program")); + Assert.False(dim.IsTime()); + Assert.Equal("Purchase.programId", dim.Of()); + Assert.Equal(TYPE_RELATIONSHIP, purchase.ChildByTypeAndName(TYPE_RELATIONSHIP, "program")!.Type); + } + + [Fact] + public void Time_dimension_grains_and_via_read_through() + { + var r = LoadPositiveFixture(); + var dim = Assert.IsType(Root(r, "Purchase").ChildByTypeAndName(TYPE_DIMENSION, "programCreatedAt")); + Assert.True(dim.IsTime()); + Assert.Equal("Purchase.program", dim.Via()); + Assert.Equal([GRAIN_MONTH, GRAIN_YEAR], dim.Grains()); + } + + [Fact] + public void Segment_filter_reads_the_desugared_row_scope() + { + var r = LoadPositiveFixture(); + var seg = Assert.IsType(Root(r, "Purchase").ChildByTypeAndName(TYPE_SEGMENT, "active")); + var filter = seg.Filter(); + Assert.NotNull(filter); + var clause = Assert.IsAssignableFrom>(filter!["status"]); + Assert.Equal("active", clause[FILTER_OP_EQ]); + } + + [Fact] + public void Iso_duration_pattern_accepts_durations_and_refuses_the_degenerate_forms() + { + foreach (var ok in new[] { "-P7D", "P1Y", "+P2W", "-PT12H", "P1Y2M3W4DT5H6M7S", "PT30M" }) + { + Assert.True(ISO_DURATION_RE.IsMatch(ok), ok); + } + // "P7D\n" guards the .NET `$`-before-newline trap; "P٧D" the Unicode-digit one. + foreach (var bad in new[] { "P", "-P", "PT", "P1DT", "7D", "P7", "-P7d", "P1.5D", "", "P7D\n", "P٧D" }) + { + Assert.False(ISO_DURATION_RE.IsMatch(bad), bad); + } + } +} diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs new file mode 100644 index 000000000..a052b8857 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs @@ -0,0 +1,283 @@ +// ReportingValidationTests — FR-044 ValidateReporting behaviour the conformance corpus +// does not pin: exact message text, the inherited-member dedupe (one broken base member = +// one error; distinct inheritors each reported), the relative-date desugar, and F1's +// "operand values only" walk. +// +// C# parity port of selected cases from the TS reference suite +// server/typescript/packages/metadata/test/reporting-validation.test.ts +// (the "relative dates" and "inherited members" blocks). Every expected message is the +// TS text verbatim — the ports share one message contract. + +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public class ReportingValidationTests +{ + private static LoadResult Load(JsonNode doc) => + new MetaDataLoader().Load([new InMemoryStringSource(doc.ToJsonString(), id: "meta.shop.json")]); + + private static JsonNode Fixture(string name) => + JsonNode.Parse(File.ReadAllText(Path.Combine(CorpusRoot.Path, name, "input", "meta.shop.json")))!; + + private static JsonArray RootChildren(JsonNode doc) => doc["metadata.root"]!["children"]!.AsArray(); + + /// The body of the root-level object named . + private static JsonObject ObjectBody(JsonNode doc, string name) => + RootChildren(doc) + .Select(w => w!.AsObject().First().Value!.AsObject()) + .Single(b => (string?)b["name"] == name); + + private static JsonArray ChildrenOf(JsonNode doc, string name) => ObjectBody(doc, name)["children"]!.AsArray(); + + private static JsonNode Wrap(string typeSubType, string json) => new JsonObject { [typeSubType] = JsonNode.Parse(json) }; + + /// Replace the child of object keyed + /// and named — selected by + /// (type, name), never by index, so a fixture reorder cannot retarget the edit. + private static void ReplaceChild(JsonNode doc, string objName, string typeSubType, string childName, string json) + { + var kids = ChildrenOf(doc, objName); + int i = kids.Select((w, idx) => (w, idx)) + .Single(t => t.w!.AsObject().First().Key == typeSubType + && (string?)t.w!.AsObject().First().Value!["name"] == childName).idx; + kids[i] = Wrap(typeSubType, json); + } + + private static string Single(LoadResult r, ErrorCode code) + { + Assert.Equal([code], r.Errors.Select(e => e.Code)); + return r.Errors[0].Message; + } + + private static JsonNode Positive() => Fixture("reporting-vocabulary"); + + private static JsonNode Inherited() => Fixture("reporting-inherited-members"); + + // ------------------------------------------------------------------------- + // Relative dates (F1 / F2) + // ------------------------------------------------------------------------- + + [Fact] + public void F2_a_now_key_plus_other_keys_is_refused_on_a_reporting_host() + { + var m = Positive(); + ObjectBody(m, "DailyRevenue")["@filter"] = + JsonNode.Parse("""{ "purchasedAt": { "gte": { "now": "-P7D", "x": 1 } } }"""); + string msg = Single(Load(m), ErrorCode.ERR_BAD_ATTR_FILTER); + Assert.Equal( + "report 'acme::shop::DailyRevenue': @filter on 'purchasedAt' has a malformed relative date " + + "{\"now\":\"-P7D\",\"x\":1}; a relative date is exactly { now: \"\" } with no other keys.", + msg); + } + + [Fact] + public void F1_a_now_key_plus_other_keys_is_refused_on_a_non_reporting_host() + { + var m = Positive(); + RootChildren(m).Add(Wrap("object.projection", """ + { "name": "RecentPurchase", + "@filter": { "purchasedAt": { "gte": { "now": "-P7D", "x": 1 } } }, + "children": [ + { "source.rdb": { "@kind": "view", "@view": "recent_purchases" } }, + { "field.long": { "name": "id", "extends": "Purchase.id" } }, + { "field.timestamp": { "name": "purchasedAt", "extends": "Purchase.purchasedAt" } }, + { "identity.primary": { "name": "id", "extends": "Purchase.id" } } ] } + """)); + string msg = Single(Load(m), ErrorCode.ERR_BAD_ATTR_FILTER); + Assert.Equal( + "object.projection 'acme::shop::RecentPurchase': @filter uses a relative date ({ now: ... }), which is " + + "legal only in the @filter of a segment, measure.aggregate or object.report.", + msg); + } + + [Fact] + public void F2_a_relative_value_survives_desugaring_unchanged() + { + var m = Positive(); + ChildrenOf(m, "WorkoutEvent").Add(Wrap("segment.filter", + """{ "name": "recent", "@filter": { "occurredAt": { "gte": { "now": "-P7D" } } } }""")); + var r = Load(m); + Assert.Empty(r.Errors); + var seg = Assert.IsType( + r.Root.Children().Single(c => c.Name == "WorkoutEvent").ChildByTypeAndName(TYPE_SEGMENT, "recent")); + var clause = Assert.IsAssignableFrom>(seg.Filter()!["occurredAt"]); + var relative = Assert.IsAssignableFrom>(clause[FILTER_OP_GTE]); + Assert.Equal("-P7D", Assert.Single(relative).Value); + } + + [Fact] + public void F2_the_operator_less_shorthand_is_an_implicit_eq_and_refused_as_such() + { + // `{ f: { now: ... } }` is a relative VALUE (like any other shorthand value it means + // `eq`), never the op `now`; F2 then refuses it because eq is not a range op. + var m = Positive(); + ChildrenOf(m, "WorkoutEvent").Add(Wrap("segment.filter", + """{ "name": "recent", "@filter": { "occurredAt": { "now": "-P7D" } } }""")); + string msg = Single(Load(m), ErrorCode.ERR_BAD_ATTR_FILTER); + Assert.Equal( + "segment 'recent' on entity 'acme::shop::WorkoutEvent': @filter on 'occurredAt' puts a relative date " + + "under op 'eq'; relative dates are legal only under gt, gte, lt and lte.", + msg); + } + + [Fact] + public void F1_a_field_literally_named_now_is_a_field_not_a_relative_date() + { + var m = Positive(); + RootChildren(m).Add(Wrap("object.projection", """ + { "name": "NowView", + "@filter": { "now": { "eq": 1 } }, + "children": [ + { "source.rdb": { "@kind": "view", "@view": "now_view" } }, + { "field.long": { "name": "id", "extends": "Purchase.id" } }, + { "field.int": { "name": "now" } }, + { "identity.primary": { "name": "id", "extends": "Purchase.id" } } ] } + """)); + // The projection is otherwise valid, so the model loads clean: nothing reads `now` as a relative date. + Assert.Empty(Load(m).Errors); + } + + [Fact] + public void F1_a_field_named_now_inside_or_does_not_hide_a_real_relative_value_beside_it() + { + var m = Positive(); + RootChildren(m).Add(Wrap("object.projection", """ + { "name": "NowView", + "@filter": { "or": [ { "now": { "eq": 1 } }, { "purchasedAt": { "gte": { "now": "-P7D" } } } ] }, + "children": [ + { "source.rdb": { "@kind": "view", "@view": "now_view" } }, + { "field.long": { "name": "id", "extends": "Purchase.id" } }, + { "field.int": { "name": "now" } }, + { "field.timestamp": { "name": "purchasedAt", "extends": "Purchase.purchasedAt" } }, + { "identity.primary": { "name": "id", "extends": "Purchase.id" } } ] } + """)); + Assert.Single(Load(m).Errors, e => e.Message.Contains("relative date", System.StringComparison.Ordinal)); + } + + // ------------------------------------------------------------------------- + // Inherited members (ADR-0039) — one broken rule = one error + // ------------------------------------------------------------------------- + + [Fact] + public void A_broken_member_on_an_abstract_base_is_reported_once_not_once_per_inheritor() + { + var m = Inherited(); + ReplaceChild(m, "BaseEvent", "measure.aggregate", "events", + """{ "name": "events", "@agg": "sum", "@of": "BaseEvent.nope" }"""); + string msg = Single(Load(m), ErrorCode.ERR_INVALID_MEASURE); + Assert.Equal( + "measure 'events' on entity 'acme::shop::BaseEvent': @of 'BaseEvent.nope' names no field 'nope' on " + + "'acme::shop::BaseEvent'.", + msg); + } + + [Fact] + public void A_broken_base_dimension_and_base_segment_filter_are_each_reported_once() + { + var m = Inherited(); + ReplaceChild(m, "BaseEvent", "dimension.time", "occurredAt", + """{ "name": "occurredAt", "@of": "BaseEvent.nope", "@grains": ["day", "week"] }"""); + ChildrenOf(m, "BaseEvent").Add(Wrap("segment.filter", """{ "name": "recent", "@filter": { "nope": 1 } }""")); + Assert.Equal( + [ErrorCode.ERR_INVALID_DIMENSION, ErrorCode.ERR_BAD_ATTR_FILTER], + Load(m).Errors.Select(e => e.Code)); + } + + [Fact] + public void A_broken_base_via_is_reported_once() + { + var m = Inherited(); + ChildrenOf(m, "BaseEvent").Add(Wrap("dimension.attribute", + """{ "name": "viaNothing", "@of": "BaseEvent.id", "@via": "BaseEvent.nope" }""")); + Single(Load(m), ErrorCode.ERR_INVALID_DIMENSION); + } + + [Fact] + public void Two_inheritors_that_break_the_same_inherited_member_the_same_way_are_each_reported() + { + var m = Inherited(); + ChildrenOf(m, "WorkoutEvent").Add(Wrap("field.string", """{ "name": "occurredAt" }""")); + RootChildren(m).Add(Wrap("object.entity", """ + { "name": "LoginEvent", "extends": "BaseEvent", "children": [ + { "source.rdb": { "@table": "login_events" } }, + { "field.string": { "name": "occurredAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } ] } + """)); + var errors = Load(m).Errors; + Assert.Equal([ErrorCode.ERR_INVALID_DIMENSION, ErrorCode.ERR_INVALID_DIMENSION], errors.Select(e => e.Code)); + Assert.Contains("(inherited by 'acme::shop::WorkoutEvent')", errors[0].Message); + Assert.Contains("(inherited by 'acme::shop::LoginEvent')", errors[1].Message); + } + + [Fact] + public void An_inheritor_whose_override_breaks_an_inherited_member_is_reported_naming_the_inheritor() + { + // WorkoutEvent overrides occurredAt as a string: the inherited time dimension is + // fine on BaseEvent and broken on WorkoutEvent (D3). + var m = Inherited(); + ChildrenOf(m, "WorkoutEvent").Add(Wrap("field.string", """{ "name": "occurredAt" }""")); + string msg = Single(Load(m), ErrorCode.ERR_INVALID_DIMENSION); + Assert.Equal( + "dimension 'occurredAt' on entity 'acme::shop::BaseEvent' (inherited by 'acme::shop::WorkoutEvent'): a time " + + "dimension's @of must be a field.date or field.timestamp, but 'BaseEvent.occurredAt' is field.string.", + msg); + } + + // ------------------------------------------------------------------------- + // Reports — message text for the cross-entity and repeat cases + // ------------------------------------------------------------------------- + + [Fact] + public void R3_a_bare_measure_of_another_entity_names_its_owner() + { + var m = Positive(); + ObjectBody(m, "StoreTotals")["@measures"] = JsonNode.Parse("""["purchases", "starters"]"""); + string msg = Single(Load(m), ErrorCode.ERR_REPORT_FOREIGN_MEASURE); + Assert.Equal( + "report 'acme::shop::StoreTotals' lists measure 'starters', which belongs to 'acme::shop::WorkoutEvent', " + + "not @from 'acme::shop::Purchase'. All measures of a report come from @from; make a second report over " + + "'acme::shop::WorkoutEvent'.", + msg); + } + + [Fact] + public void R6_a_measure_listed_twice_names_the_repeat() + { + var m = Positive(); + ObjectBody(m, "StoreTotals")["@measures"] = JsonNode.Parse("""["purchases", "purchases"]"""); + Assert.Equal( + "report 'acme::shop::StoreTotals': @measures lists 'purchases' more than once.", + Single(Load(m), ErrorCode.ERR_INVALID_REPORT)); + } + + [Fact] + public void R5_a_writable_source_names_every_read_only_kind() + { + var m = Positive(); + ObjectBody(m, "StoreTotals")["children"] = JsonNode.Parse("""[{ "source.rdb": { "@table": "store_totals" } }]"""); + Assert.Equal( + "report 'acme::shop::StoreTotals': source.rdb is writable; a report is read-only, so its source must " + + "declare a read-only @kind (view, materializedView, storedProc or tableFunction).", + Single(Load(m), ErrorCode.ERR_INVALID_REPORT)); + } + + [Fact] + public void R6_an_attribute_dimension_and_a_measure_with_the_same_name_collide() + { + // An attribute dimension derives its bare name, so dimension `revenue` and measure + // `revenue` would both become report field `revenue`. + var m = Positive(); + ChildrenOf(m, "Purchase").Add(Wrap("dimension.attribute", """{ "name": "revenue", "@of": "Purchase.status" }""")); + ObjectBody(m, "StoreTotals")["@dimensions"] = JsonNode.Parse("""["revenue"]"""); + Assert.Equal( + "report 'acme::shop::StoreTotals': dimension item 'revenue' and measure 'revenue' both derive report " + + "field 'revenue'. Report field names must be unique; rename the measure or drop one item.", + Single(Load(m), ErrorCode.ERR_INVALID_REPORT)); + } +} diff --git a/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs b/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs index a957e51aa..d32203d23 100644 --- a/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs +++ b/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs @@ -7,7 +7,7 @@ namespace MetaObjects.Core.Object; /// -/// Object concern constants — the object subtypes (base, entity, value, projection). +/// Object concern constants — the object subtypes (base, entity, value, projection, report). /// public static class ObjectConstants { @@ -17,6 +17,7 @@ public static class ObjectConstants // - entity : persistent record (typically has @primary identity) // - value : value-object (no identity; equality by content) // - projection : derived read-only representation of entities (FR-024, ADR-0028) + // - report : declared dimensions x measures of one entity, compiled to a read-only view (FR-044) // // No object-level attrs: a Java-runtime materialization strategy enum has no // place in a cross-language metamodel core (ADR-0003 §4) and no conformance @@ -26,6 +27,7 @@ public static class ObjectConstants public const string OBJECT_SUBTYPE_ENTITY = "entity"; public const string OBJECT_SUBTYPE_VALUE = "value"; public const string OBJECT_SUBTYPE_PROJECTION = "projection"; + public const string OBJECT_SUBTYPE_REPORT = "report"; // ----------------------------------------------------------------------- // FR-014 — TPH discriminator attrs (registered on every object subtype). @@ -58,11 +60,31 @@ public static class ObjectConstants /// public const string OBJECT_PROJECTION_ATTR_FILTER = "filter"; + // ----------------------------------------------------------------------- + // FR-044 — object.report attrs. Mirrors TS OBJECT_REPORT_ATTR_*. + // ----------------------------------------------------------------------- + + /// The object.entity whose rows the report aggregates. + public const string OBJECT_REPORT_ATTR_FROM = "from"; + + /// Dimension names of @from; a time dimension is written name:grain. + public const string OBJECT_REPORT_ATTR_DIMENSIONS = "dimensions"; + + /// Measure names of @from. + public const string OBJECT_REPORT_ATTR_MEASURES = "measures"; + + /// Optional segment of @from scoping the rows. + public const string OBJECT_REPORT_ATTR_SEGMENT = "segment"; + + /// Optional row scope over @from's fields (an attr.filter). + public const string OBJECT_REPORT_ATTR_FILTER = "filter"; + public static readonly string[] OBJECT_SUBTYPES = [ BaseTypes.SUBTYPE_BASE, OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, OBJECT_SUBTYPE_PROJECTION, + OBJECT_SUBTYPE_REPORT, ]; } diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs b/server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs new file mode 100644 index 000000000..51ea8e1ec --- /dev/null +++ b/server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs @@ -0,0 +1,64 @@ +// Free accessors over an `object.report` node (FR-044). Used by the Plan 2 lowering and +// by the loader's report validation, so the `name:grain` parse and the derived-field-name +// rule have exactly one definition. +// +// Ported 1:1 from +// server/typescript/packages/metadata/src/core/reporting/report-accessors.ts. + +using MetaObjects.Meta; + +namespace MetaObjects.Core.Reporting; + +/// One @dimensions item of a report: name or name:grain. +public sealed record ReportDimensionItem(string Name, string? Grain = null); + +/// Value helpers shared by the reporting node classes and accessors. +internal static class ReportingValues +{ + /// + /// A string-list attr value: a list keeps its string elements, a bare string is a + /// one-element list, anything else is empty. Mirrors the TS stringList helper. + /// + public static IReadOnlyList StringList(object? v) => v switch + { + string s => new[] { s }, + IEnumerable items => items.OfType().ToList().AsReadOnly(), + _ => Array.Empty(), + }; +} + +/// Accessors over an object.report node. +// ADR-0039: every read below uses the RESOLVING Attr() accessor — a report that `extends` +// an abstract report inherits its @from / @dimensions / @measures. +public static class ReportAccessors +{ + /// The @from entity name of a report. + public static string? ReportFrom(MetaData obj) => obj.Attr(OBJECT_REPORT_ATTR_FROM) as string; + + /// The @dimensions items, each name or name:grain (split at the first :). + public static IReadOnlyList ReportDimensionItems(MetaData obj) => + ReportingValues.StringList(obj.Attr(OBJECT_REPORT_ATTR_DIMENSIONS)) + .Select(raw => + { + int i = raw.IndexOf(REPORT_DIMENSION_GRAIN_SEPARATOR, StringComparison.Ordinal); + return i == -1 + ? new ReportDimensionItem(raw) + : new ReportDimensionItem(raw[..i], raw[(i + REPORT_DIMENSION_GRAIN_SEPARATOR.Length)..]); + }) + .ToList() + .AsReadOnly(); + + /// The @measures names. + public static IReadOnlyList ReportMeasureNames(MetaData obj) => + ReportingValues.StringList(obj.Attr(OBJECT_REPORT_ATTR_MEASURES)); + + /// + /// The derived report field for a dimension item: name (attribute) or + /// name + Capitalized(grain) (time), e.g. purchasedAt:day → purchasedAtDay. + /// + public static string ReportDerivedFieldName(ReportDimensionItem item) + { + if (string.IsNullOrEmpty(item.Grain)) return item.Name; + return item.Name + char.ToUpperInvariant(item.Grain[0]) + item.Grain[1..]; + } +} diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs b/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs new file mode 100644 index 000000000..ce1671358 --- /dev/null +++ b/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs @@ -0,0 +1,131 @@ +// Reporting concern constants (FR-044) — subtypes, attr keys and the closed sets the +// registry enforces through `allowedValues`. +// +// Colocated per ADR-0003. Mirrors +// server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts. +// +// `dimension`, `measure` and `segment` are children of `object.entity` only (never +// root-level), and `object.report` (see ObjectConstants) references them by name. The +// type-name constants live in Shared/BaseTypes.cs beside every other base type. + +using System.Text.RegularExpressions; + +namespace MetaObjects.Core.Reporting; + +/// +/// Reporting concern constants — the dimension / measure / segment subtypes, their attr +/// keys, the closed grain and aggregate sets, and the relative-date filter value. +/// +public static class ReportingConstants +{ + // ----------------------------------------------------------------------- + // Subtypes + // ----------------------------------------------------------------------- + + /// Groups by a column value as-is (no grain, no truncation). + public const string DIMENSION_SUBTYPE_ATTRIBUTE = "attribute"; + + /// Groups by a date/timestamp column truncated to a grain. + public const string DIMENSION_SUBTYPE_TIME = "time"; + + public static readonly string[] DIMENSION_SUBTYPES = [DIMENSION_SUBTYPE_ATTRIBUTE, DIMENSION_SUBTYPE_TIME]; + + /// One aggregate over the declaring entity's own rows. + public const string MEASURE_SUBTYPE_AGGREGATE = "aggregate"; + + /// + /// A quotient of two measure.aggregate siblings, numerator / NULLIF(denominator, 0). + /// measure.derived is deliberately NOT registered: it waits for FR-037 R5. + /// + public const string MEASURE_SUBTYPE_RATIO = "ratio"; + + public static readonly string[] MEASURE_SUBTYPES = [MEASURE_SUBTYPE_AGGREGATE, MEASURE_SUBTYPE_RATIO]; + + /// + /// A named, reusable row filter. The only concrete segment subtype: every *.base + /// in the registry is an abstract anchor, so authors write segment.filter. + /// + public const string SEGMENT_SUBTYPE_FILTER = "filter"; + + public static readonly string[] SEGMENT_SUBTYPES = [SEGMENT_SUBTYPE_FILTER]; + + // ----------------------------------------------------------------------- + // Attrs (on dimension / measure / segment nodes) + // ----------------------------------------------------------------------- + + /// Dotted Entity.field reference(s) naming the grouped / aggregated column(s). + public const string REPORTING_ATTR_OF = "of"; + + /// Optional dotted to-one relationship path from the owning entity to the @of entity. + public const string REPORTING_ATTR_VIA = "via"; + + /// The grains a dimension.time supports. + public const string REPORTING_ATTR_GRAINS = "grains"; + + /// The aggregate function of a measure.aggregate. + public const string REPORTING_ATTR_AGG = "agg"; + + /// Count distinct values (legal only with @agg: count). + public const string REPORTING_ATTR_DISTINCT = "distinct"; + + /// Row scope (an attr.filter) on a measure.aggregate or segment.filter. + public const string REPORTING_ATTR_FILTER = "filter"; + + /// Name of a segment declared on the same entity. + public const string REPORTING_ATTR_SEGMENT = "segment"; + + /// measure.ratio operand names. + public const string REPORTING_ATTR_NUMERATOR = "numerator"; + + public const string REPORTING_ATTR_DENOMINATOR = "denominator"; + + // ----------------------------------------------------------------------- + // Closed sets — mirrored by `allowedValues` in spec/metamodel/reporting.json. + // Order is part of the contract (it is the order the registry manifest records). + // ----------------------------------------------------------------------- + + /// Weeks start on Monday (ISO-8601) in every lowering. + public const string GRAIN_HOUR = "hour"; + public const string GRAIN_DAY = "day"; + public const string GRAIN_WEEK = "week"; + public const string GRAIN_MONTH = "month"; + public const string GRAIN_QUARTER = "quarter"; + public const string GRAIN_YEAR = "year"; + + public static readonly string[] TIME_GRAINS = + [GRAIN_HOUR, GRAIN_DAY, GRAIN_WEEK, GRAIN_MONTH, GRAIN_QUARTER, GRAIN_YEAR]; + + public const string AGG_COUNT = "count"; + public const string AGG_SUM = "sum"; + public const string AGG_AVG = "avg"; + public const string AGG_MIN = "min"; + public const string AGG_MAX = "max"; + + public static readonly string[] MEASURE_AGGS = [AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX]; + + /// Separator in an object.report @dimensions item: name or name:grain. + public const string REPORT_DIMENSION_GRAIN_SEPARATOR = ":"; + + // ----------------------------------------------------------------------- + // Relative-date filter values (spec §4 R4; rules F1/F2) + // ----------------------------------------------------------------------- + + /// + /// The single key of a relative-date filter value: { now: "<ISO-8601 duration>" } + /// means "the current time plus that duration", evaluated when the view is queried. + /// Legal only in the @filter of a segment, measure.aggregate or + /// object.report. + /// + public const string FILTER_RELATIVE_NOW = "now"; + + /// + /// A signed ISO-8601 duration (-P7D, P1Y2M, -PT12H). The lookaheads + /// refuse the degenerate P and PT forms (a designator with no component). + /// The TS reference pattern, translated for .NET semantics: [0-9] rather than + /// \d (which matches every Unicode digit in .NET) and \z rather than + /// $ (which also matches before a trailing newline in .NET). + /// + public static readonly Regex ISO_DURATION_RE = new( + @"^[+-]?P(?!\z)([0-9]+Y)?([0-9]+M)?([0-9]+W)?([0-9]+D)?(T(?=[0-9])([0-9]+H)?([0-9]+M)?([0-9]+S)?)?\z", + RegexOptions.CultureInvariant); +} diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportingSchema.cs b/server/csharp/MetaObjects/Core/Reporting/ReportingSchema.cs new file mode 100644 index 000000000..e7702276b --- /dev/null +++ b/server/csharp/MetaObjects/Core/Reporting/ReportingSchema.cs @@ -0,0 +1,101 @@ +// Reporting attribute schemas — the per-subtype attr sets for dimension.*, measure.* and +// segment.*, plus the object.report attrs (FR-044). +// +// Colocated per ADR-0003. Mirrors the canonical spec/metamodel/reporting.json and the +// object.report block of spec/metamodel/object.json (which this port embeds + reads): +// DESCRIPTIONS are deliberately NOT hand-copied here — FR-033 sources every description +// from the shared JSON via Registry.ApplySpecDescriptions. Only the facets the manifest +// needs but the description pass does not carry (value type / array-ness / requiredness / +// allowedValues) are declared here. + +using MetaObjects.Core.Attr; +using MetaObjects.Core.Object; + +namespace MetaObjects.Core.Reporting; + +/// Attribute schemas for the reporting concern. +public static class ReportingSchema +{ + private static AttrSchema Str(string name, bool required, bool isArray = false, + IReadOnlyList? allowedValues = null) => + new(Name: name, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, Required: required, + AllowedValues: allowedValues, IsArray: isArray); + + private static AttrSchema Filter(string name, bool required) => + new(Name: name, ValueType: AttrConstants.ATTR_SUBTYPE_FILTER, Required: required); + + /// dimension.attribute — @of (required), @via (optional). + private static readonly IReadOnlyList DimensionAttributeAttrs = + [ + Str(ReportingConstants.REPORTING_ATTR_OF, required: true), + Str(ReportingConstants.REPORTING_ATTR_VIA, required: false), + ]; + + /// dimension.time — @of, @via, and the closed @grains set (declaration order is the contract). + private static readonly IReadOnlyList DimensionTimeAttrs = + [ + Str(ReportingConstants.REPORTING_ATTR_OF, required: true), + Str(ReportingConstants.REPORTING_ATTR_VIA, required: false), + Str(ReportingConstants.REPORTING_ATTR_GRAINS, required: true, isArray: true, + allowedValues: [.. ReportingConstants.TIME_GRAINS]), + ]; + + /// measure.aggregate — @agg (closed set), @of (isArray; a bare string is one column), + /// @distinct, @filter, @segment. + private static readonly IReadOnlyList MeasureAggregateAttrs = + [ + Str(ReportingConstants.REPORTING_ATTR_AGG, required: true, + allowedValues: [.. ReportingConstants.MEASURE_AGGS]), + Str(ReportingConstants.REPORTING_ATTR_OF, required: true, isArray: true), + new(Name: ReportingConstants.REPORTING_ATTR_DISTINCT, ValueType: AttrConstants.ATTR_SUBTYPE_BOOLEAN, + Required: false), + Filter(ReportingConstants.REPORTING_ATTR_FILTER, required: false), + Str(ReportingConstants.REPORTING_ATTR_SEGMENT, required: false), + ]; + + /// measure.ratio — @numerator, @denominator (both required). + private static readonly IReadOnlyList MeasureRatioAttrs = + [ + Str(ReportingConstants.REPORTING_ATTR_NUMERATOR, required: true), + Str(ReportingConstants.REPORTING_ATTR_DENOMINATOR, required: true), + ]; + + /// segment.filter — the required @filter. + private static readonly IReadOnlyList SegmentFilterAttrs = + [ + Filter(ReportingConstants.REPORTING_ATTR_FILTER, required: true), + ]; + + /// Attrs per dimension subtype. + public static readonly IReadOnlyDictionary> DimensionAttrsMap = + new Dictionary> + { + [ReportingConstants.DIMENSION_SUBTYPE_ATTRIBUTE] = DimensionAttributeAttrs, + [ReportingConstants.DIMENSION_SUBTYPE_TIME] = DimensionTimeAttrs, + }; + + /// Attrs per measure subtype. + public static readonly IReadOnlyDictionary> MeasureAttrsMap = + new Dictionary> + { + [ReportingConstants.MEASURE_SUBTYPE_AGGREGATE] = MeasureAggregateAttrs, + [ReportingConstants.MEASURE_SUBTYPE_RATIO] = MeasureRatioAttrs, + }; + + /// Attrs per segment subtype. + public static readonly IReadOnlyDictionary> SegmentAttrsMap = + new Dictionary> + { + [ReportingConstants.SEGMENT_SUBTYPE_FILTER] = SegmentFilterAttrs, + }; + + /// object.report — @from, @dimensions, @measures, @segment, @filter. + public static readonly IReadOnlyList ReportAttrs = + [ + Str(ObjectConstants.OBJECT_REPORT_ATTR_FROM, required: true), + Str(ObjectConstants.OBJECT_REPORT_ATTR_DIMENSIONS, required: false, isArray: true), + Str(ObjectConstants.OBJECT_REPORT_ATTR_MEASURES, required: true, isArray: true), + Str(ObjectConstants.OBJECT_REPORT_ATTR_SEGMENT, required: false), + Filter(ObjectConstants.OBJECT_REPORT_ATTR_FILTER, required: false), + ]; +} diff --git a/server/csharp/MetaObjects/CoreTypes.cs b/server/csharp/MetaObjects/CoreTypes.cs index ac60bec0e..328b4c063 100644 --- a/server/csharp/MetaObjects/CoreTypes.cs +++ b/server/csharp/MetaObjects/CoreTypes.cs @@ -15,6 +15,7 @@ using MetaObjects.Core.Identity; using MetaObjects.Core.Index; using MetaObjects.Core.Relationship; +using MetaObjects.Core.Reporting; using MetaObjects.Core.Requirement; using MetaObjects.Persistence.Origin; using MetaObjects.Persistence.Source; @@ -267,7 +268,7 @@ private static void RegisterCoreTypeDefs(TypeRegistry registry) (tid, n) => new MetaRoot(tid, n), [])); - // object — 4 subtypes (base, entity, value, projection) + // object — 5 subtypes (base, entity, value, projection, report) List objectRules = [ Wildcard(TYPE_FIELD), @@ -306,9 +307,16 @@ private static void RegisterCoreTypeDefs(TypeRegistry registry) // attr.filter object lowered to a view-level WHERE). Strict attr scoping // (from spec object.json's projection allow-list) keeps it here and prunes // the discriminator attrs, which projection does not declare. - List objectAttrs = subType == OBJECT_SUBTYPE_PROJECTION - ? [.. ObjectSchema.ObjectAttrs, ObjectSchema.ProjectionFilterAttr] - : ObjectSchema.ObjectAttrs.ToList(); + // FR-044: object.report carries @from / @dimensions / @measures / @segment / + // @filter. Strict attr scoping (from spec object.json's report allow-list) + // keeps those and prunes the discriminator attrs, which a report does not + // declare; its structural child graph also comes from the spec. + List objectAttrs = subType switch + { + OBJECT_SUBTYPE_PROJECTION => [.. ObjectSchema.ObjectAttrs, ObjectSchema.ProjectionFilterAttr], + OBJECT_SUBTYPE_REPORT => [.. ObjectSchema.ObjectAttrs, .. ReportingSchema.ReportAttrs], + _ => ObjectSchema.ObjectAttrs.ToList(), + }; List rules = subType == OBJECT_SUBTYPE_PROJECTION ? new List(projectionRules) @@ -628,6 +636,47 @@ private static void RegisterCoreTypeDefs(TypeRegistry registry) // is unconditional (the status enum, shape, levels); `meta verify` owns the // status-conditional resolution, where the severity actually depends on data. + // FR-044 reporting vocabulary — dimension / measure / segment. Declared as children + // of `object.entity` (spec/metamodel/object.json), never root-level, so no root + // wildcard is added above. Three types in one provider file (reporting.json) + // because they are one vocabulary: a report names dimensions and measures, and + // both may reference a segment. `measure.derived` is NOT registered (waits for + // FR-037 R5). Descriptions + whenToUse come from the embedded reporting.json via + // ApplySpecDescriptions; the placeholder descriptions below are overwritten there. + foreach (string subType in DIMENSION_SUBTYPES) + { + registry.Register( + Def( + TYPE_DIMENSION, + subType, + $"Dimension ({subType})", + [Wildcard(TYPE_ATTR)], + (tid, n) => new MetaDimension(tid, n), + ReportingSchema.DimensionAttrsMap[subType].ToList())); + } + foreach (string subType in MEASURE_SUBTYPES) + { + registry.Register( + Def( + TYPE_MEASURE, + subType, + $"Measure ({subType})", + [Wildcard(TYPE_ATTR)], + (tid, n) => new MetaMeasure(tid, n), + ReportingSchema.MeasureAttrsMap[subType].ToList())); + } + foreach (string subType in SEGMENT_SUBTYPES) + { + registry.Register( + Def( + TYPE_SEGMENT, + subType, + $"Segment ({subType})", + [Wildcard(TYPE_ATTR)], + (tid, n) => new MetaSegment(tid, n), + ReportingSchema.SegmentAttrsMap[subType].ToList())); + } + // template — fourth-pillar metatype (FR-004). prompt + output + toolcall; // attr-only children. A single MetaTemplate class backs every subtype (mirrors // source/requirement); per-subtype attr schemas drive validation (prompt + diff --git a/server/csharp/MetaObjects/Errors.cs b/server/csharp/MetaObjects/Errors.cs index 4a42bcb22..df640c696 100644 --- a/server/csharp/MetaObjects/Errors.cs +++ b/server/csharp/MetaObjects/Errors.cs @@ -231,6 +231,23 @@ public enum ErrorCode // @fields), whichever is declared supplies no key, or a named field does not exist // in the entity's effective field set. ERR_INVALID_INDEX, + // FR-044 — reporting vocabulary (ValidationPasses.ValidateReporting). Rule ids + // D1-D4 / M1-M6 / R1-R7 are the cross-port rule table; S1/F1/F2 reuse + // ERR_BAD_ATTR_FILTER. + // A dimension's @of/@via does not resolve, @via crosses a to-many hop, or a + // time dimension's column or grains do not fit its type (D1-D4). + ERR_INVALID_DIMENSION, + // A measure's @of is not a field of its own entity, a tuple/@distinct/@agg + // combination is illegal, @segment does not resolve, or a ratio operand is not a + // measure.aggregate of the same entity (M1-M6). + ERR_INVALID_MEASURE, + // An object.report's @from is not an entity, a @dimensions/@measures/@segment + // item does not resolve, it declares a field/identity or a writable source, or + // two items derive the same report field name (R1-R7). + ERR_INVALID_REPORT, + // An object.report lists a measure that belongs to an entity other than @from; + // v1 refuses multi-fact reports (R3). + ERR_REPORT_FOREIGN_MEASURE, // FR-039 — @status: retired declares @implementedBy. Refused rather than exempted: // a retired capability has no implementation BY DEFINITION, so forbidding the // attribute makes the dangling-reference class unreachable instead of tolerated. diff --git a/server/csharp/MetaObjects/GlobalUsings.cs b/server/csharp/MetaObjects/GlobalUsings.cs index 2845e58a9..fc271fe67 100644 --- a/server/csharp/MetaObjects/GlobalUsings.cs +++ b/server/csharp/MetaObjects/GlobalUsings.cs @@ -21,6 +21,7 @@ global using static MetaObjects.Core.Index.IndexConstants; global using static MetaObjects.Core.Relationship.RelationshipConstants; global using static MetaObjects.Core.Requirement.RequirementConstants; +global using static MetaObjects.Core.Reporting.ReportingConstants; global using static MetaObjects.Persistence.Source.SourceConstants; global using static MetaObjects.Persistence.Origin.OriginConstants; global using static MetaObjects.Persistence.Db.DbConstants; diff --git a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs index aceccdbe8..150a7ef74 100644 --- a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs +++ b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs @@ -485,6 +485,10 @@ public LoadResult Load(IReadOnlyList sources) // aggregate-derived field-ref fails closed with ERR_BAD_ATTR_FILTER. errors.AddRange(ValidationPasses.ValidateProjectionFilter(root)); + // Pass 7c (FR-044): reporting vocabulary cross-node rules (dimensions, measures, + // segments, reports, relative-date filter values). + errors.AddRange(ValidationPasses.ValidateReporting(root, _registry)); + // Pass 8: @storage cross-attribute validation on field.object errors.AddRange(ValidationPasses.ValidateFieldObjectStorage(root)); diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs new file mode 100644 index 000000000..f1670a03d --- /dev/null +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -0,0 +1,861 @@ +// FR-044 — cross-node rules for the reporting vocabulary (ValidationPasses.ValidateReporting). +// +// Ported rule-for-rule, WITH THE SAME MESSAGE TEXT, from the TS reference +// server/typescript/packages/metadata/src/loader/reporting-validation.ts. The rule ids +// (D1…F2) match the rule table in the FR-044 plan and the error fixtures in +// fixtures/conformance/error-*; the fixtures are the contract. +// +// Two design rules hold throughout, so one broken rule yields exactly one error: +// - No cascades. A member that fails a structural rule is not checked further +// (a dimension whose @via fails D2 skips D1/D3/D4; a report whose @from fails +// R1 skips R2/R3/R6/R7 and its @filter; an invalid @dimensions/@measures item +// derives no report field for R6). +// - Each error's source is the offending node (the dimension / measure / segment / +// report, or for R4/R5 the declared child), so a conformance fixture's jsonPath +// points at it. +// +// Inheritance (ADR-0039): an entity's members are read through Children(), so a member +// declared on an abstract base is validated against every entity that inherits it. +// Members declared on an entity are validated first (pass 1), then inherited ones +// (pass 2); an error already reported for the same node with the same message is not +// repeated, so a broken base member is reported ONCE, and a failure that only an +// inheritor exposes carries " (inherited by '')". + +using System.Text.Encodings.Web; +using System.Text.Json; +using MetaObjects.Core.Reporting; +using MetaObjects.Meta; + +namespace MetaObjects.Loader; + +public static partial class ValidationPasses +{ + // ------------------------------------------------------------------------- + // Closed sets the rules consult + // ------------------------------------------------------------------------- + + /// M4 — the field subtypes sum/avg accept. + private static readonly string[] ReportingNumericFieldSubtypes = + [ + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, + ]; + + /// M4 — the field subtypes min/max refuse (no total order). + private static readonly string[] ReportingUnorderedFieldSubtypes = + [FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP]; + + /// D3 / F2 — the temporal field subtypes. + private static readonly string[] ReportingTemporalFieldSubtypes = [FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIMESTAMP]; + + /// F2 — the only ops a relative-date value may sit under. + private static readonly string[] ReportingRelativeDateOps = [FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE]; + + /// JSON.stringify-equivalent options for values quoted in a message. + private static readonly JsonSerializerOptions ReportingMessageJson = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + // ========================================================================= + // Entry point + // ========================================================================= + + /// + /// FR-044 — validate the reporting vocabulary: dimensions (D1–D4), measures (M1–M6), + /// segment / measure / report row scopes (S1, F2), reports (R1–R7), and relative-date + /// filter values outside a reporting host (F1). classifies + /// which own attrs are attr.filter-typed for the F1 walk (the TS reference reads + /// the attr node's subType, which C# does not materialise for inline attrs). + /// + public static IReadOnlyList ValidateReporting(MetaData root, TypeRegistry registry) + { + var sink = new ReportingErrorSink(); + // ADR-0039: root has no super; Children()==OwnChildren() but resolving is the default. + var objects = root.Children().Where(c => c.Type == TYPE_OBJECT).ToList(); + var entities = objects.Where(o => o.SubType == OBJECT_SUBTYPE_ENTITY).ToList(); + + // Pass 1: every member against the entity that declares it (an abstract base + // included — its members must be self-consistent). Pass 2: inherited members + // against each inheriting entity, so an override that breaks one is caught. + foreach (var entity in entities) CheckEntityMembers(root, entity, true, sink); + foreach (var entity in entities) CheckEntityMembers(root, entity, false, sink); + + foreach (var report in objects.Where(o => o.SubType == OBJECT_SUBTYPE_REPORT)) + { + CheckReport(root, report, sink); + } + + // F1 on every host that is not a reporting host. + CheckNoRelativeDates(root, registry, sink); + return sink.Errors.AsReadOnly(); + } + + /// + /// Collects errors, dropping a repeat — the shape an unmodified inherited member's + /// failure takes when it is re-validated under an inheriting entity. A message is + /// head + suffix + body. A pass-2 error (suffix " (inherited by '<entity>')") + /// is dropped when pass 1 already reported the same failure without a suffix, or the + /// same inheritor already reported it; a second inheritor's identical failure is still + /// reported, under its own name. + /// + private sealed class ReportingErrorSink + { + public readonly List Errors = []; + private readonly Dictionary> _seen = new(ReferenceEqualityComparer.Instance); + + public void Push(MetaData node, ErrorCode code, string head, string body, string suffix = "") + { + // `baseKey` drops a pass-2 copy of a failure pass 1 already reported; a pass-2 + // entry is keyed WITH its suffix, so two inheritors that break the same + // inherited member the same way are each reported. + string baseKey = $"{code}\u0000{head}{body}"; + string key = $"{baseKey}\u0000{suffix}"; + if (!_seen.TryGetValue(node, out var keys)) + { + keys = new HashSet(StringComparer.Ordinal); + _seen[node] = keys; + } + if (keys.Contains(baseKey) || keys.Contains(key)) return; + keys.Add(suffix == "" ? baseKey : key); + Errors.Add(new MetaError($"{head}{suffix}{body}", code, Envelope: node.Source)); + } + } + + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- + + /// + /// Split a dotted Owner.child[.child…] reference at the first . after the + /// last ::, so an FQN owner (acme::shop::Purchase.program) keeps its + /// package. Null when there is no owner, no child, or an empty child segment. + /// + private static (string Owner, string[] Path)? ReportingSplitDotted(string reference) + { + int lastSep = reference.LastIndexOf(PACKAGE_SEPARATOR, StringComparison.Ordinal); + int segStart = lastSep == -1 ? 0 : lastSep + PACKAGE_SEPARATOR.Length; + int dot = reference.IndexOf(CHILD_REF_SEPARATOR, segStart, StringComparison.Ordinal); + if (dot <= segStart) return null; + string[] path = reference[(dot + CHILD_REF_SEPARATOR.Length)..].Split(CHILD_REF_SEPARATOR); + if (path.Any(s => s == "")) return null; + return (reference[..dot], path); + } + + /// True when is or an entity it extends. + private static bool IsSelfOrAncestor(MetaData? candidate, MetaData entity) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + for (MetaData? n = entity; n is not null && !visited.Contains(n); n = n.SuperData) + { + if (ReferenceEquals(n, candidate)) return true; + visited.Add(n); + } + return false; + } + + private static MetaData? ReportingChildOfType(MetaData obj, string type, string name) => + // ADR-0039: resolving — inherited members (via extends) are visible. Keyed by + // (type, name): an entity may carry a same-named dimension and relationship. + obj.Children().FirstOrDefault(c => c.Type == type && c.Name == name); + + private static MetaData? ReportingFieldOf(MetaData obj, string name) => + ReportingChildOfType(obj, TYPE_FIELD, name); + + private static IReadOnlyDictionary? AsPlainObject(object? v) => + v as IReadOnlyDictionary; + + /// An object operand carrying a now key — a relative-date value, + /// well-formed (exactly { now }) or not. A malformed one is refused, never read as data. + private static bool IsRelativeValue(object? v) => + v is IReadOnlyDictionary o && o.ContainsKey(FILTER_RELATIVE_NOW); + + /// True for a well-formed relative value: exactly the one key now. + private static bool IsExactRelativeValue(IReadOnlyDictionary v) => v.Count == 1; + + /// The relative value an op's operand carries: the operand itself, or one inside an array operand. + private static IReadOnlyDictionary? RelativeOperand(object? v) + { + if (IsRelativeValue(v)) return (IReadOnlyDictionary)v!; + if (v is IReadOnlyList list) + { + return list.FirstOrDefault(IsRelativeValue) as IReadOnlyDictionary; + } + return null; + } + + /// Deep search of an operand VALUE: is a relative value (well-formed or not) anywhere inside it? + private static bool OperandContainsRelativeValue(object? v) + { + if (IsRelativeValue(v)) return true; + if (v is IReadOnlyList list) return list.Any(OperandContainsRelativeValue); + if (v is IReadOnlyDictionary obj) return obj.Values.Any(OperandContainsRelativeValue); + return false; + } + + /// + /// Does a filter contain a relative value in any operand? Walks the filter grammar + /// (and/or arrays, { field: { op: operand } }) so only operand VALUES + /// are searched: a field key that happens to be named now is a field, not a relative date. + /// + private static bool FilterContainsRelativeValue(object? filter) + { + if (filter is not IReadOnlyDictionary f) return false; + foreach (var (key, clause) in f) + { + if (key == FILTER_COMPOSE_OR || key == FILTER_COMPOSE_AND) + { + if (clause is IReadOnlyList subs && subs.Any(FilterContainsRelativeValue)) return true; + continue; + } + if (IsRelativeValue(clause)) return true; // un-desugared shorthand + if (clause is IReadOnlyDictionary ops && ops.Values.Any(OperandContainsRelativeValue)) + { + return true; + } + } + return false; + } + + /// The JS JSON.stringify equivalent used in messages. + private static string JsonStringify(object? v) => JsonSerializer.Serialize(v, ReportingMessageJson); + + private static string QuoteValue(object? v) => v is string s ? s : JsonStringify(v); + + /// <type>.<subType> '<name>', or just <type>.<subType> for an unnamed node. + private static string ReportingChildLabel(MetaData node) + { + string head = $"{node.Type}.{node.SubType}"; + return node.Name != "" ? $"{head} '{node.Name}'" : head; + } + + /// <type>.<subType> '<FQN>' for a root-level object, else its + /// child label plus in <parent label>. + private static string ReportingNodeLabel(MetaData node) + { + var parent = node.Parent; + if (parent is null || parent.Parent is null) + { + return $"{node.Type}.{node.SubType} '{node.ResolutionKey()}'"; + } + return $"{ReportingChildLabel(node)} in {ReportingNodeLabel(parent)}"; + } + + // ------------------------------------------------------------------------- + // D1–D4, M1–M6, S1/F2 — members of an object.entity + // ------------------------------------------------------------------------- + + /// Validation context for one member of one entity. + /// The metadata root. + /// The entity whose Children() the member was reached through. + /// The entity that declares the member (the host, or an ancestor of it). + /// <kind> '<name>' on entity '<declaring FQN>' — every member message starts with it. + /// "" in pass 1; (inherited by '<host FQN>') in pass 2. + /// The error sink. + private sealed record MemberCtx( + MetaData Root, MetaData Host, MetaData Declaring, string Label, string Suffix, ReportingErrorSink Sink); + + /// + /// The FQN a member message names for : the DECLARING entity in + /// place of the host, so a failure is worded identically whichever entity reached the + /// member (the suffix names the inheritor) and the sink's repeat test holds. + /// + private static string Shown(MemberCtx ctx, MetaData entity) => + (ReferenceEquals(entity, ctx.Host) ? ctx.Declaring : entity).ResolutionKey(); + + private static void CheckEntityMembers(MetaData root, MetaData entity, bool declaredHere, ReportingErrorSink sink) + { + // ADR-0039: resolving — inherited members are validated against this entity. + foreach (var member in entity.Children()) + { + if (member.Type != TYPE_DIMENSION && member.Type != TYPE_MEASURE && member.Type != TYPE_SEGMENT) continue; + var declaring = member.Parent ?? entity; + if (ReferenceEquals(declaring, entity) != declaredHere) continue; + var ctx = new MemberCtx( + root, + entity, + declaring, + $"{member.Type} '{member.Name}' on entity '{declaring.ResolutionKey()}'", + declaredHere ? "" : $" (inherited by '{entity.ResolutionKey()}')", + sink); + switch (member) + { + case MetaDimension dim: + CheckDimension(ctx, dim); + break; + case MetaMeasure measure: + CheckMeasure(ctx, measure); + break; + case MetaSegment segment: + var filter = segment.Filter(); + if (filter is not null) + { + CheckReportingFilter(filter, entity, declaring.ResolutionKey(), ctx.Label, segment, ctx.Suffix, sink); + } + break; + } + } + } + + private static void CheckDimension(MemberCtx ctx, MetaDimension dim) + { + void Err(string message) => + ctx.Sink.Push(dim, ErrorCode.ERR_INVALID_DIMENSION, ctx.Label, $": {message}", ctx.Suffix); + + // D2 — the @via walk; its terminal is the entity @of must name. + MetaData ofEntity = ctx.Host; + string? via = dim.Via(); + if (via is not null) + { + var terminal = WalkToOneVia(ctx, via, Err); + if (terminal is null) return; + ofEntity = terminal; + } + + // D1 — @of is Entity.field on the owning entity (or the @via terminal). + string? of = dim.Of(); + if (of is null) return; // missing @of is ERR_MISSING_REQUIRED_ATTR (attr schema pass) + var parts = ReportingSplitDotted(of); + if (parts is null || parts.Value.Path.Length != 1) + { + Err($"@of '{of}' must be Entity.field."); + return; + } + var named = NamingRefs.ResolveObjectRef(ctx.Root, parts.Value.Owner, NamingRefs.EffectivePackage(ctx.Declaring)); + if (!IsSelfOrAncestor(named, ofEntity)) + { + if (via is null) + { + Err($"@of '{of}' must name a field of the owning entity '{ctx.Declaring.ResolutionKey()}'. " + + "Reach another entity's field with @via."); + } + else + { + Err($"@of '{of}' must name a field of '{Shown(ctx, ofEntity)}', the entity @via '{via}' reaches."); + } + return; + } + string fieldName = parts.Value.Path[0]; + var field = ReportingFieldOf(ofEntity, fieldName); + if (field is null) + { + Err($"@of '{of}' names no field '{fieldName}' on '{Shown(ctx, ofEntity)}'."); + return; + } + + if (!dim.IsTime()) return; + // D3 — a time dimension groups a date or timestamp. + if (!ReportingTemporalFieldSubtypes.Contains(field.SubType)) + { + Err($"a time dimension's @of must be a field.date or field.timestamp, but '{of}' is field.{field.SubType}."); + return; + } + // D4 — a date has no hour. + if (field.SubType == FIELD_SUBTYPE_DATE && dim.Grains().Contains(GRAIN_HOUR)) + { + Err($"grain 'hour' is impossible on '{of}', a field.date (a date has no hour). Remove 'hour' from @grains."); + } + } + + /// + /// D2 — walk Owner.hop[.hop...]: Owner is the owning entity, and every hop is a + /// to-one relationship.* or an identity.reference. Returns the terminal + /// entity, or null after reporting the first failure. + /// + private static MetaData? WalkToOneVia(MemberCtx ctx, string via, Action err) + { + var parts = ReportingSplitDotted(via); + if (parts is null) + { + err($"@via '{via}' must be Owner.hop[.hop...], starting at the owning entity."); + return null; + } + var owner = NamingRefs.ResolveObjectRef(ctx.Root, parts.Value.Owner, NamingRefs.EffectivePackage(ctx.Declaring)); + if (!IsSelfOrAncestor(owner, ctx.Host)) + { + err($"@via '{via}' must start at the owning entity '{ctx.Declaring.ResolutionKey()}'."); + return null; + } + MetaData current = ctx.Host; + foreach (string hopName in parts.Value.Path) + { + var hop = + ReportingChildOfType(current, TYPE_RELATIONSHIP, hopName) ?? + // ADR-0039: resolving — an inherited identity.reference is a hop just the same. + current.Children().FirstOrDefault(c => + c.Type == TYPE_IDENTITY && c.SubType == IDENTITY_SUBTYPE_REFERENCE && c.Name == hopName); + if (hop is null) + { + err($"@via '{via}' names '{hopName}', which is not a relationship or identity.reference of " + + $"'{Shown(ctx, current)}'."); + return null; + } + bool isReference = hop.Type == TYPE_IDENTITY; + // ADR-0039: resolving — @cardinality may be inherited via extends. + if (!isReference && !Equals(hop.Attr(RELATIONSHIP_ATTR_CARDINALITY), CARDINALITY_ONE)) + { + err($"@via '{via}' crosses relationship '{hopName}' on '{Shown(ctx, current)}', which is not to-one. " + + "A dimension follows only @cardinality: one relationships and identity.reference hops, so grouping " + + "can never multiply the measured rows."); + return null; + } + // ADR-0039: resolving — the hop target attr may be inherited via extends. + var targetRef = hop.Attr(isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); + // ADR-0042 — a hop target resolves in the package of the entity declaring the hop. + var target = targetRef is string tr + ? NamingRefs.ResolveObjectRef(ctx.Root, tr, NamingRefs.EffectivePackage(current)) + : null; + if (target is null) + { + err($"@via '{via}' hop '{hopName}' on '{Shown(ctx, current)}' targets no object."); + return null; + } + current = target; + } + return current; + } + + private static void CheckMeasure(MemberCtx ctx, MetaMeasure measure) + { + void Err(string message) => + ctx.Sink.Push(measure, ErrorCode.ERR_INVALID_MEASURE, ctx.Label, $": {message}", ctx.Suffix); + + if (measure.IsRatio()) + { + CheckRatioOperands(ctx, measure, Err); + return; + } + if (measure.SubType != MEASURE_SUBTYPE_AGGREGATE) return; + + CheckAggregateColumns(ctx, measure, Err); + + // M5 — @segment names a segment of the owning entity. + string? segment = measure.SegmentName(); + if (segment is not null && ReportingChildOfType(ctx.Host, TYPE_SEGMENT, segment) is null) + { + Err($"@segment '{segment}' names no segment of '{ctx.Declaring.ResolutionKey()}'."); + } + + // S1 / F2 — the measure's own row scope. + var filter = measure.Filter(); + if (filter is not null) + { + CheckReportingFilter(filter, ctx.Host, ctx.Declaring.ResolutionKey(), ctx.Label, measure, ctx.Suffix, ctx.Sink); + } + } + + /// M1–M4, in order; the first failure stops the chain (no M2+M3 double report). + private static void CheckAggregateColumns(MemberCtx ctx, MetaMeasure measure, Action err) + { + string? agg = measure.Agg(); + var columns = measure.OfColumns(); + + // M1 — every @of item is a field of the owning entity. + var fields = new List(); + foreach (string item in columns) + { + var parts = ReportingSplitDotted(item); + if (parts is null || parts.Value.Path.Length != 1) + { + err($"@of '{item}' must be Entity.field."); + return; + } + var named = NamingRefs.ResolveObjectRef(ctx.Root, parts.Value.Owner, NamingRefs.EffectivePackage(ctx.Declaring)); + if (!IsSelfOrAncestor(named, ctx.Host)) + { + err($"@of '{item}' must name a field of the owning entity '{ctx.Declaring.ResolutionKey()}'. " + + "A measure aggregates its own entity's rows; declare it on the entity that owns the column."); + return; + } + var field = ReportingFieldOf(ctx.Host, parts.Value.Path[0]); + if (field is null) + { + err($"@of '{item}' names no field '{parts.Value.Path[0]}' on '{ctx.Declaring.ResolutionKey()}'."); + return; + } + fields.Add(field); + } + + // M2 — a tuple is a distinct count only. + if (columns.Count > 1 && (agg != AGG_COUNT || !measure.Distinct())) + { + err($"@of lists {columns.Count} columns; a tuple is legal only with @agg: count and @distinct: true " + + "(a distinct count of the tuple)."); + return; + } + + // M3 — @distinct is a count modifier. + if (measure.Distinct() && agg is not null && agg != AGG_COUNT) + { + err($"@distinct: true requires @agg: count, not '{agg}'."); + return; + } + + // M4 — the aggregate must be meaningful for the column's type. + var single = fields.Count == 1 ? fields[0] : null; + if (single is null || agg is null) return; + string first = columns[0]; + if ((agg == AGG_SUM || agg == AGG_AVG) && !ReportingNumericFieldSubtypes.Contains(single.SubType)) + { + err($"@agg '{agg}' needs a numeric field (field.int, long, double, float, decimal or currency), " + + $"but '{first}' is field.{single.SubType}."); + return; + } + if ((agg == AGG_MIN || agg == AGG_MAX) && ReportingUnorderedFieldSubtypes.Contains(single.SubType)) + { + err($"@agg '{agg}' cannot order '{first}', a field.{single.SubType}."); + } + } + + /// M6 — each operand names a measure.aggregate of the same entity. + private static void CheckRatioOperands(MemberCtx ctx, MetaMeasure ratio, Action err) + { + (string Attr, string? Ref)[] operands = + [ + (REPORTING_ATTR_NUMERATOR, ratio.Numerator()), + (REPORTING_ATTR_DENOMINATOR, ratio.Denominator()), + ]; + foreach (var (attr, @ref) in operands) + { + if (@ref is null) continue; // missing operand is ERR_MISSING_REQUIRED_ATTR + var target = ReportingChildOfType(ctx.Host, TYPE_MEASURE, @ref); + if (target is null) + { + err($"@{attr} '{@ref}' names no measure of '{ctx.Declaring.ResolutionKey()}'."); + } + else if (target.SubType != MEASURE_SUBTYPE_AGGREGATE) + { + err($"@{attr} '{@ref}' is a measure.{target.SubType}; a ratio's operands must be measure.aggregate " + + "(a ratio of ratios is not supported)."); + } + } + } + + // ------------------------------------------------------------------------- + // S1 / F2 — a reporting-host @filter over its entity + // ------------------------------------------------------------------------- + + /// + /// Validate a canonical (post-desugar) attr.filter against 's + /// fields: every key names a field (S1), every op is legal for that field (S1), and a + /// relative-date operand sits on a date/timestamp, under a range op, with a valid + /// ISO-8601 duration (F2). One error per offending clause op. + /// + private static void CheckReportingFilter( + IReadOnlyDictionary filter, + MetaData entity, + string entityKey, + string hostLabel, + MetaData host, + string suffix, + ReportingErrorSink sink) + { + void Err(string message) => + sink.Push(host, ErrorCode.ERR_BAD_ATTR_FILTER, hostLabel, $": {message}", suffix); + + foreach (var (key, clause) in filter) + { + if (key == FILTER_COMPOSE_OR || key == FILTER_COMPOSE_AND) + { + if (clause is not IReadOnlyList subs) + { + Err($"@filter '{key}' must be an array of sub-clauses."); + continue; + } + foreach (var sub in subs) + { + if (sub is IReadOnlyDictionary subFilter) + { + CheckReportingFilter(subFilter, entity, entityKey, hostLabel, host, suffix, sink); + } + else + { + Err($"@filter '{key}' contains a non-object sub-clause."); + } + } + continue; + } + var field = ReportingFieldOf(entity, key); + if (field is null) + { + Err($"@filter names '{key}', which is not a field of '{entityKey}'."); + continue; + } + if (clause is not IReadOnlyDictionary ops || ops.Count == 0) + { + Err($"@filter on '{key}' must be an {{ op: value }} object."); + continue; + } + string[] allowed = OpsForField(field); + foreach (var (op, operand) in ops) + { + if (!allowed.Contains(op)) + { + string allowedText = allowed.Length > 0 ? string.Join(", ", allowed) : "(none)"; + Err($"@filter on '{key}' uses op '{op}', which is not allowed for field.{field.SubType}. " + + $"Allowed ops: {allowedText}."); + continue; + } + var relative = RelativeOperand(operand); + if (relative is null) continue; + if (!IsExactRelativeValue(relative)) + { + Err($"@filter on '{key}' has a malformed relative date {JsonStringify(relative)}; a relative date is " + + "exactly { now: \"\" } with no other keys."); + continue; + } + if (!ReportingTemporalFieldSubtypes.Contains(field.SubType)) + { + Err($"@filter on '{key}' uses a relative date ({{ now: ... }}), but '{key}' is field.{field.SubType}; " + + "relative dates apply only to field.date and field.timestamp."); + continue; + } + if (!ReportingRelativeDateOps.Contains(op)) + { + Err($"@filter on '{key}' puts a relative date under op '{op}'; relative dates are legal only under " + + "gt, gte, lt and lte."); + continue; + } + var duration = relative[FILTER_RELATIVE_NOW]; + if (duration is not string d || !ISO_DURATION_RE.IsMatch(d)) + { + Err($"@filter on '{key}' has relative date '{QuoteValue(duration)}', which is not an ISO-8601 " + + "duration (e.g. '-P7D', '-PT12H')."); + } + } + } + } + + // ------------------------------------------------------------------------- + // R1–R7 — object.report + // ------------------------------------------------------------------------- + + private static void CheckReport(MetaData root, MetaData report, ReportingErrorSink sink) + { + string label = $"report '{report.ResolutionKey()}'"; + void Err(string message, MetaData? node = null, ErrorCode code = ErrorCode.ERR_INVALID_REPORT) => + sink.Push(node ?? report, code, label, message); + + // R4 — a report's fields and identity are derived, never declared. + // ADR-0039: own — the rule is about what the author declared on THIS report. + foreach (var child in report.OwnChildren()) + { + if (child.Type == TYPE_FIELD || child.Type == TYPE_IDENTITY) + { + Err($" declares {ReportingChildLabel(child)}; a report's fields and identity are derived " + + "from @dimensions and @measures, never declared.", child); + } + } + + // R5 — a report is read-only, so any source it has is read-only. + // ADR-0039: resolving — an inherited source binds the report just the same. + foreach (var source in report.Children().Where(c => c.Type == TYPE_SOURCE)) + { + if (source is MetaSource ms && ms.IsWritable()) + { + Err($": {ReportingChildLabel(source)} is writable; a report is read-only, so its source must " + + "declare a read-only @kind (view, materializedView, storedProc or tableFunction).", source); + } + } + + // R1 — @from resolves to an object.entity. Without it, R2/R3/R6/R7 and the + // @filter have nothing to resolve against, so they are skipped. + string? fromRef = ReportAccessors.ReportFrom(report); + if (fromRef is null) return; // missing @from is ERR_MISSING_REQUIRED_ATTR + var from = NamingRefs.ResolveObjectRef(root, fromRef, NamingRefs.EffectivePackage(report)); + if (from is null) + { + Err($": @from '{fromRef}' does not resolve to an object."); + return; + } + if (from.Type != TYPE_OBJECT || from.SubType != OBJECT_SUBTYPE_ENTITY) + { + Err($": @from '{fromRef}' is an {from.Type}.{from.SubType}; a report aggregates the rows of an object.entity."); + return; + } + string fromKey = from.ResolutionKey(); + + // R6 — derived field name -> the item that derived it ("dimension item 'x'" / "measure 'y'"). + var derived = new Dictionary(StringComparer.Ordinal); + void Claim(string fieldName, string what) + { + if (derived.TryGetValue(fieldName, out var prior)) + { + if (prior == what) + { + // The same measure listed twice: name the repeat, not a "collision" with itself. + Err($": @measures lists '{fieldName}' more than once."); + return; + } + Err($": {prior} and {what} both derive report field '{fieldName}'. Report field names must be unique; " + + "rename the measure or drop one item."); + return; + } + derived[fieldName] = what; + } + + // R2 — each @dimensions item names a dimension of @from, with a grain exactly when it is a time dimension. + var seenItems = new HashSet(StringComparer.Ordinal); + foreach (var item in ReportAccessors.ReportDimensionItems(report)) + { + string raw = item.Grain is null ? item.Name : $"{item.Name}{REPORT_DIMENSION_GRAIN_SEPARATOR}{item.Grain}"; + if (!seenItems.Add(raw)) + { + Err($": @dimensions lists '{raw}' more than once."); + continue; + } + if (ReportingChildOfType(from, TYPE_DIMENSION, item.Name) is not MetaDimension dim) + { + Err($": @dimensions item '{raw}' names no dimension of @from '{fromKey}'."); + continue; + } + if (dim.IsTime()) + { + var grains = dim.Grains(); + if (item.Grain is null) + { + Err($": @dimensions item '{raw}' names time dimension '{item.Name}' without a grain; write " + + $"'{item.Name}:' with a grain from its @grains ({string.Join(", ", grains)})."); + continue; + } + if (!grains.Contains(item.Grain)) + { + Err($": @dimensions item '{raw}' uses grain '{item.Grain}', which time dimension '{item.Name}' does not " + + $"declare. Its @grains: {string.Join(", ", grains)}."); + continue; + } + } + else if (item.Grain is not null) + { + Err($": @dimensions item '{raw}' gives a grain to attribute dimension '{item.Name}'; only a time " + + "dimension takes a grain."); + continue; + } + Claim(ReportAccessors.ReportDerivedFieldName(item), $"dimension item '{raw}'"); + } + + // R3 — each @measures item names a measure of @from. + foreach (string item in ReportAccessors.ReportMeasureNames(report)) + { + string? measureName = CheckReportMeasure(root, report, from, item, label, sink); + if (measureName is not null) Claim(measureName, $"measure '{measureName}'"); + } + + // R7 — @segment names a segment of @from. + // ADR-0039: resolving — a report may inherit @segment via extends. + if (report.Attr(OBJECT_REPORT_ATTR_SEGMENT) is string segment && + ReportingChildOfType(from, TYPE_SEGMENT, segment) is null) + { + Err($": @segment '{segment}' names no segment of @from '{fromKey}'."); + } + + // S1 / F2 — the report's row scope over @from. + // ADR-0039: resolving — a report may inherit @filter via extends. + if (AsPlainObject(report.Attr(OBJECT_REPORT_ATTR_FILTER)) is { } filter) + { + CheckReportingFilter(filter, from, fromKey, label, report, "", sink); + } + } + + /// + /// R3 for one @measures item (bare name or dotted Entity.name). + /// Returns the measure's name when it is a measure of @from (for R6), else reports + /// ERR_REPORT_FOREIGN_MEASURE (it is another entity's measure) or ERR_INVALID_REPORT + /// (it is nobody's) and returns null. + /// + private static string? CheckReportMeasure( + MetaData root, MetaData report, MetaData from, string item, string label, ReportingErrorSink sink) + { + string fromKey = from.ResolutionKey(); + MetaData? owner; + var parts = ReportingSplitDotted(item); + if (parts is not null && parts.Value.Path.Length == 1) + { + owner = NamingRefs.ResolveObjectRef(root, parts.Value.Owner, NamingRefs.EffectivePackage(report)); + string name = parts.Value.Path[0]; + if (owner is not null && IsSelfOrAncestor(owner, from) && + ReportingChildOfType(from, TYPE_MEASURE, name) is not null) + { + return name; + } + if (owner is not null && ReportingChildOfType(owner, TYPE_MEASURE, name) is null) owner = null; + } + else if (!item.Contains(CHILD_REF_SEPARATOR, StringComparison.Ordinal)) + { + string name = item; + if (ReportingChildOfType(from, TYPE_MEASURE, name) is not null) return name; + // ADR-0039: root has no super; Children()==OwnChildren() but resolving is the default. + owner = root.Children().FirstOrDefault(o => + o.Type == TYPE_OBJECT && + o.SubType == OBJECT_SUBTYPE_ENTITY && + ReportingChildOfType(o, TYPE_MEASURE, name) is not null); + } + else + { + owner = null; + } + + if (owner is not null) + { + string ownerKey = owner.ResolutionKey(); + string bare = item[(item.LastIndexOf(CHILD_REF_SEPARATOR, StringComparison.Ordinal) + 1)..]; + sink.Push( + report, + ErrorCode.ERR_REPORT_FOREIGN_MEASURE, + label, + $" lists measure '{bare}', which belongs to " + + $"'{ownerKey}', not @from '{fromKey}'. All measures of a report come from @from; make a second " + + $"report over '{ownerKey}'."); + } + else + { + sink.Push( + report, + ErrorCode.ERR_INVALID_REPORT, + label, + $": @measures item '{item}' names no measure of @from '{fromKey}' or of any other entity."); + } + return null; + } + + // ------------------------------------------------------------------------- + // F1 — relative-date values only on reporting hosts + // ------------------------------------------------------------------------- + + /// True for the hosts whose @filter may carry a relative-date value. + private static bool IsReportingFilterHost(MetaData node) => + node.Type == TYPE_SEGMENT || + (node.Type == TYPE_MEASURE && node.SubType == MEASURE_SUBTYPE_AGGREGATE) || + (node.Type == TYPE_OBJECT && node.SubType == OBJECT_SUBTYPE_REPORT); + + /// + /// F1 — walk the whole tree and refuse a { now: ... } value in any attr.filter + /// outside a reporting host (a projection @filter, a dataGrid preset, an + /// origin.aggregate/first @filter): those hosts have no lowering for it. + /// + private static void CheckNoRelativeDates(MetaData node, TypeRegistry registry, ReportingErrorSink sink) + { + if (!IsReportingFilterHost(node)) + { + // ADR-0039: own — only locally declared filters are lowered, and the walk + // visits every declared node exactly once (an inherited filter is checked + // where it is declared; origin.* never inherits, ADR-0029). + foreach (var (attrName, value) in node.OwnAttrs()) + { + bool isFilterAttr = + registry.FindAttrSchema(node.Type, node.SubType, attrName)?.ValueType == ATTR_SUBTYPE_FILTER; + if (isFilterAttr && FilterContainsRelativeValue(value)) + { + sink.Push( + node, + ErrorCode.ERR_BAD_ATTR_FILTER, + ReportingNodeLabel(node), + $": @{attrName} uses a relative date ({{ now: ... }}), which is legal only in the " + + "@filter of a segment, measure.aggregate or object.report."); + } + } + } + // ADR-0039: own — a tree walk; each declared node is visited once, at its declaration. + foreach (var child in node.OwnChildren()) CheckNoRelativeDates(child, registry, sink); + } +} diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 0eea68ba3..207c28680 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -41,7 +41,7 @@ namespace MetaObjects.Loader; /// (Effective, extends-resolving reads also live in the typed node getters — /// MetaField/MetaIdentity/MetaRelationship — used by codegen and runtime.) /// -public static class ValidationPasses +public static partial class ValidationPasses { // ------------------------------------------------------------------------- // Result types diff --git a/server/csharp/MetaObjects/Meta/MetaDimension.cs b/server/csharp/MetaObjects/Meta/MetaDimension.cs new file mode 100644 index 000000000..cc164cf76 --- /dev/null +++ b/server/csharp/MetaObjects/Meta/MetaDimension.cs @@ -0,0 +1,36 @@ +// MetaDimension — concrete node class for type=dimension nodes (FR-044). +// +// Ported 1:1 from +// server/typescript/packages/metadata/src/core/reporting/meta-dimension.ts. + +using MetaObjects.Core.Reporting; + +namespace MetaObjects.Meta; + +/// +/// Concrete node class for dimension.* nodes. Extends directly. +/// +// ADR-0039: every getter below uses the RESOLVING Attr() accessor — a dimension declared +// on an abstract base entity is read through the same accessors as one declared on the +// concrete entity. +public class MetaDimension(TypeId typeId, string name) : MetaData(typeId, name) +{ + /// True for dimension.time (grain truncation); false for dimension.attribute. + public bool IsTime() => SubType == DIMENSION_SUBTYPE_TIME; + + /// Dotted Entity.field reference naming the grouped column. + public string? Of() => Attr(REPORTING_ATTR_OF) as string; + + /// Optional dotted to-one relationship path from the owning entity to the @of entity. + public string? Via() => Attr(REPORTING_ATTR_VIA) as string; + + /// + /// The grains a dimension.time supports (empty for dimension.attribute). + /// A bare string is one grain; values outside the closed grain set are dropped. + /// + public IReadOnlyList Grains() => + ReportingValues.StringList(Attr(REPORTING_ATTR_GRAINS)) + .Where(g => TIME_GRAINS.Contains(g)) + .ToList() + .AsReadOnly(); +} diff --git a/server/csharp/MetaObjects/Meta/MetaMeasure.cs b/server/csharp/MetaObjects/Meta/MetaMeasure.cs new file mode 100644 index 000000000..9f805990e --- /dev/null +++ b/server/csharp/MetaObjects/Meta/MetaMeasure.cs @@ -0,0 +1,44 @@ +// MetaMeasure — concrete node class for type=measure nodes (FR-044). +// +// Ported 1:1 from +// server/typescript/packages/metadata/src/core/reporting/meta-measure.ts. + +using MetaObjects.Core.Reporting; + +namespace MetaObjects.Meta; + +/// +/// Concrete node class for measure.* nodes. Extends directly. +/// +// ADR-0039: every getter below uses the RESOLVING Attr() accessor. +public class MetaMeasure(TypeId typeId, string name) : MetaData(typeId, name) +{ + /// True for measure.ratio; false for measure.aggregate. + public bool IsRatio() => SubType == MEASURE_SUBTYPE_RATIO; + + /// The aggregate function (measure.aggregate only); null outside the closed set. + public string? Agg() => + Attr(REPORTING_ATTR_AGG) is string s && MEASURE_AGGS.Contains(s) ? s : null; + + /// True when @distinct is set (legal only with @agg: count). + public bool Distinct() => Attr(REPORTING_ATTR_DISTINCT) is true; + + /// + /// The Entity.field references in @of: a bare string is one column, a list + /// is the tuple form. + /// + public IReadOnlyList OfColumns() => ReportingValues.StringList(Attr(REPORTING_ATTR_OF)); + + /// Name of a segment declared on the same entity; combines with @filter by AND. + public string? SegmentName() => Attr(REPORTING_ATTR_SEGMENT) as string; + + /// The canonical row-scope filter, when one is declared. + public IReadOnlyDictionary? Filter() => + Attr(REPORTING_ATTR_FILTER) as IReadOnlyDictionary; + + /// Name of the measure.aggregate sibling used as the numerator (measure.ratio only). + public string? Numerator() => Attr(REPORTING_ATTR_NUMERATOR) as string; + + /// Name of the measure.aggregate sibling used as the denominator (measure.ratio only). + public string? Denominator() => Attr(REPORTING_ATTR_DENOMINATOR) as string; +} diff --git a/server/csharp/MetaObjects/Meta/MetaObject.cs b/server/csharp/MetaObjects/Meta/MetaObject.cs index 4f77e2cf9..0a7fcf6bf 100644 --- a/server/csharp/MetaObjects/Meta/MetaObject.cs +++ b/server/csharp/MetaObjects/Meta/MetaObject.cs @@ -158,6 +158,9 @@ public bool IsWriteThrough() => /// True when the object's subtype is projection (a derived read-only model). public bool IsProjection() => SubType == OBJECT_SUBTYPE_PROJECTION; + /// True when the object's subtype is report (FR-044: declared dimensions x measures). + public bool IsReport() => SubType == OBJECT_SUBTYPE_REPORT; + // ------------------------------------------------------------------------- // Fields // ------------------------------------------------------------------------- diff --git a/server/csharp/MetaObjects/Meta/MetaSegment.cs b/server/csharp/MetaObjects/Meta/MetaSegment.cs new file mode 100644 index 000000000..972b074a5 --- /dev/null +++ b/server/csharp/MetaObjects/Meta/MetaSegment.cs @@ -0,0 +1,17 @@ +// MetaSegment — concrete node class for type=segment nodes (FR-044). +// +// Ported 1:1 from +// server/typescript/packages/metadata/src/core/reporting/meta-segment.ts. + +namespace MetaObjects.Meta; + +/// +/// Concrete node class for segment.* nodes. Extends directly. +/// +// ADR-0039: the getter below uses the RESOLVING Attr() accessor. +public class MetaSegment(TypeId typeId, string name) : MetaData(typeId, name) +{ + /// The named row scope: a canonical attr.filter over the declaring entity's fields. + public IReadOnlyDictionary? Filter() => + Attr(REPORTING_ATTR_FILTER) as IReadOnlyDictionary; +} diff --git a/server/csharp/MetaObjects/Parser.cs b/server/csharp/MetaObjects/Parser.cs index 207502e75..e22166a27 100644 --- a/server/csharp/MetaObjects/Parser.cs +++ b/server/csharp/MetaObjects/Parser.cs @@ -1498,6 +1498,15 @@ private static void ApplyReservedKeys( return new Dictionary(StringComparer.Ordinal) { [FILTER_OP_IN] = raw }.AsReadOnly(); + // FR-044 F2: an object carrying a `now` key is a relative-date VALUE, never an + // op map (`now` is not an operator). Shorthand `{ f: { now: "-P7D" } }` therefore + // means `eq` like every other shorthand value, and survives as an opaque operand; + // validation then refuses it (`eq` is not a range op) instead of reading `now` as + // an op. An explicit-op clause `{ f: { gte: { now: ... } } }` passes through below. + if (raw is IReadOnlyDictionary relative && relative.ContainsKey(FILTER_RELATIVE_NOW)) + return new Dictionary(StringComparer.Ordinal) + { [FILTER_OP_EQ] = raw }.AsReadOnly(); + // already-object → pass through (explicit op clause) if (raw is IReadOnlyDictionary obj) return obj; diff --git a/server/csharp/MetaObjects/Registry/Spec/SpecMetamodelReader.cs b/server/csharp/MetaObjects/Registry/Spec/SpecMetamodelReader.cs index 40850cf82..d02c74021 100644 --- a/server/csharp/MetaObjects/Registry/Spec/SpecMetamodelReader.cs +++ b/server/csharp/MetaObjects/Registry/Spec/SpecMetamodelReader.cs @@ -1,6 +1,6 @@ // FR-033 — the C# reader for the shared spec/metamodel/*.json provider files. // -// The 18 JSON files (one per concern provider) are the cross-port single source of +// The 19 JSON files (one per concern provider) are the cross-port single source of // truth for every type / attr / common-attr DESCRIPTION (+ optional rules/example/ // whenToUse). They are byte-identical across the ports by design, so each port READS // them rather than hand-copying the prose — the exact duplication FR-033 kills. @@ -112,12 +112,12 @@ public sealed record SpecExtendsDirective( /// public sealed class SpecMetamodelReader { - /// The 18 shared provider-definition file names (keep in lockstep with spec/metamodel/). + /// The 19 shared provider-definition file names (keep in lockstep with spec/metamodel/). public static readonly IReadOnlyList SpecFiles = new[] { "attr.json", "db.json", "documentation.json", "field.json", "identity.json", "index.json", "layout.json", "object.json", "origin.json", "prompt.json", - "relationship.json", "requirement.json", "source.json", "template.json", + "relationship.json", "reporting.json", "requirement.json", "source.json", "template.json", "ui.json", "ui-web.json", "validator.json", "view.json", }; @@ -170,7 +170,7 @@ private SpecMetamodelReader() { } // ------------------------------------------------------------------ /// - /// Load + parse the 18 embedded spec/metamodel/*.json files off this + /// Load + parse the 19 embedded spec/metamodel/*.json files off this /// assembly's manifest resources (AOT-safe). /// /// If a spec file is missing or unparseable. diff --git a/server/csharp/MetaObjects/RegistryManifest.cs b/server/csharp/MetaObjects/RegistryManifest.cs index c4dacacd5..8553b1ec2 100644 --- a/server/csharp/MetaObjects/RegistryManifest.cs +++ b/server/csharp/MetaObjects/RegistryManifest.cs @@ -374,7 +374,7 @@ private static ManifestType ToManifestType(TypeDefinition def) => /// unstable (semver major-0). The 1.0 cut flips this to "1.0". /// Emitted as the FIRST top-level key in the registry manifest (C4). /// - public const string MetamodelVersion = "1.0"; + public const string MetamodelVersion = "1.1"; /// /// Emit the canonical registry manifest as a byte-stable JSON string. diff --git a/server/csharp/MetaObjects/Shared/BaseTypes.cs b/server/csharp/MetaObjects/Shared/BaseTypes.cs index 028e69997..d457d8d35 100644 --- a/server/csharp/MetaObjects/Shared/BaseTypes.cs +++ b/server/csharp/MetaObjects/Shared/BaseTypes.cs @@ -38,6 +38,14 @@ public static class BaseTypes /// (UNIVERSALITY). /// public const string TYPE_REQUIREMENT = "requirement"; + /// + /// FR-044 reporting vocabulary — a named group-by attribute (dimension.attribute, + /// dimension.time), a named aggregate (measure.aggregate, measure.ratio) + /// and a named row filter (segment.filter), all declared on an object.entity. + /// + public const string TYPE_DIMENSION = "dimension"; + public const string TYPE_MEASURE = "measure"; + public const string TYPE_SEGMENT = "segment"; public static readonly string[] BASE_TYPES = [ @@ -55,6 +63,9 @@ public static class BaseTypes TYPE_TEMPLATE, TYPE_INDEX, TYPE_REQUIREMENT, + TYPE_DIMENSION, + TYPE_MEASURE, + TYPE_SEGMENT, ]; // ----------------------------------------------------------------------- diff --git a/server/csharp/MetaObjects/SpecMetamodel/object.json b/server/csharp/MetaObjects/SpecMetamodel/object.json index 65bf80f74..cb5f9946f 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/object.json +++ b/server/csharp/MetaObjects/SpecMetamodel/object.json @@ -24,6 +24,9 @@ "children": [ { "type": "relationship", "subType": "*", "name": "*", "min": 0, "max": null }, { "type": "template", "subType": "*", "name": "*", "min": 0, "max": null }, + { "type": "dimension", "subType": "*", "name": "*", "min": 0, "max": null }, + { "type": "measure", "subType": "*", "name": "*", "min": 0, "max": null }, + { "type": "segment", "subType": "*", "name": "*", "min": 0, "max": null }, { "type": "attr", "subType": "string", "name": "discriminator", "min": 0, "max": 1, "description": "FR-014: names the field on this entity (resolvable via extends:) that holds the subtype-discriminator value. Subtypes of this entity declare @discriminatorValue to bind their rows to a discriminator value. The discriminator field itself is an ordinary field declaration (typically field.enum or field.int / field.string)." }, { "type": "attr", "subType": "string", "name": "discriminatorValue", "min": 0, "max": 1, "description": "FR-014: on a subtype of an entity with @discriminator — the value that identifies rows of this subtype in the shared discriminator field. Wire form is always a string; the underlying field's subtype (enum / int / string) controls codegen + storage coercion. Required on every concrete subtype of a discriminated entity." } ] @@ -49,6 +52,20 @@ "children": [ { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row-scope predicate (a portable attr.filter object: eq/ne/gt/gte/lt/lte/like/in/isNull with and/or, desugared to { field: { op: value } } at parse time) selecting which rows the view returns — lowered to an outer SQL WHERE. Resolves against the projection's own declared fields; an aggregate-derived field is not addressable (fail-closed)." } ] + }, + { + "type": "object", + "subType": "report", + "extendsBase": true, + "description": "A declared report (FR-044): a fixed combination of dimensions and measures of ONE entity (@from), compiled to a read-only view. One row per distinct dimension tuple; no dimensions means exactly one row. Fields are DERIVED, not declared: one per dimension (a time dimension's field is , e.g. purchasedAtDay) and one per measure. Every measure must belong to @from (two fact tables are two reports). @filter and @segment scope rows before grouping and combine by AND. Read-only: no writes, no get-by-id.", + "whenToUse": "Dashboard totals, per-day series and per-group summaries that would otherwise be hand-written GROUP BY queries.", + "children": [ + { "type": "attr", "subType": "string", "name": "from", "min": 1, "max": 1, "description": "The object.entity whose rows the report aggregates." }, + { "type": "attr", "subType": "string", "name": "dimensions", "isArray": true, "min": 0, "max": 1, "description": "Dimension names of @from; a time dimension is written 'name:grain'. Absent means one global row." }, + { "type": "attr", "subType": "string", "name": "measures", "isArray": true, "min": 1, "max": 1, "description": "Measure names of @from." }, + { "type": "attr", "subType": "string", "name": "segment", "min": 0, "max": 1, "description": "Optional segment of @from scoping the rows." }, + { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row scope over @from's fields; may use relative-date values." } + ] } ] } diff --git a/server/csharp/MetaObjects/SpecMetamodel/reporting.json b/server/csharp/MetaObjects/SpecMetamodel/reporting.json new file mode 100644 index 000000000..024196d63 --- /dev/null +++ b/server/csharp/MetaObjects/SpecMetamodel/reporting.json @@ -0,0 +1,43 @@ +{ + "provider": "metaobjects-core-types", + "types": [ + { "type": "dimension", "subType": "attribute", + "description": "A named group-by attribute of the entity that declares it (FR-044). Groups report rows by a column value as-is. @of names Entity.field: the owning entity, or the @via terminal. @via may follow only to-one hops, so grouping by a related row's column can never multiply the measured rows.", + "whenToUse": "A column a dashboard groups by: product, status, region. Declare it once on the fact entity and reference it by name from reports.", + "children": [ + { "type": "attr", "subType": "string", "name": "of", "min": 1, "max": 1, "description": "Dotted Entity.field reference naming the grouped column (e.g. 'Purchase.programId', or 'Program.title' with @via)." }, + { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted to-one relationship path from the owning entity to the entity @of names (e.g. 'Purchase.program'). Every hop must be @cardinality: one or an identity.reference." } + ] }, + { "type": "dimension", "subType": "time", + "description": "A named time dimension (FR-044): groups report rows by a date or timestamp column truncated to a grain. @of names a field.date or field.timestamp. Weeks start on Monday (ISO-8601) in every lowering. A report names it as 'dimension:grain' and the derived report field is (e.g. purchasedAtDay).", + "whenToUse": "Per-day, per-week or per-month series on a dashboard.", + "children": [ + { "type": "attr", "subType": "string", "name": "of", "min": 1, "max": 1, "description": "Dotted Entity.field reference naming the date or timestamp column." }, + { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted to-one relationship path, as on dimension.attribute." }, + { "type": "attr", "subType": "string", "name": "grains", "isArray": true, "min": 1, "max": 1, "allowedValues": ["hour", "day", "week", "month", "quarter", "year"], "description": "The grains this dimension supports. Weeks start Monday (ISO-8601). 'hour' is refused on a field.date." } + ] }, + { "type": "measure", "subType": "aggregate", + "description": "A named aggregate over the declaring entity's own rows (FR-044). @agg count without @distinct counts rows; with @distinct it counts distinct values of @of (a list in @of is a distinct count of the tuple). Unlike origin.aggregate, count is NOT distinct by default: a measure aggregates its own rows and dimensions reach only to-one paths, so no join inflates it.", + "whenToUse": "A number a dashboard shows: revenue, purchases, distinct buyers, last activity.", + "children": [ + { "type": "attr", "subType": "string", "name": "agg", "min": 1, "max": 1, "allowedValues": ["count", "sum", "avg", "min", "max"], "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields." }, + { "type": "attr", "subType": "string", "name": "of", "isArray": true, "min": 1, "max": 1, "description": "Dotted Entity.field reference(s) on the declaring entity. A bare string is one column; more than one requires @agg: count and @distinct: true." }, + { "type": "attr", "subType": "boolean", "name": "distinct", "min": 0, "max": 1, "description": "Count distinct values. Legal only with @agg: count." }, + { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row scope (a portable attr.filter over the declaring entity's fields). May use relative-date values ({ now: \"-P7D\" }). Combines with @segment by AND." }, + { "type": "attr", "subType": "string", "name": "segment", "min": 0, "max": 1, "description": "Optional name of a segment declared on the same entity. Combines with @filter by AND." } + ] }, + { "type": "measure", "subType": "ratio", + "description": "A named quotient of two measure.aggregate siblings (FR-044), lowered as numerator / NULLIF(denominator, 0) and typed decimal. A zero denominator yields null.", + "whenToUse": "Averages per unit that are not a plain avg: average days engaged per starter.", + "children": [ + { "type": "attr", "subType": "string", "name": "numerator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity." }, + { "type": "attr", "subType": "string", "name": "denominator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity." } + ] }, + { "type": "segment", "subType": "filter", + "description": "A named, reusable row filter on the declaring entity (FR-044). Measures and reports reference it by name; exporters emit it as a named segment.", + "whenToUse": "The same filter (e.g. 'active purchase') would otherwise be repeated in several measures or reports.", + "children": [ + { "type": "attr", "subType": "filter", "name": "filter", "min": 1, "max": 1, "description": "The row scope: a portable attr.filter over the declaring entity's fields. May use relative-date values." } + ] } + ] +} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java index 8a0b4fd9f..449ee6a56 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java @@ -19,7 +19,24 @@ public static Collection getFilteredMetaData(MetaDataLoader loader, Me } public static Collection getFilteredMetaData(MetaDataLoader loader, Class clazz, MetaDataFilters filters ) { - return filterMetaData( loader.getMetaData( clazz ), clazz, filters ); + // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). + // Dropped here because every direct per-object generator (the Java model tier, the + // Mustache and PlantUML generators) selects its objects through this overload. + List generatable = new ArrayList<>(); + for (T md : loader.getMetaData( clazz )) { + if (!isReport(md)) generatable.add(md); + } + return filterMetaData( generatable, clazz, filters ); + } + + /** + * True for an {@code object.report} (FR-044). Plan 1 registers and validates the + * reporting vocabulary but gives a report no lowering yet, so no generator emits for + * one — including a report that declares a read-only {@code source.rdb @kind: view} + * (R5 allows one), which would otherwise pass every source-keyed gate. + */ + public static boolean isReport(MetaData md) { + return md instanceof MetaObject && MetaObject.SUBTYPE_REPORT.equals(md.getSubType()); } private static Collection filterMetaData( Collection in, Class clazz, MetaDataFilters filtersIn ) { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt index a44e566f2..a617ce8d4 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNamesGenerator.kt @@ -13,6 +13,7 @@ import com.metaobjects.generator.GeneratorException import com.metaobjects.generator.GeneratorIOWriter import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase import com.metaobjects.generator.util.GeneratedFileWriter +import com.metaobjects.generator.util.GeneratorUtil import com.metaobjects.loader.MetaDataLoader import com.metaobjects.`object`.MetaObject import java.io.OutputStream @@ -94,6 +95,8 @@ open class KotlinNamesGenerator : // Pass 1 — every object that participates in the database (#248). val emitted = mutableSetOf() for (entity in loader.metaObjects) { + // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). + if (GeneratorUtil.isReport(entity)) continue if (emit(entity, outRoot, strategy)) emitted += entity.name } // Pass 2 — the abstract bases those participants EXTEND, each carrying the columns diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt new file mode 100644 index 000000000..0eeabc7a9 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt @@ -0,0 +1,161 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.generator.kotlin.apidocs.DocsPaths +import com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsRenderer +import com.metaobjects.generator.kotlin.apidocs.KotlinApiModelBuilder +import com.metaobjects.generator.util.GeneratedFileWriter +import com.metaobjects.metadata.ktx.loadDirectory +import com.metaobjects.`object`.MetaObject +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.TreeMap +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * FR-044 Plan 1 — the reporting vocabulary is INERT in every Kotlin generator. + * + * Plan 1 registers `dimension.*`, `measure.*`, `segment.*` and `object.report` and validates + * them at load, but gives none of them output: a report's lowering lands in Plan 2/3. Until + * then a model that USES the vocabulary must generate exactly what the same model without it + * generates, byte for byte, through every generator in [GENERATOR_REGISTRY]. + * + * The model pair is `fixtures/codegen-noop/reporting/{with,without}`, shared with the other + * four ports' copies of this test. `with/` carries a report that declares a read-only + * `source.rdb @kind: view` (R5 allows one) — the shape that leaked in C#. + */ +class ReportingInertTest { + + private fun modelDir(variant: String): Path { + var cur: Path? = Paths.get("").toAbsolutePath() + while (cur != null) { + val candidate = cur.resolve("fixtures/codegen-noop/reporting/$variant") + if (Files.isDirectory(candidate)) return candidate + cur = cur.parent + } + throw IllegalStateException("Could not locate fixtures/codegen-noop/reporting from ${Paths.get("").toAbsolutePath()}") + } + + // One loader name for both variants, so nothing that stamps it into output can differ. + private fun load(variant: String) = loadDirectory("reporting-inert", modelDir(variant)) + + /** + * Run a generator suite into a fresh directory and read back every file it wrote: + * relative path to contents. A throw is recorded as the single entry ``, so a + * generator that cannot run from a bare model must at least fail identically. + */ + private fun emit(variant: String, suite: List): Map { + val loader = load(variant) + val outDir = Files.createTempDirectory("reporting-inert-") + val templateRoot = Files.createTempDirectory("reporting-inert-templates-") + try { + try { + GeneratedFileWriter.beginRun().use { run -> + for (info in suite) { + val gen = info.factory() + run.attributeTo(info.name) + gen.setArgs(mapOf( + "outputDir" to outDir.toString(), + // KotlinValidatorGenerator emits a package-level stub and needs one. + "packageName" to "acme.shop", + "templateRoot" to templateRoot.toString(), + )) + gen.execute(loader) + } + } + } catch (e: Exception) { + return mapOf(THREW to e.message.toString()) + } + val files = TreeMap() + Files.walk(outDir).use { s -> + s.filter { it.isRegularFile() }.forEach { files[outDir.relativize(it).toString()] = it.readText() } + } + return files + } finally { + outDir.toFile().deleteRecursively() + templateRoot.toFile().deleteRecursively() + } + } + + private fun sameOrLeak(label: String, expected: Map, actual: Map): String? { + if (expected.keys.toList() != actual.keys.toList()) { + return "$label: emitted file set ${expected.keys} became ${actual.keys}" + } + val differing = expected.keys.filter { expected[it] != actual[it] } + return if (differing.isEmpty()) null else "$label: $differing differ once reporting nodes are declared" + } + + @Test + fun `the with-model really carries the vocabulary`() { + // Else every comparison below is vacuously green. + val reports = load("with").metaObjects + .filter { it.subType == MetaObject.SUBTYPE_REPORT } + .map { it.shortName } + .sorted() + assertEquals(listOf("DailyRevenue", "ProgramEngagement", "StoreTotals"), reports) + assertFalse(load("without").metaObjects.any { it.subType == MetaObject.SUBTYPE_REPORT }) + } + + @Test + fun `every generator emits the same files with and without reporting nodes`() { + // Every generator is compared before anything is asserted, so one red run names + // every leak rather than the first. + val leaks = GENERATOR_REGISTRY.values.mapNotNull { info -> + sameOrLeak(info.name, emit("without", listOf(info)), emit("with", listOf(info))) + } + assertTrue(leaks.isEmpty(), leaks.joinToString("\n")) + } + + @Test + fun `exactly these generators cannot run from a bare model`() { + // Each is compared above on its error message alone, which proves nothing about its + // output. Pinned by name so a generator that starts throwing cannot drop out + // silently; the list may only shrink. + val threw = GENERATOR_REGISTRY.values.filter { THREW in emit("without", listOf(it)) }.map { it.name }.sorted() + assertEquals(emptyList(), threw) + } + + @Test + fun `every runnable generator in one run emits the same files`() { + val runnable = GENERATOR_REGISTRY.values.filter { THREW !in emit("without", listOf(it)) } + val expected = emit("without", runnable) + assertFalse(THREW in expected, "the combined suite threw: ${expected[THREW]}") + assertTrue(expected.size > 10, "only ${expected.size} files — the suite barely ran") + val leak = sameOrLeak("combined", expected, emit("with", runnable)) + assertTrue(leak == null, leak) + } + + /** + * The api docs surface: every unit page, the index and the agent page. A report has no + * generated API to document, and its derived fields do not exist until its lowering lands. + */ + private fun apiDocs(variant: String): Map { + val model = KotlinApiModelBuilder().build(load(variant), "shop") + val renderer = KotlinApiDocsRenderer() + val pages = TreeMap() + for (unit in model.units) { + pages[DocsPaths.docPageOutputPath(DocsPaths.Layout.PACKAGE, unit.pkg, unit.node)] = + renderer.renderUnitPage(unit, null) + } + pages["README.md"] = renderer.renderIndex(model, DocsPaths.Layout.PACKAGE) + pages["AGENT-API.md"] = renderer.renderAgentApi(model) + return pages + } + + @Test + fun `api docs are the same with and without reporting nodes`() { + val expected = apiDocs("without") + assertTrue(expected.size > 3, "only ${expected.size} pages — the docs barely ran") + val leak = sameOrLeak("api docs", expected, apiDocs("with")) + assertTrue(leak == null, leak) + } + + private companion object { + const val THREW = "" + } +} diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java index 8215bc363..165a19285 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java @@ -12,6 +12,7 @@ import com.metaobjects.generator.spring.SpringRenderHelperGenerator; import com.metaobjects.generator.spring.SpringRepositoryGenerator; import com.metaobjects.io.util.IOUtil; +import com.metaobjects.generator.util.GeneratorUtil; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.generator.util.RestSurfaceGate; import com.metaobjects.object.MetaObject; @@ -84,6 +85,9 @@ public JavaApiModel build(MetaDataLoader loader, String project) { // Objects: one unit per object.entity / object.value (entity vs value drives // which symbol categories appliesTo lets through). for (MetaObject obj : loader.getMetaObjects()) { + // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). + // It has no generated API to document, and its derived fields do not exist yet. + if (GeneratorUtil.isReport(obj)) continue; ApiUnit unit = buildObjectUnit(obj, loader); if (unit != null) { units.add(unit); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java index 21d8c0a89..db2a63beb 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNamesGenerator.java @@ -18,6 +18,7 @@ import com.metaobjects.identity.MetaIdentity; import com.metaobjects.index.Index; import com.metaobjects.index.LookupIndex; +import com.metaobjects.generator.util.GeneratorUtil; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.object.MetaObject; import com.metaobjects.source.MetaSource; @@ -127,6 +128,8 @@ public void execute(MetaDataLoader loader) { // Pass 1 — every object that participates in the database (#248). Set emitted = new HashSet<>(); for (MetaObject entity : loader.getMetaObjects()) { + // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). + if (GeneratorUtil.isReport(entity)) continue; if (emit(entity, outRoot, strategy, false)) emitted.add(entity.getName()); } // Pass 2 — the abstract bases those participants EXTEND, each carrying the columns diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java new file mode 100644 index 000000000..5e7a8d69b --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java @@ -0,0 +1,208 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.generator.Generator; +import com.metaobjects.generator.GeneratorRegistry; +import com.metaobjects.generator.GeneratorRegistry.GeneratorInfo; +import com.metaobjects.generator.apidocs.ApiUnit; +import com.metaobjects.generator.apidocs.DocsPaths; +import com.metaobjects.generator.apidocs.JavaApiDocsRenderer; +import com.metaobjects.generator.apidocs.JavaApiModel; +import com.metaobjects.generator.apidocs.JavaApiModelBuilder; +import com.metaobjects.generator.util.GeneratedFileWriter; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * FR-044 Plan 1 — the reporting vocabulary is INERT in every Java generator. + * + *

Plan 1 registers {@code dimension.*}, {@code measure.*}, {@code segment.*} and + * {@code object.report} and validates them at load, but gives none of them output: a + * report's lowering lands in Plan 2/3. Until then a model that USES the vocabulary must + * generate exactly what the same model without it generates, byte for byte, through every + * generator in {@link GeneratorRegistry}. + * + *

The model pair is {@code fixtures/codegen-noop/reporting/{with,without}}, shared with the + * other four ports' copies of this test. {@code with/} carries a report that declares a + * read-only {@code source.rdb @kind: view} (R5 allows one) — the shape that leaked in C#. + */ +public class ReportingInertTest extends SharedRegistryTestBase { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private static final String THREW = ""; + private static final java.util.regex.Pattern GENERATED_ON = + java.util.regex.Pattern.compile("Generated On:[^\\n]*"); + + private static Path model(String variant) { + return SpringTestFixtures.findCorpusRoot().getParent() + .resolve("codegen-noop/reporting/" + variant + "/meta.shop.json"); + } + + private MetaDataLoader load(String variant) throws Exception { + String json = Files.readString(model(variant), StandardCharsets.UTF_8); + // One loader name for both variants: the model tier stamps it into generated code. + return SpringTestFixtures.loadFixture(tempFolder.newFolder().toPath(), "shop", json); + } + + private static Generator instantiate(GeneratorInfo info) throws Exception { + return (Generator) Class.forName(info.classname()).getDeclaredConstructor().newInstance(); + } + + /** + * Run a generator suite into a fresh directory and read back every file it wrote: + * relative path to contents. A throw is recorded as the single entry {@code }, so a + * generator that cannot run from a bare model must at least fail identically. + */ + private Map emit(String variant, List suite) throws Exception { + MetaDataLoader loader = load(variant); + Path outDir = tempFolder.newFolder().toPath(); + Map files = new TreeMap<>(); + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + for (GeneratorInfo info : suite) { + Generator gen = instantiate(info); + run.attributeTo(gen.getClass().getSimpleName()); + Map args = new LinkedHashMap<>(); + args.put("outputDir", outDir.toString()); + args.put("templateRoot", tempFolder.newFolder().toString()); + // The model generator needs its emission shape named; this is the one + // CodegenCompileConformanceTest compiles. + if ("entity".equals(info.stableName())) { + args.put("type", "class"); + args.put("flavor", "pojoAware"); + } + gen.setArgs(args).execute(loader); + } + } catch (Exception | LinkageError e) { + files.clear(); + files.put(THREW, String.valueOf(e.getMessage())); + return files; + } + try (Stream s = Files.walk(outDir)) { + for (Path p : s.filter(Files::isRegularFile).collect(Collectors.toList())) { + // The model tier stamps a wall-clock "Generated On:" line into every class + // header; two runs a second apart differ there and nowhere else by design. + files.put(outDir.relativize(p).toString(), + GENERATED_ON.matcher(Files.readString(p, StandardCharsets.UTF_8)).replaceAll("Generated On:

Metamodel — dimension types

+

Each section below is one dimension.<subType>. The Attributes table lists +the subtype's own + concern-contributed attributes (provider-tagged); universal +documentation attributes are omitted here (see providers.md). +Allowed children lists the structural child rules with their cardinality +(min..max, * = unbounded).

+

dimension.attribute

+

A named group-by attribute of the entity that declares it (FR-044). Groups report rows by a column value as-is. @of names Entity.field: the owning entity, or the @via terminal. @via may follow only to-one hops, so grouping by a related row's column can never multiply the measured rows.

+

When to use: A column a dashboard groups by: product, status, region. Declare it once on the fact entity and reference it by name from reports.

+

Attributes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeTypeRequiredDefaultAllowed valuesProviderDescription
@ofstringyes—Dotted Entity.field reference naming the grouped column (e.g. 'Purchase.programId', or 'Program.title' with @via).
@viastringno—Optional dotted to-one relationship path from the owning entity to the entity @of names (e.g. 'Purchase.program'). Every hop must be @cardinality: one or an identity.reference.
+

Allowed children

+

No structural children.

+

dimension.time

+

A named time dimension (FR-044): groups report rows by a date or timestamp column truncated to a grain. @of names a field.date or field.timestamp. Weeks start on Monday (ISO-8601) in every lowering. A report names it as 'dimension:grain' and the derived report field is (e.g. purchasedAtDay).

+

When to use: Per-day, per-week or per-month series on a dashboard.

+

Attributes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeTypeRequiredDefaultAllowed valuesProviderDescription
@grainsstring[]yeshour, day, week, month, quarter, year—The grains this dimension supports. Weeks start Monday (ISO-8601). 'hour' is refused on a field.date.
@ofstringyes—Dotted Entity.field reference naming the date or timestamp column.
@viastringno—Optional dotted to-one relationship path, as on dimension.attribute.
+

Allowed children

+

No structural children.

+ + +
+ +
+ + diff --git a/site-reference/types/measure.html b/site-reference/types/measure.html new file mode 100644 index 000000000..ee77c5484 --- /dev/null +++ b/site-reference/types/measure.html @@ -0,0 +1,167 @@ + + + + + + Metamodel — `measure` types | MetaObjects + + + + + + + + + + +
+

Metamodel reference

+

Metamodel — measure types

+

Each section below is one measure.<subType>. The Attributes table lists +the subtype's own + concern-contributed attributes (provider-tagged); universal +documentation attributes are omitted here (see providers.md). +Allowed children lists the structural child rules with their cardinality +(min..max, * = unbounded).

+

measure.aggregate

+

A named aggregate over the declaring entity's own rows (FR-044). @agg count without @distinct counts rows; with @distinct it counts distinct values of @of (a list in @of is a distinct count of the tuple). Unlike origin.aggregate, count is NOT distinct by default: a measure aggregates its own rows and dimensions reach only to-one paths, so no join inflates it.

+

When to use: A number a dashboard shows: revenue, purchases, distinct buyers, last activity.

+

Attributes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeTypeRequiredDefaultAllowed valuesProviderDescription
@aggstringyescount, sum, avg, min, max—The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields.
@distinctbooleanno—Count distinct values. Legal only with @agg: count.
@filterfilterno—Optional row scope (a portable attr.filter over the declaring entity's fields). May use relative-date values ({ now: "-P7D" }). Combines with @segment by AND.
@ofstring[]yes—Dotted Entity.field reference(s) on the declaring entity. A bare string is one column; more than one requires @agg: count and @distinct: true.
@segmentstringno—Optional name of a segment declared on the same entity. Combines with @filter by AND.
+

Allowed children

+

No structural children.

+

measure.ratio

+

A named quotient of two measure.aggregate siblings (FR-044), lowered as numerator / NULLIF(denominator, 0) and typed decimal. A zero denominator yields null.

+

When to use: Averages per unit that are not a plain avg: average days engaged per starter.

+

Attributes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeTypeRequiredDefaultAllowed valuesProviderDescription
@denominatorstringyes—Name of a measure.aggregate on the same entity.
@numeratorstringyes—Name of a measure.aggregate on the same entity.
+

Allowed children

+

No structural children.

+ +
+
+ +
+ + diff --git a/site-reference/types/object.html b/site-reference/types/object.html index 5f09b86b5..44c207d52 100644 --- a/site-reference/types/object.html +++ b/site-reference/types/object.html @@ -103,11 +103,14 @@

object.entity

Allowed children

    +
  • dimension.* — 0..*
  • field.* — 0..*
  • identity.* — 0..*
  • index.* — 0..*
  • layout.* — 0..*
  • +
  • measure.* — 0..*
  • relationship.* — 0..*
  • +
  • segment.* — 0..*
  • source.* — 0..*
  • template.* — 0..*
  • validator.* — 0..*
  • @@ -149,6 +152,78 @@

    object.projection

  • source.* — 0..*
  • validator.* — 0..*
+

object.report

+

A declared report (FR-044): a fixed combination of dimensions and measures of ONE entity (@from), compiled to a read-only view. One row per distinct dimension tuple; no dimensions means exactly one row. Fields are DERIVED, not declared: one per dimension (a time dimension's field is , e.g. purchasedAtDay) and one per measure. Every measure must belong to @from (two fact tables are two reports). @filter and @segment scope rows before grouping and combine by AND. Read-only: no writes, no get-by-id.

+

Owning provider: metaobjects-core-types

+

When to use: Dashboard totals, per-day series and per-group summaries that would otherwise be hand-written GROUP BY queries.

+

Attributes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeTypeRequiredDefaultAllowed valuesProviderDescription
@dimensionsstring[]nometaobjects-core-typesDimension names of @from; a time dimension is written 'name:grain'. Absent means one global row.
@filterfilternometaobjects-core-typesOptional row scope over @from's fields; may use relative-date values.
@fromstringyesmetaobjects-core-typesThe object.entity whose rows the report aggregates.
@measuresstring[]yesmetaobjects-core-typesMeasure names of @from.
@segmentstringnometaobjects-core-typesOptional segment of @from scoping the rows.
+

Allowed children

+
    +
  • field.* — 0..*
  • +
  • identity.* — 0..*
  • +
  • index.* — 0..*
  • +
  • layout.* — 0..*
  • +
  • source.* — 0..*
  • +
  • validator.* — 0..*
  • +

object.value

A value object — pure shape with NO identity and NO source, ever. Constructed (by caller / embedding), never populated from a store. May extends an entity's fields to reuse shape. Equality is by content.

Owning provider: metaobjects-core-types

diff --git a/site-reference/types/segment.html b/site-reference/types/segment.html new file mode 100644 index 000000000..55e54d452 --- /dev/null +++ b/site-reference/types/segment.html @@ -0,0 +1,94 @@ + + + + + + Metamodel — `segment` types | MetaObjects + + + + + + + + + + +
+

Metamodel reference

+

Metamodel — segment types

+

Each section below is one segment.<subType>. The Attributes table lists +the subtype's own + concern-contributed attributes (provider-tagged); universal +documentation attributes are omitted here (see providers.md). +Allowed children lists the structural child rules with their cardinality +(min..max, * = unbounded).

+

segment.filter

+

A named, reusable row filter on the declaring entity (FR-044). Measures and reports reference it by name; exporters emit it as a named segment.

+

When to use: The same filter (e.g. 'active purchase') would otherwise be repeated in several measures or reports.

+

Attributes

+ + + + + + + + + + + + + + + + + + + + + +
AttributeTypeRequiredDefaultAllowed valuesProviderDescription
@filterfilteryes—The row scope: a portable attr.filter over the declaring entity's fields. May use relative-date values.
+

Allowed children

+

No structural children.

+ +
+
+ +
+ + diff --git a/spec/metamodel/object.json b/spec/metamodel/object.json index 65bf80f74..cb5f9946f 100644 --- a/spec/metamodel/object.json +++ b/spec/metamodel/object.json @@ -24,6 +24,9 @@ "children": [ { "type": "relationship", "subType": "*", "name": "*", "min": 0, "max": null }, { "type": "template", "subType": "*", "name": "*", "min": 0, "max": null }, + { "type": "dimension", "subType": "*", "name": "*", "min": 0, "max": null }, + { "type": "measure", "subType": "*", "name": "*", "min": 0, "max": null }, + { "type": "segment", "subType": "*", "name": "*", "min": 0, "max": null }, { "type": "attr", "subType": "string", "name": "discriminator", "min": 0, "max": 1, "description": "FR-014: names the field on this entity (resolvable via extends:) that holds the subtype-discriminator value. Subtypes of this entity declare @discriminatorValue to bind their rows to a discriminator value. The discriminator field itself is an ordinary field declaration (typically field.enum or field.int / field.string)." }, { "type": "attr", "subType": "string", "name": "discriminatorValue", "min": 0, "max": 1, "description": "FR-014: on a subtype of an entity with @discriminator — the value that identifies rows of this subtype in the shared discriminator field. Wire form is always a string; the underlying field's subtype (enum / int / string) controls codegen + storage coercion. Required on every concrete subtype of a discriminated entity." } ] @@ -49,6 +52,20 @@ "children": [ { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row-scope predicate (a portable attr.filter object: eq/ne/gt/gte/lt/lte/like/in/isNull with and/or, desugared to { field: { op: value } } at parse time) selecting which rows the view returns — lowered to an outer SQL WHERE. Resolves against the projection's own declared fields; an aggregate-derived field is not addressable (fail-closed)." } ] + }, + { + "type": "object", + "subType": "report", + "extendsBase": true, + "description": "A declared report (FR-044): a fixed combination of dimensions and measures of ONE entity (@from), compiled to a read-only view. One row per distinct dimension tuple; no dimensions means exactly one row. Fields are DERIVED, not declared: one per dimension (a time dimension's field is , e.g. purchasedAtDay) and one per measure. Every measure must belong to @from (two fact tables are two reports). @filter and @segment scope rows before grouping and combine by AND. Read-only: no writes, no get-by-id.", + "whenToUse": "Dashboard totals, per-day series and per-group summaries that would otherwise be hand-written GROUP BY queries.", + "children": [ + { "type": "attr", "subType": "string", "name": "from", "min": 1, "max": 1, "description": "The object.entity whose rows the report aggregates." }, + { "type": "attr", "subType": "string", "name": "dimensions", "isArray": true, "min": 0, "max": 1, "description": "Dimension names of @from; a time dimension is written 'name:grain'. Absent means one global row." }, + { "type": "attr", "subType": "string", "name": "measures", "isArray": true, "min": 1, "max": 1, "description": "Measure names of @from." }, + { "type": "attr", "subType": "string", "name": "segment", "min": 0, "max": 1, "description": "Optional segment of @from scoping the rows." }, + { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row scope over @from's fields; may use relative-date values." } + ] } ] } diff --git a/spec/metamodel/reporting.json b/spec/metamodel/reporting.json new file mode 100644 index 000000000..024196d63 --- /dev/null +++ b/spec/metamodel/reporting.json @@ -0,0 +1,43 @@ +{ + "provider": "metaobjects-core-types", + "types": [ + { "type": "dimension", "subType": "attribute", + "description": "A named group-by attribute of the entity that declares it (FR-044). Groups report rows by a column value as-is. @of names Entity.field: the owning entity, or the @via terminal. @via may follow only to-one hops, so grouping by a related row's column can never multiply the measured rows.", + "whenToUse": "A column a dashboard groups by: product, status, region. Declare it once on the fact entity and reference it by name from reports.", + "children": [ + { "type": "attr", "subType": "string", "name": "of", "min": 1, "max": 1, "description": "Dotted Entity.field reference naming the grouped column (e.g. 'Purchase.programId', or 'Program.title' with @via)." }, + { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted to-one relationship path from the owning entity to the entity @of names (e.g. 'Purchase.program'). Every hop must be @cardinality: one or an identity.reference." } + ] }, + { "type": "dimension", "subType": "time", + "description": "A named time dimension (FR-044): groups report rows by a date or timestamp column truncated to a grain. @of names a field.date or field.timestamp. Weeks start on Monday (ISO-8601) in every lowering. A report names it as 'dimension:grain' and the derived report field is (e.g. purchasedAtDay).", + "whenToUse": "Per-day, per-week or per-month series on a dashboard.", + "children": [ + { "type": "attr", "subType": "string", "name": "of", "min": 1, "max": 1, "description": "Dotted Entity.field reference naming the date or timestamp column." }, + { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted to-one relationship path, as on dimension.attribute." }, + { "type": "attr", "subType": "string", "name": "grains", "isArray": true, "min": 1, "max": 1, "allowedValues": ["hour", "day", "week", "month", "quarter", "year"], "description": "The grains this dimension supports. Weeks start Monday (ISO-8601). 'hour' is refused on a field.date." } + ] }, + { "type": "measure", "subType": "aggregate", + "description": "A named aggregate over the declaring entity's own rows (FR-044). @agg count without @distinct counts rows; with @distinct it counts distinct values of @of (a list in @of is a distinct count of the tuple). Unlike origin.aggregate, count is NOT distinct by default: a measure aggregates its own rows and dimensions reach only to-one paths, so no join inflates it.", + "whenToUse": "A number a dashboard shows: revenue, purchases, distinct buyers, last activity.", + "children": [ + { "type": "attr", "subType": "string", "name": "agg", "min": 1, "max": 1, "allowedValues": ["count", "sum", "avg", "min", "max"], "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields." }, + { "type": "attr", "subType": "string", "name": "of", "isArray": true, "min": 1, "max": 1, "description": "Dotted Entity.field reference(s) on the declaring entity. A bare string is one column; more than one requires @agg: count and @distinct: true." }, + { "type": "attr", "subType": "boolean", "name": "distinct", "min": 0, "max": 1, "description": "Count distinct values. Legal only with @agg: count." }, + { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row scope (a portable attr.filter over the declaring entity's fields). May use relative-date values ({ now: \"-P7D\" }). Combines with @segment by AND." }, + { "type": "attr", "subType": "string", "name": "segment", "min": 0, "max": 1, "description": "Optional name of a segment declared on the same entity. Combines with @filter by AND." } + ] }, + { "type": "measure", "subType": "ratio", + "description": "A named quotient of two measure.aggregate siblings (FR-044), lowered as numerator / NULLIF(denominator, 0) and typed decimal. A zero denominator yields null.", + "whenToUse": "Averages per unit that are not a plain avg: average days engaged per starter.", + "children": [ + { "type": "attr", "subType": "string", "name": "numerator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity." }, + { "type": "attr", "subType": "string", "name": "denominator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity." } + ] }, + { "type": "segment", "subType": "filter", + "description": "A named, reusable row filter on the declaring entity (FR-044). Measures and reports reference it by name; exporters emit it as a named segment.", + "whenToUse": "The same filter (e.g. 'active purchase') would otherwise be repeated in several measures or reports.", + "children": [ + { "type": "attr", "subType": "filter", "name": "filter", "min": 1, "max": 1, "description": "The row scope: a portable attr.filter over the declaring entity's fields. May use relative-date values." } + ] } + ] +} diff --git a/spec/roadmap.md b/spec/roadmap.md index 071290709..479cb2a50 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -161,7 +161,7 @@ under **Shipped**; planned FRs under **Planned** + the **Release plan**. ✅ shi | FR-041 | Public A/B drift benchmark — coding agents with vs without MetaObjects, pre-registered, friction-first | 📋 **design settled 2026-09-12, unbuilt** — the "proving the value" work; it is what licenses the claims FR-042 §4 withholds. Revised after an adversarial two-reviewer design review (spec §14): the scored task set is now held out from the friction pass (the draft tuned Arm B on the tasks it would later be scored on, with no symmetric loop for the control), Arm A is derived from Arm B's generated output so the seeds differ only in the model and the gate, `n` is calibrated from a pilot instead of asserted (one identical config measured 25/51/28 turns at temperature 0 — 41% CV), H2 is time-to-**correct** rather than time-to-done, the primary is analysed intention-to-treat so an arm cannot win by not finishing, and escaped defects are reported split by whether `meta verify` already covers the invariant class (it covers four of five, so H1 is partly definitional for those). First deliverable is the Phase 0b friction log. Design: `docs/superpowers/specs/2026-09-11-fr-041-drift-ab-benchmark-design.md` | 1.x | — | | FR-042 | First-touch positioning — one typed model, two verbs (README, llms, sites) | 🟢 shipped on the four first-touch surfaces — pitch **locked** 2026-09-12 (two verbs: Generate + Verify; requirements fold into Verify; H1 model-first), Verify clause amended 2026-09-14 to "fails or warns", six pillars carry maturity labels, do-not-say list gated in the `gates` lane. **Remaining:** the drift terminal recording for the .dev hero, and the drift-demo page its CTA should point at (spec §8). Design: `docs/superpowers/specs/2026-09-11-fr-042-first-touch-positioning-design.md` | — | — | | FR-043 | **Libraries** — reusable declared design: model metadata + the requirements that make it checkable (+ an implied generator selection), opted into by name | 🟢 **design approved 2026-09-13, un-deferred** — proposed as a SIXTH pillar. Not greenfield: `library/ai/llm-call.yaml` already ships one (opt-in via the loader's `libraries: ["ai"]`, embedded per port under an `embedded-library drift` gate, with `trace-helper` codegen beside it). Generalises it — requirements as a component, discovery through the codegen catalog's `kind: "library"`, declared package→generator coupling replacing `trace-helper`'s hard-coded `LlmCallBase`, `overlay: true` as the adaptation door and `meta eject` for repackaging. **No new metamodel vocabulary; `metamodelVersion` does not move.** Second library `iam` (users, typed nestable groups, roles as permission bundles, global and group-scoped grants) ships `stability: preview`; its model is verified to load clean under strict. Phase 2: third-party authoring over FR-023's deferred transports. Design: `docs/superpowers/specs/2026-09-13-fr-043-feature-and-nfr-packages-design.md` | 1.1 | — | -| FR-044 | **Core reporting** — declared measures, dimensions, segments and reports (compiled to SQL views in every port), plus Cube and dbt MetricFlow exporters | 📋 **requirements drafted 2026-10-02** — Cube-shaped vocabulary in the core, served in-repo as views; a full semantic layer is delegated to established tools through reference exporters. A query-time engine is parked with a re-entry trigger. Six open decisions (D1–D6) await the maintainer. Additive: `metamodelVersion` 1.0 → 1.1. Depends on FR-037 R5 wave 1 (arithmetic) for `measure.derived` only. Design: `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` | 1.1 | [#391](https://github.com/metaobjectsdev/metaobjects/issues/391) | +| FR-044 | **Core reporting** — declared measures, dimensions, segments and reports (compiled to SQL views in every port), plus Cube and dbt MetricFlow exporters | 🟢 **Plan 1 of 5 shipped on `main` (2026-10-03)** — the vocabulary (`dimension.attribute`, `dimension.time`, `measure.aggregate`, `measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value) is registered and loader-validated in all five ports, gated by 27 new conformance fixtures and the new `codegen-noop` corpus; decisions D1–D6 are settled. `metamodelVersion` reads **`1.1`** on `main` (1.0.x PATCH releases held until 1.1 ships). **Reports generate nothing yet** — view lowering and REST are Plans 2–3, then the Cube / dbt MetricFlow exporters; `measure.derived` stays unregistered until FR-037 R5. A query-time engine remains parked with its re-entry trigger. Feature doc: `docs/features/reporting.md`. Design: `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` | 1.1 | [#391](https://github.com/metaobjectsdev/metaobjects/issues/391) | _(FR-001 was the original metamodel foundation — pre-dates the FR-numbered tracking.)_ _(FR-032 was developed under the working number "FR-026" — see commit history; renumbered to avoid the FR-026=Forms collision. Design: `docs/superpowers/specs/2026-06-13-fr-032-canonical-fqn-refs-design.md`, ADR-0032.)_