Skip to content

Commit cd91e25

Browse files
committed
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.
1 parent 9e47468 commit cd91e25

7 files changed

Lines changed: 28 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,8 @@ it until 1.1 ships._
241241
row per spine row; (2) `@segment` and `@filter` on a `@spine` report scope the facts and never
242242
remove a spine row (they sit in the join condition; there is no `WHERE`); (3) a fact row whose
243243
reference is null or matches nothing is in no row of a `@spine` report (without `@spine` such
244-
rows still form a null group); (4) a ratio's operand carries its own `@default` into the ratio,
244+
a row still counts: it falls in a null group through a nullable reference, a `LEFT OUTER`
245+
join, and is dropped through a required one, an `INNER` join); (4) a ratio's operand carries its own `@default` into the ratio,
245246
so `revenue` defaulted to `0` over `buyers` reads `0`, not null, and the ratio's own `@default`
246247
then covers a zero denominator. New load rules, gated by 10 new shared conformance fixtures
247248
(37 for reporting, 374 in all): a `@spine` that is not a to-one path from `@from`, a `@spine`

‎docs/features/reporting.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ and the one every runtime reads.
211211
| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it (you or a migration tool own the DDL), but the runtime still reads it through the shape below. |
212212
| `@kind: materializedView`, `storedProc` or `tableFunction` | `meta migrate` skips it, as for a projection. |
213213

