From 59795abbac5756c2d5b3fc4c458cd1c962167de3 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 13:30:17 -0400 Subject: [PATCH 01/29] feat(metamodel): register the FR-044 reporting vocabulary in TypeScript (metamodel 1.1) Registers dimension.attribute, dimension.time, measure.aggregate, measure.ratio, segment.filter and object.report (the ADR-0023 list agreed for FR-044), with their node classes and the report accessors Plan 2's lowering will use. object.entity gains dimension/measure/segment child wildcards; object.report inherits the object.base children like object.projection. measure.derived is deliberately absent. metamodelVersion moves 1.0 -> 1.1; the version check classifies all nine changes ADDITIVE. The registry manifests of the other four ports are regenerated for the byte-match, but those ports do not register the vocabulary yet, so their registry-conformance is red until the fan-out lands. Also updates the derived artifacts the vocabulary changes: metamodel docs, site payload and reference, the registry coverage baseline (the six new subtypes are untested until the conformance fixtures land), the BASE_TYPES count, and the project's own requirements ledger with the harness regenerated from it. --- examples/showcase/site-payload.json | 4 +- fixtures/metamodel-docs/expected/INDEX.md | 6 + fixtures/metamodel-docs/expected/providers.md | 3 +- .../expected/types/dimension.md | 47 ++++ .../metamodel-docs/expected/types/measure.md | 49 ++++ .../metamodel-docs/expected/types/object.md | 30 ++ .../metamodel-docs/expected/types/segment.md | 28 ++ .../registry-conformance/coverage-report.json | 8 +- .../expected-registry.json | 261 +++++++++++++++++- .../csharp/RequirementHarnessTests.cs | 26 +- .../java/RequirementHarnessTest.java | 32 ++- .../kotlin/RequirementHarnessTest.kt | 32 ++- .../python/test_requirement_harness.py | 32 ++- .../typescript/requirement-harness.test.ts | 32 ++- metaobjects/meta.requirements.yaml | 94 +++++++ scripts/generate-embedded-metamodel.ts | 2 + scripts/site/reference.test.ts | 4 +- server/csharp/MetaObjects/RegistryManifest.cs | 2 +- .../registry/RegistryManifest.java | 2 +- .../src/metaobjects/registry_manifest.py | 2 +- .../codegen-ts/test/shared-model-file.test.ts | 2 +- .../packages/metadata/src/constants.ts | 1 + .../packages/metadata/src/core-types.ts | 41 +++ .../src/core/object/object-constants.ts | 17 ++ .../core/object/object-definition.embedded.ts | 72 +++++ .../src/core/reporting/meta-dimension.ts | 41 +++ .../src/core/reporting/meta-measure.ts | 67 +++++ .../src/core/reporting/meta-segment.ts | 14 + .../src/core/reporting/report-accessors.ts | 46 +++ .../src/core/reporting/reporting-constants.ts | 74 +++++ .../reporting-definition.embedded.ts | 174 ++++++++++++ .../typescript/packages/metadata/src/index.ts | 11 + .../metadata/src/registry-manifest.ts | 2 +- .../metadata/src/shared/base-types.ts | 9 + .../packages/metadata/test/index.test.ts | 2 +- .../object-definition-completeness.test.ts | 33 ++- .../metadata/test/reporting-registry.test.ts | 33 +++ site-reference/index.html | 30 ++ site-reference/providers.html | 3 +- site-reference/types/dimension.html | 149 ++++++++++ site-reference/types/measure.html | 167 +++++++++++ site-reference/types/object.html | 75 +++++ site-reference/types/segment.html | 94 +++++++ spec/metamodel/object.json | 17 ++ spec/metamodel/reporting.json | 43 +++ 45 files changed, 1890 insertions(+), 23 deletions(-) create mode 100644 fixtures/metamodel-docs/expected/types/dimension.md create mode 100644 fixtures/metamodel-docs/expected/types/measure.md create mode 100644 fixtures/metamodel-docs/expected/types/segment.md create mode 100644 server/typescript/packages/metadata/src/core/reporting/meta-dimension.ts create mode 100644 server/typescript/packages/metadata/src/core/reporting/meta-measure.ts create mode 100644 server/typescript/packages/metadata/src/core/reporting/meta-segment.ts create mode 100644 server/typescript/packages/metadata/src/core/reporting/report-accessors.ts create mode 100644 server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts create mode 100644 server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts create mode 100644 server/typescript/packages/metadata/test/reporting-registry.test.ts create mode 100644 site-reference/types/dimension.html create mode 100644 site-reference/types/measure.html create mode 100644 site-reference/types/segment.html create mode 100644 spec/metamodel/reporting.json diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index f9c50bb7f..f48913888 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 + "baseTypes": 17 }, "snippets": { "showcase-model": { 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/registry-conformance/coverage-report.json b/fixtures/registry-conformance/coverage-report.json index e16b558ef..a2ec53f47 100644 --- a/fixtures/registry-conformance/coverage-report.json +++ b/fixtures/registry-conformance/coverage-report.json @@ -1,5 +1,5 @@ { - "registeredSubTypeCount": 59, + "registeredSubTypeCount": 65, "exercisedSubTypeCount": 47, "untestedSubTypes": [ "attr.boolean", @@ -10,6 +10,12 @@ "attr.int", "attr.intMap", "attr.long", + "dimension.attribute", + "dimension.time", + "measure.aggregate", + "measure.ratio", + "object.report", + "segment.filter", "validator.atLeastOne", "validator.comparison", "validator.presentIff", 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/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/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java index 599f5f73e..14d009c8c 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java +++ b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java @@ -274,7 +274,7 @@ public static List metamodelProviders() { * the 1.0 cut will flip it to {@code "1.0"}. Mirrors the TS reference's * {@code METAMODEL_VERSION} constant. */ - public static final String METAMODEL_VERSION = "1.0"; + public static final String METAMODEL_VERSION = "1.1"; // ------------------------------------------------------------------ // SP-G Phase1 Units2-3 — manifest emitter exclusions (documented, uniform diff --git a/server/python/src/metaobjects/registry_manifest.py b/server/python/src/metaobjects/registry_manifest.py index 19483a0ea..4027a38e7 100644 --- a/server/python/src/metaobjects/registry_manifest.py +++ b/server/python/src/metaobjects/registry_manifest.py @@ -255,7 +255,7 @@ def _sorted_children(definition: TypeDefinition) -> list[dict[str, object]]: return children -METAMODEL_VERSION = "1.0" +METAMODEL_VERSION = "1.1" """Rolled-up spec-version for the cross-port registry manifest. ``"0"`` = pre-1.0 / unstable (semver major-0). Flips to ``"1.0"`` at the diff --git a/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts b/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts index 33d6258dd..ac9d4d7cb 100644 --- a/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts @@ -208,7 +208,7 @@ describe("sharedModelFile()", () => { schema_version: 1, name: "acme-common", version: "1.0.0", - metamodelVersion: "1.0", + metamodelVersion: "1.1", artifact: "acme-common.metaobjects.json", integrity: PINNED_HASH, packages: ["acme::common"], diff --git a/server/typescript/packages/metadata/src/constants.ts b/server/typescript/packages/metadata/src/constants.ts index 90050fe1a..ea5362283 100644 --- a/server/typescript/packages/metadata/src/constants.ts +++ b/server/typescript/packages/metadata/src/constants.ts @@ -45,6 +45,7 @@ export * from "./core/index/index-constants.js"; export * from "./core/object/object-constants.js"; export * from "./core/query/query-constants.js"; export * from "./core/relationship/relationship-constants.js"; +export * from "./core/reporting/reporting-constants.js"; export * from "./core/validator/validator-constants.js"; export * from "./persistence/db/db-constants.js"; export * from "./persistence/origin/origin-constants.js"; diff --git a/server/typescript/packages/metadata/src/core-types.ts b/server/typescript/packages/metadata/src/core-types.ts index 6144aa720..3f9290dbf 100644 --- a/server/typescript/packages/metadata/src/core-types.ts +++ b/server/typescript/packages/metadata/src/core-types.ts @@ -60,6 +60,15 @@ import { INDEX_SUBTYPES } from "./core/index/index-constants.js"; import { MetaRequirement } from "./core/requirement/meta-requirement.js"; import { REQUIREMENT_DEFINITION } from "./core/requirement/requirement-definition.embedded.js"; import { REQUIREMENT_SUBTYPES } from "./core/requirement/requirement-constants.js"; +import { MetaDimension } from "./core/reporting/meta-dimension.js"; +import { MetaMeasure } from "./core/reporting/meta-measure.js"; +import { MetaSegment } from "./core/reporting/meta-segment.js"; +import { REPORTING_DEFINITION } from "./core/reporting/reporting-definition.embedded.js"; +import { + DIMENSION_SUBTYPES, + MEASURE_SUBTYPES, + SEGMENT_SUBTYPES, +} from "./core/reporting/reporting-constants.js"; import { TYPE_METADATA, TYPE_OBJECT, @@ -75,6 +84,9 @@ import { TYPE_TEMPLATE, TYPE_INDEX, TYPE_REQUIREMENT, + TYPE_DIMENSION, + TYPE_MEASURE, + TYPE_SEGMENT, SUBTYPE_ROOT, } from "./shared/base-types.js"; import { CHILD_RULE_WILDCARD } from "./shared/structural.js"; @@ -510,6 +522,35 @@ function registerCoreTypeDefs(registry: TypeRegistry): void { registry.register(reqDef); } + // 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 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). + const REPORTING_FACTORIES: FactoryMap = { + ...Object.fromEntries( + DIMENSION_SUBTYPES.map((subType) => [ + `${TYPE_DIMENSION}.${subType}`, + (typeId: TypeId, name: string) => new MetaDimension(typeId, name), + ]), + ), + ...Object.fromEntries( + MEASURE_SUBTYPES.map((subType) => [ + `${TYPE_MEASURE}.${subType}`, + (typeId: TypeId, name: string) => new MetaMeasure(typeId, name), + ]), + ), + ...Object.fromEntries( + SEGMENT_SUBTYPES.map((subType) => [ + `${TYPE_SEGMENT}.${subType}`, + (typeId: TypeId, name: string) => new MetaSegment(typeId, name), + ]), + ), + }; + for (const reportingDef of defineProviderFromData(REPORTING_DEFINITION, REPORTING_FACTORIES)) { + registry.register(reportingDef); + } + // Declare the core cross-references ON their TypeDefinitions, so the loader's // registry-derived validation resolves them generically (a dangling target fails the // load). Set on the concrete subtypes the parser produces. (Production moves these into diff --git a/server/typescript/packages/metadata/src/core/object/object-constants.ts b/server/typescript/packages/metadata/src/core/object/object-constants.ts index 464f6bab3..50c55be27 100644 --- a/server/typescript/packages/metadata/src/core/object/object-constants.ts +++ b/server/typescript/packages/metadata/src/core/object/object-constants.ts @@ -14,12 +14,15 @@ import { SUBTYPE_BASE } from "../../shared/base-types.js"; export const OBJECT_SUBTYPE_ENTITY = "entity"; export const OBJECT_SUBTYPE_VALUE = "value"; export const OBJECT_SUBTYPE_PROJECTION = "projection"; +// - report : declared dimensions x measures of one entity, compiled to a read-only view (FR-044) +export const OBJECT_SUBTYPE_REPORT = "report"; export const OBJECT_SUBTYPES = [ SUBTYPE_BASE, OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, OBJECT_SUBTYPE_PROJECTION, + OBJECT_SUBTYPE_REPORT, ] as const; export type ObjectSubType = (typeof OBJECT_SUBTYPES)[number]; @@ -49,3 +52,17 @@ export const OBJECT_ATTR_DISCRIMINATOR_VALUE = "discriminatorValue"; * but resolves against the projection's OWN declared fields rather than one * aggregated entity. An aggregate-derived field is not addressable (fail-closed). */ export const OBJECT_PROJECTION_ATTR_FILTER = "filter"; + +// --------------------------------------------------------------------------- +// FR-044 — object.report attrs +// --------------------------------------------------------------------------- +/** The object.entity whose rows the report aggregates. */ +export const OBJECT_REPORT_ATTR_FROM = "from"; +/** Dimension names of @from; a time dimension is written `name:grain`. */ +export const OBJECT_REPORT_ATTR_DIMENSIONS = "dimensions"; +/** Measure names of @from. */ +export const OBJECT_REPORT_ATTR_MEASURES = "measures"; +/** Optional segment of @from scoping the rows. */ +export const OBJECT_REPORT_ATTR_SEGMENT = "segment"; +/** Optional row scope over @from's fields (an attr.filter). */ +export const OBJECT_REPORT_ATTR_FILTER = "filter"; diff --git a/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts b/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts index 0e107f30c..dc19e244c 100644 --- a/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts @@ -80,6 +80,27 @@ export const OBJECT_DEFINITION: ProviderDefinition = { "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", @@ -132,6 +153,57 @@ export const OBJECT_DEFINITION: ProviderDefinition = { "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/typescript/packages/metadata/src/core/reporting/meta-dimension.ts b/server/typescript/packages/metadata/src/core/reporting/meta-dimension.ts new file mode 100644 index 000000000..5550b91ff --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/meta-dimension.ts @@ -0,0 +1,41 @@ +// MetaDimension — concrete node class for type=dimension nodes (FR-044). +// +// Extends MetaData directly. Accessors are RESOLVING (ADR-0039): a dimension +// declared on an abstract base entity is read through the same accessors as one +// declared on the concrete entity. + +import { MetaData } from "../../shared/meta-data.js"; +import { + DIMENSION_SUBTYPE_TIME, + REPORTING_ATTR_GRAINS, + REPORTING_ATTR_OF, + REPORTING_ATTR_VIA, + TIME_GRAINS, + type TimeGrain, +} from "./reporting-constants.js"; + +export class MetaDimension extends MetaData { + /** True for `dimension.time` (grain truncation); false for `dimension.attribute`. */ + isTime(): boolean { + return this.subType === DIMENSION_SUBTYPE_TIME; + } + + /** Dotted `Entity.field` reference naming the grouped column. */ + of(): string | undefined { + const v = this.attr(REPORTING_ATTR_OF); + return typeof v === "string" ? v : undefined; + } + + /** Optional dotted to-one relationship path from the owning entity to the `@of` entity. */ + via(): string | undefined { + const v = this.attr(REPORTING_ATTR_VIA); + return typeof v === "string" ? v : undefined; + } + + /** The grains a `dimension.time` supports (empty for `dimension.attribute`). */ + grains(): TimeGrain[] { + const v = this.attr(REPORTING_ATTR_GRAINS); + const items = Array.isArray(v) ? v : typeof v === "string" ? [v] : []; + return items.filter((g): g is TimeGrain => (TIME_GRAINS as readonly unknown[]).includes(g)); + } +} diff --git a/server/typescript/packages/metadata/src/core/reporting/meta-measure.ts b/server/typescript/packages/metadata/src/core/reporting/meta-measure.ts new file mode 100644 index 000000000..9274d401c --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/meta-measure.ts @@ -0,0 +1,67 @@ +// MetaMeasure — concrete node class for type=measure nodes (FR-044). +// +// Extends MetaData directly. Accessors are RESOLVING (ADR-0039). + +import { MetaData } from "../../shared/meta-data.js"; +import { + MEASURE_AGGS, + MEASURE_SUBTYPE_RATIO, + REPORTING_ATTR_AGG, + REPORTING_ATTR_DENOMINATOR, + REPORTING_ATTR_DISTINCT, + REPORTING_ATTR_FILTER, + REPORTING_ATTR_NUMERATOR, + REPORTING_ATTR_OF, + REPORTING_ATTR_SEGMENT, + type MeasureAgg, +} from "./reporting-constants.js"; + +function optionalString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} + +export class MetaMeasure extends MetaData { + /** True for `measure.ratio`; false for `measure.aggregate`. */ + isRatio(): boolean { + return this.subType === MEASURE_SUBTYPE_RATIO; + } + + /** The aggregate function (`measure.aggregate` only). */ + agg(): MeasureAgg | undefined { + const v = this.attr(REPORTING_ATTR_AGG); + return (MEASURE_AGGS as readonly unknown[]).includes(v) ? (v as MeasureAgg) : undefined; + } + + /** True when `@distinct` is set (legal only with `@agg: count`). */ + distinct(): boolean { + return this.attr(REPORTING_ATTR_DISTINCT) === true; + } + + /** The `Entity.field` references in `@of`: a bare string is one column, a list is the tuple form. */ + ofColumns(): string[] { + const v = this.attr(REPORTING_ATTR_OF); + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string"); + return typeof v === "string" ? [v] : []; + } + + /** Name of a segment declared on the same entity; combines with `@filter` by AND. */ + segmentName(): string | undefined { + return optionalString(this.attr(REPORTING_ATTR_SEGMENT)); + } + + /** The canonical row-scope filter, when one is declared. */ + filter(): Record | undefined { + const v = this.attr(REPORTING_ATTR_FILTER); + return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) : undefined; + } + + /** Name of the `measure.aggregate` sibling used as the numerator (`measure.ratio` only). */ + numerator(): string | undefined { + return optionalString(this.attr(REPORTING_ATTR_NUMERATOR)); + } + + /** Name of the `measure.aggregate` sibling used as the denominator (`measure.ratio` only). */ + denominator(): string | undefined { + return optionalString(this.attr(REPORTING_ATTR_DENOMINATOR)); + } +} diff --git a/server/typescript/packages/metadata/src/core/reporting/meta-segment.ts b/server/typescript/packages/metadata/src/core/reporting/meta-segment.ts new file mode 100644 index 000000000..1834319ea --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/meta-segment.ts @@ -0,0 +1,14 @@ +// MetaSegment — concrete node class for type=segment nodes (FR-044). +// +// Extends MetaData directly. Accessors are RESOLVING (ADR-0039). + +import { MetaData } from "../../shared/meta-data.js"; +import { REPORTING_ATTR_FILTER } from "./reporting-constants.js"; + +export class MetaSegment extends MetaData { + /** The named row scope: a canonical attr.filter over the declaring entity's fields. */ + filter(): Record | undefined { + const v = this.attr(REPORTING_ATTR_FILTER); + return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) : undefined; + } +} diff --git a/server/typescript/packages/metadata/src/core/reporting/report-accessors.ts b/server/typescript/packages/metadata/src/core/reporting/report-accessors.ts new file mode 100644 index 000000000..af3a6e324 --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/report-accessors.ts @@ -0,0 +1,46 @@ +// 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. + +import type { MetaData } from "../../shared/meta-data.js"; +import { + OBJECT_REPORT_ATTR_DIMENSIONS, + OBJECT_REPORT_ATTR_FROM, + OBJECT_REPORT_ATTR_MEASURES, +} from "../object/object-constants.js"; +import { REPORT_DIMENSION_GRAIN_SEPARATOR } from "./reporting-constants.js"; + +export interface ReportDimensionItem { + readonly name: string; + readonly grain?: string; +} + +function stringList(v: unknown): string[] { + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string"); + return typeof v === "string" ? [v] : []; +} + +/** The `@from` entity name of a report. */ +export function reportFrom(obj: MetaData): string | undefined { + const v = obj.attr(OBJECT_REPORT_ATTR_FROM); + return typeof v === "string" ? v : undefined; +} + +/** The `@dimensions` items, each `name` or `name:grain`. */ +export function reportDimensionItems(obj: MetaData): ReportDimensionItem[] { + return stringList(obj.attr(OBJECT_REPORT_ATTR_DIMENSIONS)).map((raw) => { + const i = raw.indexOf(REPORT_DIMENSION_GRAIN_SEPARATOR); + return i === -1 ? { name: raw } : { name: raw.slice(0, i), grain: raw.slice(i + 1) }; + }); +} + +/** The `@measures` names. */ +export function reportMeasureNames(obj: MetaData): string[] { + return stringList(obj.attr(OBJECT_REPORT_ATTR_MEASURES)); +} + +/** The derived report field for a dimension item: `name` (attribute) or `name` + Capitalized(grain) (time). */ +export function reportDerivedFieldName(item: ReportDimensionItem): string { + if (item.grain === undefined || item.grain === "") return item.name; + return item.name + item.grain.charAt(0).toUpperCase() + item.grain.slice(1); +} diff --git a/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts new file mode 100644 index 000000000..ad3a9a6d4 --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts @@ -0,0 +1,74 @@ +// Reporting concern constants (FR-044) — type names, subtypes, attr keys and +// the closed sets the registry enforces through `allowedValues`. +// +// `dimension`, `measure` and `segment` are children of `object.entity` only +// (never root-level), and `object.report` (see object-constants.ts) references +// them by name. The type-name constants live in shared/base-types.ts beside +// every other base type and are re-exported here so a reporting consumer has +// one import site. + +import { TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT } from "../../shared/base-types.js"; + +export { TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT }; + +// --------------------------------------------------------------------------- +// Subtypes +// --------------------------------------------------------------------------- + +/** Groups by a column value as-is (no grain, no truncation). */ +export const DIMENSION_SUBTYPE_ATTRIBUTE = "attribute"; +/** Groups by a date/timestamp column truncated to a grain. */ +export const DIMENSION_SUBTYPE_TIME = "time"; +export const DIMENSION_SUBTYPES = [DIMENSION_SUBTYPE_ATTRIBUTE, DIMENSION_SUBTYPE_TIME] as const; +export type DimensionSubType = (typeof DIMENSION_SUBTYPES)[number]; + +/** One aggregate over the declaring entity's own rows. */ +export const 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. */ +export const MEASURE_SUBTYPE_RATIO = "ratio"; +export const MEASURE_SUBTYPES = [MEASURE_SUBTYPE_AGGREGATE, MEASURE_SUBTYPE_RATIO] as const; +export type MeasureSubType = (typeof MEASURE_SUBTYPES)[number]; + +/** A named, reusable row filter. The only concrete segment subtype: every `*.base` + * in the registry is an abstract anchor, so authors write `segment.filter`. */ +export const SEGMENT_SUBTYPE_FILTER = "filter"; +export const SEGMENT_SUBTYPES = [SEGMENT_SUBTYPE_FILTER] as const; +export type SegmentSubType = (typeof SEGMENT_SUBTYPES)[number]; + +// --------------------------------------------------------------------------- +// Attrs (on dimension / measure / segment nodes) +// --------------------------------------------------------------------------- + +/** Dotted `Entity.field` reference(s) naming the grouped / aggregated column(s). */ +export const REPORTING_ATTR_OF = "of"; +/** Optional dotted to-one relationship path from the owning entity to the `@of` entity. */ +export const REPORTING_ATTR_VIA = "via"; +/** The grains a `dimension.time` supports. */ +export const REPORTING_ATTR_GRAINS = "grains"; +/** The aggregate function of a `measure.aggregate`. */ +export const REPORTING_ATTR_AGG = "agg"; +/** Count distinct values (legal only with `@agg: count`). */ +export const REPORTING_ATTR_DISTINCT = "distinct"; +/** Row scope (an attr.filter) on a `measure.aggregate` or `segment.filter`. */ +export const REPORTING_ATTR_FILTER = "filter"; +/** Name of a segment declared on the same entity. */ +export const REPORTING_ATTR_SEGMENT = "segment"; +/** `measure.ratio` operand names. */ +export const REPORTING_ATTR_NUMERATOR = "numerator"; +export const 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. */ +export const TIME_GRAINS = ["hour", "day", "week", "month", "quarter", "year"] as const; +export type TimeGrain = (typeof TIME_GRAINS)[number]; + +export const MEASURE_AGGS = ["count", "sum", "avg", "min", "max"] as const; +export type MeasureAgg = (typeof MEASURE_AGGS)[number]; + +/** Separator in an `object.report` `@dimensions` item: `name` or `name:grain`. */ +export const REPORT_DIMENSION_GRAIN_SEPARATOR = ":"; diff --git a/server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts b/server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts new file mode 100644 index 000000000..f6a7457c9 --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts @@ -0,0 +1,174 @@ +// AUTO-GENERATED by scripts/generate-embedded-metamodel.ts — DO NOT EDIT. +// Canonical source: repo-root spec/metamodel/reporting.json +// Regenerate: bun run scripts/generate-embedded-metamodel.ts +// +// Embeds the canonical FR-033 ProviderDefinition so the provider can register +// itself wherever the on-disk spec/ tree is unavailable (bundled builds). +import type { ProviderDefinition } from "../../provider-data.js"; + +export const REPORTING_DEFINITION: ProviderDefinition = { + "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/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 201dda6ea..bd01d7b6c 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -42,6 +42,17 @@ export * from "./core/identity/identity-constants.js"; export * from "./core/index/index-constants.js"; export * from "./core/requirement/requirement-constants.js"; export { MetaRequirement } from "./core/requirement/meta-requirement.js"; +export * from "./core/reporting/reporting-constants.js"; +export { MetaDimension } from "./core/reporting/meta-dimension.js"; +export { MetaMeasure } from "./core/reporting/meta-measure.js"; +export { MetaSegment } from "./core/reporting/meta-segment.js"; +export { + reportFrom, + reportDimensionItems, + reportMeasureNames, + reportDerivedFieldName, + type ReportDimensionItem, +} from "./core/reporting/report-accessors.js"; // Shared `@implementedBy` resolution — one resolver for the CLI's requirement // checks and codegen's requirement-test fan-out (FR-038). export { diff --git a/server/typescript/packages/metadata/src/registry-manifest.ts b/server/typescript/packages/metadata/src/registry-manifest.ts index 7b442c70f..ac3d6db9d 100644 --- a/server/typescript/packages/metadata/src/registry-manifest.ts +++ b/server/typescript/packages/metadata/src/registry-manifest.ts @@ -112,7 +112,7 @@ export interface ManifestType { * constant read `"0.10"`). Bump with that script — never by hand — so the manifest and * all four port constants move together. */ -export const METAMODEL_VERSION = "1.0"; +export const METAMODEL_VERSION = "1.1"; /** The full canonical manifest. All collections are sorted for byte-stability. */ interface RegistryManifest { diff --git a/server/typescript/packages/metadata/src/shared/base-types.ts b/server/typescript/packages/metadata/src/shared/base-types.ts index 8eedc7cd6..89c53952f 100644 --- a/server/typescript/packages/metadata/src/shared/base-types.ts +++ b/server/typescript/packages/metadata/src/shared/base-types.ts @@ -22,6 +22,12 @@ export const TYPE_INDEX = "index"; /** Capability / requirement records — `requirement.functional` (existence check) * and `requirement.architectural` (universality check). Ruling amendment 3. */ export const 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`. */ +export const TYPE_DIMENSION = "dimension"; +export const TYPE_MEASURE = "measure"; +export const TYPE_SEGMENT = "segment"; export const BASE_TYPES = [ TYPE_METADATA, @@ -38,6 +44,9 @@ export const BASE_TYPES = [ TYPE_TEMPLATE, TYPE_INDEX, TYPE_REQUIREMENT, + TYPE_DIMENSION, + TYPE_MEASURE, + TYPE_SEGMENT, ] as const; export type BaseType = (typeof BASE_TYPES)[number]; diff --git a/server/typescript/packages/metadata/test/index.test.ts b/server/typescript/packages/metadata/test/index.test.ts index 8df79aaf0..a88f22445 100644 --- a/server/typescript/packages/metadata/test/index.test.ts +++ b/server/typescript/packages/metadata/test/index.test.ts @@ -313,7 +313,7 @@ describe("Public API surface — @metaobjectsdev/metadata index", () => { }); test("BASE_TYPES array is exported and correct length", () => { - expect(BASE_TYPES).toHaveLength(14); + expect(BASE_TYPES).toHaveLength(17); expect(BASE_TYPES).toContain(TYPE_OBJECT); expect(BASE_TYPES).toContain(TYPE_FIELD); expect(BASE_TYPES).toContain(TYPE_LAYOUT); diff --git a/server/typescript/packages/metadata/test/object-definition-completeness.test.ts b/server/typescript/packages/metadata/test/object-definition-completeness.test.ts index ede460b4a..0ceeffd04 100644 --- a/server/typescript/packages/metadata/test/object-definition-completeness.test.ts +++ b/server/typescript/packages/metadata/test/object-definition-completeness.test.ts @@ -23,12 +23,16 @@ import { TYPE_OBJECT, TYPE_TEMPLATE, TYPE_RELATIONSHIP, + TYPE_DIMENSION, + TYPE_MEASURE, + TYPE_SEGMENT, } from "../src/shared/base-types.js"; import { OBJECT_SUBTYPES, OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, OBJECT_SUBTYPE_PROJECTION, + OBJECT_SUBTYPE_REPORT, } from "../src/core/object/object-constants.js"; import { SUBTYPE_BASE } from "../src/shared/base-types.js"; @@ -71,10 +75,21 @@ const PROJECTION_FILTER: Record = { filter: { valueType: "filter", required: false }, }; +// FR-044 — object.report carries its five declaration attrs. The array-valued ones +// (@dimensions, @measures) are `string` attrs with isArray, so valueType stays "string". +const REPORT_ATTRS: Record = { + from: { valueType: "string", required: true }, + dimensions: { valueType: "string", required: false }, + measures: { valueType: "string", required: true }, + segment: { valueType: "string", required: false }, + filter: { valueType: "filter", required: false }, +}; + function expectedAttrsFor(subType: string): Record { if (subType === OBJECT_SUBTYPE_ENTITY) return { ...DISCRIMINATOR }; if (subType === OBJECT_SUBTYPE_VALUE) return { ...NORMALIZE }; if (subType === OBJECT_SUBTYPE_PROJECTION) return { ...PROJECTION_FILTER }; + if (subType === OBJECT_SUBTYPE_REPORT) return { ...REPORT_ATTRS }; // base carries NO attrs (discriminator moved to entity only). return {}; } @@ -86,18 +101,27 @@ function expectedAttrsFor(subType: string): Record { // attrs enforce via the named AttrSchema set, ERR_UNKNOWN_ATTR). const BASE_RULE_TYPES = ["field", "identity", "validator", "layout", "source", "index"]; const VALUE_RULE_TYPES = [...BASE_RULE_TYPES, TYPE_RELATIONSHIP]; -const ENTITY_RULE_TYPES = [...BASE_RULE_TYPES, TYPE_RELATIONSHIP, TYPE_TEMPLATE]; +// FR-044: object.entity also hosts the reporting vocabulary (dimension / measure / segment). +const ENTITY_RULE_TYPES = [ + ...BASE_RULE_TYPES, + TYPE_RELATIONSHIP, + TYPE_TEMPLATE, + TYPE_DIMENSION, + TYPE_MEASURE, + TYPE_SEGMENT, +]; const PROJECTION_RULE_TYPES = [...BASE_RULE_TYPES]; function expectedChildTypesFor(subType: string): string[] { if (subType === OBJECT_SUBTYPE_ENTITY) return ENTITY_RULE_TYPES; if (subType === OBJECT_SUBTYPE_VALUE) return VALUE_RULE_TYPES; if (subType === OBJECT_SUBTYPE_PROJECTION) return PROJECTION_RULE_TYPES; + if (subType === OBJECT_SUBTYPE_REPORT) return BASE_RULE_TYPES; // report inherits base only (derived fields) return BASE_RULE_TYPES; // base } describe("object provider — strict per-subtype completeness (FR-033 S1-object)", () => { - test("registers all 4 object subtypes", () => { + test("registers all 5 object subtypes", () => { const registered = registry.allSubTypesOf(TYPE_OBJECT).sort(); expect(registered).toEqual([...OBJECT_SUBTYPES].sort()); }); @@ -139,11 +163,12 @@ describe("object provider — strict per-subtype completeness (FR-033 S1-object) }); } - test("composed childRule counts: base=6, value=7, entity=8, projection=6", () => { + test("composed childRule counts: base=6, value=7, entity=11, projection=6, report=6", () => { expect(registry.find(TYPE_OBJECT, SUBTYPE_BASE)!.childRules.length).toBe(6); expect(registry.find(TYPE_OBJECT, OBJECT_SUBTYPE_VALUE)!.childRules.length).toBe(7); - expect(registry.find(TYPE_OBJECT, OBJECT_SUBTYPE_ENTITY)!.childRules.length).toBe(8); + expect(registry.find(TYPE_OBJECT, OBJECT_SUBTYPE_ENTITY)!.childRules.length).toBe(11); expect(registry.find(TYPE_OBJECT, OBJECT_SUBTYPE_PROJECTION)!.childRules.length).toBe(6); + expect(registry.find(TYPE_OBJECT, OBJECT_SUBTYPE_REPORT)!.childRules.length).toBe(6); }); test("only object.entity carries the template childRule", () => { diff --git a/server/typescript/packages/metadata/test/reporting-registry.test.ts b/server/typescript/packages/metadata/test/reporting-registry.test.ts new file mode 100644 index 000000000..61348de36 --- /dev/null +++ b/server/typescript/packages/metadata/test/reporting-registry.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { composeRegistry, coreProviders } from "../src/index.js"; +import { + TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT, + DIMENSION_SUBTYPE_ATTRIBUTE, DIMENSION_SUBTYPE_TIME, + MEASURE_SUBTYPE_AGGREGATE, MEASURE_SUBTYPE_RATIO, SEGMENT_SUBTYPE_FILTER, + TIME_GRAINS, MEASURE_AGGS, +} from "../src/core/reporting/reporting-constants.js"; +import { TYPE_OBJECT } from "../src/shared/base-types.js"; +import { OBJECT_SUBTYPE_REPORT } from "../src/core/object/object-constants.js"; + +describe("FR-044 reporting vocabulary registration", () => { + const registry = composeRegistry(coreProviders); + + test("registers every agreed type and subtype", () => { + for (const [type, sub] of [ + [TYPE_DIMENSION, DIMENSION_SUBTYPE_ATTRIBUTE], [TYPE_DIMENSION, DIMENSION_SUBTYPE_TIME], + [TYPE_MEASURE, MEASURE_SUBTYPE_AGGREGATE], [TYPE_MEASURE, MEASURE_SUBTYPE_RATIO], + [TYPE_SEGMENT, SEGMENT_SUBTYPE_FILTER], [TYPE_OBJECT, OBJECT_SUBTYPE_REPORT], + ] as const) { + expect(registry.find(type, sub)).toBeDefined(); + } + }); + + test("does not register measure.derived (waits for FR-037 R5)", () => { + expect(registry.find(TYPE_MEASURE, "derived")).toBeUndefined(); + }); + + test("closed sets match the spec", () => { + expect([...TIME_GRAINS]).toEqual(["hour", "day", "week", "month", "quarter", "year"]); + expect([...MEASURE_AGGS]).toEqual(["count", "sum", "avg", "min", "max"]); + }); +}); diff --git a/site-reference/index.html b/site-reference/index.html index ed33a9e6b..d8a331fc6 100644 --- a/site-reference/index.html +++ b/site-reference/index.html @@ -116,6 +116,16 @@ 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 + + +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 + + 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 @@ -236,6 +246,16 @@ 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 + + +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 + + metadata.root Root metadata document types/metadata.md#metadataroot @@ -256,6 +276,11 @@ 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 + + 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 @@ -316,6 +341,11 @@ 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 + + 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 diff --git a/site-reference/providers.html b/site-reference/providers.html index dd4e1e3b8..e6fe700aa 100644 --- a/site-reference/providers.html +++ b/site-reference/providers.html @@ -51,7 +51,7 @@ INDEX.md lists by type.

metaobjects-core-types

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:

  • field.base: @default, @mutability, @required, @unique
  • @@ -68,6 +68,7 @@

    metaobjects-core-types

  • 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/site-reference/types/dimension.html b/site-reference/types/dimension.html new file mode 100644 index 000000000..f8a274a8f --- /dev/null +++ b/site-reference/types/dimension.html @@ -0,0 +1,149 @@ + + + + + + Metamodel — `dimension` types | MetaObjects + + + + + + + + + + +
    +

    Metamodel reference

    +

    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." } + ] } + ] +} From 674a1f8a048761914edace7e4e47cd68e1774b22 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 13:52:35 -0400 Subject: [PATCH 02/29] feat(metadata): validate the FR-044 reporting vocabulary at load (TypeScript) --- fixtures/conformance/ERROR-CODES.json | 6 +- .../src/core/reporting/reporting-constants.ts | 14 + .../packages/metadata/src/errors.ts | 17 + .../metadata/src/loader/meta-data-loader.ts | 5 + .../src/loader/reporting-validation.ts | 827 +++++++++++++++ .../test/reporting-validation.test.ts | 972 ++++++++++++++++++ 6 files changed, 1840 insertions(+), 1 deletion(-) create mode 100644 server/typescript/packages/metadata/src/loader/reporting-validation.ts create mode 100644 server/typescript/packages/metadata/test/reporting-validation.test.ts 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/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts index ad3a9a6d4..c1d6d2f40 100644 --- a/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts @@ -72,3 +72,17 @@ export type MeasureAgg = (typeof MEASURE_AGGS)[number]; /** Separator in an `object.report` `@dimensions` item: `name` or `name:grain`. */ export const REPORT_DIMENSION_GRAIN_SEPARATOR = ":"; + +// --------------------------------------------------------------------------- +// Relative-date filter values (FR-044 R4) +// --------------------------------------------------------------------------- + +/** The single key of a relative-date filter value: `{ now: "" }` + * 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`. */ +export const 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). */ +export const ISO_DURATION_RE = + /^[+-]?P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/; diff --git a/server/typescript/packages/metadata/src/errors.ts b/server/typescript/packages/metadata/src/errors.ts index d4fa63ec3..92d1ee82d 100644 --- a/server/typescript/packages/metadata/src/errors.ts +++ b/server/typescript/packages/metadata/src/errors.ts @@ -218,6 +218,23 @@ export const ERROR_CODES = [ // @fields), whichever is declared supplies no key, or a named field does not exist // on the owning entity's effective (resolved via extends) field set. "ERR_INVALID_INDEX", + // FR-044 — reporting vocabulary (validateReporting, loader/reporting-validation.ts). + // 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 — a requirement.* with @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 diff --git a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts index 02c785c86..85c38fb72 100644 --- a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts +++ b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts @@ -26,6 +26,7 @@ import type { LoaderWarning } from "../source.js"; import { codeSource, resolvedSource } from "../source.js"; import { parseJson } from "../parser-json.js"; import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateM2MJunctionPairing, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js"; +import { validateReporting } from "./reporting-validation.js"; import { runRegisteredValidation } from "./validation-registry.js"; import { validateSourceRoles } from "../persistence/source/validate-source-roles.js"; import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js"; @@ -646,6 +647,10 @@ export class MetaDataLoader { // an aggregate-derived or dangling ref is fail-closed (ERR_BAD_ATTR_FILTER). errors.push(...validateProjectionFilter(root)); + // FR-044 — reporting vocabulary cross-node rules (dimensions, measures, + // segments, reports, relative-date filter values). + errors.push(...validateReporting(root)); + // FR-017 — M:N relationship validation (deferred-resolution): @through names a // junction declaring two identity.reference children; @sourceRefField matches one; // @symmetric is self-join-only + mutually exclusive with @sourceRefField; M:N attrs diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts new file mode 100644 index 000000000..8971b8bb2 --- /dev/null +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -0,0 +1,827 @@ +// FR-044 — cross-node rules for the reporting vocabulary. The rule ids (D1…F2) +// match the rule table in docs/superpowers/plans/2026-10-03-fr-044-plan-1-reporting-vocabulary.md +// and the error fixtures in fixtures/conformance/error-*. Every port implements +// the same table WITH THE SAME MESSAGE TEXT; 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 '')". + +import type { MetaData } from "../shared/meta-data.js"; +import { ParseError, type ErrorCode } from "../errors.js"; +import { resolveObjectRef } from "../naming-refs.js"; +import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js"; +import { + TYPE_OBJECT, + TYPE_FIELD, + TYPE_IDENTITY, + TYPE_RELATIONSHIP, + TYPE_SOURCE, + TYPE_DIMENSION, + TYPE_MEASURE, + TYPE_SEGMENT, +} from "../shared/base-types.js"; +import { + OBJECT_SUBTYPE_ENTITY, + OBJECT_SUBTYPE_REPORT, + OBJECT_REPORT_ATTR_FILTER, + OBJECT_REPORT_ATTR_SEGMENT, +} from "../core/object/object-constants.js"; +import { + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_LONG, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, + FIELD_SUBTYPE_CURRENCY, + FIELD_SUBTYPE_BOOLEAN, + FIELD_SUBTYPE_OBJECT, + FIELD_SUBTYPE_MAP, + FIELD_SUBTYPE_DATE, + FIELD_SUBTYPE_TIMESTAMP, +} from "../core/field/field-constants.js"; +import { + IDENTITY_SUBTYPE_REFERENCE, + IDENTITY_REFERENCE_ATTR_REFERENCES, +} from "../core/identity/identity-constants.js"; +import { + RELATIONSHIP_ATTR_OBJECT_REF, + RELATIONSHIP_ATTR_CARDINALITY, + CARDINALITY_ONE, +} from "../core/relationship/relationship-constants.js"; +import { + FILTER_COMPOSE_OR, + FILTER_COMPOSE_AND, + FILTER_OP_GT, + FILTER_OP_GTE, + FILTER_OP_LT, + FILTER_OP_LTE, + opsForField, +} from "../core/query/query-constants.js"; +import { ATTR_SUBTYPE_FILTER } from "../core/attr/attr-constants.js"; +import { isWritableSource } from "../shared/node-guards.js"; +import { + FILTER_RELATIVE_NOW, + ISO_DURATION_RE, + MEASURE_SUBTYPE_AGGREGATE, +} from "../core/reporting/reporting-constants.js"; +import { MetaDimension } from "../core/reporting/meta-dimension.js"; +import { MetaMeasure } from "../core/reporting/meta-measure.js"; +import { MetaSegment } from "../core/reporting/meta-segment.js"; +import { + reportDerivedFieldName, + reportDimensionItems, + reportFrom, + reportMeasureNames, +} from "../core/reporting/report-accessors.js"; + +// --------------------------------------------------------------------------- +// Closed sets the rules consult +// --------------------------------------------------------------------------- + +/** M4 — the field subtypes `sum`/`avg` accept. */ +const NUMERIC_FIELD_SUBTYPES: readonly string[] = [ + 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). */ +const UNORDERED_FIELD_SUBTYPES: readonly string[] = [ + FIELD_SUBTYPE_BOOLEAN, + FIELD_SUBTYPE_OBJECT, + FIELD_SUBTYPE_MAP, +]; + +/** D3 / F2 — the temporal field subtypes. */ +const TEMPORAL_FIELD_SUBTYPES: readonly string[] = [FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIMESTAMP]; + +/** F2 — the only ops a relative-date value may sit under. */ +const RELATIVE_DATE_OPS: readonly string[] = [FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE]; + +const AGG_COUNT = "count"; +const AGG_SUM = "sum"; +const AGG_AVG = "avg"; +const AGG_MIN = "min"; +const AGG_MAX = "max"; +const GRAIN_HOUR = "hour"; + +const ERR_INVALID_DIMENSION: ErrorCode = "ERR_INVALID_DIMENSION"; +const ERR_INVALID_MEASURE: ErrorCode = "ERR_INVALID_MEASURE"; +const ERR_INVALID_REPORT: ErrorCode = "ERR_INVALID_REPORT"; +const ERR_REPORT_FOREIGN_MEASURE: ErrorCode = "ERR_REPORT_FOREIGN_MEASURE"; +const ERR_BAD_ATTR_FILTER: ErrorCode = "ERR_BAD_ATTR_FILTER"; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +export function validateReporting(root: MetaData): ParseError[] { + const sink = new ErrorSink(); + // ADR-0039: root has no super; children()==ownChildren() but resolving is the default. + const objects = root.children().filter((c) => c.type === TYPE_OBJECT); + const entities = objects.filter((o) => o.subType === OBJECT_SUBTYPE_ENTITY); + + // 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. + for (const entity of entities) checkEntityMembers(root, entity, true, sink); + for (const entity of entities) checkEntityMembers(root, entity, false, sink); + + for (const report of objects.filter((o) => o.subType === OBJECT_SUBTYPE_REPORT)) { + checkReport(root, report, sink); + } + + // F1 on every host that is not a reporting host. + checkNoRelativeDates(root, sink); + return sink.errors; +} + +/** Collects errors, dropping an exact repeat (same node, code and message) — + * the shape an unmodified inherited member's failure takes when it is + * re-validated under an inheriting entity. A message is `head + suffix + body`; + * the repeat test ignores the suffix (" (inherited by '')"), so the + * pass-2 copy of a failure pass 1 already reported is dropped. */ +class ErrorSink { + readonly errors: ParseError[] = []; + private readonly seen = new Map>(); + + push(node: MetaData, code: ErrorCode, head: string, body: string, suffix = ""): void { + const key = `${code}\u0000${head}${body}`; + let keys = this.seen.get(node); + if (keys === undefined) { + keys = new Set(); + this.seen.set(node, keys); + } + if (keys.has(key)) return; + keys.add(key); + this.errors.push(new ParseError(`${head}${suffix}${body}`, { code, source: node.source })); + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** The node's package for ADR-0042 bare-reference resolution. */ +function pkgOf(node: MetaData): string { + return node.package ?? node.fileDefaultPackage ?? ""; +} + +/** + * Split a dotted `Owner.child[.child…]` reference at the first `.` after the + * last `::` (the same rule `_refNamedOwner` applies to extends refs), so an + * FQN owner (`acme::shop::Purchase.program`) keeps its package. Undefined when + * there is no owner, no child, or an empty child segment. + */ +function splitDotted(ref: string): { owner: string; path: string[] } | undefined { + const lastSep = ref.lastIndexOf(PACKAGE_SEPARATOR); + const segStart = lastSep === -1 ? 0 : lastSep + PACKAGE_SEPARATOR.length; + const dot = ref.indexOf(CHILD_REF_SEPARATOR, segStart); + if (dot <= segStart) return undefined; + const path = ref.slice(dot + CHILD_REF_SEPARATOR.length).split(CHILD_REF_SEPARATOR); + if (path.some((s) => s === "")) return undefined; + return { owner: ref.slice(0, dot), path }; +} + +/** True when `candidate` is `entity` or an entity it extends (the super chain). */ +function isSelfOrAncestor(candidate: MetaData | undefined, entity: MetaData): boolean { + const visited = new Set(); + for (let n: MetaData | undefined = entity; n !== undefined && !visited.has(n); n = n.superData) { + if (n === candidate) return true; + visited.add(n); + } + return false; +} + +function childOfType(obj: MetaData, type: string, name: string): MetaData | undefined { + // ADR-0039: resolving — inherited members (via extends) are visible. + return obj.children().find((c) => c.type === type && c.name === name); +} + +function fieldOf(obj: MetaData, name: string): MetaData | undefined { + return childOfType(obj, TYPE_FIELD, name); +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** `{ now: }` — exactly the one key. */ +function isRelativeValue(v: unknown): v is Record { + if (!isPlainObject(v)) return false; + const keys = Object.keys(v); + return keys.length === 1 && keys[0] === FILTER_RELATIVE_NOW; +} + +/** The relative value an op's operand carries: the operand itself, or one inside an array operand. */ +function relativeOperand(v: unknown): Record | undefined { + if (isRelativeValue(v)) return v; + if (Array.isArray(v)) return v.find(isRelativeValue); + return undefined; +} + +/** Deep search: does a filter value contain a relative value anywhere? */ +function containsRelativeValue(v: unknown): boolean { + if (isRelativeValue(v)) return true; + if (Array.isArray(v)) return v.some(containsRelativeValue); + if (isPlainObject(v)) return Object.values(v).some(containsRelativeValue); + return false; +} + +function quoteValue(v: unknown): string { + return typeof v === "string" ? v : JSON.stringify(v); +} + +/** `. ''`, or just `.` for an unnamed node (e.g. a source or origin). */ +function childLabel(node: MetaData): string { + const head = `${node.type}.${node.subType}`; + return node.name !== "" ? `${head} '${node.name}'` : head; +} + +/** `. ''` for a root-level object, else its childLabel plus ` in `. */ +function nodeLabel(node: MetaData): string { + const parent = node.parent; + if (parent === undefined || parent.parent === undefined) { + return `${node.type}.${node.subType} '${node.resolutionKey()}'`; + } + return `${childLabel(node)} in ${nodeLabel(parent)}`; +} + +// --------------------------------------------------------------------------- +// D1–D4, M1–M6, S1/F2 — members of an object.entity +// --------------------------------------------------------------------------- + +/** Validation context for one member of one entity. */ +interface MemberCtx { + readonly root: MetaData; + /** The entity whose children() the member was reached through. */ + readonly host: MetaData; + /** The entity that declares the member (the host, or an ancestor of it). */ + readonly declaring: MetaData; + /** ` '' on entity ''` — every member message starts with it. */ + readonly label: string; + /** "" in pass 1; ` (inherited by '')` in pass 2. */ + readonly suffix: string; + readonly sink: ErrorSink; +} + +/** + * The FQN a member message names for `entity`: 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 ErrorSink repeat test holds. + */ +function shown(ctx: MemberCtx, entity: MetaData): string { + return (entity === ctx.host ? ctx.declaring : entity).resolutionKey(); +} + +function checkEntityMembers(root: MetaData, entity: MetaData, declaredHere: boolean, sink: ErrorSink): void { + // ADR-0039: resolving — inherited members are validated against this entity. + for (const member of entity.children()) { + if (member.type !== TYPE_DIMENSION && member.type !== TYPE_MEASURE && member.type !== TYPE_SEGMENT) continue; + const declaring = member.parent ?? entity; + if ((declaring === entity) !== declaredHere) continue; + const ctx: MemberCtx = { + root, + host: entity, + declaring, + label: `${member.type} '${member.name}' on entity '${declaring.resolutionKey()}'`, + suffix: declaredHere ? "" : ` (inherited by '${entity.resolutionKey()}')`, + sink, + }; + if (member instanceof MetaDimension) checkDimension(ctx, member); + else if (member instanceof MetaMeasure) checkMeasure(ctx, member); + else if (member instanceof MetaSegment) { + const filter = member.filter(); + if (filter !== undefined) { + checkFilter(filter, entity, declaring.resolutionKey(), ctx.label, member, ctx.suffix, sink); + } + } + } +} + +function checkDimension(ctx: MemberCtx, dim: MetaDimension): void { + const err = (message: string): void => + ctx.sink.push(dim, ERR_INVALID_DIMENSION, ctx.label, `: ${message}`, ctx.suffix); + + // D2 — the @via walk; its terminal is the entity @of must name. + let ofEntity = ctx.host; + const via = dim.via(); + if (via !== undefined) { + const terminal = walkToOneVia(ctx, via, err); + if (terminal === undefined) return; + ofEntity = terminal; + } + + // D1 — @of is Entity.field on the owning entity (or the @via terminal). + const of = dim.of(); + if (of === undefined) return; // missing @of is ERR_MISSING_REQUIRED_ATTR (attr schema pass) + const parts = splitDotted(of); + if (parts === undefined || parts.path.length !== 1) { + err(`@of '${of}' must be Entity.field.`); + return; + } + const named = resolveObjectRef(ctx.root, parts.owner, pkgOf(ctx.declaring)).node; + if (!isSelfOrAncestor(named, ofEntity)) { + if (via === undefined) { + 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; + } + const fieldName = parts.path[0]!; + const field = fieldOf(ofEntity, fieldName); + if (field === undefined) { + 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 (!TEMPORAL_FIELD_SUBTYPES.includes(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().includes(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 undefined after reporting the first failure. + */ +function walkToOneVia(ctx: MemberCtx, via: string, err: (message: string) => void): MetaData | undefined { + const parts = splitDotted(via); + if (parts === undefined) { + err(`@via '${via}' must be Owner.hop[.hop...], starting at the owning entity.`); + return undefined; + } + const owner = resolveObjectRef(ctx.root, parts.owner, pkgOf(ctx.declaring)).node; + if (!isSelfOrAncestor(owner, ctx.host)) { + err(`@via '${via}' must start at the owning entity '${ctx.declaring.resolutionKey()}'.`); + return undefined; + } + let current = ctx.host; + for (const hopName of parts.path) { + const hop = + childOfType(current, TYPE_RELATIONSHIP, hopName) ?? + current + .children() + .find((c) => c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_REFERENCE && c.name === hopName); + if (hop === undefined) { + err( + `@via '${via}' names '${hopName}', which is not a relationship or identity.reference of ` + + `'${shown(ctx, current)}'.`, + ); + return undefined; + } + const isReference = hop.type === TYPE_IDENTITY; + if (!isReference && 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 undefined; + } + const 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. + const target = + typeof targetRef === "string" ? resolveObjectRef(ctx.root, targetRef, pkgOf(current)).node : undefined; + if (target === undefined) { + err(`@via '${via}' hop '${hopName}' on '${shown(ctx, current)}' targets no object.`); + return undefined; + } + current = target; + } + return current; +} + +function checkMeasure(ctx: MemberCtx, measure: MetaMeasure): void { + const err = (message: string): void => + ctx.sink.push(measure, 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. + const segment = measure.segmentName(); + if (segment !== undefined && childOfType(ctx.host, TYPE_SEGMENT, segment) === undefined) { + err(`@segment '${segment}' names no segment of '${ctx.declaring.resolutionKey()}'.`); + } + + // S1 / F2 — the measure's own row scope. + const filter = measure.filter(); + if (filter !== undefined) { + checkFilter(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). */ +function checkAggregateColumns(ctx: MemberCtx, measure: MetaMeasure, err: (message: string) => void): void { + const agg = measure.agg(); + const columns = measure.ofColumns(); + + // M1 — every @of item is a field of the owning entity. + const fields: MetaData[] = []; + for (const item of columns) { + const parts = splitDotted(item); + if (parts === undefined || parts.path.length !== 1) { + err(`@of '${item}' must be Entity.field.`); + return; + } + const named = resolveObjectRef(ctx.root, parts.owner, pkgOf(ctx.declaring)).node; + 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; + } + const field = fieldOf(ctx.host, parts.path[0]!); + if (field === undefined) { + err(`@of '${item}' names no field '${parts.path[0]!}' on '${ctx.declaring.resolutionKey()}'.`); + return; + } + fields.push(field); + } + + // M2 — a tuple is a distinct count only. + if (columns.length > 1 && (agg !== AGG_COUNT || !measure.distinct())) { + err( + `@of lists ${columns.length} 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 !== undefined && agg !== AGG_COUNT) { + err(`@distinct: true requires @agg: count, not '${agg}'.`); + return; + } + + // M4 — the aggregate must be meaningful for the column's type. + const field = fields.length === 1 ? fields[0] : undefined; + if (field === undefined || agg === undefined) return; + const item = columns[0]!; + if ((agg === AGG_SUM || agg === AGG_AVG) && !NUMERIC_FIELD_SUBTYPES.includes(field.subType)) { + err( + `@agg '${agg}' needs a numeric field (field.int, long, double, float, decimal or currency), ` + + `but '${item}' is field.${field.subType}.`, + ); + return; + } + if ((agg === AGG_MIN || agg === AGG_MAX) && UNORDERED_FIELD_SUBTYPES.includes(field.subType)) { + err(`@agg '${agg}' cannot order '${item}', a field.${field.subType}.`); + } +} + +/** M6 — each operand names a measure.aggregate of the same entity. */ +function checkRatioOperands(ctx: MemberCtx, ratio: MetaMeasure, err: (message: string) => void): void { + const operands: [string, string | undefined][] = [ + ["numerator", ratio.numerator()], + ["denominator", ratio.denominator()], + ]; + for (const [attr, ref] of operands) { + if (ref === undefined) continue; // missing operand is ERR_MISSING_REQUIRED_ATTR + const target = childOfType(ctx.host, TYPE_MEASURE, ref); + if (target === undefined) { + 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 `entity`'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. + */ +function checkFilter( + filter: Record, + entity: MetaData, + entityKey: string, + hostLabel: string, + host: MetaData, + suffix: string, + sink: ErrorSink, +): void { + const err = (message: string): void => sink.push(host, ERR_BAD_ATTR_FILTER, hostLabel, `: ${message}`, suffix); + for (const [key, clause] of Object.entries(filter)) { + if (key === FILTER_COMPOSE_OR || key === FILTER_COMPOSE_AND) { + if (!Array.isArray(clause)) { + err(`@filter '${key}' must be an array of sub-clauses.`); + continue; + } + for (const sub of clause) { + if (isPlainObject(sub)) checkFilter(sub, entity, entityKey, hostLabel, host, suffix, sink); + else err(`@filter '${key}' contains a non-object sub-clause.`); + } + continue; + } + const field = fieldOf(entity, key); + if (field === undefined) { + err(`@filter names '${key}', which is not a field of '${entityKey}'.`); + continue; + } + if (!isPlainObject(clause) || Object.keys(clause).length === 0) { + err(`@filter on '${key}' must be an { op: value } object.`); + continue; + } + const allowed = opsForField(field); + for (const [op, operand] of Object.entries(clause)) { + if (!(allowed as readonly string[]).includes(op)) { + err( + `@filter on '${key}' uses op '${op}', which is not allowed for field.${field.subType}. ` + + `Allowed ops: ${allowed.join(", ") || "(none)"}.`, + ); + continue; + } + const relative = relativeOperand(operand); + if (relative === undefined) continue; + if (!TEMPORAL_FIELD_SUBTYPES.includes(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 (!RELATIVE_DATE_OPS.includes(op)) { + err( + `@filter on '${key}' puts a relative date under op '${op}'; relative dates are legal only under ` + + `gt, gte, lt and lte.`, + ); + continue; + } + const duration = relative[FILTER_RELATIVE_NOW]; + if (typeof duration !== "string" || !ISO_DURATION_RE.test(duration)) { + err( + `@filter on '${key}' has relative date '${quoteValue(duration)}', which is not an ISO-8601 ` + + `duration (e.g. '-P7D', '-PT12H').`, + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// R1–R7 — object.report +// --------------------------------------------------------------------------- + +function checkReport(root: MetaData, report: MetaData, sink: ErrorSink): void { + const label = `report '${report.resolutionKey()}'`; + const err = (message: string, node: MetaData = report, code: ErrorCode = ERR_INVALID_REPORT): void => + sink.push(node, 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. + for (const child of report.ownChildren()) { + if (child.type === TYPE_FIELD || child.type === TYPE_IDENTITY) { + err( + ` declares ${childLabel(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. + for (const source of report.children().filter((c) => c.type === TYPE_SOURCE)) { + if (isWritableSource(source)) { + err( + `: ${childLabel(source)} is writable; a report is read-only, so its source must ` + + `declare @kind: view.`, + 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. + const fromRef = reportFrom(report); + if (fromRef === undefined) return; // missing @from is ERR_MISSING_REQUIRED_ATTR + const from = resolveObjectRef(root, fromRef, pkgOf(report)).node; + if (from === undefined) { + 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; + } + const fromKey = from.resolutionKey(); + + // R6 — derived field name -> the item that derived it ("dimension item 'x'" / "measure 'y'"). + const derived = new Map(); + const claim = (fieldName: string, what: string): void => { + const prior = derived.get(fieldName); + if (prior === what) { + // The same measure listed twice: name the repeat, not a "collision" with itself. + err(`: @measures lists '${fieldName}' more than once.`); + return; + } + if (prior !== undefined) { + err( + `: ${prior} and ${what} both derive report field '${fieldName}'. Report field names must be unique; ` + + `rename the measure or drop one item.`, + ); + return; + } + derived.set(fieldName, what); + }; + + // R2 — each @dimensions item names a dimension of @from, with a grain exactly when it is a time dimension. + const seenItems = new Set(); + for (const item of reportDimensionItems(report)) { + const raw = item.grain === undefined ? item.name : `${item.name}:${item.grain}`; + if (seenItems.has(raw)) { + err(`: @dimensions lists '${raw}' more than once.`); + continue; + } + seenItems.add(raw); + const dim = childOfType(from, TYPE_DIMENSION, item.name); + if (!(dim instanceof MetaDimension)) { + err(`: @dimensions item '${raw}' names no dimension of @from '${fromKey}'.`); + continue; + } + if (dim.isTime()) { + const grains = dim.grains(); + if (item.grain === undefined) { + err( + `: @dimensions item '${raw}' names time dimension '${item.name}' without a grain; write ` + + `'${item.name}:' with a grain from its @grains (${grains.join(", ")}).`, + ); + continue; + } + if (!(grains as readonly string[]).includes(item.grain)) { + err( + `: @dimensions item '${raw}' uses grain '${item.grain}', which time dimension '${item.name}' does not ` + + `declare. Its @grains: ${grains.join(", ")}.`, + ); + continue; + } + } else if (item.grain !== undefined) { + err( + `: @dimensions item '${raw}' gives a grain to attribute dimension '${item.name}'; only a time ` + + `dimension takes a grain.`, + ); + continue; + } + claim(reportDerivedFieldName(item), `dimension item '${raw}'`); + } + + // R3 — each @measures item names a measure of @from. + for (const item of reportMeasureNames(report)) { + const measureName = checkReportMeasure(root, report, from, item, label, sink); + if (measureName !== undefined) claim(measureName, `measure '${measureName}'`); + } + + // R7 — @segment names a segment of @from. + const segment = report.attr(OBJECT_REPORT_ATTR_SEGMENT); + if (typeof segment === "string" && childOfType(from, TYPE_SEGMENT, segment) === undefined) { + err(`: @segment '${segment}' names no segment of @from '${fromKey}'.`); + } + + // S1 / F2 — the report's row scope over @from. + const filter = report.attr(OBJECT_REPORT_ATTR_FILTER); + if (isPlainObject(filter)) checkFilter(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 undefined. + */ +function checkReportMeasure( + root: MetaData, + report: MetaData, + from: MetaData, + item: string, + label: string, + sink: ErrorSink, +): string | undefined { + const fromKey = from.resolutionKey(); + let owner: MetaData | undefined; + let name: string; + const parts = splitDotted(item); + if (parts !== undefined && parts.path.length === 1) { + owner = resolveObjectRef(root, parts.owner, pkgOf(report)).node; + name = parts.path[0]!; + if (owner !== undefined && isSelfOrAncestor(owner, from) && childOfType(from, TYPE_MEASURE, name)) return name; + if (owner !== undefined && childOfType(owner, TYPE_MEASURE, name) === undefined) owner = undefined; + } else if (!item.includes(CHILD_REF_SEPARATOR)) { + name = item; + if (childOfType(from, TYPE_MEASURE, name) !== undefined) return name; + // ADR-0039: root has no super; children()==ownChildren() but resolving is the default. + owner = root + .children() + .find( + (o) => + o.type === TYPE_OBJECT && + o.subType === OBJECT_SUBTYPE_ENTITY && + childOfType(o, TYPE_MEASURE, name) !== undefined, + ); + } else { + owner = undefined; + } + + if (owner !== undefined) { + const ownerKey = owner.resolutionKey(); + sink.push( + report, + ERR_REPORT_FOREIGN_MEASURE, + label, + ` lists measure '${item.slice(item.lastIndexOf(CHILD_REF_SEPARATOR) + 1)}', 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, + ERR_INVALID_REPORT, + label, + `: @measures item '${item}' names no measure of @from '${fromKey}' or of any other entity.`, + ); + } + return undefined; +} + +// --------------------------------------------------------------------------- +// F1 — relative-date values only on reporting hosts +// --------------------------------------------------------------------------- + +/** True for the hosts whose `@filter` may carry a relative-date value. */ +function isReportingFilterHost(node: MetaData): boolean { + return ( + 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. + */ +function checkNoRelativeDates(node: MetaData, sink: ErrorSink): void { + 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). + for (const attr of node.ownMetaAttrs()) { + if (attr.subType === ATTR_SUBTYPE_FILTER && containsRelativeValue(attr.value)) { + sink.push( + node, + ERR_BAD_ATTR_FILTER, + nodeLabel(node), + `: @${attr.name} 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. + for (const child of node.ownChildren()) checkNoRelativeDates(child, sink); +} diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts new file mode 100644 index 000000000..1091e96b1 --- /dev/null +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -0,0 +1,972 @@ +// FR-044 — validateReporting: one test (or more) per row of the rule table in +// docs/superpowers/plans/2026-10-03-fr-044-plan-1-reporting-vocabulary.md. +// +// Every broken model is the clean spec §4 model (Purchase / Program / +// WorkoutEvent + three reports) with exactly one change, and every test +// asserts the FULL error-code list — one broken rule must produce exactly one +// error, never a cascade. + +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader } from "../src/loader/meta-data-loader.js"; +import { InMemoryStringSource } from "../src/loader/meta-data-source.js"; +import { ISO_DURATION_RE } from "../src/core/reporting/reporting-constants.js"; +import { + reportDerivedFieldName, + reportDimensionItems, +} from "../src/core/reporting/report-accessors.js"; +import { MetaMeasure } from "../src/core/reporting/meta-measure.js"; +import type { MetaData } from "../src/shared/meta-data.js"; + +// --------------------------------------------------------------------------- +// Model builders +// --------------------------------------------------------------------------- + +type Body = Record; +type Wrapper = Record; +type Model = { "metadata.root": { package: string; children: Wrapper[] } }; + +const PKG = "acme::shop"; + +function field(subType: string, name: string, extra: Body = {}): Wrapper { + return { [`field.${subType}`]: { name, ...extra } }; +} + +function primary(): Wrapper { + return { "identity.primary": { name: "id", "@fields": ["id"] } }; +} + +function fullReportingModel(): Model { + return { + "metadata.root": { + package: PKG, + children: [ + { + "object.entity": { + name: "Program", + children: [ + { "source.rdb": { "@table": "programs" } }, + field("long", "id"), + field("string", "title"), + primary(), + { + "relationship.association": { + name: "purchases", + "@objectRef": "Purchase", + "@cardinality": "many", + }, + }, + ], + }, + }, + { + "object.entity": { + name: "Purchase", + children: [ + { "source.rdb": { "@table": "purchases" } }, + field("long", "id"), + field("long", "programId"), + field("string", "customerEmail"), + field("currency", "amountCents"), + field("string", "status"), + field("boolean", "refunded"), + field("timestamp", "purchasedAt"), + field("date", "purchasedOn"), + primary(), + { + "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"], + }, + }, + { + "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", "id"), + field("long", "programId"), + field("string", "customerEmail"), + field("int", "weekNumber"), + field("int", "dayNumber"), + field("string", "eventType"), + field("timestamp", "occurredAt"), + primary(), + { "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"], + }, + }, + ], + }, + }; +} + +/** The body of the root-level object named `name`. */ +function objectBody(m: Model, name: string): Body { + for (const w of m["metadata.root"].children) { + const body = Object.values(w)[0]; + if (body !== undefined && body.name === name) return body; + } + throw new Error(`no object ${name}`); +} + +function childrenOf(m: Model, objName: string): Wrapper[] { + const body = objectBody(m, objName); + if (!Array.isArray(body.children)) body.children = []; + return body.children as Wrapper[]; +} + +/** The base type (`field`, `dimension`, …) of a `{ ".": body }` wrapper. */ +function wrapperType(w: Wrapper): string { + return Object.keys(w)[0]!.split(".")[0]!; +} + +/** Replace the child of `objName` with the same base type as `w` and named `childName`, or append `w`. */ +function setChild(m: Model, objName: string, childName: string, w: Wrapper): void { + const kids = childrenOf(m, objName); + const type = wrapperType(w); + const i = kids.findIndex((k) => wrapperType(k) === type && Object.values(k)[0]?.name === childName); + if (i === -1) kids.push(w); + else kids[i] = w; +} + +/** Shallow-merge attrs into the report (or other root object) named `objName`; `undefined` deletes. */ +function patchObject(m: Model, objName: string, attrs: Body): void { + const body = objectBody(m, objName); + for (const [k, v] of Object.entries(attrs)) { + if (v === undefined) delete body[k]; + else body[k] = v; + } +} + +function edit(fn: (m: Model) => void): Model { + const m = fullReportingModel(); + fn(m); + return m; +} + +const MEMBER_TYPES = ["dimension", "measure", "segment"]; + +/** Patch the attrs of an entity member (dimension/measure/segment) in place. */ +function patchMember(m: Model, objName: string, childName: string, attrs: Body): void { + const kids = childrenOf(m, objName); + const w = kids.find((k) => MEMBER_TYPES.includes(wrapperType(k)) && Object.values(k)[0]?.name === childName); + if (w === undefined) throw new Error(`no member ${objName}.${childName}`); + const body = Object.values(w)[0] as Body; + for (const [k, v] of Object.entries(attrs)) { + if (v === undefined) delete body[k]; + else body[k] = v; + } +} + +interface LoadedErrors { + root: MetaData; + errors: { code: string; message: string; source: unknown }[]; +} + +async function loadInline(doc: unknown): Promise { + const result = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(doc), { id: "meta.shop.json" }), + ]); + return result as unknown as LoadedErrors; +} + +async function codes(doc: unknown): Promise { + const { errors } = await loadInline(doc); + return errors.map((e) => e.code); +} + +async function single(doc: unknown, code: string): Promise { + const { errors } = await loadInline(doc); + expect(errors.map((e) => e.code)).toEqual([code]); + return errors[0]!.message; +} + +function jsonPathOf(source: unknown): string | undefined { + return typeof source === "object" && source !== null && "jsonPath" in source + ? String((source as { jsonPath: unknown }).jsonPath) + : undefined; +} + +// --------------------------------------------------------------------------- +// Clean model +// --------------------------------------------------------------------------- + +describe("validateReporting — clean model", () => { + test("a clean reporting model loads with no errors", async () => { + const { errors } = await loadInline(fullReportingModel()); + expect(errors).toEqual([]); + }); + + test("a dimension may reach its column through an identity.reference hop", async () => { + const m = edit((x) => + setChild(x, "Purchase", "programTitleByRef", { + "dimension.attribute": { + name: "programTitleByRef", + "@of": "Program.title", + "@via": "Purchase.programRef", + }, + }), + ); + expect(await codes(m)).toEqual([]); + }); + + test("FQN references resolve (ADR-0042)", async () => { + const m = edit((x) => { + patchMember(x, "Purchase", "programTitle", { + "@of": "acme::shop::Program.title", + "@via": "acme::shop::Purchase.program", + }); + patchObject(x, "StoreTotals", { "@from": "acme::shop::Purchase", "@measures": ["acme::shop::Purchase.revenue"] }); + }); + expect(await codes(m)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// D1–D4 — dimensions +// --------------------------------------------------------------------------- + +describe("validateReporting — dimensions", () => { + test("D1: @of naming a field the owning entity lacks is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "program", { "@of": "Purchase.programIdd" })); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toContain("'acme::shop::Purchase'"); + expect(msg).toContain("'program'"); + expect(msg).toContain("programIdd"); + }); + + test("D1: @of naming another entity without @via is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "program", { "@of": "Program.title" })); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toContain("@via"); + }); + + test("D1: with @via, @of must name the @via terminal", async () => { + const m = edit((x) => patchMember(x, "Purchase", "programTitle", { "@of": "Purchase.status" })); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toContain("'acme::shop::Program'"); + }); + + test("D1: @of not of the form Entity.field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "program", { "@of": "programId" })); + await single(m, "ERR_INVALID_DIMENSION"); + }); + + test("D2: @via over a to-many relationship is refused", async () => { + const m = edit((x) => + setChild(x, "Program", "buyerEmail", { + "dimension.attribute": { + name: "buyerEmail", + "@of": "Purchase.customerEmail", + "@via": "Program.purchases", + }, + }), + ); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toContain("'purchases'"); + expect(msg).toContain("to-one"); + }); + + test("D2: @via naming an unknown hop is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "programTitle", { "@via": "Purchase.nope" })); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toContain("'nope'"); + }); + + test("D2: @via must start at the owning entity", async () => { + const m = edit((x) => patchMember(x, "Purchase", "programTitle", { "@via": "WorkoutEvent.program" })); + await single(m, "ERR_INVALID_DIMENSION"); + }); + + test("D2: @via with no hop is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "programTitle", { "@via": "Purchase" })); + await single(m, "ERR_INVALID_DIMENSION"); + }); + + test("D3: dimension.time over a non-temporal field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "purchasedAt", { "@of": "Purchase.status" })); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toContain("field.string"); + }); + + test("D4: dimension.time declaring 'hour' over a field.date is refused", async () => { + const m = edit((x) => + setChild(x, "Purchase", "purchasedOn", { + "dimension.time": { name: "purchasedOn", "@of": "Purchase.purchasedOn", "@grains": ["hour", "day"] }, + }), + ); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toContain("hour"); + }); + + test("D4: a field.date time dimension without 'hour' is fine", async () => { + const m = edit((x) => + setChild(x, "Purchase", "purchasedOn", { + "dimension.time": { name: "purchasedOn", "@of": "Purchase.purchasedOn", "@grains": ["day", "month"] }, + }), + ); + expect(await codes(m)).toEqual([]); + }); + + test("error source points at the dimension node", async () => { + const m = edit((x) => patchMember(x, "Purchase", "program", { "@of": "Purchase.programIdd" })); + const { errors } = await loadInline(m); + expect(jsonPathOf(errors[0]!.source)).toContain("['dimension.attribute']"); + }); +}); + +// --------------------------------------------------------------------------- +// M1–M6 — measures +// --------------------------------------------------------------------------- + +describe("validateReporting — measures", () => { + test("M1: @of naming another entity's field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "buyers", { "@of": "WorkoutEvent.customerEmail" })); + const msg = await single(m, "ERR_INVALID_MEASURE"); + expect(msg).toContain("'buyers'"); + }); + + test("M1: @of naming an unknown field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "revenue", { "@of": "Purchase.amount" })); + await single(m, "ERR_INVALID_MEASURE"); + }); + + test("M2: a tuple @of without @distinct is refused", async () => { + const m = edit((x) => patchMember(x, "WorkoutEvent", "daysEngaged", { "@distinct": undefined })); + const msg = await single(m, "ERR_INVALID_MEASURE"); + expect(msg).toContain("daysEngaged"); + }); + + test("M2: a tuple @of with @distinct but @agg sum is ONE error, not M2+M3", async () => { + const m = edit((x) => patchMember(x, "WorkoutEvent", "daysEngaged", { "@agg": "sum" })); + await single(m, "ERR_INVALID_MEASURE"); + }); + + test("M3: @distinct with @agg sum is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "revenue", { "@distinct": true })); + const msg = await single(m, "ERR_INVALID_MEASURE"); + expect(msg).toContain("@distinct"); + }); + + test("M4: sum over a non-numeric field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "revenue", { "@of": "Purchase.status" })); + const msg = await single(m, "ERR_INVALID_MEASURE"); + expect(msg).toContain("field.string"); + }); + + test("M4: max over a boolean field is refused", async () => { + const m = edit((x) => + setChild(x, "Purchase", "anyRefund", { + "measure.aggregate": { name: "anyRefund", "@agg": "max", "@of": "Purchase.refunded" }, + }), + ); + await single(m, "ERR_INVALID_MEASURE"); + }); + + test("M4: avg over a currency field and min over a timestamp are fine", async () => { + const m = edit((x) => { + setChild(x, "Purchase", "avgRevenue", { + "measure.aggregate": { name: "avgRevenue", "@agg": "avg", "@of": "Purchase.amountCents" }, + }); + setChild(x, "Purchase", "firstPurchaseAt", { + "measure.aggregate": { name: "firstPurchaseAt", "@agg": "min", "@of": "Purchase.purchasedAt" }, + }); + }); + expect(await codes(m)).toEqual([]); + }); + + test("M5: @segment naming no segment of the owning entity is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "revenue", { "@segment": "completions" })); + const msg = await single(m, "ERR_INVALID_MEASURE"); + expect(msg).toContain("'completions'"); + }); + + test("M6: a ratio operand naming another ratio is refused", async () => { + const m = edit((x) => + setChild(x, "WorkoutEvent", "ratioOfRatio", { + "measure.ratio": { name: "ratioOfRatio", "@numerator": "avgDaysPerStarter", "@denominator": "starters" }, + }), + ); + const msg = await single(m, "ERR_INVALID_MEASURE"); + expect(msg).toContain("'avgDaysPerStarter'"); + }); + + test("M6: a ratio operand naming nothing is refused", async () => { + const m = edit((x) => patchMember(x, "WorkoutEvent", "avgDaysPerStarter", { "@denominator": "nobody" })); + await single(m, "ERR_INVALID_MEASURE"); + }); + + test("error source points at the measure node", async () => { + const m = edit((x) => patchMember(x, "Purchase", "revenue", { "@of": "Purchase.amount" })); + const { errors } = await loadInline(m); + expect(jsonPathOf(errors[0]!.source)).toContain("['measure.aggregate']"); + }); +}); + +// --------------------------------------------------------------------------- +// S1 — segment / measure / report @filter field and op checks +// --------------------------------------------------------------------------- + +describe("validateReporting — filters (S1)", () => { + test("S1: a segment @filter over an unknown field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "active", { "@filter": { state: "active" } })); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("'state'"); + expect(msg).toContain("segment 'active'"); + }); + + test("S1: a segment @filter op illegal for the field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "active", { "@filter": { refunded: { like: "x%" } } })); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("'like'"); + }); + + test("S1: a measure @filter over an unknown field is refused", async () => { + const m = edit((x) => patchMember(x, "Purchase", "revenue", { "@filter": { state: "x" } })); + await single(m, "ERR_BAD_ATTR_FILTER"); + }); + + test("S1: a report @filter over a field @from lacks is refused", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@filter": { eventType: "x" } })); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("report 'acme::shop::DailyRevenue'"); + }); + + test("S1: and/or compositions are walked", async () => { + const m = edit((x) => + patchMember(x, "Purchase", "active", { + "@filter": { or: [{ status: "active" }, { and: [{ nope: 1 }] }] }, + }), + ); + await single(m, "ERR_BAD_ATTR_FILTER"); + }); +}); + +// --------------------------------------------------------------------------- +// R1–R7 — reports +// --------------------------------------------------------------------------- + +describe("validateReporting — reports", () => { + test("R1: @from naming nothing is refused, and R2/R3/R7 are skipped", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@from": "Nowhere" })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("'Nowhere'"); + }); + + test("R1: @from naming a non-entity object is refused", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@from": "StoreTotals" })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("object.report"); + }); + + test("R2: a dimension item naming no dimension of @from is refused", async () => { + const m = edit((x) => patchObject(x, "StoreTotals", { "@dimensions": ["region"] })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("'region'"); + }); + + test("R2: a time dimension listed without a grain is refused", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@dimensions": ["purchasedAt"] })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("grain"); + }); + + test("R2: a grain the time dimension does not declare is refused", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@dimensions": ["purchasedAt:hour"] })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("'hour'"); + }); + + test("R2: an attribute dimension with a grain is refused", async () => { + const m = edit((x) => patchObject(x, "StoreTotals", { "@dimensions": ["program:day"] })); + await single(m, "ERR_INVALID_REPORT"); + }); + + test("R2: a repeated dimension item is ONE error (not also R6)", async () => { + const m = edit((x) => + patchObject(x, "DailyRevenue", { "@dimensions": ["purchasedAt:day", "purchasedAt:day"] }), + ); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("more than once"); + }); + + test("R2: two grains of one time dimension are fine", async () => { + const m = edit((x) => + patchObject(x, "DailyRevenue", { "@dimensions": ["purchasedAt:day", "purchasedAt:week"] }), + ); + expect(await codes(m)).toEqual([]); + }); + + test("R3: a bare measure of another entity is ERR_REPORT_FOREIGN_MEASURE", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@measures": ["purchases", "starters"] })); + const msg = await single(m, "ERR_REPORT_FOREIGN_MEASURE"); + expect(msg).toBe( + "report 'acme::shop::DailyRevenue' 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'.", + ); + }); + + test("R3: a dotted measure of another entity is ERR_REPORT_FOREIGN_MEASURE", async () => { + const m = edit((x) => + patchObject(x, "DailyRevenue", { "@measures": ["purchases", "WorkoutEvent.starters"] }), + ); + await single(m, "ERR_REPORT_FOREIGN_MEASURE"); + }); + + test("R3: a measure that resolves nowhere is ERR_INVALID_REPORT", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@measures": ["purchases", "refunds"] })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("'refunds'"); + }); + + test("R3: a dotted measure of @from is fine", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@measures": ["Purchase.purchases", "revenue"] })); + expect(await codes(m)).toEqual([]); + }); + + test("R4: a report declaring a field is refused", async () => { + const m = edit((x) => { + objectBody(x, "StoreTotals").children = [field("long", "purchases")]; + }); + const { errors } = await loadInline(m); + expect(errors.map((e) => e.code)).toEqual(["ERR_INVALID_REPORT"]); + expect(errors[0]!.message).toContain("field.long 'purchases'"); + expect(jsonPathOf(errors[0]!.source)).toContain("['field.long']"); + }); + + test("R4: a report declaring an identity is refused", async () => { + const m = edit((x) => { + objectBody(x, "StoreTotals").children = [{ "identity.primary": { name: "id", "@fields": ["id"] } }]; + }); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("identity.primary 'id'"); + }); + + test("R5: a report with a writable source is refused", async () => { + const m = edit((x) => { + objectBody(x, "StoreTotals").children = [{ "source.rdb": { "@table": "store_totals" } }]; + }); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toBe( + "report 'acme::shop::StoreTotals': source.rdb is writable; a report is read-only, so its source must " + + "declare @kind: view.", + ); + }); + + test("R5: a report with a view source is fine", async () => { + const m = edit((x) => { + objectBody(x, "StoreTotals").children = [{ "source.rdb": { "@kind": "view", "@view": "store_totals" } }]; + }); + expect(await codes(m)).toEqual([]); + }); + + test("R6: a dimension and a measure deriving the same field name is refused", async () => { + const m = edit((x) => { + setChild(x, "Purchase", "purchasedAtDay", { + "measure.aggregate": { name: "purchasedAtDay", "@agg": "count", "@of": "Purchase.id" }, + }); + patchObject(x, "DailyRevenue", { "@measures": ["purchasedAtDay"] }); + }); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("'purchasedAtDay'"); + }); + + test("R6: a measure listed twice is refused", async () => { + const m = edit((x) => patchObject(x, "StoreTotals", { "@measures": ["revenue", "revenue"] })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toBe("report 'acme::shop::StoreTotals': @measures lists 'revenue' more than once."); + }); + + test("R7: a report @segment naming no segment of @from is refused", async () => { + const m = edit((x) => patchObject(x, "StoreTotals", { "@segment": "completions" })); + const msg = await single(m, "ERR_INVALID_REPORT"); + expect(msg).toContain("'completions'"); + }); + + test("error source points at the report node", async () => { + const m = edit((x) => patchObject(x, "StoreTotals", { "@segment": "completions" })); + const { errors } = await loadInline(m); + expect(jsonPathOf(errors[0]!.source)).toMatch(/\['object\.report'\]$/); + }); +}); + +// --------------------------------------------------------------------------- +// F1–F2 — relative-date filter values +// --------------------------------------------------------------------------- + +describe("validateReporting — relative dates", () => { + test("F2: the duration pattern accepts ISO-8601 durations and refuses the degenerate forms", () => { + for (const ok of ["-P7D", "P1Y", "+P2W", "-PT12H", "P1Y2M3W4DT5H6M7S", "PT30M"]) { + expect(ISO_DURATION_RE.test(ok)).toBe(true); + } + for (const bad of ["P", "-P", "PT", "P1DT", "7D", "P7", "-P7d", "P1.5D", ""]) { + expect(ISO_DURATION_RE.test(bad)).toBe(false); + } + }); + + test("F2: a relative value on a segment over a timestamp is fine", async () => { + const m = edit((x) => + setChild(x, "WorkoutEvent", "recent", { + "segment.filter": { name: "recent", "@filter": { occurredAt: { gte: { now: "-P7D" } } } }, + }), + ); + expect(await codes(m)).toEqual([]); + }); + + test("F2: a relative value over a non-temporal field is refused", async () => { + const m = edit((x) => + setChild(x, "WorkoutEvent", "recent", { + "segment.filter": { name: "recent", "@filter": { dayNumber: { gte: { now: "-P7D" } } } }, + }), + ); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("field.int"); + }); + + test("F2: a relative value under a non-range op is refused", async () => { + const m = edit((x) => + setChild(x, "WorkoutEvent", "recent", { + "segment.filter": { name: "recent", "@filter": { occurredAt: { eq: { now: "-P7D" } } } }, + }), + ); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("'eq'"); + }); + + test("F2: a relative value inside an `in` list is refused", async () => { + const m = edit((x) => + setChild(x, "WorkoutEvent", "recent", { + "segment.filter": { name: "recent", "@filter": { occurredAt: { in: [{ now: "-P7D" }] } } }, + }), + ); + await single(m, "ERR_BAD_ATTR_FILTER"); + }); + + test("F2: 'P' alone is not a duration", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@filter": { purchasedAt: { gte: { now: "P" } } } })); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("'P'"); + }); + + test("F2: a non-string duration is refused", async () => { + const m = edit((x) => patchObject(x, "DailyRevenue", { "@filter": { purchasedAt: { gte: { now: 7 } } } })); + await single(m, "ERR_BAD_ATTR_FILTER"); + }); + + test("F1: a relative value on a measure.aggregate @filter is fine", async () => { + const m = edit((x) => + patchMember(x, "Purchase", "revenue", { "@filter": { purchasedAt: { lt: { now: "-P1D" } } } }), + ); + expect(await codes(m)).toEqual([]); + }); + + test("F1: a relative value in a projection @filter is refused", async () => { + const m = edit((x) => + x["metadata.root"].children.push({ + "object.projection": { + name: "RecentPurchase", + "@filter": { purchasedAt: { gte: { now: "-P7D" } } }, + children: [ + { "source.rdb": { "@kind": "view", "@view": "recent_purchases" } }, + field("long", "id", { extends: "Purchase.id" }), + field("timestamp", "purchasedAt", { extends: "Purchase.purchasedAt" }), + { "identity.primary": { name: "id", extends: "Purchase.id" } }, + ], + }, + }), + ); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("object.projection 'acme::shop::RecentPurchase'"); + }); + + test("F1: a relative value in a dataGrid preset is refused", async () => { + const m = edit((x) => { + setChild(x, "WorkoutEvent", "occurredAt", field("timestamp", "occurredAt", { "@filterable": true })); + setChild(x, "WorkoutEvent", "default", { + "layout.dataGrid": { + name: "default", + "@columns": ["occurredAt"], + "@filter": { occurredAt: { gte: { now: "-P7D" } } }, + }, + }); + }); + const { errors } = await loadInline(m); + expect(errors.map((e) => e.code)).toEqual(["ERR_BAD_ATTR_FILTER"]); + expect(errors[0]!.message).toContain("layout.dataGrid 'default'"); + expect(jsonPathOf(errors[0]!.source)).toContain("['layout.dataGrid']"); + }); + + test("F1: a relative value in an origin.aggregate @filter is refused", async () => { + const m = edit((x) => + x["metadata.root"].children.push({ + "object.projection": { + name: "ProgramStats", + children: [ + { "source.rdb": { "@kind": "view", "@view": "program_stats" } }, + field("long", "id", { extends: "Program.id" }), + field("long", "recentPurchases", { + children: [ + { + "origin.aggregate": { + "@agg": "count", + "@of": "Purchase.id", + "@via": "Program.purchases", + "@filter": { purchasedAt: { gte: { now: "-P7D" } } }, + }, + }, + ], + }), + { "identity.primary": { name: "id", extends: "Program.id" } }, + ], + }, + }), + ); + const { errors } = await loadInline(m); + expect(errors.map((e) => e.code)).toContain("ERR_BAD_ATTR_FILTER"); + const f1 = errors.filter((e) => e.message.includes("relative date")); + expect(f1.length).toBe(1); + expect(f1[0]!.message).toContain("origin.aggregate"); + }); +}); + +// --------------------------------------------------------------------------- +// Inheritance (ADR-0039) — members declared on an abstract base +// --------------------------------------------------------------------------- + +function inheritedModel(): Model { + return { + "metadata.root": { + package: PKG, + children: [ + { + "object.entity": { + name: "BaseEvent", + abstract: true, + children: [ + field("long", "id"), + field("timestamp", "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", "eventType"), + primary(), + ], + }, + }, + { + "object.report": { + name: "DailyEvents", + "@from": "WorkoutEvent", + "@dimensions": ["occurredAt:day"], + "@measures": ["events"], + }, + }, + ], + }, + }; +} + +describe("validateReporting — inherited members", () => { + test("a report over a concrete entity resolves members declared on its abstract base", async () => { + expect(await codes(inheritedModel())).toEqual([]); + }); + + test("a broken member on an abstract base is reported ONCE, not once per inheritor", async () => { + const m = inheritedModel(); + const base = objectBody(m, "BaseEvent").children as Wrapper[]; + base[3] = { "measure.aggregate": { name: "events", "@agg": "sum", "@of": "BaseEvent.nope" } }; + // The report's 'events' still resolves; only M1 fires. + expect(await codes(m)).toEqual(["ERR_INVALID_MEASURE"]); + }); + + test("a broken base dimension and base segment filter are each reported ONCE", async () => { + const m = inheritedModel(); + const base = objectBody(m, "BaseEvent").children as Wrapper[]; + base[2] = { "dimension.time": { name: "occurredAt", "@of": "BaseEvent.nope", "@grains": ["day", "week"] } }; + base.push({ "segment.filter": { name: "recent", "@filter": { nope: 1 } } }); + // The report's occurredAt:day still resolves (R2 reads the dimension, not its column). + expect(await codes(m)).toEqual(["ERR_INVALID_DIMENSION", "ERR_BAD_ATTR_FILTER"]); + }); + + test("a broken base @via is reported ONCE", async () => { + const m = inheritedModel(); + const base = objectBody(m, "BaseEvent").children as Wrapper[]; + base.push({ + "dimension.attribute": { name: "viaNothing", "@of": "BaseEvent.id", "@via": "BaseEvent.nope" }, + }); + expect(await codes(m)).toEqual(["ERR_INVALID_DIMENSION"]); + }); + + test("an inheritor whose override breaks an inherited member is reported, naming the inheritor", async () => { + const m = inheritedModel(); + // WorkoutEvent overrides occurredAt as a string: the inherited time dimension + // is fine on BaseEvent and broken on WorkoutEvent (D3). + (objectBody(m, "WorkoutEvent").children as Wrapper[]).push(field("string", "occurredAt")); + const msg = await single(m, "ERR_INVALID_DIMENSION"); + expect(msg).toBe( + "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.", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Accessors (Task 1 follow-up) +// --------------------------------------------------------------------------- + +describe("report accessors", () => { + test("reportDimensionItems parses name and name:grain", async () => { + const { root } = await loadInline(fullReportingModel()); + const report = root.children().find((c) => c.name === "DailyRevenue")!; + expect(reportDimensionItems(report)).toEqual([{ name: "purchasedAt", grain: "day" }]); + const engagement = root.children().find((c) => c.name === "ProgramEngagement")!; + expect(reportDimensionItems(engagement)).toEqual([{ name: "program" }]); + }); + + test("reportDerivedFieldName suffixes the capitalized grain; a bare name stays bare", () => { + expect(reportDerivedFieldName({ name: "purchasedAt", grain: "day" })).toBe("purchasedAtDay"); + expect(reportDerivedFieldName({ name: "purchasedAt", grain: "quarter" })).toBe("purchasedAtQuarter"); + expect(reportDerivedFieldName({ name: "program" })).toBe("program"); + }); + + test("MetaMeasure.ofColumns: a bare string @of is a one-element list; a list is the tuple", async () => { + const { root } = await loadInline(fullReportingModel()); + const purchase = root.children().find((c) => c.name === "Purchase")!; + const revenue = purchase.children().find((c) => c.name === "revenue")!; + expect(revenue).toBeInstanceOf(MetaMeasure); + expect((revenue as MetaMeasure).ofColumns()).toEqual(["Purchase.amountCents"]); + const event = root.children().find((c) => c.name === "WorkoutEvent")!; + const days = event.children().find((c) => c.name === "daysEngaged") as MetaMeasure; + expect(days.ofColumns()).toEqual([ + "WorkoutEvent.programId", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber", + ]); + }); +}); From 1d84558f3ea5bc2f50c1c404e4b6d9b53b7b3929 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 13:59:14 -0400 Subject: [PATCH 03/29] =?UTF-8?q?fix(metadata):=20FR-044=20reporting=20val?= =?UTF-8?q?idation=20=E2=80=94=20keep=20each=20inheritor's=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/reporting/reporting-constants.ts | 17 +++++- .../src/loader/reporting-validation.ts | 60 ++++++++++++------- .../test/reporting-validation.test.ts | 52 ++++++++++++++-- 3 files changed, 101 insertions(+), 28 deletions(-) diff --git a/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts index c1d6d2f40..fbf575d28 100644 --- a/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts @@ -64,17 +64,28 @@ export const REPORTING_ATTR_DENOMINATOR = "denominator"; // --------------------------------------------------------------------------- /** Weeks start on Monday (ISO-8601) in every lowering. */ -export const TIME_GRAINS = ["hour", "day", "week", "month", "quarter", "year"] as const; +export const GRAIN_HOUR = "hour"; +export const GRAIN_DAY = "day"; +export const GRAIN_WEEK = "week"; +export const GRAIN_MONTH = "month"; +export const GRAIN_QUARTER = "quarter"; +export const GRAIN_YEAR = "year"; +export const TIME_GRAINS = [GRAIN_HOUR, GRAIN_DAY, GRAIN_WEEK, GRAIN_MONTH, GRAIN_QUARTER, GRAIN_YEAR] as const; export type TimeGrain = (typeof TIME_GRAINS)[number]; -export const MEASURE_AGGS = ["count", "sum", "avg", "min", "max"] as const; +export const AGG_COUNT = "count"; +export const AGG_SUM = "sum"; +export const AGG_AVG = "avg"; +export const AGG_MIN = "min"; +export const AGG_MAX = "max"; +export const MEASURE_AGGS = [AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX] as const; export type MeasureAgg = (typeof MEASURE_AGGS)[number]; /** Separator in an `object.report` `@dimensions` item: `name` or `name:grain`. */ export const REPORT_DIMENSION_GRAIN_SEPARATOR = ":"; // --------------------------------------------------------------------------- -// Relative-date filter values (FR-044 R4) +// Relative-date filter values (spec §4 R4; rules F1/F2) // --------------------------------------------------------------------------- /** The single key of a relative-date filter value: `{ now: "" }` diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index 8971b8bb2..982a8844e 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -75,6 +75,14 @@ import { isWritableSource } from "../shared/node-guards.js"; import { FILTER_RELATIVE_NOW, ISO_DURATION_RE, + AGG_COUNT, + AGG_SUM, + AGG_AVG, + AGG_MIN, + AGG_MAX, + GRAIN_HOUR, + REPORTING_ATTR_NUMERATOR, + REPORTING_ATTR_DENOMINATOR, MEASURE_SUBTYPE_AGGREGATE, } from "../core/reporting/reporting-constants.js"; import { MetaDimension } from "../core/reporting/meta-dimension.js"; @@ -114,12 +122,6 @@ const TEMPORAL_FIELD_SUBTYPES: readonly string[] = [FIELD_SUBTYPE_DATE, FIELD_SU /** F2 — the only ops a relative-date value may sit under. */ const RELATIVE_DATE_OPS: readonly string[] = [FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE]; -const AGG_COUNT = "count"; -const AGG_SUM = "sum"; -const AGG_AVG = "avg"; -const AGG_MIN = "min"; -const AGG_MAX = "max"; -const GRAIN_HOUR = "hour"; const ERR_INVALID_DIMENSION: ErrorCode = "ERR_INVALID_DIMENSION"; const ERR_INVALID_MEASURE: ErrorCode = "ERR_INVALID_MEASURE"; @@ -152,24 +154,29 @@ export function validateReporting(root: MetaData): ParseError[] { return sink.errors; } -/** Collects errors, dropping an exact repeat (same node, code and message) — - * the shape an unmodified inherited member's failure takes when it is - * re-validated under an inheriting entity. A message is `head + suffix + body`; - * the repeat test ignores the suffix (" (inherited by '')"), so the - * pass-2 copy of a failure pass 1 already reported is dropped. */ +/** 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 '')") 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. */ class ErrorSink { readonly errors: ParseError[] = []; private readonly seen = new Map>(); push(node: MetaData, code: ErrorCode, head: string, body: string, suffix = ""): void { - const key = `${code}\u0000${head}${body}`; + // `base` 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. + const base = `${code}\u0000${head}${body}`; + const key = `${base}\u0000${suffix}`; let keys = this.seen.get(node); if (keys === undefined) { keys = new Set(); this.seen.set(node, keys); } - if (keys.has(key)) return; - keys.add(key); + if (keys.has(base) || keys.has(key)) return; + keys.add(suffix === "" ? base : key); this.errors.push(new ParseError(`${head}${suffix}${body}`, { code, source: node.source })); } } @@ -222,11 +229,15 @@ function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } -/** `{ now: }` — exactly the one key. */ +/** 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. */ function isRelativeValue(v: unknown): v is Record { - if (!isPlainObject(v)) return false; - const keys = Object.keys(v); - return keys.length === 1 && keys[0] === FILTER_RELATIVE_NOW; + return isPlainObject(v) && Object.keys(v).includes(FILTER_RELATIVE_NOW); +} + +/** True for a well-formed relative value: exactly the one key `now`. */ +function isExactRelativeValue(v: Record): boolean { + return Object.keys(v).length === 1; } /** The relative value an op's operand carries: the operand itself, or one inside an array operand. */ @@ -236,7 +247,7 @@ function relativeOperand(v: unknown): Record | undefined { return undefined; } -/** Deep search: does a filter value contain a relative value anywhere? */ +/** Deep search: does a filter value contain a relative value (well-formed or not) anywhere? */ function containsRelativeValue(v: unknown): boolean { if (isRelativeValue(v)) return true; if (Array.isArray(v)) return v.some(containsRelativeValue); @@ -509,8 +520,8 @@ function checkAggregateColumns(ctx: MemberCtx, measure: MetaMeasure, err: (messa /** M6 — each operand names a measure.aggregate of the same entity. */ function checkRatioOperands(ctx: MemberCtx, ratio: MetaMeasure, err: (message: string) => void): void { const operands: [string, string | undefined][] = [ - ["numerator", ratio.numerator()], - ["denominator", ratio.denominator()], + [REPORTING_ATTR_NUMERATOR, ratio.numerator()], + [REPORTING_ATTR_DENOMINATOR, ratio.denominator()], ]; for (const [attr, ref] of operands) { if (ref === undefined) continue; // missing operand is ERR_MISSING_REQUIRED_ATTR @@ -578,6 +589,13 @@ function checkFilter( } const relative = relativeOperand(operand); if (relative === undefined) continue; + if (!isExactRelativeValue(relative)) { + err( + `@filter on '${key}' has a malformed relative date ${JSON.stringify(relative)}; a relative date is ` + + `exactly { now: "" } with no other keys.`, + ); + continue; + } if (!TEMPORAL_FIELD_SUBTYPES.includes(field.subType)) { err( `@filter on '${key}' uses a relative date ({ now: ... }), but '${key}' is field.${field.subType}; ` + diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index 1091e96b1..503956d68 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -840,10 +840,38 @@ describe("validateReporting — relative dates", () => { }), ); const { errors } = await loadInline(m); - expect(errors.map((e) => e.code)).toContain("ERR_BAD_ATTR_FILTER"); - const f1 = errors.filter((e) => e.message.includes("relative date")); - expect(f1.length).toBe(1); - expect(f1[0]!.message).toContain("origin.aggregate"); + expect(errors.map((e) => e.code)).toEqual(["ERR_BAD_ATTR_FILTER"]); + expect(errors[0]!.message).toContain("origin.aggregate"); + }); + + test("F2: an operand with a `now` key plus other keys is refused on a reporting host", async () => { + const m = edit((x) => + patchObject(x, "DailyRevenue", { "@filter": { purchasedAt: { gte: { now: "-P7D", x: 1 } } } }), + ); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toBe( + "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.', + ); + }); + + test("F1: an operand with a `now` key plus other keys is refused on a non-reporting host", async () => { + const m = edit((x) => + x["metadata.root"].children.push({ + "object.projection": { + name: "RecentPurchase", + "@filter": { purchasedAt: { gte: { now: "-P7D", x: 1 } } }, + children: [ + { "source.rdb": { "@kind": "view", "@view": "recent_purchases" } }, + field("long", "id", { extends: "Purchase.id" }), + field("timestamp", "purchasedAt", { extends: "Purchase.purchasedAt" }), + { "identity.primary": { name: "id", extends: "Purchase.id" } }, + ], + }, + }), + ); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("relative date"); }); }); @@ -923,6 +951,22 @@ describe("validateReporting — inherited members", () => { expect(await codes(m)).toEqual(["ERR_INVALID_DIMENSION"]); }); + test("two inheritors that break the same inherited member the same way are EACH reported", async () => { + const m = inheritedModel(); + (objectBody(m, "WorkoutEvent").children as Wrapper[]).push(field("string", "occurredAt")); + m["metadata.root"].children.push({ + "object.entity": { + name: "LoginEvent", + extends: "BaseEvent", + children: [{ "source.rdb": { "@table": "login_events" } }, field("string", "occurredAt"), primary()], + }, + }); + const { errors } = await loadInline(m); + expect(errors.map((e) => e.code)).toEqual(["ERR_INVALID_DIMENSION", "ERR_INVALID_DIMENSION"]); + expect(errors[0]!.message).toContain("(inherited by 'acme::shop::WorkoutEvent')"); + expect(errors[1]!.message).toContain("(inherited by 'acme::shop::LoginEvent')"); + }); + test("an inheritor whose override breaks an inherited member is reported, naming the inheritor", async () => { const m = inheritedModel(); // WorkoutEvent overrides occurredAt as a string: the inherited time dimension From 8426f0565d2f5bdc453fb033d8ad8aeb4c2fc9ad Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 14:04:21 -0400 Subject: [PATCH 04/29] test(conformance): FR-044 reporting vocabulary fixtures (1 positive, inheritance, 22 error cases) --- AGENTS.md | 2 +- docs/CONFORMANCE.md | 6 +- examples/showcase/site-payload.json | 2 +- .../expected-errors.json | 15 + .../input/meta.shop.json | 365 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 362 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 356 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../error-measure-of-foreign/providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 354 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 362 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 393 ++++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 362 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 361 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 356 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 362 ++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 355 ++++++++++++++++ .../providers.json | 1 + .../reporting-inherited-members/expected.json | 82 ++++ .../input/meta.shop.json | 80 ++++ .../providers.json | 1 + .../reporting-vocabulary/expected.json | 373 +++++++++++++++++ .../reporting-vocabulary/input/meta.shop.json | 355 ++++++++++++++++ .../reporting-vocabulary/providers.json | 1 + .../registry-conformance/coverage-report.json | 8 +- 76 files changed, 9143 insertions(+), 12 deletions(-) create mode 100644 fixtures/conformance/error-dimension-grain-on-date/expected-errors.json create mode 100644 fixtures/conformance/error-dimension-grain-on-date/input/meta.shop.json create mode 100644 fixtures/conformance/error-dimension-grain-on-date/providers.json create mode 100644 fixtures/conformance/error-dimension-of-unresolved/expected-errors.json create mode 100644 fixtures/conformance/error-dimension-of-unresolved/input/meta.shop.json create mode 100644 fixtures/conformance/error-dimension-of-unresolved/providers.json create mode 100644 fixtures/conformance/error-dimension-time-not-temporal/expected-errors.json create mode 100644 fixtures/conformance/error-dimension-time-not-temporal/input/meta.shop.json create mode 100644 fixtures/conformance/error-dimension-time-not-temporal/providers.json create mode 100644 fixtures/conformance/error-dimension-via-to-many/expected-errors.json create mode 100644 fixtures/conformance/error-dimension-via-to-many/input/meta.shop.json create mode 100644 fixtures/conformance/error-dimension-via-to-many/providers.json create mode 100644 fixtures/conformance/error-measure-distinct-not-count/expected-errors.json create mode 100644 fixtures/conformance/error-measure-distinct-not-count/input/meta.shop.json create mode 100644 fixtures/conformance/error-measure-distinct-not-count/providers.json create mode 100644 fixtures/conformance/error-measure-of-foreign/expected-errors.json create mode 100644 fixtures/conformance/error-measure-of-foreign/input/meta.shop.json create mode 100644 fixtures/conformance/error-measure-of-foreign/providers.json create mode 100644 fixtures/conformance/error-measure-segment-unresolved/expected-errors.json create mode 100644 fixtures/conformance/error-measure-segment-unresolved/input/meta.shop.json create mode 100644 fixtures/conformance/error-measure-segment-unresolved/providers.json create mode 100644 fixtures/conformance/error-measure-sum-non-numeric/expected-errors.json create mode 100644 fixtures/conformance/error-measure-sum-non-numeric/input/meta.shop.json create mode 100644 fixtures/conformance/error-measure-sum-non-numeric/providers.json create mode 100644 fixtures/conformance/error-measure-tuple-without-distinct/expected-errors.json create mode 100644 fixtures/conformance/error-measure-tuple-without-distinct/input/meta.shop.json create mode 100644 fixtures/conformance/error-measure-tuple-without-distinct/providers.json create mode 100644 fixtures/conformance/error-ratio-operand-not-aggregate/expected-errors.json create mode 100644 fixtures/conformance/error-ratio-operand-not-aggregate/input/meta.shop.json create mode 100644 fixtures/conformance/error-ratio-operand-not-aggregate/providers.json create mode 100644 fixtures/conformance/error-relative-date-bad-duration/expected-errors.json create mode 100644 fixtures/conformance/error-relative-date-bad-duration/input/meta.shop.json create mode 100644 fixtures/conformance/error-relative-date-bad-duration/providers.json create mode 100644 fixtures/conformance/error-relative-date-non-temporal/expected-errors.json create mode 100644 fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json create mode 100644 fixtures/conformance/error-relative-date-non-temporal/providers.json create mode 100644 fixtures/conformance/error-relative-date-wrong-host/expected-errors.json create mode 100644 fixtures/conformance/error-relative-date-wrong-host/input/meta.shop.json create mode 100644 fixtures/conformance/error-relative-date-wrong-host/providers.json create mode 100644 fixtures/conformance/error-report-declares-field/expected-errors.json create mode 100644 fixtures/conformance/error-report-declares-field/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-declares-field/providers.json create mode 100644 fixtures/conformance/error-report-dimension-grain/expected-errors.json create mode 100644 fixtures/conformance/error-report-dimension-grain/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-dimension-grain/providers.json create mode 100644 fixtures/conformance/error-report-field-collision/expected-errors.json create mode 100644 fixtures/conformance/error-report-field-collision/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-field-collision/providers.json create mode 100644 fixtures/conformance/error-report-foreign-measure/expected-errors.json create mode 100644 fixtures/conformance/error-report-foreign-measure/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-foreign-measure/providers.json create mode 100644 fixtures/conformance/error-report-from-not-entity/expected-errors.json create mode 100644 fixtures/conformance/error-report-from-not-entity/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-from-not-entity/providers.json create mode 100644 fixtures/conformance/error-report-measure-unresolved/expected-errors.json create mode 100644 fixtures/conformance/error-report-measure-unresolved/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-measure-unresolved/providers.json create mode 100644 fixtures/conformance/error-report-segment-unresolved/expected-errors.json create mode 100644 fixtures/conformance/error-report-segment-unresolved/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-segment-unresolved/providers.json create mode 100644 fixtures/conformance/error-report-writable-source/expected-errors.json create mode 100644 fixtures/conformance/error-report-writable-source/input/meta.shop.json create mode 100644 fixtures/conformance/error-report-writable-source/providers.json create mode 100644 fixtures/conformance/error-segment-filter-bad-field/expected-errors.json create mode 100644 fixtures/conformance/error-segment-filter-bad-field/input/meta.shop.json create mode 100644 fixtures/conformance/error-segment-filter-bad-field/providers.json create mode 100644 fixtures/conformance/reporting-inherited-members/expected.json create mode 100644 fixtures/conformance/reporting-inherited-members/input/meta.shop.json create mode 100644 fixtures/conformance/reporting-inherited-members/providers.json create mode 100644 fixtures/conformance/reporting-vocabulary/expected.json create mode 100644 fixtures/conformance/reporting-vocabulary/input/meta.shop.json create mode 100644 fixtures/conformance/reporting-vocabulary/providers.json diff --git a/AGENTS.md b/AGENTS.md index dea015517..25c3e9089 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (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/` (358 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. - 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. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index c76f21a0f..d32b0d700 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 334 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 358 | ✓ | ✓ | 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 | ✓ | — | — | — | ✓ | @@ -200,7 +200,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 (358) | Fixture prefix | Feature doc | |---|---| @@ -394,7 +394,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 358 + 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/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index f48913888..a92cc4a73 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.1" }, "counts": { - "fixtures": 334, + "fixtures": 358, "corpora": 24, "baseTypes": 17 }, 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-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..34ffab6d1 --- /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": { + "status": { + "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-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..6d487ff2f --- /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": "purchases" + } + } + ] + } + } + ] + } +} 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-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/registry-conformance/coverage-report.json b/fixtures/registry-conformance/coverage-report.json index a2ec53f47..631dfcee5 100644 --- a/fixtures/registry-conformance/coverage-report.json +++ b/fixtures/registry-conformance/coverage-report.json @@ -1,6 +1,6 @@ { "registeredSubTypeCount": 65, - "exercisedSubTypeCount": 47, + "exercisedSubTypeCount": 53, "untestedSubTypes": [ "attr.boolean", "attr.class", @@ -10,12 +10,6 @@ "attr.int", "attr.intMap", "attr.long", - "dimension.attribute", - "dimension.time", - "measure.aggregate", - "measure.ratio", - "object.report", - "segment.filter", "validator.atLeastOne", "validator.comparison", "validator.presentIff", From bcfa427da8564581e549c6762312dc8a80d74a7d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 14:08:25 -0400 Subject: [PATCH 05/29] =?UTF-8?q?test(conformance):=20FR-044=20fixtures=20?= =?UTF-8?q?=E2=80=94=20make=20non-temporal=20F2=20fixture=20reach=20F2,=20?= =?UTF-8?q?de-collide=20declared=20field,=20map=20fixtures=20to=20a=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CONFORMANCE.md | 1 + .../error-relative-date-non-temporal/input/meta.shop.json | 2 +- .../error-report-declares-field/input/meta.shop.json | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index d32b0d700..83e399cfc 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -222,6 +222,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-*` | [FR-044 core reporting design](superpowers/specs/2026-10-02-fr-044-core-reporting-design.md) (the `dimension` / `measure` / `segment` / `object.report` vocabulary and its load-time rules) | | `smoke-empty-metadata` | [features/entities.md](features/entities.md) | ### `fixtures/yaml-conformance/` (16) 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 index 34ffab6d1..7d064af7d 100644 --- a/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json +++ b/fixtures/conformance/error-relative-date-non-temporal/input/meta.shop.json @@ -331,7 +331,7 @@ "revenue" ], "@filter": { - "status": { + "amountCents": { "gte": { "now": "-P90D" } diff --git a/fixtures/conformance/error-report-declares-field/input/meta.shop.json b/fixtures/conformance/error-report-declares-field/input/meta.shop.json index 6d487ff2f..9c289ba39 100644 --- a/fixtures/conformance/error-report-declares-field/input/meta.shop.json +++ b/fixtures/conformance/error-report-declares-field/input/meta.shop.json @@ -351,7 +351,7 @@ "children": [ { "field.long": { - "name": "purchases" + "name": "rowCount" } } ] From a7d623f712b357e1963555d6603c6afe48ca5281 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 14:22:01 -0400 Subject: [PATCH 06/29] feat(metadata): relative-date filter values, legal on reporting hosts only (TypeScript) A { now: "" } operand survives filter desugaring as an opaque value. The operator-less shorthand { field: { now: ... } } is read as the value of an implicit eq (now is not an operator), so F2 refuses it by name instead of S1 reporting a bad op. F1's search for a relative value on a non-reporting host now walks operand values only, so a field named now is no longer misread. The TS view lowering throws on a { now } operand that bypassed the loader. --- .../src/projection/extract-view-spec.ts | 20 ++++++ .../view-filter-relative-date.test.ts | 60 ++++++++++++++++ .../src/core/attr/meta-attr-filter.ts | 9 ++- .../src/loader/reporting-validation.ts | 29 ++++++-- .../test/reporting-validation.test.ts | 68 +++++++++++++++++++ 5 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 server/typescript/packages/codegen-ts/test/projection/view-filter-relative-date.test.ts diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index 1de591321..3803a15d4 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -39,6 +39,7 @@ import { FIELD_SUBTYPE_ENUM, FILTER_COMPOSE_AND, FILTER_COMPOSE_OR, + FILTER_RELATIVE_NOW, SORT_ORDER_DESC, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_CARDINALITY, @@ -104,6 +105,24 @@ function desugarClause(raw: unknown): Record { return { eq: raw }; } +/** + * FR-044 — a relative-date value `{ now: "" }` is legal only in the + * `@filter` of a segment, measure.aggregate or object.report (the loader's F1 rule), and + * this lowering has no rendering for it: it would otherwise land as a SQL literal of + * `[object Object]`. A programmatic caller skips the loader, so refuse it here, loudly. + * The report lowering (FR-044 Plan 2) replaces this throw. + */ +function assertNoRelativeDate(value: unknown, where: string): void { + const isRelative = (v: unknown): boolean => + typeof v === "object" && v !== null && !Array.isArray(v) && FILTER_RELATIVE_NOW in v; + if (isRelative(value) || (Array.isArray(value) && value.some(isRelative))) { + throw new Error( + `${where}: a relative-date filter value ({ ${FILTER_RELATIVE_NOW}: "" }) cannot be ` + + `lowered to a view; it is legal only in the @filter of a segment, measure.aggregate or object.report.`, + ); + } +} + /** * Resolve an aggregate `@filter` (`{ field: value | { op: value }, and?, or? }`) into * a {@link ViewFilterClause} with column refs resolved to `alias.column` on the @@ -190,6 +209,7 @@ function encodeIntEnumFilterValue( fieldName: string, projectionName: string, ): unknown { + assertNoRelativeDate(value, `Projection ${projectionName}: view @filter on "${fieldName}"`); if (intMap === undefined) return value; if (op === FILTER_OP_IS_NULL) return value; if (op === FILTER_OP_LIKE) { diff --git a/server/typescript/packages/codegen-ts/test/projection/view-filter-relative-date.test.ts b/server/typescript/packages/codegen-ts/test/projection/view-filter-relative-date.test.ts new file mode 100644 index 000000000..694464873 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/projection/view-filter-relative-date.test.ts @@ -0,0 +1,60 @@ +// FR-044 — a relative-date filter value ({ now: "" }) is legal only on the +// reporting hosts (loader rule F1). A programmatic caller skips the loader, so the view +// lowering must refuse it rather than emit a bogus SQL literal. + +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { extractViewSpec } from "../../src/projection/extract-view-spec.js"; + +const MODEL = { + "metadata.root": { + package: "test", + children: [ + { + "object.entity": { + name: "Order", + children: [ + { "source.rdb": { "@table": "orders" } }, + { "field.int": { name: "id" } }, + { "field.timestamp": { name: "placedAt" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ], + }, + }, + { + "object.projection": { + name: "OrderView", + "@filter": { placedAt: { gte: "2026-01-01T00:00:00Z" } }, + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_order_view" } }, + { "field.int": { name: "id", extends: "Order.id" } }, + { "field.timestamp": { name: "placedAt", extends: "Order.placedAt" } }, + { "identity.primary": { name: "id", extends: "Order.id" } }, + ], + }, + }, + ], + }, +}; + +describe("view lowering refuses a relative-date filter value", () => { + test("a { now } operand that bypassed the loader throws a clear error", async () => { + const result = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(MODEL))]); + expect(result.errors).toEqual([]); + const proj = result.root.findObject("OrderView")!; + // The loader would have refused this (F1); mutate the stored filter to simulate a + // programmatic caller that skipped it. + const filter = proj.ownAttr("filter") as Record>; + filter.placedAt = { gte: { now: "-P7D" } }; + + expect(() => extractViewSpec(proj, result.root, { columnNamingStrategy: "snake_case" })).toThrow( + /relative-date filter value.*cannot be lowered to a view/, + ); + }); + + test("the unmutated projection still extracts", async () => { + const result = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(MODEL))]); + const proj = result.root.findObject("OrderView")!; + expect(() => extractViewSpec(proj, result.root, { columnNamingStrategy: "snake_case" })).not.toThrow(); + }); +}); diff --git a/server/typescript/packages/metadata/src/core/attr/meta-attr-filter.ts b/server/typescript/packages/metadata/src/core/attr/meta-attr-filter.ts index 75200aee3..7a4a13cad 100644 --- a/server/typescript/packages/metadata/src/core/attr/meta-attr-filter.ts +++ b/server/typescript/packages/metadata/src/core/attr/meta-attr-filter.ts @@ -14,6 +14,7 @@ import { FILTER_COMPOSE_OR, FILTER_COMPOSE_AND, } from "../query/query-constants.js"; +import { FILTER_RELATIVE_NOW } from "../reporting/reporting-constants.js"; import { registerAttrClass } from "../../attr-class-map.js"; export class FilterAttr extends MetaAttr { @@ -60,7 +61,13 @@ function desugarFilterObject(filter: AttrObject): AttrObject { function desugarClause(raw: AttrJson): AttrObject { if (raw === null) return { [FILTER_OP_IS_NULL]: true }; if (Array.isArray(raw)) return { [FILTER_OP_IN]: raw }; - if (typeof raw === "object") return raw as AttrObject; + // 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. + if (typeof raw === "object") { + return FILTER_RELATIVE_NOW in raw ? { [FILTER_OP_EQ]: raw } : (raw as AttrObject); + } return { [FILTER_OP_EQ]: raw }; } diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index 982a8844e..96ec79f4a 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -122,7 +122,6 @@ const TEMPORAL_FIELD_SUBTYPES: readonly string[] = [FIELD_SUBTYPE_DATE, FIELD_SU /** F2 — the only ops a relative-date value may sit under. */ const RELATIVE_DATE_OPS: readonly string[] = [FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE]; - const ERR_INVALID_DIMENSION: ErrorCode = "ERR_INVALID_DIMENSION"; const ERR_INVALID_MEASURE: ErrorCode = "ERR_INVALID_MEASURE"; const ERR_INVALID_REPORT: ErrorCode = "ERR_INVALID_REPORT"; @@ -247,11 +246,29 @@ function relativeOperand(v: unknown): Record | undefined { return undefined; } -/** Deep search: does a filter value contain a relative value (well-formed or not) anywhere? */ -function containsRelativeValue(v: unknown): boolean { +/** Deep search of an operand VALUE: is a relative value (well-formed or not) anywhere inside it? */ +function operandContainsRelativeValue(v: unknown): boolean { if (isRelativeValue(v)) return true; - if (Array.isArray(v)) return v.some(containsRelativeValue); - if (isPlainObject(v)) return Object.values(v).some(containsRelativeValue); + if (Array.isArray(v)) return v.some(operandContainsRelativeValue); + if (isPlainObject(v)) return Object.values(v).some(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. + */ +function filterContainsRelativeValue(filter: unknown): boolean { + if (!isPlainObject(filter)) return false; + for (const [key, clause] of Object.entries(filter)) { + if (key === FILTER_COMPOSE_OR || key === FILTER_COMPOSE_AND) { + if (Array.isArray(clause) && clause.some(filterContainsRelativeValue)) return true; + continue; + } + if (isRelativeValue(clause)) return true; // un-desugared shorthand + if (isPlainObject(clause) && Object.values(clause).some(operandContainsRelativeValue)) return true; + } return false; } @@ -829,7 +846,7 @@ function checkNoRelativeDates(node: MetaData, sink: ErrorSink): void { // visits every declared node exactly once (an inherited filter is checked // where it is declared; origin.* never inherits, ADR-0029). for (const attr of node.ownMetaAttrs()) { - if (attr.subType === ATTR_SUBTYPE_FILTER && containsRelativeValue(attr.value)) { + if (attr.subType === ATTR_SUBTYPE_FILTER && filterContainsRelativeValue(attr.value)) { sink.push( node, ERR_BAD_ATTR_FILTER, diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index 503956d68..663fc46a3 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -873,6 +873,74 @@ describe("validateReporting — relative dates", () => { const msg = await single(m, "ERR_BAD_ATTR_FILTER"); expect(msg).toContain("relative date"); }); + + test("F2: a relative value survives desugaring unchanged", async () => { + const m = edit((x) => + setChild(x, "WorkoutEvent", "recent", { + "segment.filter": { name: "recent", "@filter": { occurredAt: { gte: { now: "-P7D" } } } }, + }), + ); + const { root, errors } = await loadInline(m); + expect(errors).toEqual([]); + const event = root.children().find((c) => c.name === "WorkoutEvent")!; + const seg = event.children().find((c) => c.name === "recent") as unknown as { + filter(): Record | undefined; + }; + expect(seg.filter()).toEqual({ occurredAt: { gte: { now: "-P7D" } } }); + }); + + test("F2: the operator-less shorthand is the value of an implicit eq, refused as such", async () => { + // `{ 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. + const m = edit((x) => + setChild(x, "WorkoutEvent", "recent", { + "segment.filter": { name: "recent", "@filter": { occurredAt: { now: "-P7D" } } }, + }), + ); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("relative date"); + expect(msg).toContain("op 'eq'"); + expect(msg).not.toContain("not allowed for"); + }); + + test("F1: a field literally named `now` is a field, not a relative date", async () => { + const m = edit((x) => + x["metadata.root"].children.push({ + "object.projection": { + name: "NowView", + "@filter": { now: { eq: 1 } }, + children: [ + { "source.rdb": { "@kind": "view", "@view": "now_view" } }, + field("long", "id", { extends: "Purchase.id" }), + field("int", "now"), + { "identity.primary": { name: "id", extends: "Purchase.id" } }, + ], + }, + }), + ); + const { errors } = await loadInline(m); + expect(errors.map((e) => e.message).filter((msg) => msg.includes("relative date"))).toEqual([]); + }); + + test("F1: a field named `now` inside and/or does not hide a real relative value beside it", async () => { + const m = edit((x) => + x["metadata.root"].children.push({ + "object.projection": { + name: "NowView", + "@filter": { or: [{ now: { eq: 1 } }, { purchasedAt: { gte: { now: "-P7D" } } }] }, + children: [ + { "source.rdb": { "@kind": "view", "@view": "now_view" } }, + field("long", "id", { extends: "Purchase.id" }), + field("int", "now"), + field("timestamp", "purchasedAt", { extends: "Purchase.purchasedAt" }), + { "identity.primary": { name: "id", extends: "Purchase.id" } }, + ], + }, + }), + ); + const { errors } = await loadInline(m); + expect(errors.filter((e) => e.message.includes("relative date")).length).toBe(1); + }); }); // --------------------------------------------------------------------------- From 019009ea43f26372b65168d267e880722c729ac6 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 14:37:20 -0400 Subject: [PATCH 07/29] feat(csharp): register and validate the FR-044 reporting vocabulary Registers dimension.attribute/time, measure.aggregate/ratio, segment.filter and object.report in the C# port, and ports validateReporting rule-for-rule from the TypeScript reference with the same message text. - SpecMetamodel/reporting.json (new) and object.json are byte copies of the root spec; SpecMetamodelReader lists 19 files. Attr schemas declare only value type, array-ness, requiredness and allowedValues; descriptions and the structural child graph come from the embedded JSON. - ValidationPasses.ValidateReporting (new partial file) runs after the projection-filter pass: D1-D4, M1-M6, S1, R1-R7, F1/F2. One broken rule is one error; inherited members are validated per inheritor with the TS ErrorSink dedupe. F1 classifies filter attrs through the registry, since C# keeps inline attrs as values rather than typed attr nodes. - Filter desugar: a shorthand { f: { now } } becomes { eq: { now } }; an explicit-op relative value stays an opaque operand. - Four new error codes: ERR_INVALID_DIMENSION, ERR_INVALID_MEASURE, ERR_INVALID_REPORT, ERR_REPORT_FOREIGN_MEASURE. - ReportingAccessorsTests and ReportingValidationTests (exact message text, inherited-member dedupe, relative-date desugar, F1 operand-only walk). All 24 FR-044 conformance fixtures, registry-conformance and spec-embed gates pass; codegen output for the positive fixture is byte-identical with and without the reporting vocabulary. --- .../ReportingAccessorsTests.cs | 131 +++ .../ReportingValidationTests.cs | 245 +++++ .../Core/Object/ObjectConstants.cs | 24 +- .../Core/Reporting/ReportAccessors.cs | 64 ++ .../Core/Reporting/ReportingConstants.cs | 131 +++ .../Core/Reporting/ReportingSchema.cs | 101 ++ server/csharp/MetaObjects/CoreTypes.cs | 57 +- server/csharp/MetaObjects/Errors.cs | 17 + server/csharp/MetaObjects/GlobalUsings.cs | 1 + .../MetaObjects/Loader/MetaDataLoader.cs | 4 + .../Loader/ValidationPasses.Reporting.cs | 861 ++++++++++++++++++ .../MetaObjects/Loader/ValidationPasses.cs | 2 +- .../csharp/MetaObjects/Meta/MetaDimension.cs | 36 + server/csharp/MetaObjects/Meta/MetaMeasure.cs | 44 + server/csharp/MetaObjects/Meta/MetaSegment.cs | 17 + server/csharp/MetaObjects/Parser.cs | 9 + .../Registry/Spec/SpecMetamodelReader.cs | 8 +- server/csharp/MetaObjects/Shared/BaseTypes.cs | 11 + .../MetaObjects/SpecMetamodel/object.json | 17 + .../MetaObjects/SpecMetamodel/reporting.json | 43 + 20 files changed, 1813 insertions(+), 10 deletions(-) create mode 100644 server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs create mode 100644 server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs create mode 100644 server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs create mode 100644 server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs create mode 100644 server/csharp/MetaObjects/Core/Reporting/ReportingSchema.cs create mode 100644 server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs create mode 100644 server/csharp/MetaObjects/Meta/MetaDimension.cs create mode 100644 server/csharp/MetaObjects/Meta/MetaMeasure.cs create mode 100644 server/csharp/MetaObjects/Meta/MetaSegment.cs create mode 100644 server/csharp/MetaObjects/SpecMetamodel/reporting.json 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..58ad8720f --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs @@ -0,0 +1,245 @@ +// 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) }; + + 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" } } ] } + """)); + Assert.DoesNotContain(Load(m).Errors, e => e.Message.Contains("relative date", System.StringComparison.Ordinal)); + } + + [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(); + ChildrenOf(m, "BaseEvent")[3] = Wrap("measure.aggregate", + """{ "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(); + var kids = ChildrenOf(m, "BaseEvent"); + kids[2] = Wrap("dimension.time", """{ "name": "occurredAt", "@of": "BaseEvent.nope", "@grains": ["day", "week"] }"""); + kids.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)); + } +} 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..14eba39d3 --- /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 @kind: view.", 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/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/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." } + ] } + ] +} From 984098f8c8262b4dc26b287b698752123d4f60b6 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 3 Oct 2026 14:41:30 -0400 Subject: [PATCH 08/29] feat(java): register and validate the FR-044 reporting vocabulary Registers dimension.attribute, dimension.time, measure.aggregate, measure.ratio and segment.filter (new reporting-types provider) and object.report (beside object.projection). Structural children and descriptions come from spec/metamodel/reporting.json and object.json, now embedded (19 spec files). Kotlin composes from the same JVM provider set and needs no code; the facade carries no requirement accessors, so none are added for reporting. ReportingValidation ports the TS validateReporting pass rule for rule (D1-D4, M1-M6, S1, R1-R7, F1/F2) with the same message text, the same pass-1/pass-2 inheritance dedupe and the same no-cascade short-circuits. FilterAttribute desugars an operator-less { now } clause to eq, as the TS desugarClause does. Two Java-specific findings: 1. The registry manifest decided array-ness by attr NAME alone. FR-044 makes @of an array on measure.aggregate while it stays scalar on dimension.* and origin.*, so every @of in the manifest flipped to isArray: true. The emitter now resolves the array constraint per type (own, inherited, then common attr). 2. .withEnum is decorative on attr children in this port, so the closed @agg and @grains sets the TS registry enforces generically are checked in the pass as ERR_BAD_ATTR_VALUE. Tests: all 24 FR-044 conformance fixtures and registry-conformance pass. A new test asserts every error fixture yields exactly one finding with the TS loader's code and message. Disabling the pass fails exactly the 22 error fixtures. --- server/java/metadata/pom.xml | 2 +- .../main/java/com/metaobjects/ErrorCode.java | 37 + .../com/metaobjects/attr/FilterAttribute.java | 13 + .../loader/ReportingValidation.java | 940 ++++++++++++++++++ .../metaobjects/loader/ValidationPhase.java | 20 +- .../com/metaobjects/object/MetaObject.java | 35 + .../metaobjects/object/ReportMetaObject.java | 34 + .../registry/RegistryManifest.java | 55 +- .../registry/spec/SpecMetamodelReader.java | 8 +- .../reporting/AggregateMeasure.java | 57 ++ .../reporting/AttributeDimension.java | 48 + .../metaobjects/reporting/FilterSegment.java | 40 + .../metaobjects/reporting/MetaDimension.java | 62 ++ .../metaobjects/reporting/MetaMeasure.java | 77 ++ .../metaobjects/reporting/MetaSegment.java | 38 + .../metaobjects/reporting/RatioMeasure.java | 45 + .../reporting/ReportAccessors.java | 71 ++ .../metaobjects/reporting/ReportingAttrs.java | 81 ++ .../reporting/ReportingConstants.java | 127 +++ .../ReportingTypesMetaDataProvider.java | 57 ++ .../metaobjects/reporting/TimeDimension.java | 53 + .../util/ErrorMessageConstants.java | 12 + ....metaobjects.registry.MetaDataTypeProvider | 1 + .../conformance/ConformanceTest.java | 4 +- .../loader/ReportingValidationTest.java | 383 +++++++ .../registry/spec/SpecMetamodelEmbedTest.java | 2 +- .../metaobjects/reporting/ReportingTest.java | 226 +++++ 27 files changed, 2507 insertions(+), 21 deletions(-) create mode 100644 server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/object/ReportMetaObject.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/AggregateMeasure.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/AttributeDimension.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/FilterSegment.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/MetaDimension.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/MetaMeasure.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/MetaSegment.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/RatioMeasure.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/ReportAccessors.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingAttrs.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingTypesMetaDataProvider.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/TimeDimension.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java diff --git a/server/java/metadata/pom.xml b/server/java/metadata/pom.xml index 58b09a060..242cbd5af 100644 --- a/server/java/metadata/pom.xml +++ b/server/java/metadata/pom.xml @@ -28,7 +28,7 @@ -