Skip to content

Commit c0c43e1

Browse files
authored
feat(reporting): add @spine and measure @default for FR-044 (#415)
* docs(plan): FR-044 zero rows from a dimension's entity and a default for an empty measure Design for two additions to the 1.1 reporting vocabulary, in all five ports: @spine on object.report (the report's rows come from a dimension's entity, so a tuple with no fact rows still has a row) and @default on measure.aggregate and measure.ratio (the integer a measure reads when it would be null). The plan settles where each attribute lives and what was rejected, the loader rules (R8, R9, M7, M8) and their error codes, the view SQL per dialect, what a measure reads in a row with no facts, the derived shape's nullability and the row type in every port, and filter and sort on the derived fields. The SQL shapes were executed on Postgres 16, SQLite and MySQL 8.4. The spec gains the ADR-0023 register amendment (3.2), requirements R8 and R9, two mapping rows, one acceptance bullet and the parked list. metamodelVersion stays 1.1. No code changes in this commit. * feat(metamodel): register @spine on object.report and @default on measures (TypeScript) * feat(metadata): validate @spine and a measure @default at load (TypeScript) * fix(metadata): clearer @spine messages and one error per mistake (TypeScript) * test(conformance): @spine and measure @default fixtures (2 positive, 8 error cases) Two positive fixtures (reporting-spine-and-default, reporting-spine-inherited) and the eight error fixtures of Table B, each the positive input with one change and one expected error: R8 (to-many hop, owner not @from), R9 (no dimensions, a time dimension over a fact column, a dimension through the reference's name for the spine's join), M7 (@default on a count), M8 (@default on a max of a timestamp) and the type check (@default: 0.5). attr.int and attr.long now refuse a fractional number in TypeScript: a fractional number in any int/long attribute fails to load with ERR_BAD_ATTR_VALUE (input with no valid meaning; it used to pass the attribute type check unchanged). attr.double is unchanged. No fixture, example or test model carried such a value. Metamodel corpus count 364 -> 374. The registry coverage snapshot is regenerated: spine and default are now exercised, and the stale `sortable` entries on field.double and field.float are dropped (already exercised by the api-contract projection fixtures). CAPABILITIES.json regenerates unchanged. The C#, Java, Kotlin and Python conformance lanes are red on the new fixtures until their own tasks. * fix(metadata): refuse a fractional measure @default without changing attr.int (TypeScript) Reverts the attr.int/attr.long tightening from the previous commit. It also refused a fraction in validator @min/@max, which are registered attr.int but documented as numeric values, so `validator.numeric @min: 0.5` over a double field stopped loading. That changed released vocabulary, which this plan must not do. A fractional number in an int/long attribute loads again as before. A measure's @default is now checked in validateReporting instead: a number that is not an integer on a measure.aggregate or measure.ratio is ERR_BAD_ATTR_VALUE on the measure node, with the text "@default '<value>' is not an integer. A measure's @default is a whole number (for example 0)." The check runs first; when it fires, M7/M8 are skipped. A non-number stays the attribute type check's error alone. error-measure-default-not-integer is unchanged and still yields exactly one error. The registry coverage snapshot gets back the two `sortable` entries (field.decimal, field.float) that the regeneration had dropped, so the file no longer differs from main. * feat(metadata): a spine key and a defaulted measure are not nullable in a report's shape * feat(codegen-ts): lower a report @spine and a measure @default to view SQL FR-044 Table D in TypeScript (Postgres, SQLite/D1, MySQL). A @spine report reads FROM the spine entity and walks the spine's hops back to @from, every join LEFT OUTER, with the report scope ANDed onto the ON of the join that introduces @from (no WHERE). Aliases are those of the same report without @spine. A measure or ratio @default wraps its full expression in COALESCE (a REAL literal on SQLite for a real measure). The extractor refuses a spine that reaches an entity with no table or a TPH subtype, reports a hop with no identity.reference as @spine's hop error, and refuses a dimension not reached through the spine. A model declaring neither attribute emits byte-identical SQL. * test(persistence): canonical @spine and @default reports, value and idempotence tests * test(persistence): @spine and @default read scenarios; describe them in meta docs * test(api-contract): a @spine report with a defaulted measure in the report/ sub-corpus * feat(csharp): @spine and measure @default FR-044 zero rows and measure defaults, the C# port of the TypeScript reference (Tables A, B and C). - Register @spine (string) on object.report and @default (int) on measure.aggregate and measure.ratio; byte-copy the two spec files. MetaMeasure.DefaultValue() and ReportAccessors.ReportSpine(). - Loader rules R8 (the spine is D2's to-one walk, started at @from), R9 (every listed dimension is reached through the spine, hop names compared as written), M7 (@default on a count) and M8 (@default on min/max of a non-numeric field), with the TypeScript message texts. WalkToOneVia takes a ToOneWalk (D2Walk / SpineWalk); every D2 message is unchanged. A fractional @default is already refused by this port's generic attr.int type check (ERR_BAD_ATTR_VALUE on the measure), so no second error is added; it only skips M7/M8. - ReportShape Table C: under @spine a dimension on the spine is required when its @Of is @required or a primary-key column; a defaulted measure is required. New ReportingViaHops / ReportSpineHops. - No generator change: the keyless row types a defaulted measure as long / decimal and the spine key as non-nullable. Regenerated the integration fixtures (three new rows, AppDbContext additions). - The report REST lane seeds every base table of seed.json in file order and expects ProductRevenue (and the Product / Sale entities) routed. Corpus counts: nine canonical reports, four inert reports. * feat(java): @spine and measure @default FR-044 zero rows and measure defaults, the Java port of the TypeScript reference (Tables A, B and C). Kotlin rides on the loader and ReportShape. - Register @spine (string) on object.report and @default (int) on measure.aggregate and measure.ratio. MetaMeasure.getDefaultValue() and ReportAccessors.reportSpine(). The registry manifest's null value type for an attr named default is narrowed to field registrations, so a measure's default prints int. - Loader rules R8 (the spine is D2's to-one walk, started at @from), R9 (every listed dimension is reached through the spine, hop names compared as written), M7 (@default on a count) and M8 (@default on min/max of a non-numeric field), with the TypeScript message texts. walkToOneVia takes a ToOneWalk (d2Walk / spineWalk); every D2 message is unchanged. A fractional @default is already refused by this port's generic attr.int parse (ERR_BAD_ATTR_VALUE on the measure, and the load stops there), so no second error is added; a non-integer value only skips M7/M8. - ReportShape Table C: under @spine a dimension on the spine is required when its @Of is @required or a primary-key column; a defaulted measure is required. New reportingViaHops / reportSpineHops. - No generator change: the report DTO marks the spine key and a defaulted measure @NotNull, as it does a count. OMDB reads a @spine view's empty row. - The report REST seam lane serves ProductRevenue; corpus counts: nine canonical reports, four inert reports, sixteen REST scenarios. * test(kotlin): @spine and measure @default through Exposed FR-044 zero rows and measure defaults, the Kotlin side. Kotlin reads the loader and ReportShape from Java, so no Kotlin generator changes: a spine key and a defaulted measure are required in the shape, and a required field is already a non-null Exposed column and data class property. - KotlinReportTableGeneratorTest pins Table C/F: ProgramRoster's table text; a defaulted sum, min and ratio are non-null in the table and the row while the same measure without @default stays nullable; under @spine the spine entity's key is non-null and without @spine the same dimension is nullable. - Three hand-written reference tables (ProgramRoster, ProgramLongWeeks, FitnessTotalsFilled) for the persistence lane, which now runs the three new report scenarios. KotlinCodegenMatchesReferenceTest also checks every report reference table against the same expectations (view, columns in order, nullability), so a reference cannot drift from the generator. - The report/ REST seam lane serves ProductRevenue (sixteen scenarios); the inert test counts four reports, ProgramCatalogue sourceless. * feat(python): @spine and measure @default FR-044 zero rows and measure defaults, the Python port of the TypeScript reference (Tables A, B and C). - Register @spine (string) on object.report and @default (int) on measure.aggregate and measure.ratio; byte-copy the two spec files. MetaMeasure.default_value() (refuses bool) and report_spine(). - Loader rules R8 (the spine is D2's to-one walk, started at @from), R9 (every listed dimension is reached through the spine, hop names compared as written), M7 (@default on a count) and M8 (@default on min/max of a non-numeric field), with the TypeScript message texts. _walk_to_one_via takes a _ToOneWalk (_d2_walk / _spine_walk); every D2 message is unchanged. A fractional @default is already refused by this port's generic attr.int type check (ERR_BAD_ATTR_VALUE on the measure), so no second error is added; it only skips M7/M8. - report_shape Table C: under @spine a dimension on the spine is required when its @Of is @required or a primary-key column; a defaulted measure is required. New reporting_via_hops / report_spine_hops. - No generator or runtime change: the Pydantic row model types the spine key and a defaulted measure without "| None", as it does a count, and the ObjectManager reads a @spine view's empty row. - The report REST seam lane serves ProductRevenue; corpus counts: nine canonical reports, four inert reports, sixteen REST scenarios. A new unit file ports the TypeScript exact-text assertions for R8/R9/M7/M8. * docs(reporting): @spine and measure @default * fix(reporting): review fixes — null-group wording, @SQL shape note, fail-closed spine check Without @spine a fact row with a null or unmatched reference still counts: it falls in a null group through a nullable (LEFT OUTER) reference and is dropped through a required (INNER) one. Corrected in the CHANGELOG, reporting.md and the spec's R8. reporting.md also notes that an @SQL or @Unmanaged report keeps the derived read shape, so a defaulted measure and a @spine key are non-null in every port. extractReportSpec now throws on an unresolvable spine-chain entity instead of skipping the no-table and TPH refusals. Comment-only tidy-ups in the Kotlin harness, the C# reporting passes and the Python validator. * no-mistakes(document): Updated AGENTS.md FR-044 description to reflect shipped @spine and @default attributes.
1 parent 4afad53 commit c0c43e1

195 files changed

Lines changed: 12773 additions & 649 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/rules/cross-language-porting.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Preserve the following contracts exactly across all language ports:
1717

1818
**Metamodel subtype vocabularies (must be identical across languages):** the `registry-conformance` gate (`fixtures/registry-conformance/`) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to `expected-registry.json`. **All five ports (TS / C# / Java / Kotlin / Python) are live + green** (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See `fixtures/registry-conformance/README.md`.
1919
- Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull`
20-
- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `<Name>Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, since FR-044 Plan 2, **lowered only when it declares a read-only `source.rdb` of `@kind: view`**: `meta migrate` creates that view (TypeScript only, ADR-0015), every port reads it (persistence corpus, `report-shapes.json`), C# generates its typed row and Kotlin its Exposed table object, and, since FR-044 Plan 3, every port's generators **serve it as a keyless read-only projection** (GET list with filter/sort/paging on every derived field that has a filter band, `POST` 405, no `/{id}`; row type + filter allowlist + route/controller per port, gated by the api-contract `report/` sub-corpus in the generated lane). The client UI tier (hooks, grids, forms) and a report with no view source at all stay inert, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. A served report is the predicate `servedReport` (TS) / `ReportRows.IsViewBacked` (C#) / `RestSurfaceGate.isServedReport` (Java, Kotlin) / `is_served_report` (Python): concrete, read source `@kind: view`. See [docs/features/reporting.md](docs/features/reporting.md).
20+
- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `<Name>Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, since FR-044 Plan 2, **lowered only when it declares a read-only `source.rdb` of `@kind: view`**: `meta migrate` creates that view (TypeScript only, ADR-0015), every port reads it (persistence corpus, `report-shapes.json`), C# generates its typed row and Kotlin its Exposed table object, and, since FR-044 Plan 3, every port's generators **serve it as a keyless read-only projection** (GET list with filter/sort/paging on every derived field that has a filter band, `POST` 405, no `/{id}`; row type + filter allowlist + route/controller per port, gated by the api-contract `report/` sub-corpus in the generated lane). The client UI tier (hooks, grids, forms) and a report with no view source at all stay inert, gated by the 37 `reporting` conformance fixtures and the `codegen-noop` corpus. A served report is the predicate `servedReport` (TS) / `ReportRows.IsViewBacked` (C#) / `RestSurfaceGate.isServedReport` (Java, Kotlin) / `is_served_report` (Python): concrete, read source `@kind: view`. See [docs/features/reporting.md](docs/features/reporting.md).
2121
- Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`.
2222
- Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210).
2323
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N / **junction-unpairable** → `ERR_INVALID_RELATIONSHIP`. **Unpairable means the junction declares its two references but neither resolves to the navigating entity, or neither to the `@objectRef` target** — declaring two references is NOT enough, and the loader now checks WHAT they point at (owner ruling 2026-09-20). It does so by running the real FK derivation and converting its failure, never a parallel re-implementation, so the loader and the derivation cannot drift; scope mirrors codegen's own iteration exactly — every CONCRETE, non-projection object crossed with its EFFECTIVE relationships, NOT deduped by declaration, because pairing is a property of the navigating entity and an inherited M:N can pair from one subtype and not another. Before the ruling this loaded clean and then diverged: TS and C# warned and emitted no traversal route (a silent 404), while Java, Kotlin and Python failed the build. Gated by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`.

0 commit comments

Comments
 (0)