214+
A report whose view body is your own SQL (`@sql`) or that you manage yourself (`@unmanaged`)
215+
still gets the derived read shape below, so a defaulted measure and a `@spine` key column are
216+
typed non-null in every port. Your SQL must keep them non-null (for example with `COALESCE`).
217+
214218
A **derived** report view (no `@sql`) whose `@from` entity has no table (it is abstract, or
215219
declares no writable `source.rdb`) fails `meta migrate` with an error naming the report and the
216220
entity, rather than emitting a view over a table that does not exist. A report with an `@sql`
@@ -335,8 +339,9 @@ program.
335339
`@from` are aggregated. A program whose purchases are all out of scope keeps its row, with a
336340
count of `0`. Both still name fields of `@from`.
337341
- **A fact row with no spine row is in no row.** A purchase whose `programId` is null, or
338-
matches no program, has no spine row to sit in. Without `@spine`, those rows form a null
339-
group.
342+
matches no program, has no spine row to sit in. Without `@spine`, such a row still counts: it
343+
falls in a null group through a nullable reference (`LEFT OUTER` join) and is dropped through
344+
a required one (`INNER` join; see [Dimensions, time grains and joins](#dimensions-time-grains-and-joins)).
340345
- **A row with no facts** reads `0` for a `count`, and null for a `sum`, `avg`, `min`, `max`
341346
and a ratio, unless the measure declares `@default` (next section). This holds even for a
342347
measure whose `@filter` is `isNull: true` on a fact column: the empty row's `@of` column is
@@ -672,7 +677,7 @@ row, and a `SUM` over the result would double-count. A to-one hop cannot fan out
672677
| **M5** | An `@segment` that names no `segment` child of the owning entity. |
673678
| **M6** | A `measure.ratio` whose `@numerator` or `@denominator` names no measure of the entity, or names one that is not a `measure.aggregate`. |
674679
| **M7** | A `@default` on an `@agg: count`. A count is never null, so the default could never apply. |
675-
| **M8** | A `@default` on an `@agg: min` or `max` over a field that is not numeric (M4's set). |
680+
| **M8** | A `@default` on an `@agg: min` or `max` over a field that is not numeric (not one of `int, long, double, float, decimal, currency`). |
676681

677682
A measure that breaks several of M1 to M4 reports only the first, in that order, so one
678683
mistake gives one error. M7 and M8 run only when none of M1 to M4 fired.

‎docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,10 @@ tuple among **that entity's** rows, whether or not any row of `@from` refers to
405405
(unless the measure declares R9's `@default`), and a ratio is computed from its operands as
406406
they read.
407407
- **A fact row whose reference is null, or matches no spine row, is in no row of the
408-
report.** Without `@spine` such rows form a null group; with it there is no spine row to
409-
hold them.
408+
report.** Without `@spine` such a row still counts: it falls in a null group through a
409+
nullable reference (`LEFT OUTER` join) and is dropped through a required one (`INNER` join;
410+
see "Dimensions, time grains and joins" in `docs/features/reporting.md`). With `@spine` there
411+
is no spine row to hold it.
410412
- The grain is unchanged: one row per distinct dimension tuple. To get exactly one row per
411413
row of the spine entity, list a dimension over its identity (`programId` above).
412414
- One spine per report.

‎server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
// @from fails R1 skips R2/R3/R6/R7, R8/R9 and its @filter; a report whose
1414
// @spine fails R8 skips R9, and R9 skips a dimension whose @via fails D2; an
1515
// invalid @dimensions/@measures item derives no report field for R6).
16+
// - Each error's source is the offending node (the dimension / measure / segment /
17+
// report, or for R4/R5 the declared child), so a conformance fixture's jsonPath
18+
// points at it.
1619
//
1720
// A fractional measure @default: the TypeScript reference refuses it here, in
1821
// CheckMeasure, because its generic attr.int check lets a fraction through. This port's
1922
// generic attr-type check (ValidateAttrSchema, ValueMatchesType) already refuses it with
2023
// ERR_BAD_ATTR_VALUE on the measure node, so no second error is added here; CheckMeasure
2124
// only skips M7/M8 for it, as the reference does.
22-
// - Each error's source is the offending node (the dimension / measure / segment /
23-
// report, or for R4/R5 the declared child), so a conformance fixture's jsonPath
24-
// points at it.
2525
//
2626
// Inheritance (ADR-0039): an entity's members are read through Children(), so a member
2727
// declared on an abstract base is validated against every entity that inherits it.

‎server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,13 @@ import kotlin.io.path.readText
3939
* gate excludes the framework-bound route tier in every port by design.
4040
* 4. Per scenario: a fresh in-memory H2 (PostgreSQL mode), `SchemaUtils.create(...)` on the
4141
* four generated report table objects, then the `reports` half of `seed.json`.
42-
* 5. Serve the three controllers from one embedded Tomcat ([TomcatHost]).
42+
* 5. Serve the four controllers from one embedded Tomcat ([TomcatHost]).
4343
*
4444
* No view SQL is written here (ADR-0015: TypeScript owns it). H2 creates each generated
4545
* `<R>Table` as a plain table that STANDS IN for the view, and the seed's `reports` half is
4646
* what the four views return for the seeded base tables. The base-table keys (`invoices`,
47-
* `products`, `sales`) are the full-stack lanes' input; this seam lane does not read them. What this lane proves is the
48-
* generated route over the generated binding, not the lowering.
47+
* `products`, `sales`) are the full-stack lanes' input; this seam lane does not read them.
48+
* What this lane proves is the generated route over the generated binding, not the lowering.
4949
*/
5050
@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class)
5151
class GeneratedReportControllerHarness(

‎server/python/src/metaobjects/loader/validate_reporting.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,6 @@ def claim(field_name: str, what: str) -> None:
922922
_check_spine_dimensions(root, report, from_, spine, terminal, err)
923923

924924
# S1 / F2 — the report's row scope over @from.
925-
926925
# ADR-0039: resolving.
927926
filter_value = report.get_meta_attr(OBJECT_REPORT_ATTR_FILTER)
928927
if isinstance(filter_value, dict):

‎server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,14 @@ function spinePathOf(
300300
}
301301
for (const step of path) {
302302
const entity = stepTarget(step, root);
303-
if (entity === undefined) continue; // the walk resolved it, so it is in the root
303+
if (entity === undefined) {
304+
// The walk resolved this hop, so it should be in the root. Skipping it would skip the no-table
305+
// and TPH refusals below for that entity, so fail closed instead.
306+
throw new Error(
307+
`${where} @spine '${spine}' reaches '${step.targetEntity}', which is not an entity in the model, so ` +
308+
`its table cannot be checked. Fix the @spine path or declare the entity.`,
309+
);
310+
}
304311
const head = `${where} @spine '${spine}' reaches '${entity.name}'`;
305312
if (entity.isAbstract || !hasWritableRdbSource(entity)) {
306313
throw new Error(

0 commit comments

Comments
 (0)