Repository navigation
feat(reporting): add @spine and measure @default for FR-044 - #415
Merged
Merged
Conversation
…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.
…sures (TypeScript)
…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.
…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.
…in a report's shape
…w 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.
…dempotence tests
…in meta docs
…eport/ sub-corpus
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.
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.
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.
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.
…ail-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.
The phase-1 plan PR was squash-merged, so its original commit is not an ancestor of this branch. This merge records it as one without changing any file, so the branch pushes as a fast-forward.
dmealing
added a commit
that referenced
this pull request
Oct 10, 2026
* docs(fr-044): plan 4, the Cube exporter (cube-model reference generator) The plan for the cube-model reference helper: the mapping contract per spec section 5 with a golden fixture per row, the rollup rules, name and escaping rules, the canonical golden, and a live lane against a pinned Cube instance. The Cube shapes were executed against Cube v1.7.43 and Postgres 16 before writing; @spine and @default rows are provisional on the zero-rows-and-defaults plan. * refactor(reporting): one module for the report SQL fragments (FR-044) Move q, ref, literal, FILTER_OP_SQL and cond (report-ddl-emit) and resolveReportFilter, segmentClause, declared, andOf and temporalOf (extract-report-spec) into projection/report-sql.ts so a second consumer can reuse the one filter-to-SQL translator. No behaviour change: the existing goldens pass unedited. extract-report-spec still exports temporalOf. * feat(cube-model): build the Cube model of the reporting vocabulary (FR-044) buildCubeModel turns a loaded model's dimension.*, measure.* and segment.filter members into plain Cube data: cubes, joins, dimensions, measures and segments (plan 4 Tables A to E and G). Rollups, report scope segments and Cube views are typed but left empty for later tasks. - cube/cube-model-spec.ts: the CubeModel data types. - cube/build-cube-model.ts: which cubes exist (vocabulary, join-target and alias cubes), joins, @via members, and the ordering Table H fixes. - cube/cube-members.ts: Table C dimension types and Table D measures, split out so the builder stays near its size budget. - cube/cube-names.ts: Table G name rules and member/cube collisions. - cube/cube-sql.ts: the Cube renderer (brace escaping, Jinja raw wrap). - cube/cube-errors.ts: CubeModelError and the seven ERR_CUBE_* codes. @Of, @via, segments and filters resolve through the same functions the report view lowering uses, and a join's ON columns come from the view's own hop walk. report-sql.ts gains an optional SqlRenderer on ref() and cond(); the default writes exactly what it wrote before, and every projection golden passes unedited. The Task 1 review notes are fixed there too: the local ref no longer shadows ref(), cond's errors name report-sql, and the header describes the module rather than the move. extract-report-spec.ts exports viaHopError for reuse. The canonical fitness model builds to Table H's Program, Week and Asset cubes (without rollups and the scope segment). * fix(cube-model): composite joins, shared @via resolver, review fixes (FR-044) Task 2 review, fix round 1. - A composite identity.reference joins on every column pair, ANDed in position order. A reference whose @fields count differs from the key it references is ERR_CUBE_UNMAPPABLE_JOIN (new code), naming the cube, the reference and both field lists. walkViaPath and view SQL are unchanged; extract-view-spec only exports joinColumnFor and a new hopReferenceIdentity for the full @fields list. - A hop the view walk refuses while joins are emitted (an ambiguous to-one relationship) is ERR_CUBE_UNMAPPABLE_JOIN keeping the reason. - One shared resolver per concern in projection/report-resolve.ts: dimensionOfField, resolveDimensionViaPath (with viaHopError, moved from extract-report-spec.ts), resolveAggregate and ratioOperand. The view lowering and the Cube build both call them; view messages and SQL are byte-identical and the projection goldens pass unedited. - Identifiers take the literal rule: braces escaped, raw-wrapped when holding {% or {#, endraw refused. - A self-referencing to-one hop joins an alias cube <Cube>_<hop>. A @via that returns to an entity on its path any other way, or crosses one cube twice, is refused naming the path. - A @via reuses any declared dimension (attribute or time) without @via over the field. field.uri and field.inet dimensions are strings. - CubeRollupSpec is a union of its three time forms; a type test pins that two forms at once do not compile. - An alias cube colliding with an entity cube is ERR_CUBE_NAME_COLLISION (tested). MemberNamespace.has removed. The spec header says SQL arrives escaped and free text raw. * fix(cube-model): match a hop by its reference, not its first column (FR-044) crosses() picked the join a @via step crosses by comparing the step's first foreign-key column. Two composite references onto one entity that share a leading column (fkProgram [tenantId, programId] and fkFormerProgram [tenantId, formerProgramId]) both have tenantId first, so a dimension with @via Week.fkFormerProgram was written reading {Week_fkProgram.title}: the wrong alias cube, with nothing failing. A belongs-to step now matches the hop whose identity.reference is the one the step crosses (hopReferenceIdentity, the view walk's own resolution). The one_to_one branch keeps matching by relationship name. Test: two composite references sharing a leading column, each @via reading its own alias cube. * feat(cube-model): a rollup per served report (FR-044) Table F of plan 4. A served object.report (servedReport: concrete, read source @kind view) writes into its @from cube: - a rollup named after the report: its attribute dimensions and measures in listed order (a qualified item such as Week.weeks is the measure, a @via dimension is a member of the @from cube like any other); one time dimension in the documented time_dimension + granularity form, two or more as the time_dimensions list; its @segment, then its scope segment, in segments; - for its @filter, a public segment <report>Scope (lower-camel report name) whose SQL is the filter, rendered by the view lowering's own cond with the Cube renderer, listed after the declared segments. No rollup, only the scope segment, when the report's @filter, its @segment or any listed measure's condition (a ratio's operands included) holds a relative date: a rollup is built at refresh time and would answer with the "now" of its build. The relative date is found on the lowered clauses (RelativeNow operands), never on the authored JSON. A report that is not served (sourceless, abstract, materializedView) contributes nothing. matches selects entities, not reports: a report's rollup and scope segment are members of its @from cube and are written whenever that cube is emitted as the entity's own, so selecting the entity alone keeps them. A selected @from that cannot be a cube (abstract, or no table) is refused, as the view lowering refuses it. Rollup names join the cube's member namespace: Cube 1.7.43's compiler reports a name shared by measures, dimensions, segments and pre-aggregations as "defined more than once", so a rollup named like a member, or two same-named reports on one cube, is ERR_CUBE_MEMBER_COLLISION naming both, and a report named with a Python keyword is ERR_CUBE_INVALID_NAME. The collision message now says so. The canonical model test now pins all of Table H, rollups and the recentProgramsScope segment included. * fix(cube-model): standalone alias cubes, report refusals (FR-044) Alias cubes no longer extend their target. Cube's extends copies every member and every pre-aggregation of the parent, so each alias exposed the target's measures again and rebuilt its rollups under scheduled refresh. An alias cube is now a standalone cube like a join-target cube: the target's sql_table (or a TPH subtype's sql), public: false, the target's primary-key dimensions, the members the dimensions reaching it read, and only the joins of multi-hop @via paths that continue through it, rendered from the alias. The ambiguity check walks those joins only, so a second alias of the same entity no longer counts as a route it does not have. CubeSpec drops the unused extends field. A cube is now written when a selected entity's cube reaches it through joins: an entity reached only through alias cubes gets no plain join-target cube, since nothing joins it. A served report whose @from has no cube (abstract, or no writable table) is ERR_CUBE_UNMAPPABLE_REPORT, naming the report, the entity and the reason. A TPH-subtype @from gets its rollup on the subtype's cube, whose sql applies the discriminator. Tests: alias cubes carry none of the target's measures, segments or rollups; a multi-hop @via through an alias; ambiguity through an alias; two self-references; the alias tests in their new shape; the abstract @from refusal; the TPH-subtype rollup; dimensions in report order. * feat(cube-model): deterministic Cube YAML (FR-044) renderCubeYaml writes one CubeSpec as Table H's bytes: block lists, flow lists for rollup members and meta.grains, single-quoted sql with the quote doubled, free text as a JSON string wrapped in a raw block when it holds a Jinja opener. A name a YAML reader takes for a boolean or null is quoted. Free text holding endraw and a sql value holding a line break are refused with ERR_CUBE_UNESCAPABLE_LITERAL. * fix(cube-model): lossless SQL scalars, source XOR, YAML parse oracle (FR-044) A sql value holding a control character, line break, line or paragraph separator or byte-order mark is legal model data (a string filter value), so sqlScalar now writes it as a JSON double-quoted scalar instead of refusing it; plain values stay single-quoted, so Table H is unchanged. CubeSpec carries exactly one of sqlTable or sql in its type. Tests parse the rendered YAML with Bun's built-in parser and compare it with the spec. * feat(cube-model): the cube-model generator and its mapping corpus (FR-044) cubeModel({ dialect?, filter?, target? }) wires buildCubeModel and renderCubeYaml into meta gen: one model/cubes/<Cube>.yml per cube under the target's outDir. Model scope; dialect defaults to the config's; column naming from the config. Selection (Ruling 17, amended with the controller): the build covers the whole loaded model narrowed by the generator's own filter, which is fixed config, so a cube's bytes never depend on the run. The run's selection (meta gen <Entity>, scope) only picks which files are written: each selected entity's cube and every cube it reaches through joins. A reached entity cube is written too, because a @via adds the member it reads to the cube it reaches. The filter is the build's universe, so it settles ERR_CUBE_NAME_COLLISION and steps around a refusal on an excluded entity, as the error messages advise. ERR_CUBE_UNSUPPORTED_DIALECT refuses sqlite only when the run would write a cube; a model or a selection with no reporting vocabulary writes nothing and raises nothing under any dialect. fixtures/cube-model/: 38 cases, one per Table B row (measure-default and report-spine wait for #411), Table K's extras, the controller's extras (dimension-time-two-grains, join-composite, join-self-reference, dimension-uri-inet, measure-mysql, free-text-jinja) and one case per CubeModelError code. Every expected file was written by hand from its contract row. canonical/expected/ is Table H byte for byte, written by bun run gen:cube-canonical and drift-checked. Exports cubeModel, buildCubeModel, hasReportingVocabulary, renderCubeYaml, CubeModelError, CUBE_ERROR_CODES, the ERR_CUBE_* codes and the spec types from the package root (Ruling 3). No registry entry, reference template or eject wiring (Task 6). * test(cube-model): tuple distinct count with a condition, MySQL time dimensions (FR-044) Two mapping-corpus cases, each expected tree written by hand from the plan's Tables C and D before the generator ran: - measure-count-distinct-tuple-condition (postgres): a tuple distinct count with a segment, a filter, both, and an or-group segment keeps ONE filters entry, the not-null terms first and the condition ANDed after them; an or group and a segment-plus-filter arrive parenthesised. - dimension-time-mysql: a timestamp is the backtick-quoted column, and a date, as a time dimension or an attribute, is CAST(... AS DATETIME), since MySQL's CAST has no TIMESTAMP target. * feat(cube-model): catalog entry, ejectable reference copy (FR-044) cube-model becomes a first-class catalog citizen. - Registry: layer capability, native, ejectable, no runtime packages or peers, config keys dialect and columnNamingStrategy. meta gen --list shows the row and its use-when/emits facets, read from the template header. - Reference copy src/reference/cube-model.ts (meta eject cube-model). The adopter owns which entities get a cube, the file layout and the YAML call; buildCubeModel and renderCubeYaml stay in the package. - Cross-port manifest: cube-model for typescript only. The other four ports' registry tests select their own slice, so a TS-only entry cannot break them. - Orphan cleanup, built-in and copy together: the generator opts in to the runner's orphanPolicy for model/cubes/*.yml. A stale cube file breaks Cube's whole-model compile. The runner removes only an untouched file that a prior run wrote and this run did not re-emit, refuses a hand-edited one, and skips reconciliation entirely when the run names entities (meta gen <Entity>). - Tests: the copy is byte-identical to the built-in over the canonical model and every corpus case, errors included, and is the same generator (name, target, filter, orphan namespace); orphan cleanup through the real runner for both; the inert test pins the three cubes the with/ model writes and nothing for without/; catalog gates treat cube-model like trace-helper (a model trigger the probe fixture lacks). * test(cube-model): live check against a real Cube instance (FR-044) A pinned Cube (cubejs/cube:v1.7.43, development mode) over a throwaway postgres:16-alpine on a private docker network loads the cube-model generator's output for the canonical persistence model, and: - the generated tree must equal fixtures/cube-model/canonical/expected/; - /v1/meta must compile it and list every cube and member the files declare, with Cube's type, aggregate and visibility; - each served report's Table F query (UTC) must be answered from the report's rollup when Table F emits one, and its rows must equal SELECT * FROM <view> under Table I's normalization; - every Postgres case of the mapping corpus is swapped into the same Cube (through an empty model, so a shared cube name cannot mask a stale compile) and must compile with the members its files declare. Postgres is reached only through docker exec; Cube binds one ephemeral 127.0.0.1 port. Both containers and the network are force-removed from afterAll, from every failure path in setup, and on exit or SIGINT/SIGTERM. Without docker every test is skipped behind a banner. The file is *.live.ts, run only by its path (bun run test:cube). fixtures/cube-model/canonical/seed.sql is the planning spike's seed. Two checks fail on this commit and need rulings, not edits to the corpus: FitnessTotals is answered from the ProgramMinutes rollup (Cube takes the first matching rollup in declaration order), and Cube rejects the free-text-jinja case (it parses title/description as reference templates, so braces need the SQL escape and a backslash is consumed). * ci(cube-model): run the live check as its own `cube` lane (FR-044) scripts/ci-local.sh gains a `cube` section: accepted by --only and listed in --help, run in the full no-flag run after the 5-port integration suite, kept by --integration-only, dropped by --quick (a SKIP entry) and --no-integration, and never part of `ts`/`ts-slow`. `--only cube` installs and builds the workspace first when no ts-fast or ts-slow step in the selection did. Docker down records a SKIP behind a banner, a FAIL under --strict-toolchains. scripts/integration-test.sh gains a `cube` target (`bun run test:cube`), left out of `all`. integration-tests.yml gains a `cube` matrix entry with the Bun setup and cache steps; it brings its own containers and ignores the job's Postgres sidecar. No local-ci.yml job is added, so the lane map in ci-ports-to-run.sh is unchanged (its test reads only local-ci.yml's jobs). * fix(cube-model): Cube-safe free text, rollups coarsest first (FR-044) The live lane found two defects; both are fixed in the generator and the lane keeps its assertions. Free text (Ruling 28). Cube 1.7.43 reads `title` and `description` as templates: `{x}` is a member reference, `${x}` an interpolation, a backslash an escape. So `a {b} c` failed the whole model and `C:\path` came back `C:path`. textScalar now doubles every backslash, escapes `{` and `}`, then raw-wraps when the ORIGINAL text holds `{{`, `{%` or `{#` (the `endraw` refusal stays), then writes the JSON double-quoted scalar. free-text-jinja gains a measure description with lone braces, `${` and a backslash. Its golden is updated by hand. The lane now checks that every declared title and description comes back verbatim from /v1/meta (cube title/description, member shortTitle/description). Rollup order (Ruling 29). Cube serves a query from the first matching rollup in definition order, and a finer rollup serves a coarser additive query: FitnessTotals was answered from ProgramMinutes. Each cube's rollups are now written by dimension count (attribute + time) ascending, ties in report order. In the canonical golden, Week lists FitnessTotals before ProgramMinutes and Program lists ProgramsByWeek before ProgramsByMonth. report-rollup lists ProgramTotals first. Nothing else moved. scripts/ci-local.sh --only cube is green: 10 of 10, each report served from its own rollup (dev_pre_aggregations.<cube>__<rollup>), 28 of 28 corpus cases compiled. * fix(cube-model): lane robustness, rollup tie order (FR-044) Review minors, ruled in by the controller. - The generated canonical model must equal the golden in beforeAll, before Cube starts. On drift, setup fails, naming each drifted file and the regeneration command. - startCubeStack takes `initSql`: the schema and seed are applied after Postgres is ready and before Cube starts. No rollup can be built over empty tables. - Rollup tie order: dimension count ascending, then the coarser time grain first (listed order), then fewer measures, then report order. The comment now claims only Cube's first-match rule: an exact-match rollup comes before a strictly finer one, and rows are correct either way. Day-vs-month and superset-of-measures ties are unit-tested. No golden moved. - The corpus pass gives each case a deadline drawn from one budget, and its test timeout scales with the case count. A slow Cube names the case it was on and lists the cases already rejected. - load() retries transport errors (refused, reset, aborted, a non-JSON body) with bounded backoff, inside the test timeout. - Rows are compared first. A rollup-key mismatch reports the query, Cube's SQL and whether the rows matched. - stop() warns on stderr, naming the container or network, when a removal fails for any reason but absence. It still never throws. - free-text-jinja carries a backtick in a raw-wrapped and a plain description. Cube 1.7.43 hands both back unchanged, so no encoding change. - Nits: U+2028 written as an escape in a test; the Ruling 29 tests sit under their own banner; a non-numeric MO_CUBE_READY_TIMEOUT_S is refused by name; with docker down, `--only cube` records its SKIP without installing or building. * docs(cube-model): the Cube exporter (FR-044) Document the cube-model reference generator as built, not as planned. - New docs/features/cube-export.md: what it writes, wiring (target, eject, options, dialects), the mapping as prose with a worked example, the refusals, which files a run writes and the orphan cleanup, the Cube query that reproduces a report, where a Cube query and the view differ, how the corpus and the `cube` lane check it, rollups needing Cube Store in production, and known limits (@spine and @default are not mapped yet). - reporting.md: an "Exporting to Cube" section; the "does not exist yet" paragraph no longer calls the list hook missing and names the MetricFlow exporter as on demand. - own-your-codegen.md, ports/typescript.md, docs/README.md: cube-model where reference generators are listed. cli.md lists no generators, so unchanged. - Skills: one paragraph each in metaobjects-codegen (typescript) and metaobjects-authoring (reporting). Agent-context goldens regenerated; the regen also picked up SKILL.md text that already differed on main. - CONFORMANCE.md and AGENTS.md: fixtures/cube-model/ is the 29th corpus (TypeScript only), with its table row and section; the stated total moves from 28 to 29 and counts.test.ts is green again. - CHANGELOG [Unreleased]: the cube-model generator and the `cube` lane; no vocabulary change, metamodelVersion stays 1.1. - spec/roadmap.md FR-044 row brought current. - Code comments from the build ledger: the reference header now carries the built-in's orphan-cleanup caveats (a join no longer reached; a changed scope reconciles on a full run); the corpus README row for error-unmappable-report says it needs an unrelated cube; the alias test comment no longer says Team keeps its own join. - The reference template's dialect message was four concatenated template literals, which failed check-reference-templates-lint. It is one literal now, with the same text (reference-byte-identical stays green). metaobjects/meta.requirements.yaml: the reporting branch (and objectReport) describes declaring the vocabulary and leaves lowering and serving out. No entry is about export, so the ledger is unchanged. * docs(cube-model): plan answers and as built, spec section 5 (FR-044) Plan 4 gets "Answers to the open questions" (all six accepted as recommended) and an "As built" section, in Plan 3's style. The contract tables are left as first written; each As built item names the table it changes. Tasks 9 and 10 (@default, @spine) are recorded as not built: the #411 build is not on main. Spec section 5: the relative-filter row is the view's SQL in a segment or measure filter (no rollup), the object.report row is a rollup on the @from cube (coarsest first), and the @spine row is a Cube view with no rollup. Three cells the build contradicted are corrected too: the tuple distinct count is ROW(...) with a not-null filter, @Grains is carried as meta.grains, and a to-one join is one_to_one from the side that does not hold the key. One sentence under the table points at Plan 4. * docs(cube-model): drop build-ledger ruling numbers from the plan's As built (FR-044) The ledger is not in the repo, so a ruling number told a reader nothing. * docs(cube-model): review fixes (FR-044) - A report with a relative date in its @filter, its @segment's filter or a listed measure's condition gets no rollup; its <report>Scope segment is written only when it has a @filter. Said exactly that in the authoring and codegen skills (goldens regenerated), cube-export.md, reporting.md, the CHANGELOG and the plan's answer 2. - Spec section 5: the note now also excludes measure.derived; the ratio cell gives the Postgres CAST and the MySQL form; the tuple cell names MySQL's JSON_ARRAY. - cube-export.md: an alias cube clash is ERR_CUBE_NAME_COLLISION, not a member collision; --forbid-hand-edits makes a hand edit fail verify. - ports/typescript.md: the config example has outDir; both it and reporting.md say a cube for each concrete, table-backed entity plus the join-target and alias cubes its @via dimensions need. - reporting.md: the filters entry is measure.aggregate only; measure.ratio is a number measure over its operands. - fixtures/cube-model/README.md: no ledger ruling numbers; the error-unmappable-report row states the rule. - Plan answer 5: <m>Raw belongs to the unbuilt @default mapping. - Root README capability matrix, TypeScript reporting cell: the list hook and the cube-model generator (it said "no client hook yet"). * feat(cube-model): refuse @spine reports and @default measures until mapped (FR-044) The zero-rows/measure-defaults vocabulary (@spine on object.report, an integer @default on measure.aggregate and measure.ratio) is planned and not built. When it lands, cube-model would otherwise ignore both and write wrong Cube output silently: a rollup lacking the spine's zero rows, a measure lacking the COALESCE the view applies. The build now refuses a served report that declares @spine, and a measure.aggregate or measure.ratio written on a cube that declares @default, with the new ERR_CUBE_UNMAPPED_VOCABULARY naming the node and how to proceed. A sourceless report, a report or measure on an entity the generator's filter leaves out, a measure on an entity with no table and a join-target cube's measures stay inert. The attribute names are local constants in cube-pending.ts, read with the resolving hasAttr(); the module goes when the real mapping replaces it. Adds the error-unmapped-vocabulary corpus case (41 cases, 30 trees + 11 errors) and updates the counts and the known-limits entry. * fix(cube-model): escape backslashes for Cube, key-field dimension, final review fixes (FR-044) Cube compiles every `sql` and `sql_table` as a JS template literal after Jinja, so a backslash in a SQL literal or identifier was an escape: 'a\b' reached the database as a backspace, 'A\_%' as 'A_%', a trailing backslash swallowed the closing quote and \u broke the compile. escapeToken now doubles every backslash before it escapes the braces, the order free text already uses. The escaping case gains a trailing backslash and a backslash before a brace, and the cube lane now reads each of its literals (and the braced column) back from Cube's /v1/sql and requires the view's own SQL. A negative control with the old single-backslash golden fails that check on Cube 1.7.43 (Cube's SQL holds a backspace). A declared dimension without @via named after a key field and reading that field is now that key dimension: one dimension, primary_key and public: true, with the declared title, description and grains. Over another field, or with a @via, it stays ERR_CUBE_MEMBER_COLLISION. New corpus case dimension-over-key (hand-written golden). Also from the final review: - The lane's compile pass loads the two MySQL cases too (compiling runs no SQL): 31 of the 42 cases. - Counts corrected everywhere: 42 cases, 31 trees, 11 errors. - Ledger ruling numbers replaced with the rule they stood for. - The filter option and ERR_CUBE_NAME_COLLISION remedy say an excluded entity a @via reaches is still written, as a join-target cube. - CubeViewSpec and its parts are no longer exported from the package root. - A test for a ratio whose operand declares @default; the tautological constant test is gone. - Doc statements on relative dates and the served-report row; test titles and comments that misdescribed the rollup order, the eject install set and the catalog's silent list. * chore(site): payload counts the cube-model corpus (FR-044) The new fixtures/cube-model corpus raises the corpus count from 28 to 29; the published site payload carries that count. * test(cube-model): guard tests load clean under the @spine/@default rules (FR-044) #415 registered @spine and a measure's @default with load rules R8, R9 and M7, so four models in cube-unmapped-vocabulary.test.ts no longer loaded and the build under test was never reached: - the @spine report named a hop Program does not declare (R8) and listed no dimension (R9). It now walks Program.fkOwner, an identity.reference onto a new Owner entity, and lists a dimension read through it. - the abstract-base measure put @default on a count (M7). It is a sum. Each test still pins the same ERR_CUBE_UNMAPPED_VOCABULARY refusal or the same inert result; the guard and its messages are unchanged. Comments that described the attributes as unregistered are corrected. * fix(cube-model): MySQL tuple count as the view's, one Jinja rule, review fixes (FR-044) Pre-gate review findings CR1 to CR5: - MySQL tuple distinct count: the view's own COUNT(DISTINCT a, b) as a `number` measure (a condition on the first component, as the view writes it). A count_distinct over JSON_ARRAY(a, b) compares JSON bytes, not the column collation: executed on mysql:8.4 (utf8mb4_0900_ai_ci) over rows with case and accent variants, nulls and duplicates, it counted 6 tuples where the view counts 4. Integer tuples agreed (4 and 4). - One Jinja-opener rule for SQL and free text (cube-template.ts): text holding {{, {% or {# is raw-wrapped, so a {{x}} literal is now wrapped too. - endraw is refused only when the text is raw-wrapped; unwrapped text is never inside a raw block. The error-unescapable-literal case now holds {%. - Tests: a relationship-backed @via hop crosses its own reference's alias, and an inherited one crosses the inherited reference's join. - ERR_CUBE_AMBIGUOUS_PATH says when more routes exist than it lists. - The join-target remedy text says an entity reached only through alias cubes gets no join-target cube; a false test comment about String.raw is fixed. * feat(cube-model): a measure's @default (FR-044) A measure.aggregate with `@default: n` is two Cube members: `<m>Raw`, the aggregate as before (type, sql and filters), `public: false`, then `<m>`, `type: number`, `COALESCE({<m>Raw}, n)`, carrying the measure's title and description. A measure.ratio with `@default: n` wraps its quotient in COALESCE (Postgres with the cast, MySQL without). An operand that declares its own default is referenced by member name, so its COALESCE reaches the ratio (#411 decision 4); a unit test expands the Cube members of such a ratio and requires the report view's own SQL for it, character for character. `<m>Raw` is an added member, so a clash is ERR_CUBE_MEMBER_COLLISION. A rollup lists `<m>`. #415's Tables A and E agree with the plan's Task 9 table, so the mapping is the table's. The @default half of the guard is gone; the @spine half stays until @spine is mapped, and error-unmapped-vocabulary now pins that refusal. Corpus case measure-default (hand-written golden). The codegen-noop `with/` model now exports; its defaulted ratio is pinned in the inert test. The canonical golden still cannot be regenerated: its @spine reports are refused until the next commit maps them, so the canonical cube tests and the two canonical reference-byte-identical tests stay red until then. * feat(cube-model): a @spine report as a Cube view (FR-044) A served report with @spine is a Cube view, model/views/<Report>.yml, with no rollup and no scope segment (a rollup on the spine cube is built from the fact cube and loses the zero rows). Its first join_path is the spine cube (the cube every listed dimension reaches after the spine's hops), including the member each dimension reads under the dimension's name, with the dimension's title, description and grains; its last is <Report>Facts with the listed measures. <Report>Facts is a standalone public: false cube (no `extends`, which would copy the fact cube's rollups): `SELECT * FROM <@from table> <alias>` with the report's @segment and @filter, ANDed, as its WHERE, so the scope stays inside the join and a spine row whose facts are all filtered out keeps its row. It holds @from's key and @from's own definitions of the listed measures, ratio operands and <m>Raw members. The spine cube gets one one_to_many join onto it; no reverse join is added to an ordinary cube, so ad-hoc answers are unchanged. A multi-hop spine is built with standalone chain cubes <Report>_<hop>, one per entity between @from and the spine entity. Executed on Cube 1.7.43 before building: a view includes a private key and a public: false member under an alias, include-level docs and meta reach /v1/meta, the roster view returns every program (weeks 0, sums null, defaults 0), the scoped facts cube keeps the programs whose weeks are all short, a two-hop chain equals the view lowering's SQL, and {Week.weeks, Program.id} stays rooted at Week. The guard is deleted: cube-pending.ts, ERR_CUBE_UNMAPPED_VOCABULARY and the error-unmapped-vocabulary case. renderCubeViewYaml and the view types are exported; the generator writes a view when every cube it reads is written and cleans up model/views/*.yml. Corpus cases report-spine and report-spine-multi-hop (hand-written goldens); the canonical golden gains the two facts cubes and views. The cube lane compares ProgramRoster and ProgramLongWeeks through their views (7 rows each), FitnessTotalsFilled through its rollup, and reads a defaulted operand's COALESCE inside a ratio from /v1/sql. Docs, spec section 5, roadmap and skills say both attributes are mapped. * fix(cube-model): spine review fixes (FR-044) - A TPH subtype as the spine entity is exported through its own discriminator-scoped cube, so the view's rows are that subtype's only; pinned by a unit test and documented beside the TPH @from note (the view lowering refuses both reports). - Two listed dimensions over one field and path include one member twice under two aliases. Added to the report-spine corpus case (golden edited by hand); the cube lane's compile pass shows Cube 1.7.43 accepts it. - One exported JOIN_PATH_SEPARATOR, used by the build, both generator copies and the live lane. - One definition of the report scope and of the MySQL tuple count: reportScope and mysqlTupleCount in projection/report-sql.ts, called by the view lowering (extract-report-spec, report-ddl-emit) and the exporter (cube-members, cube-reports, build-cube-model). View SQL, the canonical schema, the report shapes and the projection goldens are unchanged. - ERR_CUBE_NAME_COLLISION says "cube or view" when one side is a view. - A facts or chain cube carries no title or description of its entity. * refactor(cube-model): simplify pass over the exporter source (FR-044) Collapse the mirrored join arms, share the YAML file framing and the grains tail, derive the unmappable-dimension message from the dispatch sets, and make four helpers with no outside importers module-private. No output byte changes: the corpus, canonical golden and reference byte-identical tests are unchanged. * no-mistakes(document): Documentation updated for FR-044 Plan 4 Cube exporter: refresh date and FR-044 feature description in AGENTS.md (authority file).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Add two things to the FR-044 reporting vocabulary for the 1.1.0 release, in all five ports, because a first real adopter's report pages need them and vocabulary added after 1.1 ships would force another metamodel move: "I don't want a half baked 1.1 and then have to do a 1.2 right away".
COALESCE(..., 0). A measure should be able to declare its value when there is nothing to aggregate.Both are additions: a model that uses neither generates byte-for-byte what it does now, and
metamodelVersionstays1.1.Standing decisions still hold: compiled SQL views only, no query-time engine; a report lowers only with
source.rdb@kind: view; all measures from the report's own@fromentity and@viato-one only, every hop a declaredidentity.reference; no cross-fact reports, no anti-join, no join on a natural key;measure.derivedexcluded.What Changed
Added
@spineattribute toobject.report— a to-one path from the fact entity to a dimension entity that ensures every row of the dimension appears in the report, even with zero matching facts. Enables "show all programs even with no purchases" tables and zero-row group scenarios.Added
@defaultattribute tomeasure.aggregateandmeasure.ratio— an integer value a measure reads when it would otherwise be null (no rows, zero denominator, every value null). Eliminates client-sideCOALESCEboilerplate by letting measures declare their own empty-set behavior.Implemented both features across all five language ports (TypeScript, C#, Java, Kotlin, Python) with metamodel registration, loader validation, SQL view lowering, runtime read support, and complete test coverage (conformance fixtures, persistence scenarios, API contract integration tests).
Added 10 new conformance error fixtures for
@spineand@defaultvalidation rules (dimension not reachable through spine, measure defaults on non-numeric types, count/ratio misuse, etc.).Updated all documentation — feature guide, port-specific docs, authoring skill references, AGENTS.md, and cross-language porting rules for the new vocabulary contracts.
metamodelVersionremains1.1— both are additive attributes; a model using neither generates byte-for-byte unchanged output.Risk Assessment
✅ Low: Large but well-bounded additive feature (zero-rows spine + measure @default) implemented consistently across all five ports with matching validation rules, SQL emission logic (LEFT JOIN semantics correctly preserve spine rows), and extensive new conformance/unit tests; no contradiction of stated intent (metamodelVersion stays 1.1, measure.derived excluded, view-only lowering, additive-only) found in the diff.
Testing
Drove 12 scenarios live against product: spine dimension includes zero-fact entity rows, measure defaults provide fallback values, filter/sort work on defaulted measures, REST API serves spine reports correctly, validation catches all spine errors (R8/R9) and default errors (M7/M8), backward-compat confirmed (inert reports unchanged), cross-port metamodel vocabulary matches, codegen DDL and shape tests pass, baseline regression suite passes. 8 conformance gates green (TS / C# / Java / Kotlin / Python), 35 Python validation tests green, 38 TS inert tests green, no failures or regressions.
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchainsTS reporting conformance (report.test.ts— 2 tests)TS reporting validation (reporting-validation.test.ts— 115 tests)TS report shape tests (report-shape.test.ts— 30 tests)TS DDL emit tests (report-ddl-emit.test.ts— 42 tests)TS SQLite integration (report-views-sqlite.test.ts— 21 tests)TS API contract (api-contract-report.test.ts— 17 tests, 109s)TS reporting inert (reporting-inert.test.ts— 38 tests)TS registry conformance (registry-conformance.test.ts— 8 tests, 2053 expects)Python spine/default validation (test_reporting_validation_spine_default.py— 35 tests)Python registry conformance (test_registry_conformance.py— 3 tests)Java metadata tests (ReportShapeTest— 30 tests, full suite 1954 tests)C#ReportShapeTests— 30 testsBaselinescripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains(TS build+typecheck+conformance+unit)✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.