From adf294aa586cdcc3680731a4f65c86390593fa3e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 02:58:07 -0400 Subject: [PATCH 1/8] feat(reporting): boolean any/all aggregate for report measures Add @agg: any and @agg: all to measure.aggregate in all five ports: a boolean OR / AND over a field.boolean, typed boolean. Null values are ignored, so a group with nothing to judge reads null; @default 0 / 1 reads false / true (rule M9). Postgres lowers to bool_or / bool_and, SQLite and MySQL to MAX / MIN over 0 / 1. Cube export writes a boolean measure. Adds conformance fixtures, the canonical ProgramFlags / ProgramFlagRoster reports, persistence and api-contract scenarios, cube-model cases, docs and a changelog entry. --- AGENTS.md | 2 +- CHANGELOG.md | 12 +++ README.md | 2 +- .../references/reporting.md | 2 + docs/CONFORMANCE.md | 36 +++---- docs/features/cube-export.md | 5 +- docs/features/reporting.md | 52 ++++++++- ...2026-10-02-fr-044-core-reporting-design.md | 46 +++++++- examples/showcase/site-payload.json | 2 +- .../references/reporting.md | 2 + .../references/reporting.md | 2 + .../references/reporting.md | 2 + .../references/reporting.md | 2 + .../references/reporting.md | 2 + .../api-contract-conformance/report/meta.json | 13 ++- .../scenarios/filter-on-boolean-measure.yaml | 40 +++++++ .../scenarios/list-boolean-measure.yaml | 19 ++++ .../scenarios/sort-on-boolean-measure.yaml | 23 ++++ .../report/schema.mysql.sql | 12 +++ .../report/schema.postgres.sql | 13 +++ .../report/schema.sqlite.sql | 12 +++ .../api-contract-conformance/report/seed.json | 11 +- .../expected-errors.json | 15 +++ .../input/meta.ops.json | 50 +++++++++ .../providers.json | 1 + .../expected-errors.json | 15 +++ .../input/meta.ops.json | 49 +++++++++ .../providers.json | 1 + .../reporting-boolean-measures/expected.json | 100 ++++++++++++++++++ .../input/meta.ops.json | 92 ++++++++++++++++ .../reporting-boolean-measures/providers.json | 1 + fixtures/cube-model/README.md | 2 + .../expected/model/cubes/Program.yml | 3 + .../model/cubes/ProgramFlagRosterFacts.yml | 31 ++++++ .../canonical/expected/model/cubes/Week.yml | 27 +++++ .../model/views/ProgramFlagRoster.yml | 14 +++ fixtures/cube-model/canonical/seed.mysql.sql | 14 +-- fixtures/cube-model/canonical/seed.sql | 14 +-- .../measure-boolean-mysql/case.json | 3 + .../expected/model/cubes/Call.yml | 33 ++++++ .../measure-boolean-mysql/meta.json | 82 ++++++++++++++ .../expected/model/cubes/Call.yml | 33 ++++++ fixtures/cube-model/measure-boolean/meta.json | 82 ++++++++++++++ .../metamodel-docs/expected/types/measure.md | 4 +- fixtures/persistence-conformance/README.md | 4 +- .../canonical/meta.fitness.json | 15 ++- .../canonical/schema.mysql.sql | 22 ++++ .../canonical/schema.postgres.sql | 24 +++++ .../canonical/schema.sqlite.sql | 22 ++++ .../queries/filter-is-null.yaml | 8 +- .../queries/report-boolean-measures.yaml | 75 +++++++++++++ .../queries/report-boolean-spine.yaml | 68 ++++++++++++ .../report-shapes.json | 84 +++++++++++++++ .../expected-registry.json | 8 +- .../CodegenCompileConformanceTests.cs | 4 +- .../ReportShapeTests.cs | 15 ++- .../Api/ReportGeneratedServerFactory.cs | 2 +- .../Generated/AppDbContext.g.cs | 4 + .../Generated/ProgramFlagRoster.g.cs | 23 ++++ .../Generated/ProgramFlags.g.cs | 23 ++++ .../Generated/Week.g.cs | 2 + .../Generated/WeekNames.g.cs | 3 + .../MetaObjects/Core/Reporting/ReportShape.cs | 3 + .../Core/Reporting/ReportingConstants.cs | 5 +- .../Loader/ValidationPasses.Reporting.cs | 12 +++ .../MetaObjects/SpecMetamodel/reporting.json | 4 +- .../kotlin/KotlinReportTableGeneratorTest.kt | 2 +- .../KotlinCodegenMatchesReferenceTest.kt | 23 +++- ...portGeneratedApiContractConformanceTest.kt | 4 +- .../GeneratedReportControllerHarness.kt | 4 +- .../kotlin/tables/ProgramFlagRosterView.kt | 30 ++++++ .../kotlin/tables/ProgramFlagsView.kt | 31 ++++++ .../integration/kotlin/tables/ReportViews.kt | 3 + .../integration/kotlin/tables/WeekTable.kt | 2 + .../integration/api/ReportCorpus.java | 4 +- ...rtGeneratedApiContractConformanceTest.java | 6 +- .../loader/ReportingValidation.java | 11 ++ .../reporting/ReportReadModel.java | 4 +- .../metaobjects/reporting/ReportShape.java | 5 + .../reporting/ReportingConstants.java | 6 +- .../loader/ReportingValidationTest.java | 2 +- .../reporting/ReportReadModelTest.java | 2 +- .../metaobjects/loader/validate_reporting.py | 13 +++ .../meta/core/reporting/report_shape.py | 6 ++ .../core/reporting/reporting_constants.py | 5 +- .../metaobjects/spec_metamodel/reporting.json | 4 +- .../tests/integration/generated_report_app.py | 1 + .../integration/test_api_contract_report.py | 2 +- server/python/tests/test_report_shape.py | 4 +- .../tests/unit/test_reporting_accessors.py | 2 +- .../codegen-ts/src/cube/cube-members.ts | 30 +++++- .../codegen-ts/src/cube/cube-model-spec.ts | 2 +- .../src/projection/extract-report-spec.ts | 8 +- .../src/projection/report-ddl-emit.ts | 13 ++- .../codegen-ts/src/projection/report-spec.ts | 6 +- .../test/cube/build-cube-model.test.ts | 44 +++++++- .../test/cube/cube-model-canonical.test.ts | 2 + .../codegen-ts/test/cube/cube-yaml.test.ts | 88 ++++++++++++++- .../test/projection/report-ddl-emit.test.ts | 47 ++++++++ .../test/reference-byte-identical.test.ts | 21 ++-- .../cube-live/cube-live-support.ts | 5 +- .../cube-live/cube-model-mysql.live.ts | 2 + .../cube-live/cube-model.live.ts | 3 + .../api-contract-report-generated-server.ts | 4 +- .../src/api-contract-report-mysql-schema.ts | 1 + .../src/api-contract-report-mysql-server.ts | 1 + .../src/api-contract-report-sqlite-server.ts | 3 +- .../src/canonical-schema-mysql.ts | 1 + .../test/api-contract-report-corpus.test.ts | 16 +-- .../test/api-contract-report-mysql.test.ts | 1 + .../test/api-contract-report.test.ts | 1 + .../test/report-shapes-artifact.test.ts | 10 ++ .../test/report-views-mysql.test.ts | 4 +- .../src/core/reporting/report-shape.ts | 4 + .../src/core/reporting/reporting-constants.ts | 5 +- .../reporting-definition.embedded.ts | 8 +- .../src/loader/reporting-validation.ts | 15 +++ .../metadata/test/reporting-registry.test.ts | 2 +- .../test/reporting-validation.test.ts | 41 +++++++ .../packages/runtime-ts/src/type-coercer.ts | 6 +- .../runtime-ts/test/type-coercer.test.ts | 10 ++ site-reference/types/measure.html | 6 +- spec/metamodel/reporting.json | 4 +- 123 files changed, 1863 insertions(+), 135 deletions(-) create mode 100644 fixtures/api-contract-conformance/report/scenarios/filter-on-boolean-measure.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/list-boolean-measure.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/sort-on-boolean-measure.yaml create mode 100644 fixtures/conformance/error-measure-all-default-not-boolean/expected-errors.json create mode 100644 fixtures/conformance/error-measure-all-default-not-boolean/input/meta.ops.json create mode 100644 fixtures/conformance/error-measure-all-default-not-boolean/providers.json create mode 100644 fixtures/conformance/error-measure-any-non-boolean/expected-errors.json create mode 100644 fixtures/conformance/error-measure-any-non-boolean/input/meta.ops.json create mode 100644 fixtures/conformance/error-measure-any-non-boolean/providers.json create mode 100644 fixtures/conformance/reporting-boolean-measures/expected.json create mode 100644 fixtures/conformance/reporting-boolean-measures/input/meta.ops.json create mode 100644 fixtures/conformance/reporting-boolean-measures/providers.json create mode 100644 fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml create mode 100644 fixtures/cube-model/canonical/expected/model/views/ProgramFlagRoster.yml create mode 100644 fixtures/cube-model/measure-boolean-mysql/case.json create mode 100644 fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml create mode 100644 fixtures/cube-model/measure-boolean-mysql/meta.json create mode 100644 fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml create mode 100644 fixtures/cube-model/measure-boolean/meta.json create mode 100644 fixtures/persistence-conformance/queries/report-boolean-measures.yaml create mode 100644 fixtures/persistence-conformance/queries/report-boolean-spine.yaml create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlagRoster.g.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlags.g.cs create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagRosterView.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagsView.kt diff --git a/AGENTS.md b/AGENTS.md index a12005242..60ab85e97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,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/` (374 fixtures; 29 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/` (377 fixtures; 29 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 FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). 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. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). 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/CHANGELOG.md b/CHANGELOG.md index adb99898f..f31c1d588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -233,6 +233,18 @@ generator catalog and the Atlas evaluation are 1.2 work. three-way merge preserves hand edits. An ejected `entity` generator picks it up without a resync, because the owned copy calls the package's value-object composer. +- **A boolean `any` / `all` aggregate for reports (`@agg: any`, `@agg: all`), in all five ports.** An + adopter replacing a hand-written view that served `has_error` from Postgres `bool_or` found no + report equivalent: a report could only count the failures and map `errorCalls > 0` back to a + boolean in every repository. `@agg: any` and `@agg: all` (the names `origin.aggregate` already + uses) are a boolean OR and AND over a `field.boolean` `@of`, and the measure is a `boolean` in + every port. Null values are ignored, so a group with no rows, no row in the measure's scope or + only null values reads null; `@default: 0` reads `false` and `@default: 1` reads `true`. New + rules: `any` / `all` over a field that is not a boolean is M4, and a boolean measure's + `@default` other than `0` or `1` is M9 (both `ERR_INVALID_MEASURE`). Postgres writes `bool_or` / + `bool_and`; SQLite and MySQL write `MAX` / `MIN` over `0` / `1`. The `cube-model` generator + writes a `type: boolean` measure. See [reporting](docs/features/reporting.md#boolean-measures-any-and-all). + No `metamodelVersion` change: the vocabulary is part of 1.1. - **TypeScript: the `cube-model` reference generator (FR-044 Plan 4).** `cubeModel()` writes the reporting vocabulary as Cube data-model files, `model/cubes/.yml`: a cube for each table-backed entity that declares dimensions, measures or segments, a join for each to-one diff --git a/README.md b/README.md index d6aa08839..9674c6cb8 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ metaobjects/ ├── CLAUDE.md # project instructions for Claude ├── spec/ # canonical metamodel docs, ADRs, roadmap ├── fixtures/ # 25 cross-language conformance corpora — the oracle -│ ├── conformance/ # metamodel (loader + serializer + navigation), 374 fixtures +│ ├── conformance/ # metamodel (loader + serializer + navigation), 377 fixtures │ ├── yaml-conformance/ # YAML authoring desugar │ ├── render-conformance/ # FR-004 byte-identical render oracle │ ├── verify-conformance/ # FR-004 template-drift gate diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index 6db3b1472..5bd6e9219 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -33,6 +33,7 @@ A report declares no fields. Its columns are one per `@dimensions` item in liste | `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | | `avg` of `double` / `float` | the measure's name | `double` | no | | `min` / `max` | the measure's name | the `@of` field's type | no | +| `any` / `all` (boolean OR / AND, `bool_or` / `bool_and`; `@of` must be a `field.boolean`) | the measure's name | `boolean` | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads the default instead of null | | in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the field is `@required` or in that entity's `identity.primary`; no for a dimension beyond the spine entity | @@ -53,6 +54,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. +- An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). - A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 9081b3e82..36192e2e3 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,15 +26,15 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 374 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 377 | ✓ | ✓ | 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/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | | [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 16 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | -| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 52 (46 query + 6 migration) | all 52 (the 19 `report-*` also on SQLite, MySQL and D1) | 46 query (migrations TS-only, ADR-0015); the 19 `report-*` also on MySQL | 46 query (via Exposed); the 19 `report-*` also on SQLite and MySQL | 46 query; the 19 `report-*` also on SQLite and MySQL | 46 query (Postgres only: pg8000) | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 88 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 12 projection + 22 report) | ✓ (Fastify reference + generated lane); `report/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql); `report/` also on SQLite and MySQL | ✓ (FastAPI + pg8000) | +| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 54 (48 query + 6 migration) | all 54 (the 21 `report-*` also on SQLite, MySQL and D1) | 48 query (migrations TS-only, ADR-0015); the 21 `report-*` also on MySQL | 48 query (via Exposed); the 21 `report-*` also on SQLite and MySQL | 48 query; the 21 `report-*` also on SQLite and MySQL | 48 query (Postgres only: pg8000) | +| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 91 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 12 projection + 25 report) | ✓ (Fastify reference + generated lane); `report/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql); `report/` also on SQLite and MySQL | ✓ (FastAPI + pg8000) | | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -54,7 +54,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/requirement-test-identity-conformance/`](../fixtures/requirement-test-identity-conformance/) (the identity, skip state and digest of each generated requirement test, and the filter seam, ADR-0057) | 26 cases | ✓ (reference) | ✓ | identity function inherits via Java; emitted names asserted by its own generator test | ✓ | ✓ | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | | [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary: what is lowered, what stays inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | -| [`fixtures/cube-model/`](../fixtures/cube-model/) (FR-044 Plan 4 — the `cube-model` generator's golden Cube files, plus the canonical model's golden) | 44 cases (34 trees + 10 errors) + 1 canonical golden | ✓ (the exporter is TypeScript-only, spec R6; its `cube` lane also loads the files into a real Cube) | — | — | — | — | +| [`fixtures/cube-model/`](../fixtures/cube-model/) (FR-044 Plan 4 — the `cube-model` generator's golden Cube files, plus the canonical model's golden) | 46 cases (36 trees + 10 errors) + 1 canonical golden | ✓ (the exporter is TypeScript-only, spec R6; its `cube` lane also loads the files into a real Cube) | — | — | — | — | A ✓ means the port runs the corpus green; an explicit `n / m` is used where a port carries a ledgered divergence. The two ledgered YAML fixtures are documented @@ -127,13 +127,13 @@ manifest contains without moving these boundaries. **The Cube exporter** ([features/cube-export.md](features/cube-export.md)): -- *Mapping — gated in TypeScript only, by decision.* `fixtures/cube-model/` holds 44 cases, 34 +- *Mapping — gated in TypeScript only, by decision.* `fixtures/cube-model/` holds 46 cases, 36 with the exact tree the generator writes and 10 with the exact error message, plus the canonical model's golden. `codegen-ts/test/cube/cube-model-corpus.test.ts` and `cube-model-canonical.test.ts` run them. No other port has an exporter (spec R6), so no other port makes a claim the corpus would have to check. - *Cube accepts the output — queried on Postgres, compiled in both dialects.* The `cube` lane - (`scripts/ci-local.sh --only cube`, which needs Docker) loads the canonical golden and the 34 + (`scripts/ci-local.sh --only cube`, which needs Docker) loads the canonical golden and the 36 cases that hold a tree into a pinned Cube (`cubejs/cube:v1.7.43`) and requires each to compile, the two MySQL cases included: compiling a model runs no SQL, so the lane's Postgres data source serves them too. For the canonical reports it compares the Cube query with the @@ -142,7 +142,7 @@ manifest contains without moving these boundaries. A second live file (`cube-model-mysql.live.ts`) runs the MySQL path: the canonical golden generated with `dialect: "mysql"` is loaded into the same pinned Cube over a real MySQL 8.4 holding the report views `buildReportViews` lowers (`fixtures/cube-model/canonical/seed.mysql.sql`), - and each of the twenty-one served reports' Cube query is compared with `SELECT *` from its view, with the + and each of the twenty-three served reports' Cube query is compared with `SELECT *` from its view, with the pre-aggregation used asserted. That is the smaller of the two honest options: the two MySQL *cases* (escaping, a quoted identifier) are still compiled, not executed, and the goldens hold their SQL. The lane runs Cube in development mode, so Cube's production mode with a separate @@ -250,7 +250,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (374) +### `fixtures/conformance/` — metamodel loader + canonical serializer (377) | Fixture prefix | Feature doc | |---|---| @@ -300,12 +300,12 @@ All 37 fixtures → [features/migrations-and-drift.md](features/migrations-and-d may carry `"isArray": true`; with `fields` it is a list of objects, without it a list of scalars. The six `verify-positional-accessor-*` cases pin where `-last` is available. -### `fixtures/persistence-conformance/` (52 — 46 query + 6 migration) +### `fixtures/persistence-conformance/` (54 — 48 query + 6 migration) - `migrations/*` (6) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section) -- `queries/*` (46) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) +- `queries/*` (48) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) -**Engines beyond Postgres (FR-044 reports).** The nineteen `queries/report-*.yaml` scenarios also +**Engines beyond Postgres (FR-044 reports).** The twenty-one `queries/report-*.yaml` scenarios also run on SQLite and MySQL 8.4 wherever a port's runtime has the engine, and on D1's local runtime in TypeScript (no cloud account): TypeScript on all four, C# and Kotlin on SQLite and MySQL, Java on MySQL. **Stated limitations, not gaps:** Java's OMDB has no SQLite driver, and Python's runtime @@ -319,9 +319,9 @@ REAL, a MySQL `DECIMAL` keeps its scale, a MySQL `DATETIME` is the UTC wall cloc `report-relative-date` carries a per-engine seed (`seed-data-engine`). `op: relate` and the write ops stay Postgres-only. See [features/reporting.md](features/reporting.md#what-the-corpus-gates). -### `fixtures/api-contract-conformance/` (88) +### `fixtures/api-contract-conformance/` (91) -All 88 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port +All 91 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD routes answer identically over HTTP — list / get / create / patch+put / delete, plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1` @@ -339,7 +339,7 @@ each corpus alone could not reach, and 1 pinning the collection-URL spelling), `write-through/` (2, table-write + view-read entities), `projection/` (12, a read-only view answers reads and refuses writes with 405; a projection with no declared identity has no item route; a projection keyed on a field not named `id` is addressed by -it, whether its identity names `@fields` or derives them; decimal and float fields filter) and `report/` (22, +it, whether its identity names `@fields` or derives them; decimal and float fields filter) and `report/` (25, FR-044: a view-backed `object.report` is listed, filtered, sorted and paged on its derived fields, answers `POST` with 405 and mounts no `/{id}`; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default). All 5 ports — TS, Java, @@ -507,9 +507,9 @@ outside ASCII. The case list is in the Kotlin's identity function is the Java one; the names its generator emits are asserted by `server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRequirementTestsGeneratorTest.kt`. -### `fixtures/cube-model/` (44 cases) +### `fixtures/cube-model/` (46 cases) -All 44 cases and the canonical golden → [features/cube-export.md](features/cube-export.md). +All 46 cases and the canonical golden → [features/cube-export.md](features/cube-export.md). Each case is the smallest model for one mapping rule (an entity's cube, a join, an alias cube, a dimension type, a measure, a measure's `@default`, a segment, a relative date, a rollup, a `@spine` report's Cube view, the escaping rules) or for one @@ -524,8 +524,8 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 374 + yaml 16 + verify 37 -+ render 16 + persistence 52 + api-contract 88 + source-resolution 25 + scope 10 + +The fixtures in the nine corpora mapped above (metamodel 377 + yaml 16 + verify 37 ++ render 16 + persistence 54 + api-contract 91 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc, and so do the two requirement corpora and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/features/cube-export.md b/docs/features/cube-export.md index 4dabcadab..3f4618dcf 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -322,9 +322,10 @@ somewhere to go: an `identity.reference` onto an entity that is not a cube makes | `count` with `@distinct` | `type: count_distinct`, `sql: x` | | `count` with `@distinct` over a tuple `x1, x2` | Postgres: `type: count_distinct`, `sql: 'ROW(x1, x2)'`, with one `filters` entry `x1 IS NOT NULL AND x2 IS NOT NULL`, so a tuple with a null component is not counted, as in the view. MySQL: the view's own `COUNT(DISTINCT x1, x2)` as a `type: number` measure (a condition `c` makes it `COUNT(DISTINCT CASE WHEN c THEN x1 END, x2)`); MySQL's multi-argument form skips a tuple with a null component and compares by the columns' collation, which a `JSON_ARRAY(x1, x2)` key does not: on mysql:8.4 under the default `utf8mb4_0900_ai_ci` it counted `'abc'` and `'ABC'` as two tuples where the view counts one | | `sum`, `avg`, `min`, `max` | `type: sum`, `avg`, `min`, `max`, `sql: x` | +| `any`, `all` | `type: boolean`. Postgres: `sql: 'bool_or(x)'` / `'bool_and(x)'`, a condition `c` as `bool_or(x) FILTER (WHERE c)`. MySQL: `sql: 'MAX(x)'` / `'MIN(x)'`, a condition as `MAX(CASE WHEN c THEN x END)`. The aggregate is the view's own, written in `sql` because Postgres has no `MAX(boolean)` and the condition cannot ride in `filters`. | | any of the above with a condition `c` | the same, with one `filters` entry `c`. For a Postgres tuple, `c` is ANDed after the not-null terms in that one entry. | | `measure.ratio` | `type: number`. Postgres: `CAST({num} AS NUMERIC) / NULLIF({den}, 0)`. MySQL: `{num} / NULLIF({den}, 0)`. | -| a `measure.aggregate` with `@default: n` | two members. `Raw` is the aggregate as the rows above write it (its `type`, `sql` and `filters`), with `public: false`. `` is `type: number`, `sql: 'COALESCE({Raw}, n)'`, and carries the measure's `title` and `description`. | +| a `measure.aggregate` with `@default: n` | two members. `Raw` is the aggregate as the rows above write it (its `type`, `sql` and `filters`), with `public: false`. `` is `type: number` (`type: boolean` for an `any` or `all`, where `n` is written `TRUE` / `FALSE` on Postgres and `1` / `0` on MySQL), `sql: 'COALESCE({Raw}, n)'`, and carries the measure's `title` and `description`. | | a `measure.ratio` with `@default: n` | `type: number`, the quotient above inside `COALESCE(…, n)` | In a ratio, `{num}` and `{den}` are references to the two operand measures, so each operand is @@ -567,7 +568,7 @@ like any other generated output: a changed model that was not regenerated, a mis and a stale one are drift. A hand edit to a generated file is not drift, but the gate lists it, and `meta verify --codegen --forbid-hand-edits` makes it fail. -**The mapping corpus** is [`fixtures/cube-model/`](../../fixtures/cube-model/): 44 cases, each +**The mapping corpus** is [`fixtures/cube-model/`](../../fixtures/cube-model/): 46 cases, each the smallest model for one rule, 34 with the exact tree the generator writes and 10 with the exact error message. Every expected file was written by hand from its rule and then compared with the generator, never copied from it. diff --git a/docs/features/reporting.md b/docs/features/reporting.md index d281b7693..976cd970f 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -63,7 +63,8 @@ Closed sets: - `@grains`: `hour, day, week, month, quarter, year`. **Weeks start on Monday (ISO-8601)** in every lowering. -- `@agg`: `count, sum, avg, min, max`. +- `@agg`: `count, sum, avg, min, max, any, all`. `any` and `all` are boolean OR and AND + (Postgres `bool_or` / `bool_and`) and are the names `origin.aggregate` already uses. `@spine` is a path written like a dimension's `@via`. A measure's `@default` is an integer. @@ -164,6 +165,8 @@ What each piece means: `origin.aggregate`, whose `count` is always distinct as a join-inflation guard: a measure aggregates its own entity's rows and a dimension reaches only to-one paths, so no join inflates it. `@filter` and `@segment` scope the rows and combine by AND. +- **`@agg: any` / `all`** are boolean OR and AND over a `field.boolean` `@of`, and the measure + is a `boolean`: `hasError` is `any` of `failed`. See [Boolean measures](#boolean-measures-any-and-all). - **`measure.ratio`** is `numerator / NULLIF(denominator, 0)`, typed decimal. A zero denominator yields null, unless the ratio declares `@default`. Both operands are `measure.aggregate` siblings (a ratio of ratios is not supported). @@ -269,6 +272,7 @@ physical column name is your naming strategy applied to the **derived field name | `avg` of `int`, `long`, `currency` or `decimal` | the measure's name | `decimal` | no | | `avg` of `double` or `float` | the measure's name | `double` | no | | `min` / `max` | the measure's name | the `@of` field's type | no | +| `any` / `all` | the measure's name | `boolean` | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads its `@default` instead of null | | in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the `@of` field is required (`@required` or a `validator.required` child) or one of the spine entity's `identity.primary` `@fields`; no for a dimension reached beyond the spine entity | @@ -307,6 +311,8 @@ applied. The aggregates: 64-bit integer on every engine (Postgres casts it to `BIGINT`, MySQL to `SIGNED`, and SQLite's integer `SUM` already is one). - **`avg`, `min`, `max`** are the engine's own. +- **`any`, `all`** are a boolean OR and AND over a boolean column. See + [Boolean measures](#boolean-measures-any-and-all). - **`measure.ratio`** is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null** (or the ratio's `@default`), never an error. Each operand is repeated inline with its own conditions and its own `@default`, so an operand need not be listed in `@measures`. @@ -428,7 +434,8 @@ rating with no ratings is not `0`; null is the honest answer there. - **It is an integer.** Every case found is zero; a sentinel such as `-1` is legal too. Write a whole number with no decimal point (see [Known limits](#known-limits)). -- **Where it is legal.** A `sum`, an `avg`, a `min` or `max` over a numeric field, and a ratio. +- **Where it is legal.** A `sum`, an `avg`, a `min` or `max` over a numeric field, an `any` or + `all` (as `0` or `1`, see [Boolean measures](#boolean-measures-any-and-all)), and a ratio. It is refused on a `count`, which is never null, and on a `min` or `max` over a field that is not numeric: a made-up date would be a wrong answer. - **A ratio's operand carries its own `@default` into the ratio.** `revenue` declares @@ -446,6 +453,40 @@ measure is the same on every engine, since the empty rows sort as their default. without one sorts its null rows where the engine puts them: Postgres last ascending, SQLite and MySQL first. +### Boolean measures: `any` and `all` + +`@agg: any` is true when at least one row in the group has a true `@of`, and `@agg: all` is +true when every row with a value has a true `@of`. They are SQL's `bool_or` and `bool_and`, +and the measure is a `boolean` in every port: `true` or `false` on the wire, never `0` or `1`. + +```jsonc +{ "field.boolean": { "name": "failed" } }, +{ "measure.aggregate": { "name": "hasError", "@agg": "any", "@of": "Call.failed" } }, +{ "measure.aggregate": { "name": "allSucceeded", "@agg": "all", "@of": "Call.succeeded", "@default": 1 } } +``` + +- **Only over a `field.boolean`.** `@of` names one boolean field; anything else is refused by + rule M4. `min` and `max` still refuse a boolean, so `any` and `all` are the way to write it. + `@filter` and `@segment` scope the rows as for any measure, and `@distinct` stays a `count` + modifier. +- **Null is not false.** SQL ignores a null `@of` value. A group with no rows (an empty table, + or a `@spine` row with no facts), no row that passes the measure's `@filter` or `@segment`, + or only null values reads **null**: there is nothing to judge. A group with at least one + value reads `true` or `false` from the values it has, so `any` of `false, null` is `false` and + `all` of `true, null` is `true`. +- **`@default` is `0` or `1`.** `"@default": 0` reads `false` where the measure would be null, + `"@default": 1` reads `true` (rule M9 refuses any other number). The measure is then never + null, in the database and in every port's row type, and a filter or sort sees the default as + the value. Declare it only where that is true: `all` over no rows is vacuously true + (`@default: 1`), `any` over no rows is false (`@default: 0`). +- **How it lowers.** Postgres writes `bool_or(x)` / `bool_and(x)`, with `FILTER (WHERE …)` + for a condition. SQLite and MySQL have no boolean aggregate, and a boolean there is `0` or + `1`, so they write `MAX(x)` for `any` and `MIN(x)` for `all` (`MAX(CASE WHEN … THEN x END)` + for a condition). The runtime reads the column as the boolean it is typed as. A default is + `COALESCE(, FALSE)` on Postgres and `COALESCE(, 0)` elsewhere. +- **Cube.** The export writes a `type: boolean` measure holding the same aggregate, see + [cube-export.md](cube-export.md). + ### What the runtime does with it | Port | Read side | @@ -764,17 +805,18 @@ row, and a `SUM` over the result would double-count. A to-one hop cannot fan out | **M1** | An `@of` item that is not `Entity.field`, names an entity other than the owning one (or one it extends), or names a field that does not exist. A measure aggregates its own entity's rows. | | **M2** | More than one `@of` item without both `@agg: count` and `@distinct: true`. | | **M3** | `@distinct: true` with any `@agg` other than `count`. | -| **M4** | `sum` or `avg` over a non-numeric field (numeric is `int, long, double, float, decimal, currency`); `min` or `max` over a `boolean`, `object` or `map` field. | +| **M4** | `sum` or `avg` over a non-numeric field (numeric is `int, long, double, float, decimal, currency`); `min` or `max` over a `boolean`, `object` or `map` field; `any` or `all` over a field that is not a `boolean`. | | **M5** | An `@segment` that names no `segment` child of the owning entity. | | **M6** | A `measure.ratio` whose `@numerator` or `@denominator` names no measure of the entity, or names one that is not a `measure.aggregate`. | | **M7** | A `@default` on an `@agg: count`. A count is never null, so the default could never apply. | | **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`). | +| **M9** | A `@default` on an `@agg: any` or `all` other than `0` (false) or `1` (true). | A measure that breaks several of M1 to M4 reports only the first, in that order, so one -mistake gives one error. M7 and M8 run only when none of M1 to M4 fired. +mistake gives one error. M7, M8 and M9 run only when none of M1 to M4 fired. **A `@default` must be a whole number** (`ERR_BAD_ATTR_VALUE`, on the measure). A fraction is -refused in every port, and then M7 and M8 are not checked. A ratio's `@default` is checked for +refused in every port, and then M7, M8 and M9 are not checked. A ratio's `@default` is checked for this only. The message and two edge cases differ by port: see [Known limits](#known-limits). ### Segments and filters (`ERR_BAD_ATTR_FILTER`) diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index f2749e7ba..0ddbe26d2 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -135,7 +135,7 @@ and gets its own agreement then. Existing attribute names registered on the new nodes, with the same value grammar as today: `@of` (on `dimension.*` as a string; on `measure.aggregate` with `isArray: true`, so a bare string coerces to a one-element list and a list is the tuple form), `@via` (dimensions, -to-one only), `@agg` (`count | sum | avg | min | max` on a measure, a subset of +to-one only), `@agg` (`count | sum | avg | min | max` on a measure, plus `any | all` since R10, a subset of `origin.aggregate`'s set), `@distinct`, `@filter` (`measure.aggregate`, `segment`, `object.report`), and `@from` (`object.report`). Each registration still lands in every port's provider and in `fixtures/registry-conformance/expected-registry.json`. @@ -471,6 +471,49 @@ name fields already use for the same concept. **Admission.** RDB `COALESCE`; MongoDB `$ifNull`; Search the aggregation's `missing` value or adapter code; in memory trivial. +### R10 — `@agg: any` / `all`: a boolean aggregate (added 2026-10-10) + +**Why.** An adopter replacing a hand-written view found no report equivalent of Postgres +`bool_or` / `bool_and`. Its view served `has_error` from `bool_or(failed)`; as a report it could +only count the failures and map `errorCalls > 0` back to a boolean in every repository. + +**Shape.** Two more members of the `@agg` closed set: `any` (boolean OR) and `all` (boolean AND). +The names are `origin.aggregate`'s own (`any | all` are already in its set, ADR-0037 step 0: a name +the vocabulary already has for the same concept, not a new one), so the two aggregate families +spell it alike. No new attribute, no new subtype. + +- **Only over a `field.boolean`.** A single `@of` that is a boolean; any other subtype is M4. + `min` and `max` keep refusing a boolean (no total order, and Postgres has no `MAX(boolean)`). +- **The measure is a `boolean`** (Table B), in every port's row type and on the wire. +- **Empty and null.** SQL's `bool_or` / `bool_and` ignore null values and return null over no + values, and the lowering keeps that: a group with no rows, no row in the measure's scope, or only + null values reads **null** (nothing to judge), not `false`. A `@spine` row with no facts is the + same case. A group with at least one value reads `true` or `false` from the values it has. +- **`@default`.** Legal and an integer as before, but a boolean measure's default is `0` (false) or + `1` (true); any other number is refused (rule M9). It is the same attribute, so the measure is + then never null and R9's rules for filter, sort and the row type apply unchanged. The alternative + (refuse `@default` on a boolean) leaves a `@spine` report no way to read `false` for a row with no + facts, which is the case the adopter had. +- **Lowering.** Postgres `bool_or` / `bool_and` (a condition as `FILTER (WHERE …)`). SQLite and D1 + have no boolean aggregate and store a boolean as `0` / `1`: `MAX` for `any`, `MIN` for `all` + (a condition as `MAX(CASE WHEN … THEN x END)`). MySQL is the same as SQLite. The default is + `COALESCE(E, FALSE | TRUE)` on Postgres and `COALESCE(E, 0 | 1)` elsewhere. +- **A conditional boolean on MySQL is a `BIGINT`.** `MAX(CASE …)` loses the `TINYINT(1)` of the + column, so a driver that returns a `BIGINT` as text hands back `"1"`: the reading side maps a + boolean-typed derived field from `0`, `1`, `"0"` and `"1"`. + +**ADR-0037 walk.** (0) Not derivable: an author's statement of what to judge. (2) It configures an +existing attribute's closed set, which is a value of `@agg`, not a new attribute or subtype. + +**Admission.** RDB as above. A store with no boolean aggregate is capability-gated as for any +aggregate it cannot express exactly; none is lowered today. + +**Mapping.** Cube: `type: boolean` holding the aggregate in `sql` (Cube's `min` / `max` cannot take +a Postgres boolean, and a condition rides in the SQL, as for a MySQL tuple count). MetricFlow has +no `any` / `all` aggregation (`sum_boolean` counts the trues); the dbt exporter, when it exists, +must refuse it by name or map it to `max` / `min` over a 0/1 expression. It is not part of this +change. + ## 5. Mapping contract (core → Cube → MetricFlow) | MetaObjects | Cube | dbt MetricFlow | @@ -483,6 +526,7 @@ adapter code; in memory trivial. | `measure.aggregate` count + `@distinct` (one column) | `count_distinct` | `count_distinct` | | `measure.aggregate` count + `@distinct` (tuple) | `count_distinct` over `ROW(…)` with a not-null filter on each column (MySQL: the view's own `COUNT(DISTINCT a, b)` as a `number` measure) | `count_distinct` over an expression | | `measure.aggregate` sum/avg/min/max | `sum`/`avg`/`min`/`max` | `sum`/`average`/`min`/`max` | +| `measure.aggregate` any/all | `boolean` measure holding `bool_or(x)` / `bool_and(x)` (MySQL `MAX(x)` / `MIN(x)`) | none yet: `sum_boolean` counts the trues; the dbt exporter refuses it or maps `max` / `min` over a 0/1 expression | | `measure.ratio` | `number` measure `{a} / NULLIF({b}, 0)`; on Postgres `CAST({a} AS NUMERIC) / NULLIF({b}, 0)`, with no cast on MySQL | `ratio` metric | | `measure.derived` | `number` measure | `derived` metric | | `segment` | segment | metric `filter` / saved query filter | diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index b23674ef8..e460d6cd6 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.1" }, "counts": { - "fixtures": 374, + "fixtures": 377, "corpora": 29, "baseTypes": 17 }, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 6db3b1472..5bd6e9219 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -33,6 +33,7 @@ A report declares no fields. Its columns are one per `@dimensions` item in liste | `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | | `avg` of `double` / `float` | the measure's name | `double` | no | | `min` / `max` | the measure's name | the `@of` field's type | no | +| `any` / `all` (boolean OR / AND, `bool_or` / `bool_and`; `@of` must be a `field.boolean`) | the measure's name | `boolean` | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads the default instead of null | | in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the field is `@required` or in that entity's `identity.primary`; no for a dimension beyond the spine entity | @@ -53,6 +54,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. +- An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). - A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 6db3b1472..5bd6e9219 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -33,6 +33,7 @@ A report declares no fields. Its columns are one per `@dimensions` item in liste | `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | | `avg` of `double` / `float` | the measure's name | `double` | no | | `min` / `max` | the measure's name | the `@of` field's type | no | +| `any` / `all` (boolean OR / AND, `bool_or` / `bool_and`; `@of` must be a `field.boolean`) | the measure's name | `boolean` | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads the default instead of null | | in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the field is `@required` or in that entity's `identity.primary`; no for a dimension beyond the spine entity | @@ -53,6 +54,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. +- An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). - A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 6db3b1472..5bd6e9219 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -33,6 +33,7 @@ A report declares no fields. Its columns are one per `@dimensions` item in liste | `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | | `avg` of `double` / `float` | the measure's name | `double` | no | | `min` / `max` | the measure's name | the `@of` field's type | no | +| `any` / `all` (boolean OR / AND, `bool_or` / `bool_and`; `@of` must be a `field.boolean`) | the measure's name | `boolean` | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads the default instead of null | | in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the field is `@required` or in that entity's `identity.primary`; no for a dimension beyond the spine entity | @@ -53,6 +54,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. +- An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). - A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 6db3b1472..5bd6e9219 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -33,6 +33,7 @@ A report declares no fields. Its columns are one per `@dimensions` item in liste | `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | | `avg` of `double` / `float` | the measure's name | `double` | no | | `min` / `max` | the measure's name | the `@of` field's type | no | +| `any` / `all` (boolean OR / AND, `bool_or` / `bool_and`; `@of` must be a `field.boolean`) | the measure's name | `boolean` | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads the default instead of null | | in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the field is `@required` or in that entity's `identity.primary`; no for a dimension beyond the spine entity | @@ -53,6 +54,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. +- An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). - A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 6db3b1472..5bd6e9219 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -33,6 +33,7 @@ A report declares no fields. Its columns are one per `@dimensions` item in liste | `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | | `avg` of `double` / `float` | the measure's name | `double` | no | | `min` / `max` | the measure's name | the `@of` field's type | no | +| `any` / `all` (boolean OR / AND, `bool_or` / `bool_and`; `@of` must be a `field.boolean`) | the measure's name | `boolean` | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads the default instead of null | | in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the field is `@required` or in that entity's `identity.primary`; no for a dimension beyond the spine entity | @@ -53,6 +54,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. +- An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). - A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). diff --git a/fixtures/api-contract-conformance/report/meta.json b/fixtures/api-contract-conformance/report/meta.json index 009c3d12f..8d69d6b55 100644 --- a/fixtures/api-contract-conformance/report/meta.json +++ b/fixtures/api-contract-conformance/report/meta.json @@ -93,6 +93,7 @@ { "field.long": { "name": "id" } }, { "field.long": { "name": "productId", "@required": true } }, { "field.long": { "name": "amountCents", "@required": true } }, + { "field.boolean": { "name": "refunded" } }, { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, { "identity.reference": { "name": "fkProduct", "@fields": "productId", "@references": "Product" } }, { "dimension.attribute": { "name": "productId", "@of": "Product.id", "@via": "Sale.fkProduct" } }, @@ -103,7 +104,9 @@ { "measure.aggregate": { "name": "maxCents", "@agg": "max", "@of": "Sale.amountCents" } }, { "measure.aggregate": { "name": "largeSales", "@agg": "count", "@of": "Sale.id", "@filter": { "amountCents": { "gte": 10000 } } } }, { "measure.aggregate": { "name": "revenueCents", "@agg": "sum", "@of": "Sale.amountCents" } }, - { "measure.aggregate": { "name": "revenueOrZero", "@agg": "sum", "@of": "Sale.amountCents", "@default": 0 } } + { "measure.aggregate": { "name": "revenueOrZero", "@agg": "sum", "@of": "Sale.amountCents", "@default": 0 } }, + { "measure.aggregate": { "name": "hasRefund", "@agg": "any", "@of": "Sale.refunded" } }, + { "measure.aggregate": { "name": "hasRefundOrFalse", "@agg": "any", "@of": "Sale.refunded", "@default": 0 } } ] }}, { "object.report": { @@ -127,6 +130,14 @@ "@dimensions": ["productSku"], "@measures": ["sales", "revenueCents"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_sales_by_sku" } } ] + }}, + { "object.report": { + "name": "ProductRefunds", + "@from": "Sale", + "@spine": "Sale.fkProduct", + "@dimensions": ["productId", "productName"], + "@measures": ["sales", "hasRefund", "hasRefundOrFalse"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_product_refunds" } } ] }} ] } diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-on-boolean-measure.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-on-boolean-measure.yaml new file mode 100644 index 000000000..826ee3346 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/filter-on-boolean-measure.yaml @@ -0,0 +1,40 @@ +name: report-filter-on-boolean-measure +description: > + A filter on a boolean measure. r1: eq=true on the defaulted measure matches the one + product with a refunded sale. r2: eq=false on the plain `hasRefund` matches Beacon only, + because Cinder's null is neither true nor false. r3: isNull=true finds Cinder. r4: the + defaulted measure reads false for Cinder, so eq=false matches both Beacon and Cinder. +requests: + - id: r1 + method: GET + path: /api/product_refunds?filter[hasRefundOrFalse][eq]=true + expect: + status: 200 + body: + equals: + - { productId: 1, productName: "Atlas", sales: 2, hasRefund: true, hasRefundOrFalse: true } + - id: r2 + method: GET + path: /api/product_refunds?filter[hasRefund][eq]=false + expect: + status: 200 + body: + equals: + - { productId: 2, productName: "Beacon", sales: 1, hasRefund: false, hasRefundOrFalse: false } + - id: r3 + method: GET + path: /api/product_refunds?filter[hasRefund][isNull]=true + expect: + status: 200 + body: + equals: + - { productId: 3, productName: "Cinder", sales: 0, hasRefund: null, hasRefundOrFalse: false } + - id: r4 + method: GET + path: /api/product_refunds?filter[hasRefundOrFalse][eq]=false&sort=productId:asc + expect: + status: 200 + body: + equals: + - { productId: 2, productName: "Beacon", sales: 1, hasRefund: false, hasRefundOrFalse: false } + - { productId: 3, productName: "Cinder", sales: 0, hasRefund: null, hasRefundOrFalse: false } diff --git a/fixtures/api-contract-conformance/report/scenarios/list-boolean-measure.yaml b/fixtures/api-contract-conformance/report/scenarios/list-boolean-measure.yaml new file mode 100644 index 000000000..e57869489 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/list-boolean-measure.yaml @@ -0,0 +1,19 @@ +name: report-list-boolean-measure +description: > + GET /api/product_refunds — ProductRefunds is a @spine report whose measures are boolean + aggregates over Sale.refunded: `hasRefund` is @agg any (bool_or) and `hasRefundOrFalse` + the same with @default 0. Both are JSON booleans on the wire, never 0 / 1. Atlas has a + refunded sale, Beacon has sales and none refunded, and Cinder has no sale at all: its + `hasRefund` is null with the key present (nothing to judge), and its `hasRefundOrFalse` + reads the default, false. +requests: + - id: r1 + method: GET + path: /api/product_refunds?sort=productId:asc + expect: + status: 200 + body: + equals: + - { productId: 1, productName: "Atlas", sales: 2, hasRefund: true, hasRefundOrFalse: true } + - { productId: 2, productName: "Beacon", sales: 1, hasRefund: false, hasRefundOrFalse: false } + - { productId: 3, productName: "Cinder", sales: 0, hasRefund: null, hasRefundOrFalse: false } diff --git a/fixtures/api-contract-conformance/report/scenarios/sort-on-boolean-measure.yaml b/fixtures/api-contract-conformance/report/scenarios/sort-on-boolean-measure.yaml new file mode 100644 index 000000000..d8b5ad6f1 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/sort-on-boolean-measure.yaml @@ -0,0 +1,23 @@ +name: report-sort-on-boolean-measure +description: > + A sort on a defaulted boolean measure is deterministic on every engine: false sorts + before true, and the product with no sale sorts as its default, false, never as a null + whose place the engine chooses. Atlas is the only true row, so it is first descending. + The corpus never sorts `hasRefund` (no @default) across Cinder's null row. +requests: + - id: desc + method: GET + path: /api/product_refunds?sort=hasRefundOrFalse:desc&limit=1 + expect: + status: 200 + body: + equals: + - { productId: 1, productName: "Atlas", sales: 2, hasRefund: true, hasRefundOrFalse: true } + - id: asc-last + method: GET + path: /api/product_refunds?sort=hasRefundOrFalse:asc&limit=1&offset=2 + expect: + status: 200 + body: + equals: + - { productId: 1, productName: "Atlas", sales: 2, hasRefund: true, hasRefundOrFalse: true } diff --git a/fixtures/api-contract-conformance/report/schema.mysql.sql b/fixtures/api-contract-conformance/report/schema.mysql.sql index 759e80a0c..29c2ca4f9 100644 --- a/fixtures/api-contract-conformance/report/schema.mysql.sql +++ b/fixtures/api-contract-conformance/report/schema.mysql.sql @@ -30,6 +30,7 @@ CREATE TABLE `sales` ( `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `productId` BIGINT NOT NULL, `amountCents` BIGINT NOT NULL, + `refunded` BOOLEAN, FOREIGN KEY (`productId`) REFERENCES `products` (`id`) ); @@ -105,3 +106,14 @@ CREATE VIEW `v_sales_by_sku` AS FROM `sales` s INNER JOIN `products` p ON p.`id` = s.`productId` GROUP BY p.`sku`; + +CREATE VIEW `v_product_refunds` AS + SELECT + p.`id` AS `productId`, + p.`name` AS `productName`, + COUNT(s.`id`) AS `sales`, + MAX(s.`refunded`) AS `hasRefund`, + COALESCE(MAX(s.`refunded`), 0) AS `hasRefundOrFalse` + FROM `products` p + LEFT OUTER JOIN `sales` s ON p.`id` = s.`productId` + GROUP BY p.`id`, p.`name`; diff --git a/fixtures/api-contract-conformance/report/schema.postgres.sql b/fixtures/api-contract-conformance/report/schema.postgres.sql index 01c228bf9..11ec91ffa 100644 --- a/fixtures/api-contract-conformance/report/schema.postgres.sql +++ b/fixtures/api-contract-conformance/report/schema.postgres.sql @@ -32,6 +32,7 @@ CREATE TABLE "sales" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "productId" BIGINT NOT NULL, "amountCents" BIGINT NOT NULL, + "refunded" BOOLEAN, CONSTRAINT "sales_pkey" PRIMARY KEY ("id") ); @@ -119,3 +120,15 @@ CREATE VIEW "v_sales_by_sku" AS INNER JOIN "products" p ON p."id" = s."productId" GROUP BY p."sku"; COMMENT ON VIEW "v_sales_by_sku" IS 'metaobjects:v1:sha256:921fbbbb5285e6e313e12c1c4bf44427c0a3c5226e86fcd04c95bf23f2fd5379'; + +CREATE VIEW "v_product_refunds" AS + SELECT + p."id" AS "productId", + p."name" AS "productName", + COUNT(s."id") AS "sales", + bool_or(s."refunded") AS "hasRefund", + COALESCE(bool_or(s."refunded"), FALSE) AS "hasRefundOrFalse" + FROM "products" p + LEFT OUTER JOIN "sales" s ON p."id" = s."productId" + GROUP BY p."id", p."name"; +COMMENT ON VIEW "v_product_refunds" IS 'metaobjects:v1:sha256:afc9cfc384c2db9e29ddc26e0cac58ee5d555f452fb41446ad99eeeab05051af'; diff --git a/fixtures/api-contract-conformance/report/schema.sqlite.sql b/fixtures/api-contract-conformance/report/schema.sqlite.sql index a9ebcd26e..18fa2265f 100644 --- a/fixtures/api-contract-conformance/report/schema.sqlite.sql +++ b/fixtures/api-contract-conformance/report/schema.sqlite.sql @@ -30,6 +30,7 @@ CREATE TABLE "sales" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "productId" INTEGER NOT NULL, "amountCents" INTEGER NOT NULL, + "refunded" INTEGER, FOREIGN KEY ("productId") REFERENCES "products" ("id") ); @@ -105,3 +106,14 @@ CREATE VIEW "v_sales_by_sku" AS FROM "sales" s INNER JOIN "products" p ON p."id" = s."productId" GROUP BY p."sku"; + +CREATE VIEW "v_product_refunds" AS + SELECT + p."id" AS "productId", + p."name" AS "productName", + COUNT(s."id") AS "sales", + MAX(s."refunded") AS "hasRefund", + COALESCE(MAX(s."refunded"), 0) AS "hasRefundOrFalse" + FROM "products" p + LEFT OUTER JOIN "sales" s ON p."id" = s."productId" + GROUP BY p."id", p."name"; diff --git a/fixtures/api-contract-conformance/report/seed.json b/fixtures/api-contract-conformance/report/seed.json index c44218a43..b9d83cf72 100644 --- a/fixtures/api-contract-conformance/report/seed.json +++ b/fixtures/api-contract-conformance/report/seed.json @@ -16,9 +16,9 @@ { "id": 3, "name": "Cinder", "sku": "33333333-3333-4333-8333-333333333333", "categoryId": null } ], "sales": [ - { "id": 1, "productId": 1, "amountCents": 12000 }, - { "id": 2, "productId": 1, "amountCents": 18000 }, - { "id": 3, "productId": 2, "amountCents": 4500 } + { "id": 1, "productId": 1, "amountCents": 12000, "refunded": false }, + { "id": 2, "productId": 1, "amountCents": 18000, "refunded": true }, + { "id": 3, "productId": 2, "amountCents": 4500, "refunded": false } ], "reports": { "InvoiceStatusTotals": [ @@ -57,6 +57,11 @@ { "productId": 1, "productName": "Atlas", "sales": 2, "revenueCents": 30000, "revenueOrZero": 30000 }, { "productId": 2, "productName": "Beacon", "sales": 1, "revenueCents": 4500, "revenueOrZero": 4500 }, { "productId": 3, "productName": "Cinder", "sales": 0, "revenueCents": null, "revenueOrZero": 0 } + ], + "ProductRefunds": [ + { "productId": 1, "productName": "Atlas", "sales": 2, "hasRefund": true, "hasRefundOrFalse": true }, + { "productId": 2, "productName": "Beacon", "sales": 1, "hasRefund": false, "hasRefundOrFalse": false }, + { "productId": 3, "productName": "Cinder", "sales": 0, "hasRefund": null, "hasRefundOrFalse": false } ] } } diff --git a/fixtures/conformance/error-measure-all-default-not-boolean/expected-errors.json b/fixtures/conformance/error-measure-all-default-not-boolean/expected-errors.json new file mode 100644 index 000000000..0e81efcb9 --- /dev/null +++ b/fixtures/conformance/error-measure-all-default-not-boolean/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.ops.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[5]['measure.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-measure-all-default-not-boolean/input/meta.ops.json b/fixtures/conformance/error-measure-all-default-not-boolean/input/meta.ops.json new file mode 100644 index 000000000..db27526e0 --- /dev/null +++ b/fixtures/conformance/error-measure-all-default-not-boolean/input/meta.ops.json @@ -0,0 +1,50 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "note" + } + }, + { + "field.boolean": { + "name": "failed" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "allFailed", + "@agg": "all", + "@of": "Call.failed", + "@default": 2 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-measure-all-default-not-boolean/providers.json b/fixtures/conformance/error-measure-all-default-not-boolean/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-measure-all-default-not-boolean/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/conformance/error-measure-any-non-boolean/expected-errors.json b/fixtures/conformance/error-measure-any-non-boolean/expected-errors.json new file mode 100644 index 000000000..0e81efcb9 --- /dev/null +++ b/fixtures/conformance/error-measure-any-non-boolean/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.ops.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[5]['measure.aggregate']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-measure-any-non-boolean/input/meta.ops.json b/fixtures/conformance/error-measure-any-non-boolean/input/meta.ops.json new file mode 100644 index 000000000..d68701df5 --- /dev/null +++ b/fixtures/conformance/error-measure-any-non-boolean/input/meta.ops.json @@ -0,0 +1,49 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "note" + } + }, + { + "field.boolean": { + "name": "failed" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "hasNote", + "@agg": "any", + "@of": "Call.note" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-measure-any-non-boolean/providers.json b/fixtures/conformance/error-measure-any-non-boolean/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-measure-any-non-boolean/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/conformance/reporting-boolean-measures/expected.json b/fixtures/conformance/reporting-boolean-measures/expected.json new file mode 100644 index 000000000..4b885586e --- /dev/null +++ b/fixtures/conformance/reporting-boolean-measures/expected.json @@ -0,0 +1,100 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "note" + } + }, + { + "field.boolean": { + "name": "failed" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "hasFailure", + "@agg": "any", + "@of": [ + "Call.failed" + ] + } + }, + { + "measure.aggregate": { + "name": "allFailed", + "@agg": "all", + "@of": [ + "Call.failed" + ] + } + }, + { + "measure.aggregate": { + "name": "hasFailureOrFalse", + "@agg": "any", + "@default": 0, + "@of": [ + "Call.failed" + ] + } + }, + { + "measure.aggregate": { + "name": "allFailedOrTrue", + "@agg": "all", + "@default": 1, + "@of": [ + "Call.failed" + ] + } + } + ] + } + }, + { + "object.report": { + "name": "CallFlags", + "@from": "Call", + "@measures": [ + "hasFailure", + "allFailed", + "hasFailureOrFalse", + "allFailedOrTrue" + ], + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_call_flags" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-boolean-measures/input/meta.ops.json b/fixtures/conformance/reporting-boolean-measures/input/meta.ops.json new file mode 100644 index 000000000..8b73075c5 --- /dev/null +++ b/fixtures/conformance/reporting-boolean-measures/input/meta.ops.json @@ -0,0 +1,92 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "note" + } + }, + { + "field.boolean": { + "name": "failed" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "hasFailure", + "@agg": "any", + "@of": "Call.failed" + } + }, + { + "measure.aggregate": { + "name": "allFailed", + "@agg": "all", + "@of": "Call.failed" + } + }, + { + "measure.aggregate": { + "name": "hasFailureOrFalse", + "@agg": "any", + "@of": "Call.failed", + "@default": 0 + } + }, + { + "measure.aggregate": { + "name": "allFailedOrTrue", + "@agg": "all", + "@of": "Call.failed", + "@default": 1 + } + } + ] + } + }, + { + "object.report": { + "name": "CallFlags", + "@from": "Call", + "@measures": [ + "hasFailure", + "allFailed", + "hasFailureOrFalse", + "allFailedOrTrue" + ], + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_call_flags" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-boolean-measures/providers.json b/fixtures/conformance/reporting-boolean-measures/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/reporting-boolean-measures/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/cube-model/README.md b/fixtures/cube-model/README.md index 576a67443..9868680b7 100644 --- a/fixtures/cube-model/README.md +++ b/fixtures/cube-model/README.md @@ -59,6 +59,8 @@ never copied from it. A golden blessed from the implementation proves nothing. | `measure-ratio` | B: `measure.ratio`; D | `CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0)`, `type: number` | | `measure-mysql` | D (MySQL arms) | backtick quoting; a tuple distinct count is the view's own `COUNT(DISTINCT a, b)` as a `number` measure, a condition as `CASE WHEN … THEN a END` on the first component (a `JSON_ARRAY` key compares bytes, not the column collation, so on mysql:8.4 it counted 6 tuples where the view counts 4); a ratio without the cast | | `measure-default` | B: measure `@default`; the zero-rows / measure-defaults plan, Tables D and E | a `measure.aggregate` with `@default: n` is `Raw` (the Table D aggregate, its condition kept, `public: false`) and `` (`type: number`, `COALESCE({Raw}, n)`), with the measure's description on ``; a negative sentinel (`-1`) on an `avg`; a ratio with `@default` wraps its quotient in `COALESCE`; a ratio whose operand has its own `@default` reads it through the operand's `COALESCE` member, with or without a default of its own (decision 4); the rollup lists ``, never `Raw` | +| `measure-boolean` | B: `measure.aggregate` `@agg: any` / `all`; R10 | a boolean aggregate is `type: boolean` and the aggregate is the view's own, in `sql` (Postgres `bool_or(x)` / `bool_and(x)`; Cube's `min` / `max` cannot take a Postgres boolean): a condition rides in the SQL as `FILTER (WHERE c)`, not in `filters`; `@default: 0` / `1` is `Raw` and `` as for any default but `type: boolean`, `COALESCE({Raw}, FALSE | TRUE)` | +| `measure-boolean-mysql` | B: the same, MySQL | `MAX(x)` / `MIN(x)` over the boolean column, a condition as `MAX(CASE WHEN c THEN x END)`, the default `COALESCE({Raw}, 0 | 1)` | | `segment` | B: `segment.filter` | `=`, `IN (…)`, `LIKE` with `>` ANDed, an `or` group with `IS NULL`; YAML doubles every `'` | | `relative-date` | B: relative filter (Plan 2 Table E) | `(now() - INTERVAL 'P7D')` for an instant, `((now() AT TIME ZONE 'UTC') - INTERVAL 'P7D')` for a naive timestamp, `CAST(… AS DATE)` for a date, `+` for a future offset, and the same SQL in a measure's condition | | `report-rollup` | B: served report; F | a `rollup` named after the report: dimensions and measures in listed order, `segments` with `@segment` then the scope segment `programsByMonthScope`, the one-time-dimension form; a report with no dimensions is a measures-only rollup; rollups are written coarsest first, so `ProgramTotals` precedes `ProgramsByMonth` | diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Program.yml b/fixtures/cube-model/canonical/expected/model/cubes/Program.yml index b86199bfe..59d831073 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/Program.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/Program.yml @@ -9,6 +9,9 @@ cubes: - name: ProgramLongWeeksFacts relationship: one_to_many sql: '{CUBE}."id" = {ProgramLongWeeksFacts}."programId"' + - name: ProgramFlagRosterFacts + relationship: one_to_many + sql: '{CUBE}."id" = {ProgramFlagRosterFacts}."programId"' - name: ProgramSessions_fkWeek relationship: one_to_many sql: '{CUBE}."id" = {ProgramSessions_fkWeek}."programId"' diff --git a/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml b/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml new file mode 100644 index 000000000..480837ebc --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml @@ -0,0 +1,31 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: ProgramFlagRosterFacts + sql: 'SELECT * FROM "weeks" w' + public: false + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + measures: + - name: weeks + sql: '{CUBE}."id"' + type: count + - name: anyDeload + sql: 'bool_or({CUBE}."deload")' + type: boolean + - name: anyDeloadOrFalseRaw + sql: 'bool_or({CUBE}."deload")' + type: boolean + public: false + - name: anyDeloadOrFalse + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + type: boolean + - name: allDeloadOrTrueRaw + sql: 'bool_and({CUBE}."deload")' + type: boolean + public: false + - name: allDeloadOrTrue + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + type: boolean diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml index 183c8e6fc..1ba638ba5 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml @@ -87,6 +87,29 @@ cubes: type: sum filters: - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' + - name: anyDeload + sql: 'bool_or({CUBE}."deload")' + type: boolean + - name: allDeload + sql: 'bool_and({CUBE}."deload")' + type: boolean + - name: anyDeloadOrFalseRaw + sql: 'bool_or({CUBE}."deload")' + type: boolean + public: false + - name: anyDeloadOrFalse + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + type: boolean + - name: allDeloadOrTrueRaw + sql: 'bool_and({CUBE}."deload")' + type: boolean + public: false + - name: allDeloadOrTrue + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + type: boolean + - name: anyLongDeload + sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)' + type: boolean segments: - name: long sql: '{CUBE}."durationMinutes" >= 60' @@ -97,6 +120,10 @@ cubes: - name: FitnessTotalsFilled type: rollup measures: [CUBE.weeks, CUBE.totalMinutesOrZero, CUBE.longShareOrZero] + - name: ProgramFlags + type: rollup + measures: [CUBE.weeks, CUBE.anyDeload, CUBE.allDeload, CUBE.anyLongDeload] + dimensions: [CUBE.program] - name: WeekShapes type: rollup measures: [CUBE.weeks, CUBE.unlabelled, CUBE.labelledMinutes, CUBE.shortWeeks, CUBE.midWeeks, CUBE.longWMinutes] diff --git a/fixtures/cube-model/canonical/expected/model/views/ProgramFlagRoster.yml b/fixtures/cube-model/canonical/expected/model/views/ProgramFlagRoster.yml new file mode 100644 index 000000000..e650437e8 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/views/ProgramFlagRoster.yml @@ -0,0 +1,14 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +views: + - name: ProgramFlagRoster + cubes: + - join_path: Program + includes: + - name: id + alias: programKey + - join_path: Program.ProgramFlagRosterFacts + includes: + - name: weeks + - name: anyDeload + - name: anyDeloadOrFalse + - name: allDeloadOrTrue diff --git a/fixtures/cube-model/canonical/seed.mysql.sql b/fixtures/cube-model/canonical/seed.mysql.sql index 2bf4251ac..42dc922ec 100644 --- a/fixtures/cube-model/canonical/seed.mysql.sql +++ b/fixtures/cube-model/canonical/seed.mysql.sql @@ -16,13 +16,13 @@ INSERT INTO `programs` (`id`,`title`,`priceCents`,`status`,`created_ts`) VALUES (9, 'Open Gym', 0, 'PUBLISHED', '2026-05-17 08:00:00'), (10, 'Intro Archive', 900, 'ARCHIVED', '2026-05-31 12:00:00'), (11, 'Intro Premium', 9000, 'PUBLISHED', '2026-05-01 12:00:00'); -INSERT INTO `weeks` (`id`,`programId`,`label`,`durationMinutes`) VALUES - (10, 1, 'Week 1', 30), - (11, 1, 'Week 2', 60), - (12, 1, 'Week 2', 90), - (13, 1, NULL, 60), - (20, 2, 'Solo', 45), - (30, 5, NULL, 20); +INSERT INTO `weeks` (`id`,`programId`,`label`,`durationMinutes`,`deload`) VALUES + (10, 1, 'Week 1', 30, FALSE), + (11, 1, 'Week 2', 60, TRUE), + (12, 1, 'Week 2', 90, FALSE), + (13, 1, NULL, 60, NULL), + (20, 2, 'Solo', 45, TRUE), + (30, 5, NULL, 20, NULL); INSERT INTO `assets` (`id`,`ownerId`,`externalId`,`payload`,`recordedAt`,`observedAt`,`asOfDate`,`atTime`) VALUES ('11111111-1111-4111-8111-111111111111','aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','22222222-2222-4222-8222-222222222222','{"k": 1}','2026-05-04 03:30:00','2026-05-04 03:30:00','2026-05-03','03:30:00'), ('33333333-3333-4333-8333-333333333333','44444444-4444-4444-8444-444444444444','55555555-5555-4555-8555-555555555555','{"k": 2}','2026-05-04 03:45:00','2026-05-04 03:45:00','2026-05-03','03:45:00'), diff --git a/fixtures/cube-model/canonical/seed.sql b/fixtures/cube-model/canonical/seed.sql index b9cc97355..211632e0a 100644 --- a/fixtures/cube-model/canonical/seed.sql +++ b/fixtures/cube-model/canonical/seed.sql @@ -21,13 +21,13 @@ INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES (9, 'Open Gym', 0, 'PUBLISHED', '2026-05-17T08:00:00'), (10, 'Intro Archive', 900, 'ARCHIVED', '2026-05-31T12:00:00'), (11, 'Intro Premium', 9000, 'PUBLISHED', '2026-05-01T12:00:00'); -INSERT INTO "weeks" ("id","programId","label","durationMinutes") VALUES - (10, 1, 'Week 1', 30), - (11, 1, 'Week 2', 60), - (12, 1, 'Week 2', 90), - (13, 1, NULL, 60), - (20, 2, 'Solo', 45), - (30, 5, NULL, 20); +INSERT INTO "weeks" ("id","programId","label","durationMinutes","deload") VALUES + (10, 1, 'Week 1', 30, FALSE), + (11, 1, 'Week 2', 60, TRUE), + (12, 1, 'Week 2', 90, FALSE), + (13, 1, NULL, 60, NULL), + (20, 2, 'Solo', 45, TRUE), + (30, 5, NULL, 20, NULL); INSERT INTO "assets" ("id","ownerId","externalId","payload","recordedAt","observedAt","asOfDate","atTime") VALUES ('11111111-1111-4111-8111-111111111111','aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','22222222-2222-4222-8222-222222222222','{"k": 1}','2026-05-04T03:30:00Z','2026-05-04T03:30:00','2026-05-03','03:30:00'), ('33333333-3333-4333-8333-333333333333','44444444-4444-4444-8444-444444444444','55555555-5555-4555-8555-555555555555','{"k": 2}','2026-05-04T03:45:00Z','2026-05-04T03:45:00','2026-05-03','03:45:00'), diff --git a/fixtures/cube-model/measure-boolean-mysql/case.json b/fixtures/cube-model/measure-boolean-mysql/case.json new file mode 100644 index 000000000..7dd320624 --- /dev/null +++ b/fixtures/cube-model/measure-boolean-mysql/case.json @@ -0,0 +1,3 @@ +{ + "dialect": "mysql" +} diff --git a/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml b/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml new file mode 100644 index 000000000..ade6e9fe6 --- /dev/null +++ b/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml @@ -0,0 +1,33 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: Call + sql_table: '`calls`' + dimensions: + - name: id + sql: '{CUBE}.`id`' + type: number + primary_key: true + measures: + - name: anyFailed + sql: 'MAX({CUBE}.`failed`)' + type: boolean + - name: allFailed + sql: 'MIN({CUBE}.`failed`)' + type: boolean + - name: anySlowFailed + sql: 'MAX(CASE WHEN {CUBE}.`duration_ms` >= 1000 THEN {CUBE}.`failed` END)' + type: boolean + - name: anyFailedOrFalseRaw + sql: 'MAX({CUBE}.`failed`)' + type: boolean + public: false + - name: anyFailedOrFalse + sql: 'COALESCE({anyFailedOrFalseRaw}, 0)' + type: boolean + - name: allFailedOrTrueRaw + sql: 'MIN({CUBE}.`failed`)' + type: boolean + public: false + - name: allFailedOrTrue + sql: 'COALESCE({allFailedOrTrueRaw}, 1)' + type: boolean diff --git a/fixtures/cube-model/measure-boolean-mysql/meta.json b/fixtures/cube-model/measure-boolean-mysql/meta.json new file mode 100644 index 000000000..46f756afa --- /dev/null +++ b/fixtures/cube-model/measure-boolean-mysql/meta.json @@ -0,0 +1,82 @@ +{ + "metadata.root": { + "package": "shop", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.boolean": { + "name": "failed" + } + }, + { + "field.int": { + "name": "durationMs" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + }, + { + "measure.aggregate": { + "name": "anyFailed", + "@agg": "any", + "@of": "Call.failed" + } + }, + { + "measure.aggregate": { + "name": "allFailed", + "@agg": "all", + "@of": "Call.failed" + } + }, + { + "measure.aggregate": { + "name": "anySlowFailed", + "@agg": "any", + "@of": "Call.failed", + "@filter": { + "durationMs": { + "gte": 1000 + } + } + } + }, + { + "measure.aggregate": { + "name": "anyFailedOrFalse", + "@agg": "any", + "@of": "Call.failed", + "@default": 0 + } + }, + { + "measure.aggregate": { + "name": "allFailedOrTrue", + "@agg": "all", + "@of": "Call.failed", + "@default": 1 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml b/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml new file mode 100644 index 000000000..60627dcd7 --- /dev/null +++ b/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml @@ -0,0 +1,33 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: Call + sql_table: '"calls"' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + measures: + - name: anyFailed + sql: 'bool_or({CUBE}."failed")' + type: boolean + - name: allFailed + sql: 'bool_and({CUBE}."failed")' + type: boolean + - name: anySlowFailed + sql: 'bool_or({CUBE}."failed") FILTER (WHERE {CUBE}."duration_ms" >= 1000)' + type: boolean + - name: anyFailedOrFalseRaw + sql: 'bool_or({CUBE}."failed")' + type: boolean + public: false + - name: anyFailedOrFalse + sql: 'COALESCE({anyFailedOrFalseRaw}, FALSE)' + type: boolean + - name: allFailedOrTrueRaw + sql: 'bool_and({CUBE}."failed")' + type: boolean + public: false + - name: allFailedOrTrue + sql: 'COALESCE({allFailedOrTrueRaw}, TRUE)' + type: boolean diff --git a/fixtures/cube-model/measure-boolean/meta.json b/fixtures/cube-model/measure-boolean/meta.json new file mode 100644 index 000000000..46f756afa --- /dev/null +++ b/fixtures/cube-model/measure-boolean/meta.json @@ -0,0 +1,82 @@ +{ + "metadata.root": { + "package": "shop", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.boolean": { + "name": "failed" + } + }, + { + "field.int": { + "name": "durationMs" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + }, + { + "measure.aggregate": { + "name": "anyFailed", + "@agg": "any", + "@of": "Call.failed" + } + }, + { + "measure.aggregate": { + "name": "allFailed", + "@agg": "all", + "@of": "Call.failed" + } + }, + { + "measure.aggregate": { + "name": "anySlowFailed", + "@agg": "any", + "@of": "Call.failed", + "@filter": { + "durationMs": { + "gte": 1000 + } + } + } + }, + { + "measure.aggregate": { + "name": "anyFailedOrFalse", + "@agg": "any", + "@of": "Call.failed", + "@default": 0 + } + }, + { + "measure.aggregate": { + "name": "allFailedOrTrue", + "@agg": "all", + "@of": "Call.failed", + "@default": 1 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/metamodel-docs/expected/types/measure.md b/fixtures/metamodel-docs/expected/types/measure.md index 93dc113b7..f638fbbcf 100644 --- a/fixtures/metamodel-docs/expected/types/measure.md +++ b/fixtures/metamodel-docs/expected/types/measure.md @@ -20,8 +20,8 @@ A named aggregate over the declaring entity's own rows (FR-044). @agg count with | 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. | -| `@default` | int | no | | | — | Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio. | +| `@agg` | string | yes | | `count`, `sum`, `avg`, `min`, `max`, `any`, `all` | — | The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields; any/all (boolean OR / AND, Postgres bool_or / bool_and) need a field.boolean and type the measure boolean. | +| `@default` | int | no | | | — | Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio. | | `@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. | diff --git a/fixtures/persistence-conformance/README.md b/fixtures/persistence-conformance/README.md index 4a25ead0f..ba9d3f6f4 100644 --- a/fixtures/persistence-conformance/README.md +++ b/fixtures/persistence-conformance/README.md @@ -324,7 +324,7 @@ port's runtime. They use only `op: list` and `op: count`, single-key `sort`, `fi still the committed `canonical/schema.postgres.sql`, which creates each report's view, so a port reads the view the TypeScript migrate engine produced and never lowers a report itself. -The nine also run on SQLite and MySQL 8.4 (and TypeScript on D1's local runtime) in every +The eleven also run on SQLite and MySQL 8.4 (and TypeScript on D1's local runtime) in every port whose runtime has the engine: they execute `canonical/schema.sqlite.sql` or `canonical/schema.mysql.sql` instead, each TypeScript-produced and held equal to its generator by a test. The `expect` blocks stay the Postgres wire ones; a runner maps the engine's own @@ -348,6 +348,8 @@ then measures in `@measures` order; a time dimension is named ``, f | `report-relative-date` | a `{ now: "-P30D" }` filter, with a seed relative to the database clock | | `report-spine-zero-rows` | `@spine`: a program with no weeks has a row (`weeks` `0`, `totalMinutes` and `longShare` null, the `@default: 0` measures `0`); `count` is the number of programs; a measure filter drops the empty rows; filter and sort on a defaulted measure | | `report-spine-scoped` | `@spine` with a report `@segment`: a program whose weeks are all out of scope keeps its row, with `weeks` `0` | +| `report-boolean-measures` | `@agg: any` / `all` over a nullable `field.boolean`: a boolean on the wire (never `0` / `1`), nulls ignored, `null` for a group with nothing to judge, a `segment`-scoped `any`; `filter` by value and `isNull` | +| `report-boolean-spine` | boolean measures under `@spine`: a program with no weeks reads `null`, or the `@default: 0` / `1` (false / true); filter and sort on a defaulted boolean | | `report-default-empty` | `@default: 0` with no `@spine`, over an empty table: the twin of `report-totals-empty`, the sum and the ratio reading `0` | | `report-all-types-breakdown` | dimensions over a boolean, a string enum, a nullable int-backed enum (read as its symbol), a date and a uuid; `sum` of an int, `avg` of a decimal, `max` of a currency, `min` of a long; an inline `in` measure filter; a report `@filter` mixing a relative timestamp with a `PT` duration and a `ne` | | `report-filter-operators` | a report `@segment` built from `or`, `like` and equality, ANDed with a report `@filter` (`in`, `lt`); an inline `ne` measure filter; a segment and filter that leave no row, so an `avg` `@default: -1` applies | diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 510c37350..de21b2cad 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -37,6 +37,7 @@ { "field.long": { "name": "programId", "@required": true } }, { "field.string": { "name": "label", "@maxLength": 80 } }, { "field.int": { "name": "durationMinutes", "@required": true } }, + { "field.boolean": { "name": "deload" } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, { "identity.reference": { "name": "fkProgram", "@fields": "programId", "@references": "Program" } }, { "segment.filter": { "name": "long", "@filter": { "durationMinutes": { "gte": 60 } } } }, @@ -59,7 +60,12 @@ { "measure.aggregate": { "name": "labelledMinutes", "@agg": "sum", "@of": "Week.durationMinutes", "@filter": { "label": { "isNull": false } } } }, { "measure.aggregate": { "name": "shortWeeks", "@agg": "count", "@of": "Week.id", "@filter": { "durationMinutes": { "lt": 30 } } } }, { "measure.aggregate": { "name": "midWeeks", "@agg": "count", "@of": "Week.id", "@filter": { "durationMinutes": { "gt": 30, "lte": 60 } } } }, - { "measure.aggregate": { "name": "longWMinutes", "@agg": "sum", "@of": "Week.durationMinutes", "@segment": "long", "@filter": { "label": { "like": "W%" } } } } + { "measure.aggregate": { "name": "longWMinutes", "@agg": "sum", "@of": "Week.durationMinutes", "@segment": "long", "@filter": { "label": { "like": "W%" } } } }, + { "measure.aggregate": { "name": "anyDeload", "@agg": "any", "@of": "Week.deload" } }, + { "measure.aggregate": { "name": "allDeload", "@agg": "all", "@of": "Week.deload" } }, + { "measure.aggregate": { "name": "anyDeloadOrFalse", "@agg": "any", "@of": "Week.deload", "@default": 0 } }, + { "measure.aggregate": { "name": "allDeloadOrTrue", "@agg": "all", "@of": "Week.deload", "@default": 1 } }, + { "measure.aggregate": { "name": "anyLongDeload", "@agg": "any", "@of": "Week.deload", "@segment": "long" } } ] }}, { "object.entity": { @@ -410,6 +416,13 @@ { "object.report": { "name": "ProgramLongWeeks", "@from": "Week", "@spine": "Week.fkProgram", "@dimensions": ["programKey"], "@measures": ["weeks", "totalMinutesOrZero"], "@segment": "long", "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_long_weeks" } } ] } }, + { "object.report": { "name": "ProgramFlags", "@from": "Week", + "@dimensions": ["program"], "@measures": ["weeks", "anyDeload", "allDeload", "anyLongDeload"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_flags" } } ] } }, + { "object.report": { "name": "ProgramFlagRoster", "@from": "Week", "@spine": "Week.fkProgram", + "@dimensions": ["programKey"], + "@measures": ["weeks", "anyDeload", "anyDeloadOrFalse", "allDeloadOrTrue"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_flag_roster" } } ] } }, { "object.report": { "name": "FitnessTotalsFilled", "@from": "Week", "@measures": ["weeks", "totalMinutesOrZero", "longShareOrZero"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_fitness_totals_filled" } } ] } }, diff --git a/fixtures/persistence-conformance/canonical/schema.mysql.sql b/fixtures/persistence-conformance/canonical/schema.mysql.sql index a8a4910a9..9e079ee34 100644 --- a/fixtures/persistence-conformance/canonical/schema.mysql.sql +++ b/fixtures/persistence-conformance/canonical/schema.mysql.sql @@ -18,6 +18,7 @@ CREATE TABLE `weeks` ( `programId` BIGINT NOT NULL, `label` VARCHAR(80), `durationMinutes` INT NOT NULL, + `deload` BOOLEAN, FOREIGN KEY (`programId`) REFERENCES `programs` (`id`) ); @@ -164,6 +165,27 @@ CREATE VIEW `v_program_long_weeks` AS LEFT OUTER JOIN `weeks` w ON p.`id` = w.`programId` AND w.`durationMinutes` >= 60 GROUP BY p.`id`; +CREATE VIEW `v_program_flags` AS + SELECT + w.`programId` AS `program`, + COUNT(w.`id`) AS `weeks`, + MAX(w.`deload`) AS `anyDeload`, + MIN(w.`deload`) AS `allDeload`, + MAX(CASE WHEN w.`durationMinutes` >= 60 THEN w.`deload` END) AS `anyLongDeload` + FROM `weeks` w + GROUP BY w.`programId`; + +CREATE VIEW `v_program_flag_roster` AS + SELECT + p.`id` AS `programKey`, + COUNT(w.`id`) AS `weeks`, + MAX(w.`deload`) AS `anyDeload`, + COALESCE(MAX(w.`deload`), 0) AS `anyDeloadOrFalse`, + COALESCE(MIN(w.`deload`), 1) AS `allDeloadOrTrue` + FROM `programs` p + LEFT OUTER JOIN `weeks` w ON p.`id` = w.`programId` + GROUP BY p.`id`; + CREATE VIEW `v_fitness_totals_filled` AS SELECT COUNT(w.`id`) AS `weeks`, diff --git a/fixtures/persistence-conformance/canonical/schema.postgres.sql b/fixtures/persistence-conformance/canonical/schema.postgres.sql index bbef4aceb..69bc94c63 100644 --- a/fixtures/persistence-conformance/canonical/schema.postgres.sql +++ b/fixtures/persistence-conformance/canonical/schema.postgres.sql @@ -20,6 +20,7 @@ CREATE TABLE "weeks" ( "programId" BIGINT NOT NULL, "label" VARCHAR(80), "durationMinutes" INTEGER NOT NULL, + "deload" BOOLEAN, CONSTRAINT "weeks_pkey" PRIMARY KEY ("id") ); @@ -270,6 +271,29 @@ CREATE VIEW "v_program_long_weeks" AS GROUP BY p."id"; COMMENT ON VIEW "v_program_long_weeks" IS 'metaobjects:v1:sha256:f9eaf671509f579d4d4f0345947ff383c8192707d66b1fb24a2ece5580aaa86e'; +CREATE VIEW "v_program_flags" AS + SELECT + w."programId" AS "program", + COUNT(w."id") AS "weeks", + bool_or(w."deload") AS "anyDeload", + bool_and(w."deload") AS "allDeload", + bool_or(w."deload") FILTER (WHERE w."durationMinutes" >= 60) AS "anyLongDeload" + FROM "weeks" w + GROUP BY w."programId"; +COMMENT ON VIEW "v_program_flags" IS 'metaobjects:v1:sha256:ef0870b423472f2745be95f1ff25da1cc9cce58bd2ed3bc5927472378831643c'; + +CREATE VIEW "v_program_flag_roster" AS + SELECT + p."id" AS "programKey", + COUNT(w."id") AS "weeks", + bool_or(w."deload") AS "anyDeload", + COALESCE(bool_or(w."deload"), FALSE) AS "anyDeloadOrFalse", + COALESCE(bool_and(w."deload"), TRUE) AS "allDeloadOrTrue" + FROM "programs" p + LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" + GROUP BY p."id"; +COMMENT ON VIEW "v_program_flag_roster" IS 'metaobjects:v1:sha256:14db4d7e9e8d27fde23b5a60797ef8bf301aa8e7a080c29f4b63ce23211e16ce'; + CREATE VIEW "v_fitness_totals_filled" AS SELECT COUNT(w."id") AS "weeks", diff --git a/fixtures/persistence-conformance/canonical/schema.sqlite.sql b/fixtures/persistence-conformance/canonical/schema.sqlite.sql index feb3416ee..b8ff5a985 100644 --- a/fixtures/persistence-conformance/canonical/schema.sqlite.sql +++ b/fixtures/persistence-conformance/canonical/schema.sqlite.sql @@ -19,6 +19,7 @@ CREATE TABLE "weeks" ( "programId" INTEGER NOT NULL, "label" VARCHAR(80), "durationMinutes" INTEGER NOT NULL, + "deload" INTEGER, FOREIGN KEY ("programId") REFERENCES "programs" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); @@ -238,6 +239,27 @@ CREATE VIEW "v_program_long_weeks" AS LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" AND w."durationMinutes" >= 60 GROUP BY p."id"; +CREATE VIEW "v_program_flags" AS + SELECT + w."programId" AS "program", + COUNT(w."id") AS "weeks", + MAX(w."deload") AS "anyDeload", + MIN(w."deload") AS "allDeload", + MAX(CASE WHEN w."durationMinutes" >= 60 THEN w."deload" END) AS "anyLongDeload" + FROM "weeks" w + GROUP BY w."programId"; + +CREATE VIEW "v_program_flag_roster" AS + SELECT + p."id" AS "programKey", + COUNT(w."id") AS "weeks", + MAX(w."deload") AS "anyDeload", + COALESCE(MAX(w."deload"), 0) AS "anyDeloadOrFalse", + COALESCE(MIN(w."deload"), 1) AS "allDeloadOrTrue" + FROM "programs" p + LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" + GROUP BY p."id"; + CREATE VIEW "v_fitness_totals_filled" AS SELECT COUNT(w."id") AS "weeks", diff --git a/fixtures/persistence-conformance/queries/filter-is-null.yaml b/fixtures/persistence-conformance/queries/filter-is-null.yaml index 351943ac3..8ae84c84c 100644 --- a/fixtures/persistence-conformance/queries/filter-is-null.yaml +++ b/fixtures/persistence-conformance/queries/filter-is-null.yaml @@ -19,8 +19,8 @@ queries: filter: { label: { isNull: true } } sort: [{ field: id, dir: asc }] expect: - - { id: "2", programId: "1", label: null, durationMinutes: 45 } - - { id: "4", programId: "1", label: null, durationMinutes: 75 } + - { id: "2", programId: "1", label: null, durationMinutes: 45, deload: null } + - { id: "4", programId: "1", label: null, durationMinutes: 75, deload: null } - name: weeks-with-non-null-label op: list @@ -28,5 +28,5 @@ queries: filter: { label: { isNull: false } } sort: [{ field: id, dir: asc }] expect: - - { id: "1", programId: "1", label: "Week 1", durationMinutes: 30 } - - { id: "3", programId: "1", label: "Week 3", durationMinutes: 60 } + - { id: "1", programId: "1", label: "Week 1", durationMinutes: 30, deload: null } + - { id: "3", programId: "1", label: "Week 3", durationMinutes: 60, deload: null } diff --git a/fixtures/persistence-conformance/queries/report-boolean-measures.yaml b/fixtures/persistence-conformance/queries/report-boolean-measures.yaml new file mode 100644 index 000000000..6b0b3399d --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-boolean-measures.yaml @@ -0,0 +1,75 @@ +name: report-boolean-measures +description: | + ProgramFlags is an object.report over Week: one row per program, with the boolean + aggregates `@agg: any` (Postgres bool_or; MAX over 0/1 on SQLite and MySQL) and + `@agg: all` (bool_and; MIN) over Week.deload, a nullable field.boolean. Each measure + is a boolean on the wire (true / false), never 0 / 1. + + Null values are ignored, as in SQL: a group's `any` is null only when it has no + non-null value to judge, and so is its `all`. No @default is declared here, so the + null reaches the wire. + Program 1 deloads: false, true, false, null -> any true, all false + Program 2 deloads: true, true -> any true, all true + Program 3 deloads: false, null -> any false, all false + Program 4 deloads: null -> any null, all null (nothing to judge) + anyLongDeload is `any` scoped by the `long` segment (durationMinutes >= 60): program 1 + has long weeks 11 (true), 12 (false) and 13 (null), so it is true; programs 2 to 4 have + no long week with a value, so it is null. + + A boolean measure is filterable by its value. A null row matches neither `eq: true` nor + `eq: false`. +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'), + (3, 'Endurance', 3000, 'PUBLISHED', '2026-05-18T09:00:00'), + (4, 'Recovery', 1500, 'DRAFT', '2026-05-19T09:00:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes","deload") VALUES + (10, 1, 'Week 1', 30, FALSE), + (11, 1, 'Week 2', 60, TRUE), + (12, 1, 'Week 3', 90, FALSE), + (13, 1, NULL, 60, NULL), + (20, 2, 'Solo', 45, TRUE), + (21, 2, 'Duo', 50, TRUE), + (30, 3, 'Base', 30, FALSE), + (31, 3, 'Build', 90, NULL), + (40, 4, 'Rest', 30, NULL); +queries: + - name: all-groups + op: list + entity: ProgramFlags + sort: [{ field: program, dir: asc }] + expect: + - { program: "1", weeks: "4", anyDeload: true, allDeload: false, anyLongDeload: true } + - { program: "2", weeks: "2", anyDeload: true, allDeload: true, anyLongDeload: null } + - { program: "3", weeks: "2", anyDeload: false, allDeload: false, anyLongDeload: null } + - { program: "4", weeks: "1", anyDeload: null, allDeload: null, anyLongDeload: null } + - name: filter-any-true + op: list + entity: ProgramFlags + filter: { anyDeload: { eq: true } } + sort: [{ field: program, dir: asc }] + expect: + - { program: "1", weeks: "4", anyDeload: true, allDeload: false, anyLongDeload: true } + - { program: "2", weeks: "2", anyDeload: true, allDeload: true, anyLongDeload: null } + - name: filter-all-true + op: list + entity: ProgramFlags + filter: { allDeload: { eq: true } } + expect: + - { program: "2", weeks: "2", anyDeload: true, allDeload: true, anyLongDeload: null } + - name: filter-any-false-skips-the-null-group + op: count + entity: ProgramFlags + filter: { anyDeload: { eq: false } } + expect: 1 + - name: filter-is-null + op: list + entity: ProgramFlags + filter: { allDeload: { isNull: true } } + expect: + - { program: "4", weeks: "1", anyDeload: null, allDeload: null, anyLongDeload: null } + - name: count-groups + op: count + entity: ProgramFlags + expect: 4 diff --git a/fixtures/persistence-conformance/queries/report-boolean-spine.yaml b/fixtures/persistence-conformance/queries/report-boolean-spine.yaml new file mode 100644 index 000000000..b5f2713a9 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-boolean-spine.yaml @@ -0,0 +1,68 @@ +name: report-boolean-spine +description: | + ProgramFlagRoster is an object.report over Week with @spine: "Week.fkProgram", so a + program with no weeks still has a row. Its measures are the boolean aggregates over + Week.deload: + anyDeload @agg: any, no @default + anyDeloadOrFalse @agg: any, @default: 0 (false) + allDeloadOrTrue @agg: all, @default: 1 (true) + + A boolean measure's @default is 0 (false) or 1 (true). It is read wherever the aggregate + is null: no weeks at all (the spine row with no facts), or only null deload values. + Without a @default the measure stays null there. A defaulted measure is never null, so it + is a required field of the report and sorts and filters as its default. + Program 1 deloads: false, true, false, null -> any true, anyOrFalse true, allOrTrue false + Program 2 deloads: true, true -> any true, anyOrFalse true, allOrTrue true + Program 3 deloads: false, null -> any false, anyOrFalse false, allOrTrue false + Program 4 deloads: null -> any null, anyOrFalse false, allOrTrue true + Program 5 has no weeks -> any null, anyOrFalse false, allOrTrue true +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'), + (3, 'Endurance', 3000, 'PUBLISHED', '2026-05-18T09:00:00'), + (4, 'Recovery', 1500, 'DRAFT', '2026-05-19T09:00:00'), + (5, 'Mobility', 1200, 'DRAFT', '2026-05-20T09:00:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes","deload") VALUES + (10, 1, 'Week 1', 30, FALSE), + (11, 1, 'Week 2', 60, TRUE), + (12, 1, 'Week 3', 90, FALSE), + (13, 1, NULL, 60, NULL), + (20, 2, 'Solo', 45, TRUE), + (21, 2, 'Duo', 50, TRUE), + (30, 3, 'Base', 30, FALSE), + (31, 3, 'Build', 90, NULL), + (40, 4, 'Rest', 30, NULL); +queries: + - name: every-program-has-a-row + op: list + entity: ProgramFlagRoster + sort: [{ field: programKey, dir: asc }] + expect: + - { programKey: "1", weeks: "4", anyDeload: true, anyDeloadOrFalse: true, allDeloadOrTrue: false } + - { programKey: "2", weeks: "2", anyDeload: true, anyDeloadOrFalse: true, allDeloadOrTrue: true } + - { programKey: "3", weeks: "2", anyDeload: false, anyDeloadOrFalse: false, allDeloadOrTrue: false } + - { programKey: "4", weeks: "1", anyDeload: null, anyDeloadOrFalse: false, allDeloadOrTrue: true } + - { programKey: "5", weeks: "0", anyDeload: null, anyDeloadOrFalse: false, allDeloadOrTrue: true } + - name: the-default-is-filterable + op: list + entity: ProgramFlagRoster + filter: { anyDeloadOrFalse: { eq: false } } + sort: [{ field: programKey, dir: asc }] + expect: + - { programKey: "3", weeks: "2", anyDeload: false, anyDeloadOrFalse: false, allDeloadOrTrue: false } + - { programKey: "4", weeks: "1", anyDeload: null, anyDeloadOrFalse: false, allDeloadOrTrue: true } + - { programKey: "5", weeks: "0", anyDeload: null, anyDeloadOrFalse: false, allDeloadOrTrue: true } + - name: filter-all-or-true + op: count + entity: ProgramFlagRoster + filter: { allDeloadOrTrue: { eq: true } } + expect: 3 + - name: sort-on-the-defaulted-boolean + op: list + entity: ProgramFlagRoster + sort: [{ field: allDeloadOrTrue, dir: desc }, { field: programKey, dir: asc }] + limit: 2 + expect: + - { programKey: "2", weeks: "2", anyDeload: true, anyDeloadOrFalse: true, allDeloadOrTrue: true } + - { programKey: "4", weeks: "1", anyDeload: null, anyDeloadOrFalse: false, allDeloadOrTrue: true } diff --git a/fixtures/persistence-conformance/report-shapes.json b/fixtures/persistence-conformance/report-shapes.json index ae0a753df..5c28b57dc 100644 --- a/fixtures/persistence-conformance/report-shapes.json +++ b/fixtures/persistence-conformance/report-shapes.json @@ -294,6 +294,90 @@ } ] }, + { + "report": "fitness::ProgramFlags", + "from": "fitness::Week", + "view": "v_program_flags", + "fields": [ + { + "name": "program", + "role": "dimension", + "subType": "long", + "required": true, + "typeSource": "fitness::Week.programId" + }, + { + "name": "weeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "anyDeload", + "role": "measure", + "subType": "boolean", + "required": false, + "typeSource": null + }, + { + "name": "allDeload", + "role": "measure", + "subType": "boolean", + "required": false, + "typeSource": null + }, + { + "name": "anyLongDeload", + "role": "measure", + "subType": "boolean", + "required": false, + "typeSource": null + } + ] + }, + { + "report": "fitness::ProgramFlagRoster", + "from": "fitness::Week", + "view": "v_program_flag_roster", + "fields": [ + { + "name": "programKey", + "role": "dimension", + "subType": "long", + "required": true, + "typeSource": "fitness::Program.id" + }, + { + "name": "weeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "anyDeload", + "role": "measure", + "subType": "boolean", + "required": false, + "typeSource": null + }, + { + "name": "anyDeloadOrFalse", + "role": "measure", + "subType": "boolean", + "required": true, + "typeSource": null + }, + { + "name": "allDeloadOrTrue", + "role": "measure", + "subType": "boolean", + "required": true, + "typeSource": null + } + ] + }, { "report": "fitness::FitnessTotalsFilled", "from": "fitness::Week", diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index 9ae0cd63d..482b7eb37 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -3141,16 +3141,18 @@ "sum", "avg", "min", - "max" + "max", + "any", + "all" ], - "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields." + "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields; any/all (boolean OR / AND, Postgres bool_or / bool_and) need a field.boolean and type the measure boolean." }, { "name": "default", "valueType": "int", "isArray": false, "required": false, - "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." + "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." }, { "name": "distinct", diff --git a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs index 24d7d12f1..512281d5c 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs @@ -146,7 +146,7 @@ public void Every_generated_file_compiles_with_zero_errors(string selection, boo var files = generators.SelectMany(g => g.Generate(ctx)).ToList(); Assert.True(files.Count > 0, $"selection '{selection}' generated no files at all"); - // FR-044 — every view-backed report of the corpus (twenty-one today) generates a keyless + // FR-044 — every view-backed report of the corpus (twenty-three today) generates a keyless // row class and a filter allowlist (their routes file is in the excluded framework tier), // and no names artifact. Named, because a compile gate passes trivially over a file that // was never emitted. ProgramRoster, ProgramLongWeeks and FitnessTotalsFilled carry @@ -156,7 +156,7 @@ public void Every_generated_file_compiles_with_zero_errors(string selection, boo { "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity", "ProgramRoster", "ProgramLongWeeks", - "FitnessTotalsFilled", + "ProgramFlags", "ProgramFlagRoster", "FitnessTotalsFilled", "MeasurementStats", "MeasurementStatsFilled", "AssetDays", "AllTypesBreakdown", "ProgramsOverTime", "LiveIntroPrograms", "ProgramsEmptyScope", "WeekShapes", "SessionsByProgram", "ProgramSessions", "NodesByParent", "AuthsByKind", }) diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs index d61f46775..f070fd45e 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs @@ -54,7 +54,7 @@ public void The_artifact_covers_the_canonical_reports() var reports = LoadCanonical().Objects().Where(o => o.IsReport()).Select(o => o.Name).ToList(); Assert.Equal( ["ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity", - "ProgramRoster", "ProgramLongWeeks", "FitnessTotalsFilled", + "ProgramRoster", "ProgramLongWeeks", "ProgramFlags", "ProgramFlagRoster", "FitnessTotalsFilled", "MeasurementStats", "MeasurementStatsFilled", "AssetDays", "AllTypesBreakdown", "ProgramsOverTime", "LiveIntroPrograms", "ProgramsEmptyScope", "WeekShapes", "SessionsByProgram", "ProgramSessions", "NodesByParent", "AuthsByKind"], reports); @@ -75,6 +75,19 @@ public void The_canonical_spine_report_has_a_required_spine_key_and_defaulted_me Assert.Equal(FIELD_SUBTYPE_DECIMAL, fields["longShareOrZero"].SubType); } + [Fact] + public void The_boolean_aggregates_are_boolean_and_required_only_with_a_default() + { + // Table B / C for @agg any / all: boolean whatever the source; required only with @default. + var fields = Shape(LoadCanonical(), "ProgramFlagRoster").Fields.ToDictionary(f => f.Name); + Assert.Equal(FIELD_SUBTYPE_BOOLEAN, fields["anyDeload"].SubType); + Assert.False(fields["anyDeload"].Required); + Assert.Equal(FIELD_SUBTYPE_BOOLEAN, fields["anyDeloadOrFalse"].SubType); + Assert.True(fields["anyDeloadOrFalse"].Required); + Assert.Equal(FIELD_SUBTYPE_BOOLEAN, fields["allDeloadOrTrue"].SubType); + Assert.True(fields["allDeloadOrTrue"].Required); + } + [Fact] public void Dimensions_come_first_in_listed_order_then_measures() { diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs index f99cea627..38fe53dcf 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs @@ -43,7 +43,7 @@ internal sealed class ReportGeneratedServerFactory : IAsyncDisposable // Ordinal order. InvoiceDays is in the model and declares no source. private static readonly string[] ExpectedRoutedNames = ["Category", "Invoice", "InvoiceCalendar", "InvoiceDisputes", "InvoiceStatusTotals", "InvoiceTotals", - "InvoicesByMonth", "Product", "ProductRevenue", "Sale", "SalesByCategory", "SalesBySku"]; + "InvoicesByMonth", "Product", "ProductRefunds", "ProductRevenue", "Sale", "SalesByCategory", "SalesBySku"]; private const string SourcelessReport = "InvoiceDays"; private readonly ScenarioEngine _engine; diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs index 1a37e5134..8a9924462 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs @@ -31,6 +31,8 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet PostReferrals { get; set; } = default!; public DbSet PostTags { get; set; } = default!; public DbSet Programs { get; set; } = default!; + public DbSet ProgramFlagRosters { get; set; } = default!; + public DbSet ProgramFlags { get; set; } = default!; public DbSet ProgramLongWeeks { get; set; } = default!; public DbSet ProgramMinutes { get; set; } = default!; public DbSet ProgramRosters { get; set; } = default!; @@ -64,6 +66,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasNoKey().ToView("v_measurement_stats"); modelBuilder.Entity().HasNoKey().ToView("v_measurement_stats_filled"); modelBuilder.Entity().HasNoKey().ToView("v_nodes_by_parent"); + modelBuilder.Entity().HasNoKey().ToView("v_program_flag_roster"); + modelBuilder.Entity().HasNoKey().ToView("v_program_flags"); modelBuilder.Entity().HasNoKey().ToView("v_program_long_weeks"); modelBuilder.Entity().HasNoKey().ToView("v_program_minutes"); modelBuilder.Entity().HasNoKey().ToView("v_program_roster"); diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlagRoster.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlagRoster.g.cs new file mode 100644 index 000000000..81c49883d --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlagRoster.g.cs @@ -0,0 +1,23 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class ProgramFlagRoster +{ + [Column("programKey")] + public long ProgramKey { get; set; } + [Column("weeks")] + public long Weeks { get; set; } + [Column("anyDeload")] + public bool? AnyDeload { get; set; } + [Column("anyDeloadOrFalse")] + public bool AnyDeloadOrFalse { get; set; } + [Column("allDeloadOrTrue")] + public bool AllDeloadOrTrue { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlags.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlags.g.cs new file mode 100644 index 000000000..531bf1098 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramFlags.g.cs @@ -0,0 +1,23 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class ProgramFlags +{ + [Column("program")] + public long Program { get; set; } + [Column("weeks")] + public long Weeks { get; set; } + [Column("anyDeload")] + public bool? AnyDeload { get; set; } + [Column("allDeload")] + public bool? AllDeload { get; set; } + [Column("anyLongDeload")] + public bool? AnyLongDeload { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/Week.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/Week.g.cs index 0d212af3f..50e303fda 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/Week.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/Week.g.cs @@ -21,4 +21,6 @@ public class Week public string? Label { get; set; } [Column(WeekNames.DurationMinutesColumn)] public int DurationMinutes { get; set; } + [Column(WeekNames.DeloadColumn)] + public bool? Deload { get; set; } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekNames.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekNames.g.cs index 43fccd3ec..92c1d9008 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekNames.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekNames.g.cs @@ -19,6 +19,8 @@ public abstract class WeekNames public const string SourcePrimaryKind = "table"; public const string SourcePrimaryTable = "weeks"; + public const string DeloadField = "deload"; + public const string DeloadColumn = "deload"; public const string DurationMinutesField = "durationMinutes"; public const string DurationMinutesColumn = "durationMinutes"; public const string IdField = "id"; @@ -37,6 +39,7 @@ public abstract class WeekNames public static readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) { + ["deload"] = DeloadColumn, ["durationMinutes"] = DurationMinutesColumn, ["id"] = IdColumn, ["label"] = LabelColumn, diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs index 726d4868d..355112981 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs @@ -274,6 +274,9 @@ private static ReportField MeasureField(string item, MetaObject report, MetaObje return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_DECIMAL, required, Measure: m); if (agg == AGG_COUNT) return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_LONG, required, Measure: m); + // any / all are boolean whatever the source (the loader's M4 refuses a non-boolean @of). + if (agg == AGG_ANY || agg == AGG_ALL) + return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_BOOLEAN, required, Measure: m); var of = ResolveReportingFieldRef(m.OfColumns().FirstOrDefault() ?? "", ReportingMemberOwner(m, from), root, from) ?? throw Unresolved(reportName, $"measure '{name}' @of"); string src = of.SubType; diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs b/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs index bf7a645fd..8aa3f4ac8 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs @@ -104,7 +104,10 @@ public static class ReportingConstants 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]; + // any / all: boolean OR / AND over a field.boolean (bool_or / bool_and); the measure is boolean. + // The names, and their constants, are origin.aggregate's own (OriginConstants.AGG_ANY / AGG_ALL). + public static readonly string[] MEASURE_AGGS = + [AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX, MetaObjects.Persistence.Origin.OriginConstants.AGG_ANY, MetaObjects.Persistence.Origin.OriginConstants.AGG_ALL]; /// Separator in an object.report @dimensions item: name or name:grain. public const string REPORT_DIMENSION_GRAIN_SEPARATOR = ":"; diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs index b08659fb8..a836c760c 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -501,6 +501,13 @@ void Err(string message) => Err("@default cannot apply to @agg: count. A count is never null (it is 0 when nothing matches); " + "remove @default."); } + else if ((agg == AGG_ANY || agg == AGG_ALL) && + declaredDefault is int or long or short or double or float or decimal && + Convert.ToDouble(declaredDefault, System.Globalization.CultureInfo.InvariantCulture) is not (0 or 1)) + { + // M9 — a boolean measure's @default is 0 (false) or 1 (true). + Err($"@default '{declaredDefault}' on @agg '{agg}' must be 0 (false) or 1 (true): the measure is a boolean."); + } else if ((agg == AGG_MIN || agg == AGG_MAX) && ofField is not null && !ReportingNumericFieldSubtypes.Contains(ofField.SubType)) @@ -593,6 +600,11 @@ private static bool CheckAggregateColumns(MemberCtx ctx, MetaMeasure measure, Ac err($"@agg '{agg}' cannot order '{first}', a field.{single.SubType}."); return false; } + if ((agg == AGG_ANY || agg == AGG_ALL) && single.SubType != FIELD_SUBTYPE_BOOLEAN) + { + err($"@agg '{agg}' needs a field.boolean, but '{first}' is field.{single.SubType}."); + return false; + } return true; } diff --git a/server/csharp/MetaObjects/SpecMetamodel/reporting.json b/server/csharp/MetaObjects/SpecMetamodel/reporting.json index 019dda238..8f725ab9e 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/reporting.json +++ b/server/csharp/MetaObjects/SpecMetamodel/reporting.json @@ -20,12 +20,12 @@ "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": "agg", "min": 1, "max": 1, "allowedValues": ["count", "sum", "avg", "min", "max", "any", "all"], "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields; any/all (boolean OR / AND, Postgres bool_or / bool_and) need a field.boolean and type the measure boolean." }, { "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": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." } + { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." } ] }, { "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.", diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt index 992b42ba3..37a0d1762 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt @@ -24,7 +24,7 @@ import kotlin.test.assertTrue * `object.report`, with one column per derived field (contract Table B), and nothing for a * report that has no view. * - * The nine canonical reports are generated from the shared persistence corpus, the same + * The eleven canonical reports are generated from the shared persistence corpus, the same * model the hand-written reference tables in `integration-tests-kotlin` map and the * persistence lane reads. * diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt index 9fa70d158..9722eb0ce 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt @@ -106,7 +106,7 @@ internal class KotlinCodegenMatchesReferenceTest { ExpectedColumn("recordedAt", families = setOf("instantWithTimeZone")), ), ), - // FR-044: the nine view-backed reports. Each mirrors its hand-written reference + // FR-044: the eleven view-backed reports. Each mirrors its hand-written reference // (`tables/View.kt`) column for column: the family is the view's real column // type, and a column is nullable exactly when the reference's is. A report has no // identity, so none carries a primary key. @@ -185,6 +185,27 @@ internal class KotlinCodegenMatchesReferenceTest { ), report = "v_program_long_weeks", ), + // `@agg: any` / `all`: a BOOLEAN measure, nullable unless it declares `@default`. + "ProgramFlags" to EntityExpectation( + columns = listOf( + ExpectedColumn("program", families = setOf("long"), nullable = false), + ExpectedColumn("weeks", families = setOf("long"), nullable = false), + ExpectedColumn("anyDeload", families = setOf("bool"), nullable = true), + ExpectedColumn("allDeload", families = setOf("bool"), nullable = true), + ExpectedColumn("anyLongDeload", families = setOf("bool"), nullable = true), + ), + report = "v_program_flags", + ), + "ProgramFlagRoster" to EntityExpectation( + columns = listOf( + ExpectedColumn("programKey", families = setOf("long"), nullable = false), + ExpectedColumn("weeks", families = setOf("long"), nullable = false), + ExpectedColumn("anyDeload", families = setOf("bool"), nullable = true), + ExpectedColumn("anyDeloadOrFalse", families = setOf("bool"), nullable = false), + ExpectedColumn("allDeloadOrTrue", families = setOf("bool"), nullable = false), + ), + report = "v_program_flag_roster", + ), "FitnessTotalsFilled" to EntityExpectation( columns = listOf( ExpectedColumn("weeks", families = setOf("long"), nullable = false), diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt index de04149a8..17f4331c7 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt @@ -46,9 +46,9 @@ internal class ReportGeneratedApiContractConformanceTest { } @Test - fun `the corpus has its twenty-two scenarios`() { + fun `the corpus has its twenty-five scenarios`() { // A lane that silently ran fewer would still be green. - assertEquals(22, scenarios().count()) + assertEquals(25, scenarios().count()) } @Test diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt index 820d54ef4..24fce4f80 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt @@ -26,7 +26,7 @@ import kotlin.io.path.isRegularFile import kotlin.io.path.readText /** - * FR-044 Plan 3: host the GENERATED Kotlin Spring controllers of the four served reports + * FR-044 Plan 3: host the GENERATED Kotlin Spring controllers of the five served reports * of the `report/` corpus over real HTTP (an embedded Tomcat). Mirrors * [com.metaobjects.integration.kotlin.api.projection.generated.GeneratedProjectionControllerHarness]. * @@ -169,7 +169,7 @@ class GeneratedReportControllerHarness( */ val SERVED = listOf( "InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku", + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds", ) private fun snakeCase(s: String): String = buildString { diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagRosterView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagRosterView.kt new file mode 100644 index 000000000..8c4158315 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagRosterView.kt @@ -0,0 +1,30 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `ProgramFlagRoster` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_program_flag_roster`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * The report declares `@spine: "Week.fkProgram"`, so a program with no week still has a row, + * on which a boolean aggregate with no `@default` is NULL and one with `@default: 0` / `1` + * reads false / true (`fixtures/persistence-conformance/report-shapes.json`): + * - `programKey` = Program.id, the spine entity's key → BIGINT → `long` + * - `weeks` = a count, 0 on the empty row → BIGINT → `long` + * - `anyDeload` = `any`, no `@default` → BOOLEAN → `bool`, nullable + * - `anyDeloadOrFalse` = `any`, `@default: 0` → BOOLEAN → `bool` + * - `allDeloadOrTrue` = `all`, `@default: 1` → BOOLEAN → `bool` + * + * A report has no identity, so there is no `primaryKey`. + */ +object ProgramFlagRosterView : Table("v_program_flag_roster") { + val programKey = long("programKey") + val weeks = long("weeks") + val anyDeload = bool("anyDeload").nullable() + val anyDeloadOrFalse = bool("anyDeloadOrFalse") + val allDeloadOrTrue = bool("allDeloadOrTrue") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagsView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagsView.kt new file mode 100644 index 000000000..e470f28b2 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramFlagsView.kt @@ -0,0 +1,31 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `ProgramFlags` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_program_flags`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * The boolean measures are `@agg: any` / `all` over the nullable `Week.deload`: Postgres + * `bool_or` / `bool_and`, a BOOLEAN that is NULL for a group with no value to judge. None + * declares a `@default`, so none is required (`fixtures/persistence-conformance/report-shapes.json`): + * - `program` = Week.programId, a dimension → BIGINT → `long` + * - `weeks` = a count → BIGINT → `long` + * - `anyDeload` = `any` → BOOLEAN → `bool`, nullable + * - `allDeload` = `all` → BOOLEAN → `bool`, nullable + * - `anyLongDeload` = `any` scoped by the `long` segment → BOOLEAN → `bool`, nullable + * + * A report has no identity, so there is no `primaryKey`: it is listed and counted, never + * fetched by id. Column names are the derived field names (the corpus's `literal` naming). + */ +object ProgramFlagsView : Table("v_program_flags") { + val program = long("program") + val weeks = long("weeks") + val anyDeload = bool("anyDeload").nullable() + val allDeload = bool("allDeload").nullable() + val anyLongDeload = bool("anyLongDeload").nullable() +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt index b84926a0c..d946c83b2 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt @@ -24,6 +24,9 @@ internal object ReportViews { // A @spine report (rows from the spine entity) and a report of defaulted measures. "ProgramRoster" to ProgramRosterView, "ProgramLongWeeks" to ProgramLongWeeksView, + // Boolean `any` / `all` measures, with and without @spine. + "ProgramFlags" to ProgramFlagsView, + "ProgramFlagRoster" to ProgramFlagRosterView, "FitnessTotalsFilled" to FitnessTotalsFilledView, // Reports for the vocabulary the first nine do not reach: numeric, temporal, boolean/ // uuid/int-enum dimensions, multi-hop and self-reference paths, an empty scope. diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/WeekTable.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/WeekTable.kt index 7d0c55d1f..6284d3230 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/WeekTable.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/WeekTable.kt @@ -16,6 +16,8 @@ object WeekTable : Table("weeks") { // `field.int` → INTEGER (NOT NULL). Rolled up per Program by the v_program_stat // sum/avg/min/max aggregate projections (see ProgramStatView). val durationMinutes = integer("durationMinutes") + // `field.boolean` → BOOLEAN, nullable: read by the `any` / `all` measures of ProgramFlagsView. + val deload = bool("deload").nullable() override val primaryKey = PrimaryKey(id) } diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java index 3a70edfa3..5e65f976d 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportCorpus.java @@ -29,10 +29,10 @@ private ReportCorpus() {} /** The served reports, in declaration order. {@code ProductRevenue} declares * {@code @spine} and a measure with {@code @default}; {@code InvoiceCalendar} lists one * time dimension at two grains; {@code SalesByCategory} reaches a dimension through two - * references. */ + * references; {@code ProductRefunds} adds boolean {@code @agg any} measures. */ static final List SERVED = List.of("InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku"); + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds"); /** The report that declares no view and must generate nothing. */ static final String SOURCELESS = "InvoiceDays"; diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java index 206ff0477..140a1c809 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java @@ -26,7 +26,7 @@ * api-contract sub-corpus. Boots the corpus's served reports' GENERATED Spring controllers * on one embedded Tomcat, each behind an in-memory repository seam seeded with what its * view returns, and drives every scenario of the corpus. (Counts move with the fixture; - * the pinned numbers live in {@link #theCorpusCarriesItsTwentyTwoScenarios()} and + * the pinned numbers live in {@link #theCorpusCarriesItsTwentyFiveScenarios()} and * {@link ReportCorpus#SERVED}, not in prose.) * *

Generated lane ONLY, on purpose and on every port (see the sub-corpus README). What @@ -63,9 +63,9 @@ static Stream scenarios() { } @Test - void theCorpusCarriesItsTwentyTwoScenarios() { + void theCorpusCarriesItsTwentyFiveScenarios() { // A scenarios directory that resolved to nothing would leave this lane green and empty. - assertEquals(22, SCENARIOS.size()); + assertEquals(25, SCENARIOS.size()); } @ParameterizedTest(name = "{0}") diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java index 144d0aa52..10f6b754a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java @@ -606,6 +606,11 @@ private static void checkMeasure(MemberCtx ctx, MetaMeasure measure) { if (ReportingConstants.AGG_COUNT.equals(agg)) { err.accept("@default cannot apply to @agg: count. A count is never null (it is 0 when nothing " + "matches); remove @default."); + } else if ((ReportingConstants.AGG_ANY.equals(agg) || ReportingConstants.AGG_ALL.equals(agg)) + && declaredDefault instanceof Number n && n.doubleValue() != 0 && n.doubleValue() != 1) { + // M9 — a boolean measure's @default is 0 (false) or 1 (true). + err.accept("@default '" + declaredDefault + "' on @agg '" + agg + + "' must be 0 (false) or 1 (true): the measure is a boolean."); } else if ((ReportingConstants.AGG_MIN.equals(agg) || ReportingConstants.AGG_MAX.equals(agg)) && ofField != null && !NUMERIC_FIELD_SUBTYPES.contains(ofField.getSubType())) { @@ -692,6 +697,12 @@ private static AggregateColumns checkAggregateColumns(MemberCtx ctx, MetaMeasure err.accept("@agg '" + agg + "' cannot order '" + item + "', a field." + field.getSubType() + "."); return null; } + if ((ReportingConstants.AGG_ANY.equals(agg) || ReportingConstants.AGG_ALL.equals(agg)) + && !BooleanField.SUBTYPE_BOOLEAN.equals(field.getSubType())) { + err.accept("@agg '" + agg + "' needs a field.boolean, but '" + item + "' is field." + + field.getSubType() + "."); + return null; + } return new AggregateColumns(field); } diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java index 6c06543a2..8f448ad46 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java @@ -22,6 +22,7 @@ import com.metaobjects.attr.MetaAttribute; import com.metaobjects.attr.StringAttribute; import com.metaobjects.database.CoreDBMetaDataProvider; +import com.metaobjects.field.BooleanField; import com.metaobjects.field.CurrencyField; import com.metaobjects.field.DateField; import com.metaobjects.field.DecimalField; @@ -229,7 +230,7 @@ private static void carryTypeShape(MetaField src, MetaField field) { /** * A new, parentless field node of the derived subtype. A derived field that has a type * source always has that source's subtype (Table B), so it is built as the same node - * class; the rows with no type source produce one of four fixed subtypes. + * class; the rows with no type source produce one of five fixed subtypes. */ @SuppressWarnings({"unchecked", "rawtypes"}) private static MetaField newField(ReportShape.Field f) { @@ -242,6 +243,7 @@ private static MetaField newField(ReportShape.Field f) { case DecimalField.SUBTYPE_DECIMAL: return new DecimalField(f.name()); case DoubleField.SUBTYPE_DOUBLE: return new DoubleField(f.name()); case DateField.SUBTYPE_DATE: return new DateField(f.name()); + case BooleanField.SUBTYPE_BOOLEAN: return new BooleanField(f.name()); default: throw new MetaDataException("report read model: derived field '" + f.name() + "' has subtype field." + f.subType() + ", which Table B does not derive without a type source."); diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java index 598e0c349..d83d52298 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java @@ -18,6 +18,7 @@ import com.metaobjects.MetaData; import com.metaobjects.MetaDataException; import com.metaobjects.MetaRoot; +import com.metaobjects.field.BooleanField; import com.metaobjects.field.CurrencyField; import com.metaobjects.field.DateField; import com.metaobjects.field.DecimalField; @@ -484,6 +485,10 @@ private static Field measureField(String item, MetaObject from, MetaRoot root, M if (ReportingConstants.AGG_COUNT.equals(agg)) { return new Field(name, Role.MEASURE, LongField.SUBTYPE_LONG, required, null, null, null, m); } + // any / all are boolean whatever the source (the loader's M4 refuses a non-boolean @of). + if (ReportingConstants.AGG_ANY.equals(agg) || ReportingConstants.AGG_ALL.equals(agg)) { + return new Field(name, Role.MEASURE, BooleanField.SUBTYPE_BOOLEAN, required, null, null, null, m); + } List columns = m.getOfColumns(); MetaField of = columns.isEmpty() ? null : resolveFieldRef(columns.get(0), memberOwner(m, from), root, from); diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java index 708558238..b564e02ab 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java @@ -106,7 +106,11 @@ private ReportingConstants() { public static final String AGG_AVG = "avg"; public static final String AGG_MIN = "min"; public static final String AGG_MAX = "max"; - public static final List MEASURE_AGGS = List.of(AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX); + /** Boolean OR / AND over a {@code field.boolean} ({@code bool_or} / {@code bool_and}); the measure is boolean. */ + public static final String AGG_ANY = "any"; + public static final String AGG_ALL = "all"; + public static final List MEASURE_AGGS = List.of( + AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX, AGG_ANY, AGG_ALL); /** Separator in an {@code object.report} {@code @dimensions} item: {@code name} or {@code name:grain}. */ public static final String REPORT_DIMENSION_GRAIN_SEPARATOR = ":"; diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java index aafaa562b..5d87798f5 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java @@ -448,7 +448,7 @@ public void aggOutsideTheClosedSetIsABadAttrValue() throws IOException { assertEquals(List.of("ERR_BAD_ATTR_VALUE"), codes(got)); // Byte-identical to the TS attr-schema check (attr-schema-validate.ts, Check 3). assertEquals("measure.aggregate 'revenue' attribute '@agg' has value 'median' which is not one of " - + "the allowed values: count, sum, avg, min, max", got.get(0).getMessage()); + + "the allowed values: count, sum, avg, min, max, any, all", got.get(0).getMessage()); } @Test diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java index 16e83701e..d489c7155 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java @@ -348,7 +348,7 @@ public void isDetachedAndLeavesTheLoadedModelUntouched() { for (MetaObject o : canonical.getChildren(MetaObject.class, false)) { if (ReportReadModel.isReport(o)) models.add(ReportReadModel.of(o, canonical)); } - assertEquals(21, models.size()); + assertEquals(23, models.size()); for (ReportReadModel model : models) { assertNull("never attached", model.getParent()); diff --git a/server/python/src/metaobjects/loader/validate_reporting.py b/server/python/src/metaobjects/loader/validate_reporting.py index e6255910e..c37dfacbb 100644 --- a/server/python/src/metaobjects/loader/validate_reporting.py +++ b/server/python/src/metaobjects/loader/validate_reporting.py @@ -87,6 +87,8 @@ from ..meta.core.reporting.reporting_constants import ( AGG_AVG, AGG_COUNT, + AGG_ALL, + AGG_ANY, AGG_MAX, AGG_MIN, AGG_SUM, @@ -623,6 +625,14 @@ def err(message: str) -> None: "@default cannot apply to @agg: count. A count is never null (it is 0 when nothing matches); " "remove @default." ) + elif ( + agg in (AGG_ANY, AGG_ALL) + and isinstance(declared_default, (int, float)) + and not isinstance(declared_default, bool) + and declared_default not in (0, 1) + ): + # M9 — a boolean measure's @default is 0 (false) or 1 (true). + err(f"@default '{declared_default}' on @agg '{agg}' must be 0 (false) or 1 (true): the measure is a boolean.") elif agg in (AGG_MIN, AGG_MAX) and of_field is not None and of_field.sub_type not in _NUMERIC_FIELD_SUBTYPES: err( f"@default is a number, but '{measure.of_columns()[0]}', the @of of @agg '{agg}', is a " @@ -704,6 +714,9 @@ def _check_aggregate_columns( if agg in (AGG_MIN, AGG_MAX) and field.sub_type in _UNORDERED_FIELD_SUBTYPES: err(f"@agg '{agg}' cannot order '{item}', a field.{field.sub_type}.") return None + if agg in (AGG_ANY, AGG_ALL) and field.sub_type != FIELD_SUBTYPE_BOOLEAN: + err(f"@agg '{agg}' needs a field.boolean, but '{item}' is field.{field.sub_type}.") + return None return _CheckedColumns(field) diff --git a/server/python/src/metaobjects/meta/core/reporting/report_shape.py b/server/python/src/metaobjects/meta/core/reporting/report_shape.py index 3f1a1e8a0..a47b4a156 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_shape.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_shape.py @@ -19,6 +19,7 @@ from ...meta_root import MetaRoot from ..field.field_constants import ( FIELD_ATTR_REQUIRED, + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_DECIMAL, @@ -56,6 +57,8 @@ report_spine, ) from .reporting_constants import ( + AGG_ALL, + AGG_ANY, AGG_AVG, AGG_COUNT, AGG_SUM, @@ -379,6 +382,9 @@ def _measure_field(item: str, report: MetaObject, from_: MetaObject, root: MetaR agg = m.agg() if agg == AGG_COUNT: return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_LONG, required, measure=m) + # any / all are boolean whatever the source (the loader's M4 refuses a non-boolean @of). + if agg in (AGG_ANY, AGG_ALL): + return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_BOOLEAN, required, measure=m) cols = m.of_columns() of = resolve_reporting_field_ref(cols[0] if cols else "", reporting_member_owner(m, from_), root, from_) if of is None: diff --git a/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py b/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py index 89ce512e1..7504ba598 100644 --- a/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py +++ b/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py @@ -90,7 +90,10 @@ AGG_AVG = "avg" AGG_MIN = "min" AGG_MAX = "max" -MEASURE_AGGS: tuple[str, ...] = (AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX) +#: Boolean OR / AND over a ``field.boolean`` (Postgres ``bool_or`` / ``bool_and``); the measure is boolean. +AGG_ANY = "any" +AGG_ALL = "all" +MEASURE_AGGS: tuple[str, ...] = (AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX, AGG_ANY, AGG_ALL) #: Separator in an ``object.report`` ``@dimensions`` item: ``name`` or ``name:grain``. REPORT_DIMENSION_GRAIN_SEPARATOR = ":" diff --git a/server/python/src/metaobjects/spec_metamodel/reporting.json b/server/python/src/metaobjects/spec_metamodel/reporting.json index 019dda238..8f725ab9e 100644 --- a/server/python/src/metaobjects/spec_metamodel/reporting.json +++ b/server/python/src/metaobjects/spec_metamodel/reporting.json @@ -20,12 +20,12 @@ "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": "agg", "min": 1, "max": 1, "allowedValues": ["count", "sum", "avg", "min", "max", "any", "all"], "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields; any/all (boolean OR / AND, Postgres bool_or / bool_and) need a field.boolean and type the measure boolean." }, { "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": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." } + { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." } ] }, { "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.", diff --git a/server/python/tests/integration/generated_report_app.py b/server/python/tests/integration/generated_report_app.py index 956aba45e..2aa3ddb9e 100644 --- a/server/python/tests/integration/generated_report_app.py +++ b/server/python/tests/integration/generated_report_app.py @@ -49,6 +49,7 @@ "ProductRevenue": "product_revenue", "SalesByCategory": "sales_by_category", "SalesBySku": "sales_by_sku", + "ProductRefunds": "product_refunds", } #: The sourceless report: generated nowhere, mounted nowhere. UNSERVED_REPORT = "InvoiceDays" diff --git a/server/python/tests/integration/test_api_contract_report.py b/server/python/tests/integration/test_api_contract_report.py index 1d688432f..ffec72969 100644 --- a/server/python/tests/integration/test_api_contract_report.py +++ b/server/python/tests/integration/test_api_contract_report.py @@ -79,7 +79,7 @@ def test_exactly_the_served_reports_are_generated() -> None: """The sourceless report generates nothing; each served one generates its row model, allowlist and router (and the names module is not a route concern, so it is not asked for here: only the three generators the lane runs).""" - assert len(SERVED_REPORTS) == 8 + assert len(SERVED_REPORTS) == 9 for report, stem in SERVED_REPORTS.items(): assert f"{report}.py" in _EMITTED assert f"{stem}_filter_allowlist.py" in _EMITTED diff --git a/server/python/tests/test_report_shape.py b/server/python/tests/test_report_shape.py index 8497ae864..1b7015602 100644 --- a/server/python/tests/test_report_shape.py +++ b/server/python/tests/test_report_shape.py @@ -122,9 +122,9 @@ def test_derived_shapes_byte_match_the_committed_artifact() -> None: assert generate_report_shapes_json(_root()) == expected -def test_the_canonical_model_has_twenty_one_reports() -> None: +def test_the_canonical_model_has_twenty_three_reports() -> None: reports = [c for c in _root().children() if c.type == TYPE_OBJECT and c.sub_type == OBJECT_SUBTYPE_REPORT] - assert len(reports) == 21 + assert len(reports) == 23 assert all(isinstance(r, MetaObject) for r in reports) diff --git a/server/python/tests/unit/test_reporting_accessors.py b/server/python/tests/unit/test_reporting_accessors.py index c8b7b646a..13aa3915b 100644 --- a/server/python/tests/unit/test_reporting_accessors.py +++ b/server/python/tests/unit/test_reporting_accessors.py @@ -259,7 +259,7 @@ def test_report_derived_field_name() -> None: def test_closed_sets_keep_contract_order() -> None: assert TIME_GRAINS == ("hour", "day", "week", "month", "quarter", "year") - assert MEASURE_AGGS == ("count", "sum", "avg", "min", "max") + assert MEASURE_AGGS == ("count", "sum", "avg", "min", "max", "any", "all") # --------------------------------------------------------------------------- diff --git a/server/typescript/packages/codegen-ts/src/cube/cube-members.ts b/server/typescript/packages/codegen-ts/src/cube/cube-members.ts index 989c69a70..e10ee05e8 100644 --- a/server/typescript/packages/codegen-ts/src/cube/cube-members.ts +++ b/server/typescript/packages/codegen-ts/src/cube/cube-members.ts @@ -5,6 +5,8 @@ // in what order, is build-cube-model.ts's concern. import { + AGG_ALL, + AGG_ANY, AGG_COUNT, CHILD_REF_SEPARATOR, DOC_ATTR_DESCRIPTION, @@ -48,6 +50,7 @@ import type { CubeSegmentSpec, } from "./cube-model-spec.js"; import { CUBE_SELF, cubeColumn, cubeSqlRenderer, memberRef } from "./cube-sql.js"; +import type { ReportDialect } from "../projection/time-sql.js"; /** What every member mapping reads: the model, the dialect, the column naming strategy. */ export interface MemberContext { @@ -168,8 +171,10 @@ export interface MeasureMembers { readonly measure: CubeMeasureSpec; } -/** `COALESCE(, n)`: `n` is the measure's integer `@default`. */ -function withDefault(sql: string, n: number): string { +/** `COALESCE(, n)`: `n` is the measure's integer `@default`. A boolean measure's `@default` is + * 0 or 1, written as a boolean literal on Postgres (the view's own form). */ +function withDefault(sql: string, n: number, boolean: boolean, d: ReportDialect): string { + if (boolean && d === "postgres") return `COALESCE(${sql}, ${n === 1 ? "TRUE" : "FALSE"})`; return `COALESCE(${sql}, ${String(n)})`; } @@ -184,7 +189,7 @@ export function measureMembers(entity: MetaObject, m: MetaMeasure, where: string const num = memberRef(ratioOperand(entity, m, m.numerator(), where).name); const den = memberRef(ratioOperand(entity, m, m.denominator(), where).name); const quotient = mc.dialect === "postgres" ? `CAST(${num} AS NUMERIC) / NULLIF(${den}, 0)` : `${num} / NULLIF(${den}, 0)`; - return { measure: { name: m.name, sql: n === undefined ? quotient : withDefault(quotient, n), type: "number", ...docOf(m) } }; + return { measure: { name: m.name, sql: n === undefined ? quotient : withDefault(quotient, n, false, mc.dialect), type: "number", ...docOf(m) } }; } const aggregate = aggregateSpec(entity, m, where, mc); if (n === undefined) return { measure: { name: m.name, ...aggregate, ...docOf(m) } }; @@ -192,7 +197,12 @@ export function measureMembers(entity: MetaObject, m: MetaMeasure, where: string const raw = defaultedRawMemberName(m)!; return { raw: { name: raw, ...aggregate, public: false }, - measure: { name: m.name, sql: withDefault(memberRef(raw), n), type: "number", ...docOf(m) }, + measure: { + name: m.name, + sql: withDefault(memberRef(raw), n, aggregate.type === "boolean", mc.dialect), + type: aggregate.type === "boolean" ? "boolean" : "number", + ...docOf(m), + }, }; } @@ -225,6 +235,18 @@ function aggregateSpec( const filter = [...cols.map((x) => `${x} IS NOT NULL`), ...(c === undefined ? [] : [c])].join(" AND "); return { sql: `ROW(${cols.join(", ")})`, type: "count_distinct", filters: [{ sql: filter }] }; } + if (agg === AGG_ANY || agg === AGG_ALL) { + // A boolean OR / AND, written as the view writes it (report-ddl-emit.ts) and typed `boolean`. + // Cube's min/max cannot take a boolean column (Postgres has no MAX(boolean)), and the + // condition travels in the SQL: the aggregate is Cube's `boolean` type, which takes none. + const col = cols[0]!; + if (d === "postgres") { + const fn = agg === AGG_ANY ? "bool_or" : "bool_and"; + return { sql: c === undefined ? `${fn}(${col})` : `${fn}(${col}) FILTER (WHERE ${c})`, type: "boolean" }; + } + const fn = agg === AGG_ANY ? "MAX" : "MIN"; + return { sql: `${fn}(${c === undefined ? col : `CASE WHEN ${c} THEN ${col} END`})`, type: "boolean" }; + } const type: CubeMeasureType = agg === AGG_COUNT ? (distinct ? "count_distinct" : "count") : agg; return { sql: cols[0]!, type, ...(c === undefined ? {} : { filters: [{ sql: c }] }) }; } diff --git a/server/typescript/packages/codegen-ts/src/cube/cube-model-spec.ts b/server/typescript/packages/codegen-ts/src/cube/cube-model-spec.ts index 086bab488..d285dd598 100644 --- a/server/typescript/packages/codegen-ts/src/cube/cube-model-spec.ts +++ b/server/typescript/packages/codegen-ts/src/cube/cube-model-spec.ts @@ -18,7 +18,7 @@ export type CubeDialect = "postgres" | "mysql"; export type CubeDimensionType = "string" | "number" | "boolean" | "time"; -export type CubeMeasureType = "count" | "count_distinct" | "sum" | "avg" | "min" | "max" | "number"; +export type CubeMeasureType = "count" | "count_distinct" | "sum" | "avg" | "min" | "max" | "number" | "boolean"; export type CubeJoinRelationship = "many_to_one" | "one_to_one" | "one_to_many"; diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts index e2e968515..b23e18768 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts @@ -5,6 +5,7 @@ import { AGG_SUM, + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_DOUBLE, @@ -75,8 +76,13 @@ function aggregateOf( // Table D: `@default` is read through the measure itself, so a ratio operand that the report // does not list still carries its own. const value = measure.defaultValue(); + const derived = value === undefined ? undefined : measureDerivedSubType(measure, from, root); const defaultValue = - value === undefined ? undefined : { value, real: REAL_SUBTYPES.has(measureDerivedSubType(measure, from, root)) }; + value === undefined || derived === undefined + ? undefined + : derived === FIELD_SUBTYPE_BOOLEAN + ? { value, real: false, boolean: true as const } + : { value, real: REAL_SUBTYPES.has(derived) }; return { agg, distinct, diff --git a/server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts b/server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts index 8343f692f..478a3fb36 100644 --- a/server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts +++ b/server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts @@ -4,6 +4,7 @@ // quotes every identifier unconditionally, so a measure named `order` is valid DDL. import type { JoinNode } from "./view-spec.js"; import type { ReportAggregate, ReportColumn, ReportViewSpec } from "./report-spec.js"; +import { AGG_ALL, AGG_ANY } from "@metaobjectsdev/metadata"; import { cond, mysqlTupleCount, q, ref } from "./report-sql.js"; import { truncateToGrain, type ReportDialect } from "./time-sql.js"; @@ -30,16 +31,26 @@ function castType(cast: "bigint" | "double", d: ReportDialect): string | undefin /** Table D: `COALESCE(E, n)` for a measure with a `@default`; `E` unchanged without one. */ function withDefault(sql: string, dv: ReportAggregate["defaultValue"], d: ReportDialect): string { if (dv === undefined) return sql; + // A boolean measure's @default is 0 (false) or 1 (true): a boolean literal on Postgres only. + if (dv.boolean === true) return `COALESCE(${sql}, ${d === "postgres" ? (dv.value === 1 ? "TRUE" : "FALSE") : String(dv.value)})`; // SQLite: a REAL column keeps one storage class in every row, so its default is a REAL literal. return `COALESCE(${sql}, ${d === "sqlite" && dv.real ? `${dv.value}.0` : String(dv.value)})`; } +/** The SQL function of an `@agg`. A boolean any / all is `bool_or` / `bool_and` where the dialect has + * them (Postgres), else MAX / MIN: a boolean is 0 / 1 there, so the maximum is the OR. */ +function aggFunction(agg: ReportAggregate["agg"], d: ReportDialect): string { + if (agg === AGG_ANY) return d === "postgres" ? "bool_or" : "MAX"; + if (agg === AGG_ALL) return d === "postgres" ? "bool_and" : "MIN"; + return agg.toUpperCase(); +} + /** One aggregate (Table C): the bare aggregate, the condition by dialect, the cast, then the * Table D default around all of it. */ function aggregate(a: ReportAggregate, d: ReportDialect): string { const refs = a.refs.map((r) => ref(r, d)); const c = a.filter === undefined ? undefined : cond(a.filter, d); - const fn = a.agg.toUpperCase(); + const fn = aggFunction(a.agg, d); let sql: string; if (refs.length > 1) { // A distinct tuple count: a tuple with any NULL component is not counted, on every dialect. diff --git a/server/typescript/packages/codegen-ts/src/projection/report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/report-spec.ts index d2257040e..fe6bafa01 100644 --- a/server/typescript/packages/codegen-ts/src/projection/report-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/report-spec.ts @@ -28,8 +28,10 @@ export interface ReportAggregate { /** The Table C cast: integral sum → "bigint", floating sum → "double". */ readonly cast?: "bigint" | "double"; /** FR-044 Table D: the measure's `@default`, read when the aggregate is null. `real` when its - * derived subtype is decimal, double or float (SQLite then gets a REAL literal). */ - readonly defaultValue?: { readonly value: number; readonly real: boolean }; + * derived subtype is decimal, double or float (SQLite then gets a REAL literal). `boolean` when + * the measure is `@agg: any` / `all`: `value` is then 0 or 1 and is written as FALSE / TRUE on + * Postgres, 0 / 1 elsewhere. */ + readonly defaultValue?: { readonly value: number; readonly real: boolean; readonly boolean?: true }; } export type ReportColumn = diff --git a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts index c89c117dc..a8cb487fa 100644 --- a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts @@ -1454,6 +1454,16 @@ describe("Table H — the canonical model", () => { { joinPath: "Program.ProgramLongWeeksFacts", includes: [{ name: "weeks" }, { name: "totalMinutesOrZero" }] }, ], }, + { + name: "ProgramFlagRoster", + cubes: [ + { joinPath: "Program", includes: [{ name: "id", alias: "programKey" }] }, + { + joinPath: "Program.ProgramFlagRosterFacts", + includes: [{ name: "weeks" }, { name: "anyDeload" }, { name: "anyDeloadOrFalse" }, { name: "allDeloadOrTrue" }], + }, + ], + }, { // The third @spine report: its facts cube is reached through the Session -> Week reference. name: "ProgramSessions", @@ -1472,15 +1482,22 @@ describe("Table H — the canonical model", () => { const totalMinutesOrZero: CubeMeasureSpec = { name: "totalMinutesOrZero", sql: "COALESCE({totalMinutesOrZeroRaw}, 0)", type: "number" }; const longShare: CubeMeasureSpec = { name: "longShare", sql: "CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0)", type: "number" }; const longShareOrZero: CubeMeasureSpec = { name: "longShareOrZero", sql: "COALESCE(CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0), 0)", type: "number" }; + // @agg any / all: Cube's `boolean` measure, the aggregate as the view writes it; a @default is + // the Raw aggregate and a boolean COALESCE over it. + const anyDeload: CubeMeasureSpec = { name: "anyDeload", sql: 'bool_or({CUBE}."deload")', type: "boolean" }; + const anyDeloadOrFalseRaw: CubeMeasureSpec = { name: "anyDeloadOrFalseRaw", sql: 'bool_or({CUBE}."deload")', type: "boolean", public: false }; + const anyDeloadOrFalse: CubeMeasureSpec = { name: "anyDeloadOrFalse", sql: "COALESCE({anyDeloadOrFalseRaw}, FALSE)", type: "boolean" }; + const allDeloadOrTrueRaw: CubeMeasureSpec = { name: "allDeloadOrTrueRaw", sql: 'bool_and({CUBE}."deload")', type: "boolean", public: false }; + const allDeloadOrTrue: CubeMeasureSpec = { name: "allDeloadOrTrue", sql: "COALESCE({allDeloadOrTrueRaw}, TRUE)", type: "boolean" }; const key: CubeDimensionSpec = { name: "id", sql: '{CUBE}."id"', type: "number", primaryKey: true }; // The other canonical entities (Node, Session, Measurement, the Auth family, AllTypes) and the // join-path cubes carry no report of Table H's; only their presence is asserted here. expect(model.cubes.map((c) => c.name)).toEqual([ "Program", "Week", "Node", "Session", "Measurement", "Asset", "Auth", "BridgeAuth", "CopayAuth", "PriorAuthAuth", "ReferralAuth", "AllTypes", "Node_fkParent", "ProgramRosterFacts", "ProgramLongWeeksFacts", - "ProgramSessionsFacts", "ProgramSessions_fkWeek", + "ProgramFlagRosterFacts", "ProgramSessionsFacts", "ProgramSessions_fkWeek", ]); - const tableH = new Set(["Program", "Week", "Asset", "ProgramRosterFacts", "ProgramLongWeeksFacts"]); + const tableH = new Set(["Program", "Week", "Asset", "ProgramRosterFacts", "ProgramLongWeeksFacts", "ProgramFlagRosterFacts"]); expect(model.cubes.filter((c) => tableH.has(c.name))).toEqual([ { name: "Program", @@ -1489,6 +1506,7 @@ describe("Table H — the canonical model", () => { joins: [ { name: "ProgramRosterFacts", relationship: "one_to_many", sql: '{CUBE}."id" = {ProgramRosterFacts}."programId"' }, { name: "ProgramLongWeeksFacts", relationship: "one_to_many", sql: '{CUBE}."id" = {ProgramLongWeeksFacts}."programId"' }, + { name: "ProgramFlagRosterFacts", relationship: "one_to_many", sql: '{CUBE}."id" = {ProgramFlagRosterFacts}."programId"' }, { name: "ProgramSessions_fkWeek", relationship: "one_to_many", sql: '{CUBE}."id" = {ProgramSessions_fkWeek}."programId"' }, ], dimensions: [ @@ -1590,6 +1608,14 @@ describe("Table H — the canonical model", () => { name: "longWMinutes", sql: '{CUBE}."durationMinutes"', type: "sum", filters: [{ sql: `({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE 'W%')` }], }, + anyDeload, + { name: "allDeload", sql: 'bool_and({CUBE}."deload")', type: "boolean" }, + anyDeloadOrFalseRaw, + anyDeloadOrFalse, + allDeloadOrTrueRaw, + allDeloadOrTrue, + // A condition on a boolean aggregate travels in the SQL, as the view writes it. + { name: "anyLongDeload", sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)', type: "boolean" }, ], segments: [{ name: "long", sql: '{CUBE}."durationMinutes" >= 60' }], // Coarsest first: FitnessTotals and FitnessTotalsFilled group by nothing, so Cube's first @@ -1601,6 +1627,10 @@ describe("Table H — the canonical model", () => { name: "FitnessTotalsFilled", type: "rollup", measures: ["weeks", "totalMinutesOrZero", "longShareOrZero"], dimensions: [], segments: [], }, + { + name: "ProgramFlags", type: "rollup", measures: ["weeks", "anyDeload", "allDeload", "anyLongDeload"], + dimensions: ["program"], segments: [], + }, { name: "WeekShapes", type: "rollup", measures: ["weeks", "unlabelled", "labelledMinutes", "shortWeeks", "midWeeks", "longWMinutes"], @@ -1679,6 +1709,16 @@ describe("Table H — the canonical model", () => { segments: [], preAggregations: [], }, + { + name: "ProgramFlagRosterFacts", + sql: 'SELECT * FROM "weeks" w', + public: false, + joins: [], + dimensions: [key], + measures: [weeks, anyDeload, anyDeloadOrFalseRaw, anyDeloadOrFalse, allDeloadOrTrueRaw, allDeloadOrTrue], + segments: [], + preAggregations: [], + }, ]); }); }); diff --git a/server/typescript/packages/codegen-ts/test/cube/cube-model-canonical.test.ts b/server/typescript/packages/codegen-ts/test/cube/cube-model-canonical.test.ts index 6616a5df1..b254e5f7d 100644 --- a/server/typescript/packages/codegen-ts/test/cube/cube-model-canonical.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/cube-model-canonical.test.ts @@ -24,6 +24,7 @@ describe("the cube-model canonical golden", () => { "model/cubes/Node_fkParent.yml", "model/cubes/PriorAuthAuth.yml", "model/cubes/Program.yml", + "model/cubes/ProgramFlagRosterFacts.yml", "model/cubes/ProgramLongWeeksFacts.yml", "model/cubes/ProgramRosterFacts.yml", "model/cubes/ProgramSessionsFacts.yml", @@ -31,6 +32,7 @@ describe("the cube-model canonical golden", () => { "model/cubes/ReferralAuth.yml", "model/cubes/Session.yml", "model/cubes/Week.yml", + "model/views/ProgramFlagRoster.yml", "model/views/ProgramLongWeeks.yml", "model/views/ProgramRoster.yml", "model/views/ProgramSessions.yml", diff --git a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts index 797e37ba8..a05da9aef 100644 --- a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts @@ -90,6 +90,9 @@ cubes: - name: ProgramLongWeeksFacts relationship: one_to_many sql: '{CUBE}."id" = {ProgramLongWeeksFacts}."programId"' + - name: ProgramFlagRosterFacts + relationship: one_to_many + sql: '{CUBE}."id" = {ProgramFlagRosterFacts}."programId"' - name: ProgramSessions_fkWeek relationship: one_to_many sql: '{CUBE}."id" = {ProgramSessions_fkWeek}."programId"' @@ -276,6 +279,29 @@ cubes: type: sum filters: - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' + - name: anyDeload + sql: 'bool_or({CUBE}."deload")' + type: boolean + - name: allDeload + sql: 'bool_and({CUBE}."deload")' + type: boolean + - name: anyDeloadOrFalseRaw + sql: 'bool_or({CUBE}."deload")' + type: boolean + public: false + - name: anyDeloadOrFalse + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + type: boolean + - name: allDeloadOrTrueRaw + sql: 'bool_and({CUBE}."deload")' + type: boolean + public: false + - name: allDeloadOrTrue + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + type: boolean + - name: anyLongDeload + sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)' + type: boolean segments: - name: long sql: '{CUBE}."durationMinutes" >= 60' @@ -286,6 +312,10 @@ cubes: - name: FitnessTotalsFilled type: rollup measures: [CUBE.weeks, CUBE.totalMinutesOrZero, CUBE.longShareOrZero] + - name: ProgramFlags + type: rollup + measures: [CUBE.weeks, CUBE.anyDeload, CUBE.allDeload, CUBE.anyLongDeload] + dimensions: [CUBE.program] - name: WeekShapes type: rollup measures: [CUBE.weeks, CUBE.unlabelled, CUBE.labelledMinutes, CUBE.shortWeeks, CUBE.midWeeks, CUBE.longWMinutes] @@ -446,6 +476,55 @@ views: - name: totalMinutesOrZero `; +const PROGRAM_FLAG_ROSTER_FACTS_YML = `${HEADER} +cubes: + - name: ProgramFlagRosterFacts + sql: 'SELECT * FROM "weeks" w' + public: false + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + measures: + - name: weeks + sql: '{CUBE}."id"' + type: count + - name: anyDeload + sql: 'bool_or({CUBE}."deload")' + type: boolean + - name: anyDeloadOrFalseRaw + sql: 'bool_or({CUBE}."deload")' + type: boolean + public: false + - name: anyDeloadOrFalse + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + type: boolean + - name: allDeloadOrTrueRaw + sql: 'bool_and({CUBE}."deload")' + type: boolean + public: false + - name: allDeloadOrTrue + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + type: boolean +`; + +const PROGRAM_FLAG_ROSTER_VIEW_YML = `${HEADER} +views: + - name: ProgramFlagRoster + cubes: + - join_path: Program + includes: + - name: id + alias: programKey + - join_path: Program.ProgramFlagRosterFacts + includes: + - name: weeks + - name: anyDeload + - name: anyDeloadOrFalse + - name: allDeloadOrTrue +`; + const PROGRAM_SESSIONS_VIEW_YML = `${HEADER} views: - name: ProgramSessions @@ -498,11 +577,14 @@ describe("Table H — the canonical model's files", () => { const model = await canonicalModel(); expect(render(model, "ProgramRosterFacts")).toBe(PROGRAM_ROSTER_FACTS_YML); expect(render(model, "ProgramLongWeeksFacts")).toBe(PROGRAM_LONG_WEEKS_FACTS_YML); + expect(render(model, "ProgramFlagRosterFacts")).toBe(PROGRAM_FLAG_ROSTER_FACTS_YML); }); - test("the three @spine reports' views", async () => { + test("the four @spine reports' views", async () => { const model = await canonicalModel(); - expect(model.views.map(renderCubeViewYaml)).toEqual([PROGRAM_ROSTER_VIEW_YML, PROGRAM_LONG_WEEKS_VIEW_YML, PROGRAM_SESSIONS_VIEW_YML]); + expect(model.views.map(renderCubeViewYaml)).toEqual([ + PROGRAM_ROSTER_VIEW_YML, PROGRAM_LONG_WEEKS_VIEW_YML, PROGRAM_FLAG_ROSTER_VIEW_YML, PROGRAM_SESSIONS_VIEW_YML, + ]); }); test("rendering twice gives the same bytes", async () => { @@ -1069,7 +1151,7 @@ describe("parse oracle", () => { expect(model.cubes.map((c) => c.name)).toEqual([ "Program", "Week", "Node", "Session", "Measurement", "Asset", "Auth", "BridgeAuth", "CopayAuth", "PriorAuthAuth", "ReferralAuth", "AllTypes", "Node_fkParent", "ProgramRosterFacts", "ProgramLongWeeksFacts", - "ProgramSessionsFacts", "ProgramSessions_fkWeek", + "ProgramFlagRosterFacts", "ProgramSessionsFacts", "ProgramSessions_fkWeek", ]); for (const c of model.cubes) expect(parsed(c)).toEqual({ cubes: [expectedCube(c)] }); }); diff --git a/server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts b/server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts index 48c6f47ca..d8a037223 100644 --- a/server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts @@ -490,6 +490,53 @@ describe("emitReportViewDdl — rules", () => { const sumOf = (ref: string, extra: Partial = {}): ReportAggregate => ({ agg: "sum", distinct: false, refs: [ref], ...extra }); +// ── @agg any / all: a boolean OR / AND ────────────────────────────────────────────────── + +const boolAgg = (agg: "any" | "all", ref: string, extra: Partial = {}): ReportAggregate => + ({ agg, distinct: false, refs: [ref], ...extra }); + +describe("emitReportViewDdl — @agg any / all", () => { + const spec = baseSpec("w", "Week", [ + col("anyD", boolAgg("any", "w.deload")), + col("allD", boolAgg("all", "w.deload")), + col("anyLong", boolAgg("any", "w.deload", { filter: longWeek })), + ]); + + test("Postgres: bool_or / bool_and, a condition as FILTER (WHERE)", () => { + const sql = emitReportViewDdl(spec, pg("weeks")); + expect(sql).toContain(` bool_or(w."deload") AS "anyD"`); + expect(sql).toContain(` bool_and(w."deload") AS "allD"`); + expect(sql).toContain(` bool_or(w."deload") FILTER (WHERE w."durationMinutes" >= 60) AS "anyLong"`); + }); + + test("SQLite and MySQL: MAX / MIN over the 0 / 1 column, a condition as CASE", () => { + const lite = emitReportViewDdl(spec, sqlite("weeks")); + expect(lite).toContain(` MAX(w."deload") AS "anyD"`); + expect(lite).toContain(` MIN(w."deload") AS "allD"`); + expect(lite).toContain(` MAX(CASE WHEN w."durationMinutes" >= 60 THEN w."deload" END) AS "anyLong"`); + const my = emitReportViewDdl(spec, mysql("weeks")); + expect(my).toContain(" MAX(w.`deload`) AS `anyD`"); + expect(my).toContain(" MIN(w.`deload`) AS `allD`"); + expect(my).toContain(" MAX(CASE WHEN w.`durationMinutes` >= 60 THEN w.`deload` END) AS `anyLong`"); + }); + + test("a @default is a boolean literal on Postgres and 0 / 1 elsewhere", () => { + const withDefault = baseSpec("w", "Week", [ + col("f", boolAgg("any", "w.deload", { defaultValue: { value: 0, real: false, boolean: true } })), + col("t", boolAgg("all", "w.deload", { defaultValue: { value: 1, real: false, boolean: true } })), + ]); + const post = emitReportViewDdl(withDefault, pg("weeks")); + expect(post).toContain(` COALESCE(bool_or(w."deload"), FALSE) AS "f"`); + expect(post).toContain(` COALESCE(bool_and(w."deload"), TRUE) AS "t"`); + for (const options of [sqlite("weeks"), mysql("weeks")]) { + const out = emitReportViewDdl(withDefault, options); + expect(out).toContain("COALESCE(MAX("); + expect(out).toMatch(/COALESCE\(MAX\([^)]*\), 0\) AS .f./); + expect(out).toMatch(/COALESCE\(MIN\([^)]*\), 1\) AS .t./); + } + }); +}); + describe("emitReportViewDdl — Table D, a measure @default", () => { test("COALESCE wraps the cast, per dialect", () => { const spec = baseSpec("w", "Week", [ diff --git a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts index e2256725c..0d8b85233 100644 --- a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts @@ -747,16 +747,16 @@ describe("ADR-0034 — the cube-model reference is byte-identical to the built-i const root = await loadModelFile(CANONICAL_MODEL); const a = await outcome(builtinCubeModel, root, CANONICAL_CONFIG); const b = await outcome(refCubeModel, root, CANONICAL_CONFIG); - // A gate over an empty emit passes trivially. The three @spine reports are views, each with its + // A gate over an empty emit passes trivially. The four @spine reports are views, each with its // facts cube; ProgramSessions reaches its facts through the Session -> Week reference. expect("tree" in a ? a.tree.map(([p]) => p) : a).toEqual([ "model/cubes/AllTypes.yml", "model/cubes/Asset.yml", "model/cubes/Auth.yml", "model/cubes/BridgeAuth.yml", "model/cubes/CopayAuth.yml", "model/cubes/Measurement.yml", "model/cubes/Node.yml", "model/cubes/Node_fkParent.yml", - "model/cubes/PriorAuthAuth.yml", "model/cubes/Program.yml", "model/cubes/ProgramLongWeeksFacts.yml", - "model/cubes/ProgramRosterFacts.yml", "model/cubes/ProgramSessionsFacts.yml", + "model/cubes/PriorAuthAuth.yml", "model/cubes/Program.yml", "model/cubes/ProgramFlagRosterFacts.yml", + "model/cubes/ProgramLongWeeksFacts.yml", "model/cubes/ProgramRosterFacts.yml", "model/cubes/ProgramSessionsFacts.yml", "model/cubes/ProgramSessions_fkWeek.yml", "model/cubes/ReferralAuth.yml", "model/cubes/Session.yml", - "model/cubes/Week.yml", "model/views/ProgramLongWeeks.yml", "model/views/ProgramRoster.yml", - "model/views/ProgramSessions.yml", + "model/cubes/Week.yml", "model/views/ProgramFlagRoster.yml", "model/views/ProgramLongWeeks.yml", + "model/views/ProgramRoster.yml", "model/views/ProgramSessions.yml", ]); expect(b).toEqual(a); }); @@ -812,12 +812,13 @@ describe("ADR-0034 — the cube-model reference is byte-identical to the built-i const run = (make: () => Generator) => runTree(make, { entityFilter: ["Week"] }, { dialect: "postgres" }); const a = await run(builtinCubeModel); // Narrower than the full run (no Asset.yml), and not empty: Week reaches Program, Program the - // three facts cubes (ProgramSessionsFacts through the Session -> Week join-path cube), and with - // every cube they read written the three views are too. + // four facts cubes (ProgramSessionsFacts through the Session -> Week join-path cube), and with + // every cube they read written the four views are too. expect(a.map(([p]) => p)).toEqual([ - "model/cubes/Program.yml", "model/cubes/ProgramLongWeeksFacts.yml", "model/cubes/ProgramRosterFacts.yml", - "model/cubes/ProgramSessionsFacts.yml", "model/cubes/ProgramSessions_fkWeek.yml", "model/cubes/Week.yml", - "model/views/ProgramLongWeeks.yml", "model/views/ProgramRoster.yml", "model/views/ProgramSessions.yml", + "model/cubes/Program.yml", "model/cubes/ProgramFlagRosterFacts.yml", "model/cubes/ProgramLongWeeksFacts.yml", + "model/cubes/ProgramRosterFacts.yml", "model/cubes/ProgramSessionsFacts.yml", "model/cubes/ProgramSessions_fkWeek.yml", + "model/cubes/Week.yml", "model/views/ProgramFlagRoster.yml", "model/views/ProgramLongWeeks.yml", + "model/views/ProgramRoster.yml", "model/views/ProgramSessions.yml", ]); expect(await run(refCubeModel)).toEqual(a); }); diff --git a/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts b/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts index 4b8ba1bb5..175cabcfb 100644 --- a/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts +++ b/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts @@ -167,7 +167,10 @@ function isoInstant(s: string, where: string): string { export function normalize(kind: ColumnKind, raw: unknown, where: string): string | null { if (raw === null || raw === undefined) return null; - if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "boolean") throw new Error(`${where}: unexpected value ${JSON.stringify(raw)}`); + // A boolean measure (`@agg: any` / `all`): Cube sends a JSON boolean, the view's text is `true` / `false`. + if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "boolean") { + throw new Error(`${where}: unexpected value ${JSON.stringify(raw)}`); + } const s = String(raw); switch (kind) { case "time": diff --git a/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts b/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts index c3b61a831..54501cd70 100644 --- a/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts +++ b/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts @@ -60,6 +60,8 @@ const REPORTS: readonly ExpectedReport[] = [ { report: "AssetActivity", view: "v_asset_activity", preAggregation: "dev_pre_aggregations.asset__asset_activity", rows: 2 }, { report: "ProgramRoster", view: "v_program_roster", preAggregation: undefined, rows: 11 }, { report: "ProgramLongWeeks", view: "v_program_long_weeks", preAggregation: undefined, rows: 11 }, + { report: "ProgramFlags", view: "v_program_flags", preAggregation: "dev_pre_aggregations.week__program_flags", rows: 3 }, + { report: "ProgramFlagRoster", view: "v_program_flag_roster", preAggregation: undefined, rows: 11 }, { report: "FitnessTotalsFilled", view: "v_fitness_totals_filled", preAggregation: "dev_pre_aggregations.week__fitness_totals_filled", rows: 1 }, { report: "MeasurementStats", view: "v_measurement_stats", preAggregation: "dev_pre_aggregations.measurement__measurement_stats", rows: 1 }, { report: "MeasurementStatsFilled", view: "v_measurement_stats_filled", preAggregation: "dev_pre_aggregations.measurement__measurement_stats_filled", rows: 1 }, diff --git a/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts b/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts index cae078f1c..2e7c2d90a 100644 --- a/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts +++ b/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts @@ -115,6 +115,8 @@ const TABLE_I: readonly ExpectedReport[] = [ { report: "AssetActivity", view: "v_asset_activity", preAggregation: "dev_pre_aggregations.asset__asset_activity", rows: 2 }, { report: "ProgramRoster", view: "v_program_roster", preAggregation: undefined, rows: 11 }, { report: "ProgramLongWeeks", view: "v_program_long_weeks", preAggregation: undefined, rows: 11 }, + { report: "ProgramFlags", view: "v_program_flags", preAggregation: "dev_pre_aggregations.week__program_flags", rows: 3 }, + { report: "ProgramFlagRoster", view: "v_program_flag_roster", preAggregation: undefined, rows: 11 }, { report: "FitnessTotalsFilled", view: "v_fitness_totals_filled", preAggregation: "dev_pre_aggregations.week__fitness_totals_filled", rows: 1 }, { report: "MeasurementStats", view: "v_measurement_stats", preAggregation: "dev_pre_aggregations.measurement__measurement_stats", rows: 1 }, { report: "MeasurementStatsFilled", view: "v_measurement_stats_filled", preAggregation: "dev_pre_aggregations.measurement__measurement_stats_filled", rows: 1 }, @@ -456,6 +458,7 @@ const AGG_TYPE: Readonly> = { min: "min", max: "max", number: "number", + boolean: "boolean", }; function metaSummary(cubes: readonly MetaCube[]): Record { diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts index 05f7b442b..0526c61f2 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-report-generated-server.ts @@ -134,7 +134,8 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ CREATE TABLE IF NOT EXISTS "sales" ( "id" bigserial PRIMARY KEY, "product_id" bigint NOT NULL REFERENCES "products" ("id"), - "amount_cents" bigint NOT NULL + "amount_cents" bigint NOT NULL, + "refunded" boolean );`, }, ]; @@ -156,6 +157,7 @@ export const SERVED_REPORTS = [ { name: "ProductRevenue", registrar: "productRevenueRoutes" }, { name: "SalesByCategory", registrar: "salesByCategoryRoutes" }, { name: "SalesBySku", registrar: "salesBySkuRoutes" }, + { name: "ProductRefunds", registrar: "productRefundsRoutes" }, ] as const; export async function startGeneratedReportServer( diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts index 234650460..a560bd28b 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-schema.ts @@ -45,6 +45,7 @@ const TABLES = [ \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, \`productId\` BIGINT NOT NULL, \`amountCents\` BIGINT NOT NULL, + \`refunded\` BOOLEAN, FOREIGN KEY (\`productId\`) REFERENCES \`products\` (\`id\`) )`, ]; diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts index a4ef3d8da..5bc666003 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-report-mysql-server.ts @@ -60,6 +60,7 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, \`product_id\` BIGINT NOT NULL, \`amount_cents\` BIGINT NOT NULL, + \`refunded\` BOOLEAN, FOREIGN KEY (\`product_id\`) REFERENCES \`products\` (\`id\`) )`, }, diff --git a/server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts b/server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts index 9fdde4fbf..d3f3c4cd2 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-report-sqlite-server.ts @@ -76,7 +76,8 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ CREATE TABLE "sales" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "product_id" INTEGER NOT NULL REFERENCES "products" ("id"), - "amount_cents" INTEGER NOT NULL + "amount_cents" INTEGER NOT NULL, + "refunded" INTEGER )`, }, ]; diff --git a/server/typescript/packages/integration-tests/src/canonical-schema-mysql.ts b/server/typescript/packages/integration-tests/src/canonical-schema-mysql.ts index 600864e16..b2087c09b 100644 --- a/server/typescript/packages/integration-tests/src/canonical-schema-mysql.ts +++ b/server/typescript/packages/integration-tests/src/canonical-schema-mysql.ts @@ -44,6 +44,7 @@ const TABLES = [ \`programId\` BIGINT NOT NULL, \`label\` VARCHAR(80), \`durationMinutes\` INT NOT NULL, + \`deload\` BOOLEAN, FOREIGN KEY (\`programId\`) REFERENCES \`programs\` (\`id\`) )`, `CREATE TABLE \`assets\` ( diff --git a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts index 9558c9b6f..b750c234a 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts @@ -26,20 +26,20 @@ describe("api-contract report corpus", () => { const reports = root.objects().filter((o) => o.subType === OBJECT_SUBTYPE_REPORT); expect(reports.map((r) => r.name)).toEqual( ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", "InvoiceDays", - "ProductRevenue", "SalesByCategory", "SalesBySku"]); + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds"]); expect(reports.filter((r) => reportReadSource(r) !== undefined).map((r) => r.name)).toEqual( ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku"]); + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds"]); }); test("the route segments are the ones the scenarios call", async () => { const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); const segment = (name: string): string => pluralize(toSnakeCase(name)); expect(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "ProductRevenue", "SalesByCategory", "SalesBySku"].map(segment)).toEqual( + "ProductRevenue", "SalesByCategory", "SalesBySku", "ProductRefunds"].map(segment)).toEqual( ["invoice_status_totals", "invoices_by_months", "invoice_totals", "invoice_calendars", "invoice_disputes", - "product_revenues", "sales_by_categories", "sales_by_skus"]); - expect(root.objects().length).toBe(13); + "product_revenues", "sales_by_categories", "sales_by_skus", "product_refunds"]); + expect(root.objects().length).toBe(14); }); test("each seeded report row has exactly the report's derived fields", async () => { @@ -52,7 +52,7 @@ describe("api-contract report corpus", () => { } expect(Object.keys(seedReports())).toEqual( ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", - "SalesByCategory", "SalesBySku", "ProductRevenue"]); + "SalesByCategory", "SalesBySku", "ProductRevenue", "ProductRefunds"]); }); // A lane inserts every top-level key but `reports` in file order, so the order is the @@ -76,9 +76,9 @@ describe("api-contract report corpus", () => { } }); - test("every scenario parses, and there are twenty-two", () => { + test("every scenario parses, and there are twenty-five", () => { const scenarios = loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR); - expect(scenarios.length).toBe(22); + expect(scenarios.length).toBe(25); for (const s of scenarios) expect(s.requests.length).toBeGreaterThan(0); }); diff --git a/server/typescript/packages/integration-tests/test/api-contract-report-mysql.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report-mysql.test.ts index e4093590a..7b47cfc33 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-report-mysql.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-report-mysql.test.ts @@ -25,6 +25,7 @@ const SERVED_PATHS: Array<[string, string]> = [ ["InvoicesByMonth", "/api/invoices_by_months"], ["InvoiceTotals", "/api/invoice_totals"], ["ProductRevenue", "/api/product_revenues"], + ["ProductRefunds", "/api/product_refunds"], ]; let container: MysqlContainerHandle; diff --git a/server/typescript/packages/integration-tests/test/api-contract-report.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report.test.ts index abd09ee7f..f6e1d8526 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-report.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-report.test.ts @@ -31,6 +31,7 @@ const SERVED_PATHS: Array<[string, string]> = [ ["ProductRevenue", "/api/product_revenues"], ["SalesByCategory", "/api/sales_by_categories"], ["SalesBySku", "/api/sales_by_skus"], + ["ProductRefunds", "/api/product_refunds"], ]; describe("api contract report (FR-044) — GENERATED routes lane", () => { diff --git a/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts index 313d97aca..f74938a66 100644 --- a/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts +++ b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts @@ -46,6 +46,8 @@ describe("canonical report-shapes artifact (report-shapes.json)", () => { ["fitness::AssetActivity", "v_asset_activity"], ["fitness::ProgramRoster", "v_program_roster"], ["fitness::ProgramLongWeeks", "v_program_long_weeks"], + ["fitness::ProgramFlags", "v_program_flags"], + ["fitness::ProgramFlagRoster", "v_program_flag_roster"], ["fitness::FitnessTotalsFilled", "v_fitness_totals_filled"], ["fitness::MeasurementStats", "v_measurement_stats"], ["fitness::MeasurementStatsFilled", "v_measurement_stats_filled"], @@ -77,6 +79,14 @@ describe("canonical report-shapes artifact (report-shapes.json)", () => { ["longShare", "measure", "decimal", false], ["longShareOrZero", "measure", "decimal", true], ]); + // Table B / C for `@agg: any` / `all`: boolean whatever the source, and required only with a @default. + expect(fields("fitness::ProgramFlagRoster")).toEqual([ + ["programKey", "dimension", "long", true], + ["weeks", "measure", "long", true], + ["anyDeload", "measure", "boolean", false], + ["anyDeloadOrFalse", "measure", "boolean", true], + ["allDeloadOrTrue", "measure", "boolean", true], + ]); expect(fields("fitness::ProgramLongWeeks")).toEqual([ ["programKey", "dimension", "long", true], ["weeks", "measure", "long", true], diff --git a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts index d615248f0..3d5a1ffc0 100644 --- a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts @@ -46,7 +46,8 @@ let canonical: MetaRoot; const CANONICAL_VIEWS = [ "v_program_minutes", "v_fitness_totals", "v_programs_by_month", "v_programs_by_week", "v_recent_programs", "v_asset_activity", - "v_program_roster", "v_program_long_weeks", "v_fitness_totals_filled", + "v_program_roster", "v_program_long_weeks", "v_program_flags", "v_program_flag_roster", + "v_fitness_totals_filled", ]; /** The adopter's hand-written DDL (MySQL schema is not MetaObjects'). */ @@ -63,6 +64,7 @@ const DDL = [ programId BIGINT NOT NULL, label VARCHAR(80), durationMinutes INT NOT NULL, + deload BOOLEAN, FOREIGN KEY (programId) REFERENCES programs (id) )`, `CREATE TABLE assets ( diff --git a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts index 88dfcd8dc..1562deb6a 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -12,6 +12,7 @@ import { CHILD_REF_SEPARATOR, PACKAGE_SEPARATOR } from "../../shared/structural. import type { MetaObject } from "../object/meta-object.js"; import type { MetaField } from "../field/meta-field.js"; import { + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_DECIMAL, @@ -38,6 +39,7 @@ import { reportMeasureNames, reportSpine, } from "./report-accessors.js"; +import { AGG_ALL, AGG_ANY } from "../../persistence/origin/origin-constants.js"; import { AGG_AVG, AGG_COUNT, @@ -390,6 +392,8 @@ function deriveMeasure(m: MetaMeasure, from: MetaObject, root: MetaRoot): Measur if (m.isRatio()) return { subType: FIELD_SUBTYPE_DECIMAL }; const agg = m.agg(); if (agg === AGG_COUNT) return { subType: FIELD_SUBTYPE_LONG }; + // any / all are boolean whatever the source (the loader's M4 refuses a non-boolean @of). + if (agg === AGG_ANY || agg === AGG_ALL) return { subType: FIELD_SUBTYPE_BOOLEAN }; const of = resolveReportingFieldRef(m.ofColumns()[0] ?? "", reportingMemberOwner(m, from), root, from); if (of === undefined) return undefined; const src = of.subType; 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 2de709e44..1dd7d3839 100644 --- a/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts @@ -7,6 +7,7 @@ // every other base type and are re-exported here so a reporting consumer has // one import site. +import { AGG_ALL, AGG_ANY } from "../../persistence/origin/origin-constants.js"; import { TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT } from "../../shared/base-types.js"; export { TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT }; @@ -80,7 +81,9 @@ 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; +// `any` / `all`: boolean OR / AND over a `field.boolean` (Postgres `bool_or` / `bool_and`); the +// measure is boolean. The names, and their constants, are `origin.aggregate`'s own. +export const MEASURE_AGGS = [AGG_COUNT, AGG_SUM, AGG_AVG, AGG_MIN, AGG_MAX, AGG_ANY, AGG_ALL] as const; export type MeasureAgg = (typeof MEASURE_AGGS)[number]; /** Separator in an `object.report` `@dimensions` item: `name` or `name:grain`. */ 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 index 6de64b8a3..3d17f8052 100644 --- a/server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts @@ -91,9 +91,11 @@ export const REPORTING_DEFINITION: ProviderDefinition = { "sum", "avg", "min", - "max" + "max", + "any", + "all" ], - "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields." + "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields; any/all (boolean OR / AND, Postgres bool_or / bool_and) need a field.boolean and type the measure boolean." }, { "type": "attr", @@ -134,7 +136,7 @@ export const REPORTING_DEFINITION: ProviderDefinition = { "name": "default", "min": 0, "max": 1, - "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." + "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." } ] }, diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index 274999109..897afe8d1 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -77,6 +77,7 @@ import { } from "../core/query/query-constants.js"; import { ATTR_SUBTYPE_FILTER } from "../core/attr/attr-constants.js"; import { isWritableSource } from "../shared/node-guards.js"; +import { AGG_ALL, AGG_ANY } from "../persistence/origin/origin-constants.js"; import { FILTER_RELATIVE_NOW, ISO_DURATION_RE, @@ -556,6 +557,16 @@ function checkMeasure(ctx: MemberCtx, measure: MetaMeasure): void { `@default cannot apply to @agg: count. A count is never null (it is 0 when nothing matches); ` + `remove @default.`, ); + } else if ( + (agg === AGG_ANY || agg === AGG_ALL) && + typeof declaredDefault === "number" && + declaredDefault !== 0 && + declaredDefault !== 1 + ) { + // M9 — a boolean measure's @default is 0 (false) or 1 (true). + err( + `@default '${declaredDefault}' on @agg '${agg}' must be 0 (false) or 1 (true): the measure is a boolean.`, + ); } else if ( (agg === AGG_MIN || agg === AGG_MAX) && ofField !== undefined && @@ -648,6 +659,10 @@ function checkAggregateColumns( err(`@agg '${agg}' cannot order '${item}', a field.${field.subType}.`); return undefined; } + if ((agg === AGG_ANY || agg === AGG_ALL) && field.subType !== FIELD_SUBTYPE_BOOLEAN) { + err(`@agg '${agg}' needs a field.boolean, but '${item}' is field.${field.subType}.`); + return undefined; + } return { field }; } diff --git a/server/typescript/packages/metadata/test/reporting-registry.test.ts b/server/typescript/packages/metadata/test/reporting-registry.test.ts index 528bb3298..5deaeca98 100644 --- a/server/typescript/packages/metadata/test/reporting-registry.test.ts +++ b/server/typescript/packages/metadata/test/reporting-registry.test.ts @@ -44,6 +44,6 @@ describe("FR-044 reporting vocabulary registration", () => { 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"]); + expect([...MEASURE_AGGS]).toEqual(["count", "sum", "avg", "min", "max", "any", "all"]); }); }); diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index 306d1d728..8bc4b11c5 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -482,6 +482,31 @@ describe("validateReporting — measures", () => { await single(m, "ERR_INVALID_MEASURE"); }); + test("M4: any / all over a field that is not a boolean is refused, naming the field", async () => { + const m = edit((x) => patchMember(x, "Purchase", "revenue", { "@agg": "any" })); + const msg = await single(m, "ERR_INVALID_MEASURE"); + expect(msg).toBe( + "measure 'revenue' on entity 'acme::shop::Purchase': @agg 'any' needs a field.boolean, but " + + "'Purchase.amountCents' is field.currency.", + ); + const all = edit((x) => patchMember(x, "Purchase", "revenue", { "@agg": "all" })); + expect(await single(all, "ERR_INVALID_MEASURE")).toContain("@agg 'all' needs a field.boolean"); + }); + + test("any and all over a boolean field are fine, with or without @filter", async () => { + const m = edit((x) => { + setChild(x, "Purchase", "anyRefund", { + "measure.aggregate": { name: "anyRefund", "@agg": "any", "@of": "Purchase.refunded" }, + }); + setChild(x, "Purchase", "allRefunded", { + "measure.aggregate": { + name: "allRefunded", "@agg": "all", "@of": "Purchase.refunded", "@filter": { status: "active" }, + }, + }); + }); + expect(await codes(m)).toEqual([]); + }); + test("M4: avg over a currency field and min over a timestamp are fine", async () => { const m = edit((x) => { setChild(x, "Purchase", "avgRevenue", { @@ -1397,6 +1422,22 @@ describe("validateReporting — measure @default (M7, M8)", () => { expect(msg).toContain("@default cannot apply to @agg: count"); }); + test("M9: a boolean measure's @default is 0 or 1; 0 and 1 are fine", async () => { + const withDefault = (value: number): Model => + edit((x) => + setChild(x, "Purchase", "anyRefund", { + "measure.aggregate": { name: "anyRefund", "@agg": "any", "@of": "Purchase.refunded", "@default": value }, + }), + ); + expect(await codes(withDefault(0))).toEqual([]); + expect(await codes(withDefault(1))).toEqual([]); + expect(await single(withDefault(2), "ERR_INVALID_MEASURE")).toBe( + "measure 'anyRefund' on entity 'acme::shop::Purchase': @default '2' on @agg 'any' must be 0 (false) " + + "or 1 (true): the measure is a boolean.", + ); + expect(await single(withDefault(-1), "ERR_INVALID_MEASURE")).toContain("must be 0 (false) or 1 (true)"); + }); + test("M8: @default on a max of a timestamp is refused", async () => { const m = edit((x) => patchMember(x, "WorkoutEvent", "lastActivityAt", { "@default": 0 })); const msg = await single(m, "ERR_INVALID_MEASURE"); diff --git a/server/typescript/packages/runtime-ts/src/type-coercer.ts b/server/typescript/packages/runtime-ts/src/type-coercer.ts index aecd5ea74..03e15f6a3 100644 --- a/server/typescript/packages/runtime-ts/src/type-coercer.ts +++ b/server/typescript/packages/runtime-ts/src/type-coercer.ts @@ -212,8 +212,10 @@ function mapBooleansFromInt(entity: MetaData, row: Row): Row { if (child.type !== TYPE_FIELD) continue; if (child.subType !== FIELD_SUBTYPE_BOOLEAN) continue; const v = out[child.name]; - if (v === 0) out[child.name] = false; - else if (v === 1) out[child.name] = true; + // A report's boolean measure (`@agg: any` / `all`) under a condition is a MySQL BIGINT, which + // the pool's `bigNumberStrings` hands back as text; a plain TINYINT(1) arrives as a number. + if (v === 0 || v === "0") out[child.name] = false; + else if (v === 1 || v === "1") out[child.name] = true; } return out; } diff --git a/server/typescript/packages/runtime-ts/test/type-coercer.test.ts b/server/typescript/packages/runtime-ts/test/type-coercer.test.ts index ff1412dfa..b143cccfa 100644 --- a/server/typescript/packages/runtime-ts/test/type-coercer.test.ts +++ b/server/typescript/packages/runtime-ts/test/type-coercer.test.ts @@ -34,6 +34,16 @@ describe("coerceRowOnRead — sqlite", () => { }); }); +describe("coerceRowOnRead — mysql", () => { + test("a conditional boolean aggregate is a BIGINT, which the pool returns as text: '0' / '1' → boolean", () => { + const e = entityWithFields([{ name: "anyFailed", subType: FIELD_SUBTYPE_BOOLEAN }]); + expect(coerceRowOnRead(e, { anyFailed: "1" }, "mysql").anyFailed).toBe(true); + expect(coerceRowOnRead(e, { anyFailed: "0" }, "mysql").anyFailed).toBe(false); + expect(coerceRowOnRead(e, { anyFailed: 1 }, "mysql").anyFailed).toBe(true); + expect(coerceRowOnRead(e, { anyFailed: null }, "mysql").anyFailed).toBeNull(); + }); +}); + describe("coerceRowOnRead — postgres / memory", () => { test("native booleans pass through unchanged", () => { const e = entityWithFields([{ name: "active", subType: FIELD_SUBTYPE_BOOLEAN }]); diff --git a/site-reference/types/measure.html b/site-reference/types/measure.html index 72cb720e6..ef091ead9 100644 --- a/site-reference/types/measure.html +++ b/site-reference/types/measure.html @@ -71,9 +71,9 @@

measure.aggregate

string yes -count, sum, avg, min, max +count, sum, avg, min, max, any, all — -The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields. +The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields; any/all (boolean OR / AND, Postgres bool_or / bool_and) need a field.boolean and type the measure boolean. @default @@ -82,7 +82,7 @@

measure.aggregate

— -Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio. +Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio. @distinct diff --git a/spec/metamodel/reporting.json b/spec/metamodel/reporting.json index 019dda238..8f725ab9e 100644 --- a/spec/metamodel/reporting.json +++ b/spec/metamodel/reporting.json @@ -20,12 +20,12 @@ "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": "agg", "min": 1, "max": 1, "allowedValues": ["count", "sum", "avg", "min", "max", "any", "all"], "description": "The aggregate function. sum/avg need a numeric field; min/max refuse boolean, object and map fields; any/all (boolean OR / AND, Postgres bool_or / bool_and) need a field.boolean and type the measure boolean." }, { "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": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." } + { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." } ] }, { "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.", From e4493a9e4448bbd935378556cc832f24cbf39475 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 07:29:58 -0400 Subject: [PATCH 2/8] no-mistakes(review): refuse any/all ratio operands in M6; refresh stale counts Assisted-by: no-mistakes:claude:claude-opus-5-5 --- AGENTS.md | 2 +- CHANGELOG.md | 4 +- README.md | 2 +- .../references/reporting.md | 2 +- docs/CONFORMANCE.md | 6 +- docs/features/cube-export.md | 2 +- docs/features/reporting.md | 8 ++- ...2026-10-02-fr-044-core-reporting-design.md | 4 ++ .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../expected-errors.json | 15 +++++ .../input/meta.ops.json | 58 +++++++++++++++++++ .../providers.json | 1 + fixtures/persistence-conformance/README.md | 4 +- .../ReportingValidationTests.cs | 30 ++++++++++ .../Loader/ValidationPasses.Reporting.cs | 7 ++- .../kotlin/KotlinReportTableGeneratorTest.kt | 2 +- .../loader/ReportingValidation.java | 8 ++- .../loader/ReportingValidationTest.java | 13 +++++ .../metaobjects/loader/validate_reporting.py | 7 ++- ...test_reporting_validation_spine_default.py | 30 ++++++++++ .../src/loader/reporting-validation.ts | 7 ++- .../test/reporting-validation.test.ts | 19 ++++++ 26 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 fixtures/conformance/error-ratio-boolean-operand/expected-errors.json create mode 100644 fixtures/conformance/error-ratio-boolean-operand/input/meta.ops.json create mode 100644 fixtures/conformance/error-ratio-boolean-operand/providers.json diff --git a/AGENTS.md b/AGENTS.md index 60ab85e97..71c236663 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,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/` (377 fixtures; 29 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/` (378 fixtures; 29 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 FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). 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. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). 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/CHANGELOG.md b/CHANGELOG.md index f31c1d588..547699815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,7 +241,9 @@ generator catalog and the Atlas evaluation are 1.2 work. every port. Null values are ignored, so a group with no rows, no row in the measure's scope or only null values reads null; `@default: 0` reads `false` and `@default: 1` reads `true`. New rules: `any` / `all` over a field that is not a boolean is M4, and a boolean measure's - `@default` other than `0` or `1` is M9 (both `ERR_INVALID_MEASURE`). Postgres writes `bool_or` / + `@default` other than `0` or `1` is M9 (both `ERR_INVALID_MEASURE`). A `measure.ratio` divides + numbers, so an `any` / `all` measure as its `@numerator` or `@denominator` is refused by M6. + Postgres writes `bool_or` / `bool_and`; SQLite and MySQL write `MAX` / `MIN` over `0` / `1`. The `cube-model` generator writes a `type: boolean` measure. See [reporting](docs/features/reporting.md#boolean-measures-any-and-all). No `metamodelVersion` change: the vocabulary is part of 1.1. diff --git a/README.md b/README.md index 9674c6cb8..74918dc61 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ metaobjects/ ├── CLAUDE.md # project instructions for Claude ├── spec/ # canonical metamodel docs, ADRs, roadmap ├── fixtures/ # 25 cross-language conformance corpora — the oracle -│ ├── conformance/ # metamodel (loader + serializer + navigation), 377 fixtures +│ ├── conformance/ # metamodel (loader + serializer + navigation), 378 fixtures │ ├── yaml-conformance/ # YAML authoring desugar │ ├── render-conformance/ # FR-004 byte-identical render oracle │ ├── verify-conformance/ # FR-004 template-drift gate diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index 5bd6e9219..28bda1460 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 36192e2e3..6f22b4c09 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) | 377 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 378 | ✓ | ✓ | 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/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -250,7 +250,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (377) +### `fixtures/conformance/` — metamodel loader + canonical serializer (378) | Fixture prefix | Feature doc | |---|---| @@ -524,7 +524,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 377 + yaml 16 + verify 37 +The fixtures in the nine corpora mapped above (metamodel 378 + yaml 16 + verify 37 + render 16 + persistence 54 + api-contract 91 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc, and so do the two requirement corpora and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/docs/features/cube-export.md b/docs/features/cube-export.md index 3f4618dcf..baf1d69f9 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -569,7 +569,7 @@ and a stale one are drift. A hand edit to a generated file is not drift, but the `meta verify --codegen --forbid-hand-edits` makes it fail. **The mapping corpus** is [`fixtures/cube-model/`](../../fixtures/cube-model/): 46 cases, each -the smallest model for one rule, 34 with the exact tree the generator writes and 10 with the +the smallest model for one rule, 36 with the exact tree the generator writes and 10 with the exact error message. Every expected file was written by hand from its rule and then compared with the generator, never copied from it. `codegen-ts/test/cube/cube-model-corpus.test.ts` runs it. The canonical golden, diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 976cd970f..8d704e981 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -169,7 +169,8 @@ What each piece means: is a `boolean`: `hasError` is `any` of `failed`. See [Boolean measures](#boolean-measures-any-and-all). - **`measure.ratio`** is `numerator / NULLIF(denominator, 0)`, typed decimal. A zero denominator yields null, unless the ratio declares `@default`. Both operands are - `measure.aggregate` siblings (a ratio of ratios is not supported). + `measure.aggregate` siblings (a ratio of ratios is not supported), and neither is an + `@agg: any` or `all` measure: a ratio divides numbers. - **`segment.filter`** is a named, reusable `attr.filter`. Use it when the same row rule ("active purchase") would otherwise be repeated across measures and reports. An inline `@filter` on a measure stays legal. @@ -479,6 +480,9 @@ and the measure is a `boolean` in every port: `true` or `false` on the wire, nev null, in the database and in every port's row type, and a filter or sort sees the default as the value. Declare it only where that is true: `all` over no rows is vacuously true (`@default: 1`), `any` over no rows is false (`@default: 0`). +- **Not a ratio operand.** A `measure.ratio` divides numbers, so its `@numerator` and + `@denominator` cannot name an `any` or `all` measure (rule M6). For a share, count the + matching rows instead: `@agg: count` with a `@filter`. - **How it lowers.** Postgres writes `bool_or(x)` / `bool_and(x)`, with `FILTER (WHERE …)` for a condition. SQLite and MySQL have no boolean aggregate, and a boolean there is `0` or `1`, so they write `MAX(x)` for `any` and `MIN(x)` for `all` (`MAX(CASE WHEN … THEN x END)` @@ -807,7 +811,7 @@ row, and a `SUM` over the result would double-count. A to-one hop cannot fan out | **M3** | `@distinct: true` with any `@agg` other than `count`. | | **M4** | `sum` or `avg` over a non-numeric field (numeric is `int, long, double, float, decimal, currency`); `min` or `max` over a `boolean`, `object` or `map` field; `any` or `all` over a field that is not a `boolean`. | | **M5** | An `@segment` that names no `segment` child of the owning entity. | -| **M6** | A `measure.ratio` whose `@numerator` or `@denominator` names no measure of the entity, or names one that is not a `measure.aggregate`. | +| **M6** | A `measure.ratio` whose `@numerator` or `@denominator` names no measure of the entity, names one that is not a `measure.aggregate`, or names an `@agg: any` or `all` measure (a boolean). | | **M7** | A `@default` on an `@agg: count`. A count is never null, so the default could never apply. | | **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`). | | **M9** | A `@default` on an `@agg: any` or `all` other than `0` (false) or `1` (true). | diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index 0ddbe26d2..0a82943d1 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -494,6 +494,10 @@ spell it alike. No new attribute, no new subtype. then never null and R9's rules for filter, sort and the row type apply unchanged. The alternative (refuse `@default` on a boolean) leaves a `@spine` report no way to read `false` for a row with no facts, which is the case the adopter had. +- **Not a ratio operand.** A `measure.ratio` divides numbers, and Postgres has no cast from + boolean to numeric, so an `any` / `all` measure as `@numerator` or `@denominator` is refused + (rule M6, `ERR_INVALID_MEASURE`). The rule names these two aggregates only; it is not a general + numeric-operand rule, and a `min` / `max` operand is unchanged. - **Lowering.** Postgres `bool_or` / `bool_and` (a condition as `FILTER (WHERE …)`). SQLite and D1 have no boolean aggregate and store a boolean as `0` / `1`: `MAX` for `any`, `MIN` for `all` (a condition as `MAX(CASE WHEN … THEN x END)`). MySQL is the same as SQLite. The default is diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 5bd6e9219..28bda1460 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 5bd6e9219..28bda1460 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 5bd6e9219..28bda1460 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 5bd6e9219..28bda1460 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 5bd6e9219..28bda1460 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/conformance/error-ratio-boolean-operand/expected-errors.json b/fixtures/conformance/error-ratio-boolean-operand/expected-errors.json new file mode 100644 index 000000000..448cf8dcf --- /dev/null +++ b/fixtures/conformance/error-ratio-boolean-operand/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.ops.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[6]['measure.ratio']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-ratio-boolean-operand/input/meta.ops.json b/fixtures/conformance/error-ratio-boolean-operand/input/meta.ops.json new file mode 100644 index 000000000..54b5897c0 --- /dev/null +++ b/fixtures/conformance/error-ratio-boolean-operand/input/meta.ops.json @@ -0,0 +1,58 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.boolean": { + "name": "failed" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "calls", + "@agg": "count", + "@of": "Call.id" + } + }, + { + "measure.aggregate": { + "name": "hasError", + "@agg": "any", + "@of": "Call.failed" + } + }, + { + "measure.ratio": { + "name": "errorShare", + "@numerator": "hasError", + "@denominator": "calls" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-ratio-boolean-operand/providers.json b/fixtures/conformance/error-ratio-boolean-operand/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-ratio-boolean-operand/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/persistence-conformance/README.md b/fixtures/persistence-conformance/README.md index ba9d3f6f4..30eeb6581 100644 --- a/fixtures/persistence-conformance/README.md +++ b/fixtures/persistence-conformance/README.md @@ -318,13 +318,13 @@ Because M:N membership is a **set**, the runner compares `relate` results ### Report scenarios (FR-044) -Nineteen `queries/report-*.yaml` scenarios read a view-backed `object.report` through the +Twenty-one `queries/report-*.yaml` scenarios read a view-backed `object.report` through the port's runtime. They use only `op: list` and `op: count`, single-key `sort`, `filter` and `limit`: a report has no primary key, so there is no `op: get` and no write. The schema is still the committed `canonical/schema.postgres.sql`, which creates each report's view, so a port reads the view the TypeScript migrate engine produced and never lowers a report itself. -The eleven also run on SQLite and MySQL 8.4 (and TypeScript on D1's local runtime) in every +The twenty-one also run on SQLite and MySQL 8.4 (and TypeScript on D1's local runtime) in every port whose runtime has the engine: they execute `canonical/schema.sqlite.sql` or `canonical/schema.mysql.sql` instead, each TypeScript-produced and held equal to its generator by a test. The `expect` blocks stay the Postgres wire ones; a runner maps the engine's own diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs index de968741a..de9eb41fe 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs @@ -427,6 +427,36 @@ public void M7_default_on_a_distinct_count_of_a_tuple_is_refused() Assert.Contains("@default cannot apply to @agg: count", msg); } + private static JsonNode RatioOverABooleanMeasure(string agg, string attr) + { + var m = Positive(); + ChildrenOf(m, "Purchase").Add(Wrap("measure.aggregate", + $$"""{ "name": "refundFlag", "@agg": "{{agg}}", "@of": "Purchase.refunded" }""")); + var ratio = new JsonObject { ["name"] = "refundShare", ["@numerator"] = "revenue", ["@denominator"] = "purchases" }; + ratio[attr] = "refundFlag"; + ChildrenOf(m, "Purchase").Add(new JsonObject { ["measure.ratio"] = ratio }); + return m; + } + + [Fact] + public void M6_a_ratio_operand_naming_an_any_measure_is_refused_naming_the_operand() + { + var e = Assert.Single(Load(RatioOverABooleanMeasure("any", "@numerator")).Errors); + Assert.Equal(ErrorCode.ERR_INVALID_MEASURE, e.Code); + Assert.Equal( + "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'refundFlag' is an @agg 'any' measure, " + + "which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter).", + e.Message); + Assert.Contains("['measure.ratio']", JsonPathOf(e)); + } + + [Fact] + public void M6_a_ratio_operand_naming_an_all_measure_is_refused() + { + string msg = Single(Load(RatioOverABooleanMeasure("all", "@denominator")), ErrorCode.ERR_INVALID_MEASURE); + Assert.Contains("@denominator 'refundFlag' is an @agg 'all' measure", msg); + } + [Fact] public void M8_default_on_a_max_of_a_timestamp_is_refused() { diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs index a836c760c..21ba38d94 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -608,7 +608,7 @@ private static bool CheckAggregateColumns(MemberCtx ctx, MetaMeasure measure, Ac return true; } - /// M6 — each operand names a measure.aggregate of the same entity. + /// M6 — each operand names a measure.aggregate of the same entity, and not an any / all one. private static void CheckRatioOperands(MemberCtx ctx, MetaMeasure ratio, Action err) { (string Attr, string? Ref)[] operands = @@ -629,6 +629,11 @@ private static void CheckRatioOperands(MemberCtx ctx, MetaMeasure ratio, Action< err($"@{attr} '{@ref}' is a measure.{target.SubType}; a ratio's operands must be measure.aggregate " + "(a ratio of ratios is not supported)."); } + else if (target is MetaMeasure operand && (operand.Agg() == AGG_ANY || operand.Agg() == AGG_ALL)) + { + err($"@{attr} '{@ref}' is an @agg '{operand.Agg()}' measure, which is a boolean; a ratio divides numbers. " + + "Count the matching rows instead (@agg: count with a @filter)."); + } } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt index 37a0d1762..8353d81a0 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt @@ -24,7 +24,7 @@ import kotlin.test.assertTrue * `object.report`, with one column per derived field (contract Table B), and nothing for a * report that has no view. * - * The eleven canonical reports are generated from the shared persistence corpus, the same + * The twenty-three canonical reports are generated from the shared persistence corpus, the same * model the hand-written reference tables in `integration-tests-kotlin` map and the * persistence lane reads. * diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java index 10f6b754a..aa7b25184 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java @@ -706,7 +706,7 @@ private static AggregateColumns checkAggregateColumns(MemberCtx ctx, MetaMeasure return new AggregateColumns(field); } - /** M6 — each operand names a measure.aggregate of the same entity. */ + /** M6 — each operand names a measure.aggregate of the same entity, and not an any / all one. */ private static void checkRatioOperands(MemberCtx ctx, MetaMeasure ratio, Consumer err) { String[][] operands = { {ReportingConstants.ATTR_NUMERATOR, ratio.getNumerator()}, @@ -722,6 +722,12 @@ private static void checkRatioOperands(MemberCtx ctx, MetaMeasure ratio, Consume } else if (!ReportingConstants.MEASURE_SUBTYPE_AGGREGATE.equals(target.getSubType())) { err.accept("@" + attr + " '" + ref + "' is a measure." + target.getSubType() + "; a ratio's operands must be measure.aggregate (a ratio of ratios is not supported)."); + } else if (target instanceof MetaMeasure + && (ReportingConstants.AGG_ANY.equals(((MetaMeasure) target).getAgg()) + || ReportingConstants.AGG_ALL.equals(((MetaMeasure) target).getAgg()))) { + err.accept("@" + attr + " '" + ref + "' is an @agg '" + ((MetaMeasure) target).getAgg() + + "' measure, which is a boolean; a ratio divides numbers. " + + "Count the matching rows instead (@agg: count with a @filter)."); } } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java index 5d87798f5..371ca36ce 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java @@ -162,6 +162,8 @@ public void everyErrorFixtureYieldsTheReferenceCodeAndMessage() throws IOExcepti "measure 'daysEngaged' on entity 'acme::shop::WorkoutEvent': @of lists 4 columns; a tuple is legal only with @agg: count and @distinct: true (a distinct count of the tuple)."}); expect.put("error-ratio-operand-not-aggregate", new String[]{"ERR_INVALID_MEASURE", "measure 'ratioOfRatio' on entity 'acme::shop::WorkoutEvent': @numerator 'avgDaysPerStarter' is a measure.ratio; a ratio's operands must be measure.aggregate (a ratio of ratios is not supported)."}); + expect.put("error-ratio-boolean-operand", new String[]{"ERR_INVALID_MEASURE", + "measure 'errorShare' on entity 'acme::ops::Call': @numerator 'hasError' is an @agg 'any' measure, which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter)."}); expect.put("error-relative-date-bad-duration", new String[]{"ERR_BAD_ATTR_FILTER", "report 'acme::shop::DailyRevenue': @filter on 'purchasedAt' has relative date '90 days', which is not an ISO-8601 duration (e.g. '-P7D', '-PT12H')."}); expect.put("error-relative-date-non-temporal", new String[]{"ERR_BAD_ATTR_FILTER", @@ -838,6 +840,17 @@ public void defaultOnASumAnAvgAMinOfAnIntAndARatioIsFine() throws IOException { assertEquals(List.of(), loadErrors(doc)); } + @Test + public void aRatioDenominatorNamingAnAllMeasureIsRefused() throws IOException { + JsonObject doc = cleanModel(); + childrenOf(doc, "Purchase").add(wrap("measure.aggregate", + "{\"name\":\"refundFlag\",\"@agg\":\"all\",\"@of\":\"Purchase.refunded\"}")); + childrenOf(doc, "Purchase").add(wrap("measure.ratio", + "{\"name\":\"refundShare\",\"@numerator\":\"revenue\",\"@denominator\":\"refundFlag\"}")); + String msg = single(doc, "ERR_INVALID_MEASURE"); + assertTrue(msg, msg.contains("@denominator 'refundFlag' is an @agg 'all' measure")); + } + @Test public void aMeasureThatBreaksM4AndDeclaresDefaultReportsM4Only() throws IOException { JsonObject doc = cleanModel(); diff --git a/server/python/src/metaobjects/loader/validate_reporting.py b/server/python/src/metaobjects/loader/validate_reporting.py index c37dfacbb..f4947cc27 100644 --- a/server/python/src/metaobjects/loader/validate_reporting.py +++ b/server/python/src/metaobjects/loader/validate_reporting.py @@ -721,7 +721,7 @@ def _check_aggregate_columns( def _check_ratio_operands(ctx: _MemberCtx, ratio: MetaMeasure, err: Callable[[str], None]) -> None: - """M6 — each operand names a measure.aggregate of the same entity.""" + """M6 — each operand names a measure.aggregate of the same entity, and not an any / all one.""" operands: list[tuple[str, str | None]] = [ (REPORTING_ATTR_NUMERATOR, ratio.numerator()), (REPORTING_ATTR_DENOMINATOR, ratio.denominator()), @@ -737,6 +737,11 @@ def _check_ratio_operands(ctx: _MemberCtx, ratio: MetaMeasure, err: Callable[[st f"@{attr_name} '{ref}' is a measure.{target.sub_type}; a ratio's operands must be measure.aggregate " f"(a ratio of ratios is not supported)." ) + elif isinstance(target, MetaMeasure) and target.agg() in (AGG_ANY, AGG_ALL): + err( + f"@{attr_name} '{ref}' is an @agg '{target.agg()}' measure, which is a boolean; a ratio divides numbers. " + f"Count the matching rows instead (@agg: count with a @filter)." + ) # --------------------------------------------------------------------------- diff --git a/server/python/tests/unit/test_reporting_validation_spine_default.py b/server/python/tests/unit/test_reporting_validation_spine_default.py index 262037ed1..46e671cc4 100644 --- a/server/python/tests/unit/test_reporting_validation_spine_default.py +++ b/server/python/tests/unit/test_reporting_validation_spine_default.py @@ -723,6 +723,36 @@ def test_m7_default_on_a_distinct_count_of_a_tuple_is_refused() -> None: assert "@default cannot apply to @agg: count" in msg +def _ratio_over_a_boolean_measure(agg: str, attr: str) -> Model: + def fn(x: Model) -> None: + _set_child( + x, + "Purchase", + "refundFlag", + {"measure.aggregate": {"name": "refundFlag", "@agg": agg, "@of": "Purchase.refunded"}}, + ) + ratio = {"name": "refundShare", "@numerator": "revenue", "@denominator": "purchases"} + ratio[attr] = "refundFlag" + _set_child(x, "Purchase", "refundShare", {"measure.ratio": ratio}) + + return _edit(fn) + + +def test_m6_a_ratio_operand_naming_an_any_measure_is_refused_naming_the_operand() -> None: + errors = _errors(_ratio_over_a_boolean_measure("any", "@numerator")) + assert [e.code.value for e in errors] == ["ERR_INVALID_MEASURE"] + assert errors[0].message == ( + "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'refundFlag' is an @agg 'any' measure, " + "which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter)." + ) + assert "['measure.ratio']" in _json_path(errors[0]) + + +def test_m6_a_ratio_operand_naming_an_all_measure_is_refused() -> None: + msg = _single(_ratio_over_a_boolean_measure("all", "@denominator"), "ERR_INVALID_MEASURE") + assert "@denominator 'refundFlag' is an @agg 'all' measure" in msg + + def test_m8_default_on_a_max_of_a_timestamp_is_refused() -> None: msg = _single( _edit(lambda x: _patch_member(x, "WorkoutEvent", "lastActivityAt", {"@default": 0})), "ERR_INVALID_MEASURE" diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index 897afe8d1..897b40675 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -666,7 +666,7 @@ function checkAggregateColumns( return { field }; } -/** M6 — each operand names a measure.aggregate of the same entity. */ +/** M6 — each operand names a measure.aggregate of the same entity, and not an any / all one. */ function checkRatioOperands(ctx: MemberCtx, ratio: MetaMeasure, err: (message: string) => void): void { const operands: [string, string | undefined][] = [ [REPORTING_ATTR_NUMERATOR, ratio.numerator()], @@ -682,6 +682,11 @@ function checkRatioOperands(ctx: MemberCtx, ratio: MetaMeasure, err: (message: s `@${attr} '${ref}' is a measure.${target.subType}; a ratio's operands must be measure.aggregate ` + `(a ratio of ratios is not supported).`, ); + } else if (target instanceof MetaMeasure && (target.agg() === AGG_ANY || target.agg() === AGG_ALL)) { + err( + `@${attr} '${ref}' is an @agg '${target.agg()}' measure, which is a boolean; a ratio divides numbers. ` + + `Count the matching rows instead (@agg: count with a @filter).`, + ); } } } diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index 8bc4b11c5..cb7619c36 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -535,6 +535,25 @@ describe("validateReporting — measures", () => { expect(msg).toContain("'avgDaysPerStarter'"); }); + test("M6: a ratio operand naming an any / all measure is refused, naming the operand", async () => { + const withOperand = (agg: string, attr: string) => + edit((x) => { + setChild(x, "Purchase", "refundFlag", { + "measure.aggregate": { name: "refundFlag", "@agg": agg, "@of": "Purchase.refunded" }, + }); + setChild(x, "Purchase", "refundShare", { + "measure.ratio": { name: "refundShare", "@numerator": "revenue", "@denominator": "purchases", [attr]: "refundFlag" }, + }); + }); + expect(await single(withOperand("any", "@numerator"), "ERR_INVALID_MEASURE")).toBe( + "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'refundFlag' is an @agg 'any' measure, " + + "which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter).", + ); + expect(await single(withOperand("all", "@denominator"), "ERR_INVALID_MEASURE")).toContain( + "@denominator 'refundFlag' is an @agg 'all' measure", + ); + }); + 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"); From 6fe71dc88065f5a65349444a7058226c956421d0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 08:02:12 -0400 Subject: [PATCH 3/8] no-mistakes(review): require numeric ratio operands in M6; refresh stale counts Assisted-by: no-mistakes:claude:claude-opus-5-5 --- .claude/rules/cross-language-porting.md | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 20 +++- README.md | 2 +- .../references/reporting.md | 2 +- docs/CONFORMANCE.md | 6 +- docs/features/api-contract.md | 2 +- docs/features/reporting.md | 19 ++-- ...2026-10-02-fr-044-core-reporting-design.md | 13 ++- examples/showcase/site-payload.json | 2 +- .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../references/reporting.md | 2 +- .../api-contract-conformance/report/README.md | 22 ++-- .../expected-errors.json | 15 +++ .../input/meta.ops.json | 58 ++++++++++ .../providers.json | 1 + .../expected.json | 103 ++++++++++++++++++ .../input/meta.ops.json | 95 ++++++++++++++++ .../providers.json | 1 + fixtures/metamodel-docs/expected/INDEX.md | 2 +- .../metamodel-docs/expected/types/measure.md | 6 +- .../expected-registry.json | 6 +- .../ReportingValidationTests.cs | 48 +++++--- .../MetaObjects/Core/Reporting/ReportShape.cs | 50 +++++---- .../Loader/ValidationPasses.Reporting.cs | 10 +- .../MetaObjects/SpecMetamodel/reporting.json | 6 +- .../KotlinCodegenMatchesReferenceTest.kt | 4 +- .../GeneratedReportControllerHarness.kt | 4 +- .../integration/kotlin/tables/ReportViews.kt | 2 +- .../loader/ReportingValidation.java | 18 +-- .../metaobjects/reporting/ReportShape.java | 48 +++++--- .../loader/ReportingValidationTest.java | 63 +++++++++-- .../metaobjects/loader/validate_reporting.py | 17 ++- .../meta/core/reporting/report_shape.py | 39 +++++-- .../metaobjects/spec_metamodel/reporting.json | 6 +- ...test_reporting_validation_spine_default.py | 58 ++++++---- .../test/report-views-mysql.test.ts | 4 +- .../src/core/reporting/report-shape.ts | 18 ++- .../reporting-definition.embedded.ts | 6 +- .../src/loader/reporting-validation.ts | 18 +-- .../test/reporting-validation.test.ts | 65 ++++++++--- site-reference/index.html | 2 +- site-reference/types/measure.html | 6 +- spec/metamodel/reporting.json | 6 +- 47 files changed, 687 insertions(+), 200 deletions(-) create mode 100644 fixtures/conformance/error-ratio-non-numeric-operand/expected-errors.json create mode 100644 fixtures/conformance/error-ratio-non-numeric-operand/input/meta.ops.json create mode 100644 fixtures/conformance/error-ratio-non-numeric-operand/providers.json create mode 100644 fixtures/conformance/reporting-ratio-numeric-min-max/expected.json create mode 100644 fixtures/conformance/reporting-ratio-numeric-min-max/input/meta.ops.json create mode 100644 fixtures/conformance/reporting-ratio-numeric-min-max/providers.json diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md index dd3cb0ffc..ae20d2f7d 100644 --- a/.claude/rules/cross-language-porting.md +++ b/.claude/rules/cross-language-porting.md @@ -17,7 +17,7 @@ Preserve the following contracts exactly across all language ports: **Metamodel subtype vocabularies (must be identical across languages):** the `registry-conformance` gate (`fixtures/registry-conformance/`) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to `expected-registry.json`. **All five ports (TS / C# / Java / Kotlin / Python) are live + green** (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See `fixtures/registry-conformance/README.md`. - Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull` -- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`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). +- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, 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 43 `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). - Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`. - Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210). - Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N / **junction-unpairable** → `ERR_INVALID_RELATIONSHIP`. **Unpairable means the junction declares its two references but neither resolves to the navigating entity, or neither to the `@objectRef` target** — declaring two references is NOT enough, and the loader now checks WHAT they point at (owner ruling 2026-09-20). It does so by running the real FK derivation and converting its failure, never a parallel re-implementation, so the loader and the derivation cannot drift; scope mirrors codegen's own iteration exactly — every CONCRETE, non-projection object crossed with its EFFECTIVE relationships, NOT deduped by declaration, because pairing is a property of the navigating entity and an inherited M:N can pair from one subtype and not another. Before the ruling this loaded clean and then diverged: TS and C# warned and emitted no traversal route (a silent 404), while Java, Kotlin and Python failed the build. Gated by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`. diff --git a/AGENTS.md b/AGENTS.md index 71c236663..c4461cc74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,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/` (378 fixtures; 29 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/` (380 fixtures; 29 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 FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). 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. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). 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/CHANGELOG.md b/CHANGELOG.md index 547699815..b6f958b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,10 +241,9 @@ generator catalog and the Atlas evaluation are 1.2 work. every port. Null values are ignored, so a group with no rows, no row in the measure's scope or only null values reads null; `@default: 0` reads `false` and `@default: 1` reads `true`. New rules: `any` / `all` over a field that is not a boolean is M4, and a boolean measure's - `@default` other than `0` or `1` is M9 (both `ERR_INVALID_MEASURE`). A `measure.ratio` divides - numbers, so an `any` / `all` measure as its `@numerator` or `@denominator` is refused by M6. - Postgres writes `bool_or` / - `bool_and`; SQLite and MySQL write `MAX` / `MIN` over `0` / `1`. The `cube-model` generator + `@default` other than `0` or `1` is M9 (both `ERR_INVALID_MEASURE`). An `any` / `all` measure + is not a legal `measure.ratio` operand (rule M6, tightened under **Changed**). Postgres writes + `bool_or` / `bool_and`; SQLite and MySQL write `MAX` / `MIN` over `0` / `1`. The `cube-model` generator writes a `type: boolean` measure. See [reporting](docs/features/reporting.md#boolean-measures-any-and-all). No `metamodelVersion` change: the vocabulary is part of 1.1. - **TypeScript: the `cube-model` reference generator (FR-044 Plan 4).** `cubeModel()` writes the @@ -715,6 +714,19 @@ until you regenerate. skill and the always-on context no longer send an agent to eject a generator it is not going to change. The audit skill reports an owned copy it can prove unmodified as a finding, and reads an import of the four as a blocker only on a 1.0.x install. +- **Validation tightening: a `measure.ratio` operand must be a numeric measure (rule M6), in all + five ports.** `@numerator` and `@denominator` must each name a `measure.aggregate` whose result + type is numeric (`int`, `long`, `double`, `float`, `decimal` or `currency`): a `count`, a + `sum`, an `avg`, or a `min` or `max` of a numeric field. M6 used to check only that the operand + was a `measure.aggregate`. It now also refuses an `@agg: any` or `all` operand (a boolean) and + a `min` or `max` over a field that is not numeric (a date, timestamp, string, boolean, uuid or + enum), with `ERR_INVALID_MEASURE` on the ratio. A model that already declared such a ratio now + fails at load. Before, it loaded and then failed at view creation on Postgres (there is no + cast from a boolean or a timestamp to `NUMERIC`) or returned a number with no meaning on SQLite + and MySQL. To fix a refused model, divide numeric measures: for a share, count the matching + rows with `@agg: count` and a `@filter`. Gated by three new conformance fixtures + (`error-ratio-boolean-operand`, `error-ratio-non-numeric-operand` and + `reporting-ratio-numeric-min-max`). See [reporting](docs/features/reporting.md#measures-err_invalid_measure). - **TypeScript: `RequirementTestArgs` and `RequirementView` gained required fields — a compile break for code that builds them by hand.** `RequirementTestArgs` has six new required fields (`package`, `unit`, `id`, `witnessKey`, `skip`, `digest`) and diff --git a/README.md b/README.md index 74918dc61..327dde28b 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ metaobjects/ ├── CLAUDE.md # project instructions for Claude ├── spec/ # canonical metamodel docs, ADRs, roadmap ├── fixtures/ # 25 cross-language conformance corpora — the oracle -│ ├── conformance/ # metamodel (loader + serializer + navigation), 378 fixtures +│ ├── conformance/ # metamodel (loader + serializer + navigation), 380 fixtures │ ├── yaml-conformance/ # YAML authoring desugar │ ├── render-conformance/ # FR-004 byte-identical render oracle │ ├── verify-conformance/ # FR-004 template-drift gate diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index 28bda1460..8deee8d92 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Each operand must be a numeric measure: a `count`, a `sum`, an `avg`, or a `min` or `max` of a numeric field. An `any` or `all` (a boolean), or a `min` or `max` of a date, timestamp, string, uuid or enum, is refused as `@numerator` or `@denominator`. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 6f22b4c09..481b357a3 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) | 378 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 380 | ✓ | ✓ | 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/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -250,7 +250,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (378) +### `fixtures/conformance/` — metamodel loader + canonical serializer (380) | Fixture prefix | Feature doc | |---|---| @@ -524,7 +524,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 378 + yaml 16 + verify 37 +The fixtures in the nine corpora mapped above (metamodel 380 + yaml 16 + verify 37 + render 16 + persistence 54 + api-contract 91 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc, and so do the two requirement corpora and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 3883b2a30..de3e27a07 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -480,7 +480,7 @@ the empty rows, `isNull=true` matches nothing, and a sort on it orders the same engine. Gated by [`fixtures/api-contract-conformance/report/`](../../fixtures/api-contract-conformance/report/) -(22 scenarios), **generated lane only, on all five ports**, for the reason `projection/` +(25 scenarios), **generated lane only, on all five ports**, for the reason `projection/` gives. The columns, their types and the per-port generated files are in [reporting.md](reporting.md#how-a-report-is-served). diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 8d704e981..b20335743 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -169,8 +169,9 @@ What each piece means: is a `boolean`: `hasError` is `any` of `failed`. See [Boolean measures](#boolean-measures-any-and-all). - **`measure.ratio`** is `numerator / NULLIF(denominator, 0)`, typed decimal. A zero denominator yields null, unless the ratio declares `@default`. Both operands are - `measure.aggregate` siblings (a ratio of ratios is not supported), and neither is an - `@agg: any` or `all` measure: a ratio divides numbers. + `measure.aggregate` siblings (a ratio of ratios is not supported), and each must be a + numeric measure: a `count`, a `sum`, an `avg`, or a `min` or `max` of a numeric field. A + ratio divides numbers, so a boolean, date, timestamp or string operand is refused (rule M6). - **`segment.filter`** is a named, reusable `attr.filter`. Use it when the same row rule ("active purchase") would otherwise be repeated across measures and reports. An inline `@filter` on a measure stays legal. @@ -481,8 +482,8 @@ and the measure is a `boolean` in every port: `true` or `false` on the wire, nev the value. Declare it only where that is true: `all` over no rows is vacuously true (`@default: 1`), `any` over no rows is false (`@default: 0`). - **Not a ratio operand.** A `measure.ratio` divides numbers, so its `@numerator` and - `@denominator` cannot name an `any` or `all` measure (rule M6). For a share, count the - matching rows instead: `@agg: count` with a `@filter`. + `@denominator` must name numeric measures, and an `any` or `all` measure is a boolean (rule + M6). For a share, count the matching rows instead: `@agg: count` with a `@filter`. - **How it lowers.** Postgres writes `bool_or(x)` / `bool_and(x)`, with `FILTER (WHERE …)` for a condition. SQLite and MySQL have no boolean aggregate, and a boolean there is `0` or `1`, so they write `MAX(x)` for `any` and `MIN(x)` for `all` (`MAX(CASE WHEN … THEN x END)` @@ -692,7 +693,7 @@ the anti-join by hand, and make two reports. ### What the corpus gates -Nineteen shared scenarios under `fixtures/persistence-conformance/queries/report-*.yaml` read the +Twenty-one shared scenarios under `fixtures/persistence-conformance/queries/report-*.yaml` read the canonical reports through every port's runtime (list and count, filter, sort, an empty table, the Monday boundary, an hour bucket, a relative window, a `@spine` report with a program that has no weeks, a `@spine` report whose segment leaves a program no facts, and defaulted measures @@ -704,7 +705,7 @@ tests read the `@spine` and `@default` views and their column types on Postgres, MySQL, and on Postgres and SQLite also a fact whose reference is null, an `isNull` condition and a two-hop spine. -The nineteen report scenarios run on more than Postgres, wherever a port's runtime has the engine: +The twenty-one report scenarios run on more than Postgres, wherever a port's runtime has the engine: | | Postgres | SQLite | MySQL 8.4 | D1 (local runtime) | |---|---|---|---|---| @@ -722,14 +723,14 @@ stay the Postgres wire ones, never loosened: the engine's own spelling (a REAL r a scale-keeping `DECIMAL` and a zoneless `DATETIME` on MySQL) is mapped on the actual side by each runner, and `report-relative-date` spells its seed per engine (`seed-data-engine`). D1 has no separate corpus: it is SQLite behind Cloudflare's binding, so TypeScript runs the same -scenarios and the same twenty-two REST scenarios through Miniflare's local D1 (`meta migrate +scenarios and the same twenty-five REST scenarios through Miniflare's local D1 (`meta migrate --dialect d1`), with no cloud account. A port without the engine is a stated limitation, not a gap to fill by building a driver: Java's OMDB and Python's `ObjectManager` ship the drivers they ship, and a view-reading test does not justify a new one. -The REST surface is gated by twenty-two scenarios under +The REST surface is gated by twenty-five scenarios under [`fixtures/api-contract-conformance/report/`](../../fixtures/api-contract-conformance/report/), run in the **generated lane on all five ports**: list (a dimension with a segment-scoped sum that is null for one group), a time dimension at a grain (`YYYY-MM-DD`), a no-dimension @@ -811,7 +812,7 @@ row, and a `SUM` over the result would double-count. A to-one hop cannot fan out | **M3** | `@distinct: true` with any `@agg` other than `count`. | | **M4** | `sum` or `avg` over a non-numeric field (numeric is `int, long, double, float, decimal, currency`); `min` or `max` over a `boolean`, `object` or `map` field; `any` or `all` over a field that is not a `boolean`. | | **M5** | An `@segment` that names no `segment` child of the owning entity. | -| **M6** | A `measure.ratio` whose `@numerator` or `@denominator` names no measure of the entity, names one that is not a `measure.aggregate`, or names an `@agg: any` or `all` measure (a boolean). | +| **M6** | A `measure.ratio` whose `@numerator` or `@denominator` names no measure of the entity, names one that is not a `measure.aggregate`, or names one whose result is not numeric (not one of `int, long, double, float, decimal, currency`): an `any` or `all`, or a `min` or `max` over a field that is not numeric, such as a date, timestamp, string, uuid or enum. The result type is the measure's column type in [The columns you get](#the-columns-you-get). | | **M7** | A `@default` on an `@agg: count`. A count is never null, so the default could never apply. | | **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`). | | **M9** | A `@default` on an `@agg: any` or `all` other than `0` (false) or `1` (true). | diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index 0a82943d1..6b7a08925 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -494,10 +494,15 @@ spell it alike. No new attribute, no new subtype. then never null and R9's rules for filter, sort and the row type apply unchanged. The alternative (refuse `@default` on a boolean) leaves a `@spine` report no way to read `false` for a row with no facts, which is the case the adopter had. -- **Not a ratio operand.** A `measure.ratio` divides numbers, and Postgres has no cast from - boolean to numeric, so an `any` / `all` measure as `@numerator` or `@denominator` is refused - (rule M6, `ERR_INVALID_MEASURE`). The rule names these two aggregates only; it is not a general - numeric-operand rule, and a `min` / `max` operand is unchanged. +- **Not a ratio operand.** A `measure.ratio` divides numbers, so rule M6 requires each of + `@numerator` and `@denominator` to name a `measure.aggregate` whose result type (Table B) is + numeric: `int`, `long`, `double`, `float`, `decimal` or `currency`. The rule reads the derived + type, not the aggregate's name, so it refuses an `any` / `all` measure (boolean) and also a + `min` / `max` over a field that is not numeric (a date, timestamp, string, boolean, uuid or + enum), and allows a `min` / `max` over a numeric field. The lowering casts each operand to + `NUMERIC`, which Postgres refuses for a boolean or a timestamp at view creation and SQLite and + MySQL answer with a number that means nothing; the loader now refuses the model instead + (`ERR_INVALID_MEASURE`). - **Lowering.** Postgres `bool_or` / `bool_and` (a condition as `FILTER (WHERE …)`). SQLite and D1 have no boolean aggregate and store a boolean as `0` / `1`: `MAX` for `any`, `MIN` for `all` (a condition as `MAX(CASE WHEN … THEN x END)`). MySQL is the same as SQLite. The default is diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index e460d6cd6..05a1233ce 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.1" }, "counts": { - "fixtures": 377, + "fixtures": 380, "corpora": 29, "baseTypes": 17 }, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 28bda1460..8deee8d92 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Each operand must be a numeric measure: a `count`, a `sum`, an `avg`, or a `min` or `max` of a numeric field. An `any` or `all` (a boolean), or a `min` or `max` of a date, timestamp, string, uuid or enum, is refused as `@numerator` or `@denominator`. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 28bda1460..8deee8d92 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Each operand must be a numeric measure: a `count`, a `sum`, an `avg`, or a `min` or `max` of a numeric field. An `any` or `all` (a boolean), or a `min` or `max` of a date, timestamp, string, uuid or enum, is refused as `@numerator` or `@denominator`. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 28bda1460..8deee8d92 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Each operand must be a numeric measure: a `count`, a `sum`, an `avg`, or a `min` or `max` of a numeric field. An `any` or `all` (a boolean), or a `min` or `max` of a date, timestamp, string, uuid or enum, is refused as `@numerator` or `@denominator`. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 28bda1460..8deee8d92 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Each operand must be a numeric measure: a `count`, a `sum`, an `avg`, or a `min` or `max` of a numeric field. An `any` or `all` (a boolean), or a `min` or `max` of a date, timestamp, string, uuid or enum, is refused as `@numerator` or `@denominator`. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 28bda1460..8deee8d92 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -55,7 +55,7 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur - A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. - A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. So are `avg`, `min` and `max`. - An `any` or `all` over no rows, or only null values, is **null**, not `false`. `"@default": 0` reads `false` and `"@default": 1` reads `true` (any other number is refused). -- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Its operands are numbers: an `any` or `all` measure as `@numerator` or `@denominator` is refused. For a share, use an `@agg: count` with a `@filter`. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. Each operand must be a numeric measure: a `count`, a `sum`, an `avg`, or a `min` or `max` of a numeric field. An `any` or `all` (a boolean), or a `min` or `max` of a date, timestamp, string, uuid or enum, is refused as `@numerator` or `@denominator`. For a share, use an `@agg: count` with a `@filter`. - A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). **Ask: is zero true here, or is null?** If "nothing to aggregate" means zero (revenue of a program nobody bought is `0`), declare `"@default": 0` on the measure (`revenue` in the example under "Rows with no facts"). If it means unknown (the average rating of a product with no ratings is not `0`), leave it null. Do not default in the client what the model can state once. diff --git a/fixtures/api-contract-conformance/report/README.md b/fixtures/api-contract-conformance/report/README.md index faf7a85d7..e417a142e 100644 --- a/fixtures/api-contract-conformance/report/README.md +++ b/fixtures/api-contract-conformance/report/README.md @@ -62,8 +62,8 @@ object uses (`InvoicesByMonth` is `/api/invoices_by_months`). ## The model -Four entities (`Invoice`; `Category`, `Product` and their child `Sale`) and nine reports, -eight served and one sourceless on purpose. +Four entities (`Invoice`; `Category`, `Product` and their child `Sale`) and ten reports, +nine served and one sourceless on purpose. | Report | Route | View | Derived fields | |---|---|---|---| @@ -76,6 +76,7 @@ eight served and one sourceless on purpose. | `InvoiceDisputes` | `/api/invoice_disputes` | `v_invoice_disputes` | a boolean dimension, `invoices`, `bigInvoices` | | `SalesByCategory` | `/api/sales_by_categories` | `v_sales_by_category` | `categoryName` (through `Sale.fkProduct.fkCategory`), `sales`, `revenueCents`, `maxCents`, `largeSales` | | `SalesBySku` | `/api/sales_by_skus` | `v_sales_by_sku` | `productSku` (a uuid, through `Sale.fkProduct`), `sales`, `revenueCents` | +| `ProductRefunds` | `/api/product_refunds` | `v_product_refunds` | `productId`, `productName`, `sales`, `hasRefund` (a boolean `any`), `hasRefundOrFalse` (the same with `@default: 0`) | `ProductRevenue` is `@from: Sale` with `@spine: "Sale.fkProduct"`: its view reads FROM `products` and LEFT JOINs `sales`, so its spine key `productId` and `productName` are never @@ -91,9 +92,9 @@ sits in the model so that a port which serves every report it finds fails this c ``` report/ ├── README.md # this file -├── meta.json # Invoice, Category, Product, Sale + nine object.report nodes (eight served, one sourceless) +├── meta.json # Invoice, Category, Product, Sale + ten object.report nodes (nine served, one sourceless) ├── seed.json # the base tables (`invoices`, `categories`, `products`, `sales`) and `reports` (what the views return) -├── schema.postgres.sql # TypeScript-produced: the four tables and the eight views +├── schema.postgres.sql # TypeScript-produced: the four tables and the nine views ├── schema.sqlite.sql # the same, for SQLite and D1 (`meta migrate --dialect sqlite`) ├── schema.mysql.sql # the same, for MySQL 8.4: the tables plus `buildReportViews` bodies └── scenarios/ @@ -118,7 +119,10 @@ report/ ├── filter-on-string-dimension.yaml # `like` and `eq` on a string dimension ├── filter-on-filtered-measure.yaml # a filter on a measure that carries its own @filter ├── filter-boolean-dimension.yaml # a boolean dimension - └── filter-uuid-dimension.yaml # a uuid dimension: eq, ne, in, and its wire spelling + ├── filter-uuid-dimension.yaml # a uuid dimension: eq, ne, in, and its wire spelling + ├── list-boolean-measure.yaml # @agg any: true, false, null with no facts, and its @default + ├── filter-on-boolean-measure.yaml # eq and isNull on a boolean measure, plain and defaulted + └── sort-on-boolean-measure.yaml # a defaulted boolean measure sorts false before true ``` ### Why `seed.json` has two halves @@ -154,11 +158,11 @@ hand-rolled reference server would answer every scenario by construction. | Port | Generated lane | Note | |---|---|---| -| TypeScript | wired | `server/typescript/packages/integration-tests/test/api-contract-report.test.ts` (22 scenarios + a seed-vs-view check). Full stack: generated Fastify routes over the real views on Testcontainers Postgres. `api-contract-report-sqlite.test.ts` runs the same 22 on SQLite, `api-contract-report-mysql.test.ts` on MySQL 8.4, and the SQLite test file also runs them through local D1 (Miniflare) | +| TypeScript | wired | `server/typescript/packages/integration-tests/test/api-contract-report.test.ts` (25 scenarios + a seed-vs-view check). Full stack: generated Fastify routes over the real views on Testcontainers Postgres. `api-contract-report-sqlite.test.ts` runs the same 25 on SQLite, `api-contract-report-mysql.test.ts` on MySQL 8.4, and the SQLite test file also runs them through local D1 (Miniflare) | | C# | wired | `server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs`. Full stack: generated routes and EF Core over `schema.postgres.sql` on Testcontainers Postgres, over `schema.sqlite.sql` on SQLite, and over `schema.mysql.sql` on MySQL 8.4 | -| Java | wired | `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java` (22 scenarios + a scenario-count check). Generated controllers behind an in-memory repository seeded from `reports` | -| Kotlin | wired | `server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt` (22 scenarios, the count check, and a check that the sourceless report generated nothing). Generated controllers over the generated Exposed table objects, seeded from `reports` | -| Python | wired | `server/python/tests/integration/test_api_contract_report.py` (22 scenarios, a check that exactly the served reports are generated, and `/api/invoice_days` is `404`). Generated routers behind in-memory repositories seeded from `reports` | +| Java | wired | `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java` (25 scenarios + a scenario-count check). Generated controllers behind an in-memory repository seeded from `reports` | +| Kotlin | wired | `server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt` (25 scenarios, the count check, and a check that the sourceless report generated nothing). Generated controllers over the generated Exposed table objects, seeded from `reports` | +| Python | wired | `server/python/tests/integration/test_api_contract_report.py` (25 scenarios, a check that exactly the served reports are generated, and `/api/invoice_days` is `404`). Generated routers behind in-memory repositories seeded from `reports` | The scenarios use only assertion keys every runner already has (`equals`, `length`, `envelope`, `error` with `field`, and a status with no `body`). diff --git a/fixtures/conformance/error-ratio-non-numeric-operand/expected-errors.json b/fixtures/conformance/error-ratio-non-numeric-operand/expected-errors.json new file mode 100644 index 000000000..448cf8dcf --- /dev/null +++ b/fixtures/conformance/error-ratio-non-numeric-operand/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_MEASURE", + "source": { + "format": "json", + "files": [ + "meta.ops.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[6]['measure.ratio']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-ratio-non-numeric-operand/input/meta.ops.json b/fixtures/conformance/error-ratio-non-numeric-operand/input/meta.ops.json new file mode 100644 index 000000000..c73f38eac --- /dev/null +++ b/fixtures/conformance/error-ratio-non-numeric-operand/input/meta.ops.json @@ -0,0 +1,58 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.timestamp": { + "name": "endedAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "calls", + "@agg": "count", + "@of": "Call.id" + } + }, + { + "measure.aggregate": { + "name": "lastEndedAt", + "@agg": "max", + "@of": "Call.endedAt" + } + }, + { + "measure.ratio": { + "name": "endRate", + "@numerator": "lastEndedAt", + "@denominator": "calls" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-ratio-non-numeric-operand/providers.json b/fixtures/conformance/error-ratio-non-numeric-operand/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-ratio-non-numeric-operand/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/conformance/reporting-ratio-numeric-min-max/expected.json b/fixtures/conformance/reporting-ratio-numeric-min-max/expected.json new file mode 100644 index 000000000..a3b62d34c --- /dev/null +++ b/fixtures/conformance/reporting-ratio-numeric-min-max/expected.json @@ -0,0 +1,103 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.int": { + "name": "durationSeconds" + } + }, + { + "field.currency": { + "name": "costCents" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "calls", + "@agg": "count", + "@of": [ + "Call.id" + ] + } + }, + { + "measure.aggregate": { + "name": "longestSeconds", + "@agg": "max", + "@of": [ + "Call.durationSeconds" + ] + } + }, + { + "measure.aggregate": { + "name": "shortestSeconds", + "@agg": "min", + "@of": [ + "Call.durationSeconds" + ] + } + }, + { + "measure.aggregate": { + "name": "highestCost", + "@agg": "max", + "@of": [ + "Call.costCents" + ] + } + }, + { + "measure.ratio": { + "name": "durationSpread", + "@denominator": "shortestSeconds", + "@numerator": "longestSeconds" + } + }, + { + "measure.ratio": { + "name": "highestCostPerCall", + "@denominator": "calls", + "@numerator": "highestCost" + } + } + ] + } + }, + { + "object.report": { + "name": "CallExtremes", + "@from": "Call", + "@measures": [ + "calls", + "durationSpread", + "highestCostPerCall" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-ratio-numeric-min-max/input/meta.ops.json b/fixtures/conformance/reporting-ratio-numeric-min-max/input/meta.ops.json new file mode 100644 index 000000000..853c195c2 --- /dev/null +++ b/fixtures/conformance/reporting-ratio-numeric-min-max/input/meta.ops.json @@ -0,0 +1,95 @@ +{ + "metadata.root": { + "package": "acme::ops", + "children": [ + { + "object.entity": { + "name": "Call", + "children": [ + { + "source.rdb": { + "@table": "calls" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.int": { + "name": "durationSeconds" + } + }, + { + "field.currency": { + "name": "costCents" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "measure.aggregate": { + "name": "calls", + "@agg": "count", + "@of": "Call.id" + } + }, + { + "measure.aggregate": { + "name": "longestSeconds", + "@agg": "max", + "@of": "Call.durationSeconds" + } + }, + { + "measure.aggregate": { + "name": "shortestSeconds", + "@agg": "min", + "@of": "Call.durationSeconds" + } + }, + { + "measure.aggregate": { + "name": "highestCost", + "@agg": "max", + "@of": "Call.costCents" + } + }, + { + "measure.ratio": { + "name": "durationSpread", + "@numerator": "longestSeconds", + "@denominator": "shortestSeconds" + } + }, + { + "measure.ratio": { + "name": "highestCostPerCall", + "@numerator": "highestCost", + "@denominator": "calls" + } + } + ] + } + }, + { + "object.report": { + "name": "CallExtremes", + "@from": "Call", + "@measures": [ + "calls", + "durationSpread", + "highestCostPerCall" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-ratio-numeric-min-max/providers.json b/fixtures/conformance/reporting-ratio-numeric-min-max/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/reporting-ratio-numeric-min-max/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/metamodel-docs/expected/INDEX.md b/fixtures/metamodel-docs/expected/INDEX.md index 0c8db3fc7..9bc262830 100644 --- a/fixtures/metamodel-docs/expected/INDEX.md +++ b/fixtures/metamodel-docs/expected/INDEX.md @@ -53,7 +53,7 @@ children, and cardinality of a subtype. Universal documentation attributes | `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) | +| `measure.ratio` | A named quotient of two numeric 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) | diff --git a/fixtures/metamodel-docs/expected/types/measure.md b/fixtures/metamodel-docs/expected/types/measure.md index f638fbbcf..d634eb5a5 100644 --- a/fixtures/metamodel-docs/expected/types/measure.md +++ b/fixtures/metamodel-docs/expected/types/measure.md @@ -33,7 +33,7 @@ _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. +A named quotient of two numeric 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. @@ -42,8 +42,8 @@ A named quotient of two measure.aggregate siblings (FR-044), lowered as numerato | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | | `@default` | int | no | | | — | Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio. | -| `@denominator` | string | yes | | | — | Name of a measure.aggregate on the same entity. | -| `@numerator` | string | yes | | | — | Name of a measure.aggregate on the same entity. | +| `@denominator` | string | yes | | | — | Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field. | +| `@numerator` | string | yes | | | — | Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field. | **Allowed children** diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index 482b7eb37..c1888bd28 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -3188,7 +3188,7 @@ { "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.", + "description": "A named quotient of two numeric 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": [ { @@ -3203,14 +3203,14 @@ "valueType": "string", "isArray": false, "required": true, - "description": "Name of a measure.aggregate on the same entity." + "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, { "name": "numerator", "valueType": "string", "isArray": false, "required": true, - "description": "Name of a measure.aggregate on the same entity." + "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." } ], "children": [] diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs index de9eb41fe..0f72c398f 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs @@ -427,34 +427,56 @@ public void M7_default_on_a_distinct_count_of_a_tuple_is_refused() Assert.Contains("@default cannot apply to @agg: count", msg); } - private static JsonNode RatioOverABooleanMeasure(string agg, string attr) + /// refundShare = revenue / purchases, with + /// renamed onto a new measure operand. + private static JsonNode RatioOver(string attr, string agg, string of) { var m = Positive(); ChildrenOf(m, "Purchase").Add(Wrap("measure.aggregate", - $$"""{ "name": "refundFlag", "@agg": "{{agg}}", "@of": "Purchase.refunded" }""")); + $$"""{ "name": "operand", "@agg": "{{agg}}", "@of": "{{of}}" }""")); var ratio = new JsonObject { ["name"] = "refundShare", ["@numerator"] = "revenue", ["@denominator"] = "purchases" }; - ratio[attr] = "refundFlag"; + ratio[attr] = "operand"; ChildrenOf(m, "Purchase").Add(new JsonObject { ["measure.ratio"] = ratio }); return m; } + private static string M6NotNumeric(string result) => + $"measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'operand' is a {result} measure; a ratio " + + "divides numbers, so its operands must be numeric measures (a count, a sum, an avg, or a min or max of a " + + "numeric field)."; + [Fact] - public void M6_a_ratio_operand_naming_an_any_measure_is_refused_naming_the_operand() + public void M6_a_ratio_operand_whose_result_is_a_boolean_is_refused_on_the_ratio_node() { - var e = Assert.Single(Load(RatioOverABooleanMeasure("any", "@numerator")).Errors); + var e = Assert.Single(Load(RatioOver("@numerator", "any", "Purchase.refunded")).Errors); Assert.Equal(ErrorCode.ERR_INVALID_MEASURE, e.Code); - Assert.Equal( - "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'refundFlag' is an @agg 'any' measure, " + - "which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter).", - e.Message); + Assert.Equal(M6NotNumeric("boolean"), e.Message); Assert.Contains("['measure.ratio']", JsonPathOf(e)); + string msg = Single(Load(RatioOver("@denominator", "all", "Purchase.refunded")), ErrorCode.ERR_INVALID_MEASURE); + Assert.Contains("@denominator 'operand' is a boolean measure", msg); } - [Fact] - public void M6_a_ratio_operand_naming_an_all_measure_is_refused() + [Theory] + [InlineData("max", "Purchase.purchasedAt", "timestamp")] + [InlineData("min", "Purchase.purchasedOn", "date")] + [InlineData("max", "Purchase.status", "string")] + public void M6_a_min_or_max_over_a_field_that_is_not_numeric_is_refused_as_a_ratio_operand( + string agg, string of, string result) { - string msg = Single(Load(RatioOverABooleanMeasure("all", "@denominator")), ErrorCode.ERR_INVALID_MEASURE); - Assert.Contains("@denominator 'refundFlag' is an @agg 'all' measure", msg); + Assert.Equal(M6NotNumeric(result), Single(Load(RatioOver("@numerator", agg, of)), ErrorCode.ERR_INVALID_MEASURE)); + } + + [Theory] + [InlineData("count", "Purchase.id")] + [InlineData("sum", "Purchase.amountCents")] + [InlineData("avg", "Purchase.amountCents")] + [InlineData("min", "Purchase.amountCents")] + [InlineData("max", "Purchase.programId")] + public void M6_a_count_a_sum_an_avg_and_a_min_or_max_over_a_numeric_field_are_legal_ratio_operands( + string agg, string of) + { + Assert.Empty(Load(RatioOver("@numerator", agg, of)).Errors); + Assert.Empty(Load(RatioOver("@denominator", agg, of)).Errors); } [Fact] diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs index 355112981..a66898ada 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs @@ -267,37 +267,49 @@ private static ReportField MeasureField(string item, MetaObject report, MetaObje } var m = DeclaredMember(from, TYPE_MEASURE, name) ?? throw Unresolved(reportName, $"measure '{item}' on '{from.Name}'"); - string? agg = m.Agg(); + var (subType, typeSource) = DeriveMeasure(m, from, root) + ?? throw Unresolved(reportName, $"measure '{name}' @of"); // Table C: a count is never null; any other measure is not null when it has a @default. - bool required = (!m.IsRatio() && agg == AGG_COUNT) || m.DefaultValue() is not null; - if (m.IsRatio()) - return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_DECIMAL, required, Measure: m); - if (agg == AGG_COUNT) - return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_LONG, required, Measure: m); + bool required = (!m.IsRatio() && m.Agg() == AGG_COUNT) || m.DefaultValue() is not null; + return new ReportField(name, ReportFieldRole.Measure, subType, required, typeSource, Measure: m); + } + + /// + /// Table B for one measure: its subtype, and the @of field it carries type attrs + /// from (if any). Null when its @of does not resolve. + /// + private static (string SubType, MetaField? TypeSource)? DeriveMeasure(MetaMeasure m, MetaObject from, MetaRoot root) + { + if (m.IsRatio()) return (FIELD_SUBTYPE_DECIMAL, null); + string? agg = m.Agg(); + if (agg == AGG_COUNT) return (FIELD_SUBTYPE_LONG, null); // any / all are boolean whatever the source (the loader's M4 refuses a non-boolean @of). - if (agg == AGG_ANY || agg == AGG_ALL) - return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_BOOLEAN, required, Measure: m); - var of = ResolveReportingFieldRef(m.OfColumns().FirstOrDefault() ?? "", ReportingMemberOwner(m, from), root, from) - ?? throw Unresolved(reportName, $"measure '{name}' @of"); + if (agg == AGG_ANY || agg == AGG_ALL) return (FIELD_SUBTYPE_BOOLEAN, null); + var of = ResolveReportingFieldRef(m.OfColumns().FirstOrDefault() ?? "", ReportingMemberOwner(m, from), root, from); + if (of is null) return null; string src = of.SubType; if (agg == AGG_SUM) { - if (src == FIELD_SUBTYPE_CURRENCY) - return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_CURRENCY, required, of, Measure: m); + if (src == FIELD_SUBTYPE_CURRENCY) return (FIELD_SUBTYPE_CURRENCY, of); string sumType = SumLong.Contains(src) ? FIELD_SUBTYPE_LONG : Floating.Contains(src) ? FIELD_SUBTYPE_DOUBLE : FIELD_SUBTYPE_DECIMAL; - return new ReportField(name, ReportFieldRole.Measure, sumType, required, Measure: m); - } - if (agg == AGG_AVG) - { - string avgType = Floating.Contains(src) ? FIELD_SUBTYPE_DOUBLE : FIELD_SUBTYPE_DECIMAL; - return new ReportField(name, ReportFieldRole.Measure, avgType, required, Measure: m); + return (sumType, null); } + if (agg == AGG_AVG) return (Floating.Contains(src) ? FIELD_SUBTYPE_DOUBLE : FIELD_SUBTYPE_DECIMAL, null); // min / max keep the source field's type. - return new ReportField(name, ReportFieldRole.Measure, src, required, of, Measure: m); + return (src, of); } + /// + /// Table B's subtype for one measure of , whether or not a report + /// lists it (a ratio's operands are measures a report need not list). The same rule + /// applies to a listed measure. Null when its @of does not resolve, + /// which the loader (rule M6) reads before the model is known to be valid. + /// + public static string? MeasureResultSubType(MetaMeasure measure, MetaObject from, MetaRoot root) => + DeriveMeasure(measure, from, root)?.SubType; + /// /// Table B, with Table C's required. Throws /// naming the report when a reference does not resolve (a report that passed the loader's diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs index 21ba38d94..69b79e9c9 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -608,7 +608,7 @@ private static bool CheckAggregateColumns(MemberCtx ctx, MetaMeasure measure, Ac return true; } - /// M6 — each operand names a measure.aggregate of the same entity, and not an any / all one. + /// M6 — each operand names a measure.aggregate of the same entity whose result is a number. private static void CheckRatioOperands(MemberCtx ctx, MetaMeasure ratio, Action err) { (string Attr, string? Ref)[] operands = @@ -629,10 +629,12 @@ private static void CheckRatioOperands(MemberCtx ctx, MetaMeasure ratio, Action< err($"@{attr} '{@ref}' is a measure.{target.SubType}; a ratio's operands must be measure.aggregate " + "(a ratio of ratios is not supported)."); } - else if (target is MetaMeasure operand && (operand.Agg() == AGG_ANY || operand.Agg() == AGG_ALL)) + else if (target is MetaMeasure operand && ctx.Host is MetaObject from && ctx.Root is MetaRoot root && + ReportShapes.MeasureResultSubType(operand, from, root) is { } result && + !ReportingNumericFieldSubtypes.Contains(result)) { - err($"@{attr} '{@ref}' is an @agg '{operand.Agg()}' measure, which is a boolean; a ratio divides numbers. " + - "Count the matching rows instead (@agg: count with a @filter)."); + err($"@{attr} '{@ref}' is a {result} measure; a ratio divides numbers, so its operands must be numeric " + + "measures (a count, a sum, an avg, or a min or max of a numeric field)."); } } } diff --git a/server/csharp/MetaObjects/SpecMetamodel/reporting.json b/server/csharp/MetaObjects/SpecMetamodel/reporting.json index 8f725ab9e..67f998e83 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/reporting.json +++ b/server/csharp/MetaObjects/SpecMetamodel/reporting.json @@ -28,11 +28,11 @@ { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." } ] }, { "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.", + "description": "A named quotient of two numeric 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": "attr", "subType": "string", "name": "numerator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, + { "type": "attr", "subType": "string", "name": "denominator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." } ] }, { "type": "segment", "subType": "filter", diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt index 9722eb0ce..f7f388bed 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt @@ -106,7 +106,7 @@ internal class KotlinCodegenMatchesReferenceTest { ExpectedColumn("recordedAt", families = setOf("instantWithTimeZone")), ), ), - // FR-044: the eleven view-backed reports. Each mirrors its hand-written reference + // FR-044: the twenty-three view-backed reports. Each mirrors its hand-written reference // (`tables/View.kt`) column for column: the family is the view's real column // type, and a column is nullable exactly when the reference's is. A report has no // identity, so none carries a primary key. @@ -214,7 +214,7 @@ internal class KotlinCodegenMatchesReferenceTest { ), report = "v_fitness_totals_filled", ), - // Reports for the vocabulary the first nine do not reach (numeric, temporal, boolean/uuid/ + // Reports for the vocabulary the first eleven do not reach (numeric, temporal, boolean/uuid/ // int-enum dimensions, multi-hop and self-reference paths, an empty scope). "MeasurementStats" to EntityExpectation( columns = listOf( diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt index 24fce4f80..66cb3b69f 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/generated/GeneratedReportControllerHarness.kt @@ -26,7 +26,7 @@ import kotlin.io.path.isRegularFile import kotlin.io.path.readText /** - * FR-044 Plan 3: host the GENERATED Kotlin Spring controllers of the five served reports + * FR-044 Plan 3: host the GENERATED Kotlin Spring controllers of the nine served reports * of the `report/` corpus over real HTTP (an embedded Tomcat). Mirrors * [com.metaobjects.integration.kotlin.api.projection.generated.GeneratedProjectionControllerHarness]. * @@ -115,7 +115,7 @@ class GeneratedReportControllerHarness( } } - /** Rebuild a fresh in-memory H2, the four stand-in tables, the seed, and Tomcat. */ + /** Rebuild a fresh in-memory H2, the nine stand-in tables, the seed, and Tomcat. */ fun reset() { val dbName = "report_invoice_${dbSeq.incrementAndGet()}" val db = Database.connect("jdbc:h2:mem:$dbName;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt index d946c83b2..c88c220c3 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt @@ -28,7 +28,7 @@ internal object ReportViews { "ProgramFlags" to ProgramFlagsView, "ProgramFlagRoster" to ProgramFlagRosterView, "FitnessTotalsFilled" to FitnessTotalsFilledView, - // Reports for the vocabulary the first nine do not reach: numeric, temporal, boolean/ + // Reports for the vocabulary the first eleven do not reach: numeric, temporal, boolean/ // uuid/int-enum dimensions, multi-hop and self-reference paths, an empty scope. "MeasurementStats" to MeasurementStatsView, "MeasurementStatsFilled" to MeasurementStatsFilledView, diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java index aa7b25184..4207883d3 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java @@ -45,6 +45,7 @@ import com.metaobjects.reporting.MetaSegment; import com.metaobjects.reporting.ReportAccessors; import com.metaobjects.reporting.ReportAccessors.ReportDimensionItem; +import com.metaobjects.reporting.ReportShape; import com.metaobjects.reporting.ReportingConstants; import com.metaobjects.source.MetaSource; import com.metaobjects.util.MetaDataUtil; @@ -360,7 +361,7 @@ private static String nodeLabel(MetaData node) { private record MemberCtx( MetaRoot root, /* The entity whose resolving children the member was reached through. */ - MetaData host, + MetaObject host, /* The entity that declares the member (the host, or an ancestor of it). */ MetaData declaring, /* {@code '' on entity ''} — every member message starts with it. */ @@ -706,7 +707,7 @@ private static AggregateColumns checkAggregateColumns(MemberCtx ctx, MetaMeasure return new AggregateColumns(field); } - /** M6 — each operand names a measure.aggregate of the same entity, and not an any / all one. */ + /** M6 — each operand names a measure.aggregate of the same entity whose result is a number. */ private static void checkRatioOperands(MemberCtx ctx, MetaMeasure ratio, Consumer err) { String[][] operands = { {ReportingConstants.ATTR_NUMERATOR, ratio.getNumerator()}, @@ -722,12 +723,13 @@ private static void checkRatioOperands(MemberCtx ctx, MetaMeasure ratio, Consume } else if (!ReportingConstants.MEASURE_SUBTYPE_AGGREGATE.equals(target.getSubType())) { err.accept("@" + attr + " '" + ref + "' is a measure." + target.getSubType() + "; a ratio's operands must be measure.aggregate (a ratio of ratios is not supported)."); - } else if (target instanceof MetaMeasure - && (ReportingConstants.AGG_ANY.equals(((MetaMeasure) target).getAgg()) - || ReportingConstants.AGG_ALL.equals(((MetaMeasure) target).getAgg()))) { - err.accept("@" + attr + " '" + ref + "' is an @agg '" + ((MetaMeasure) target).getAgg() - + "' measure, which is a boolean; a ratio divides numbers. " - + "Count the matching rows instead (@agg: count with a @filter)."); + } else if (target instanceof MetaMeasure) { + String result = ReportShape.measureResultSubType((MetaMeasure) target, ctx.host(), ctx.root()); + if (result != null && !NUMERIC_FIELD_SUBTYPES.contains(result)) { + err.accept("@" + attr + " '" + ref + "' is a " + result + + " measure; a ratio divides numbers, so its operands must be numeric measures " + + "(a count, a sum, an avg, or a min or max of a numeric field)."); + } } } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java index d83d52298..32aba78bd 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java @@ -475,40 +475,58 @@ private static Field measureField(String item, MetaObject from, MetaRoot root, M } MetaMeasure m = declaredMember(from, MetaMeasure.class, name); if (m == null) throw unresolved(report, "measure '" + item + "' on '" + from.getShortName() + "'"); - String agg = m.getAgg(); + MeasureDerivation derived = deriveMeasure(m, from, root); + if (derived == null) throw unresolved(report, "measure '" + name + "' @of"); // Table C: a count is never null (with or without @distinct); any other measure is // not null when it has a @default. - boolean required = (!m.isRatio() && ReportingConstants.AGG_COUNT.equals(agg)) || m.getDefaultValue() != null; - if (m.isRatio()) { - return new Field(name, Role.MEASURE, DecimalField.SUBTYPE_DECIMAL, required, null, null, null, m); - } - if (ReportingConstants.AGG_COUNT.equals(agg)) { - return new Field(name, Role.MEASURE, LongField.SUBTYPE_LONG, required, null, null, null, m); - } + boolean required = (!m.isRatio() && ReportingConstants.AGG_COUNT.equals(m.getAgg())) + || m.getDefaultValue() != null; + return new Field(name, Role.MEASURE, derived.subType(), required, derived.typeSource(), null, null, m); + } + + /** One measure's Table B subtype, and the {@code @of} field it carries type attrs from (if any). */ + private record MeasureDerivation(String subType, MetaField typeSource) {} + + /** Table B for one measure. {@code null} when its {@code @of} does not resolve. */ + private static MeasureDerivation deriveMeasure(MetaMeasure m, MetaObject from, MetaRoot root) { + if (m.isRatio()) return new MeasureDerivation(DecimalField.SUBTYPE_DECIMAL, null); + String agg = m.getAgg(); + if (ReportingConstants.AGG_COUNT.equals(agg)) return new MeasureDerivation(LongField.SUBTYPE_LONG, null); // any / all are boolean whatever the source (the loader's M4 refuses a non-boolean @of). if (ReportingConstants.AGG_ANY.equals(agg) || ReportingConstants.AGG_ALL.equals(agg)) { - return new Field(name, Role.MEASURE, BooleanField.SUBTYPE_BOOLEAN, required, null, null, null, m); + return new MeasureDerivation(BooleanField.SUBTYPE_BOOLEAN, null); } List columns = m.getOfColumns(); MetaField of = columns.isEmpty() ? null : resolveFieldRef(columns.get(0), memberOwner(m, from), root, from); - if (of == null) throw unresolved(report, "measure '" + name + "' @of"); + if (of == null) return null; String src = of.getSubType(); if (ReportingConstants.AGG_SUM.equals(agg)) { if (CurrencyField.SUBTYPE_CURRENCY.equals(src)) { - return new Field(name, Role.MEASURE, CurrencyField.SUBTYPE_CURRENCY, required, of, null, null, m); + return new MeasureDerivation(CurrencyField.SUBTYPE_CURRENCY, of); } String subType = SUM_LONG.contains(src) ? LongField.SUBTYPE_LONG : FLOATING.contains(src) ? DoubleField.SUBTYPE_DOUBLE : DecimalField.SUBTYPE_DECIMAL; - return new Field(name, Role.MEASURE, subType, required, null, null, null, m); + return new MeasureDerivation(subType, null); } if (ReportingConstants.AGG_AVG.equals(agg)) { - String subType = FLOATING.contains(src) ? DoubleField.SUBTYPE_DOUBLE : DecimalField.SUBTYPE_DECIMAL; - return new Field(name, Role.MEASURE, subType, required, null, null, null, m); + return new MeasureDerivation( + FLOATING.contains(src) ? DoubleField.SUBTYPE_DOUBLE : DecimalField.SUBTYPE_DECIMAL, null); } // min / max keep the source field's type. - return new Field(name, Role.MEASURE, src, required, of, null, null, m); + return new MeasureDerivation(src, of); + } + + /** + * Table B's subtype for one measure of {@code from}, whether or not a report lists it (a + * ratio's operands are measures a report need not list). The same rule {@link #of(MetaObject, MetaRoot)} applies + * to a listed measure. {@code null} when its {@code @of} does not resolve, which the loader + * (rule M6) reads before the model is known to be valid. + */ + public static String measureResultSubType(MetaMeasure measure, MetaObject from, MetaRoot root) { + MeasureDerivation derived = deriveMeasure(measure, from, root); + return derived == null ? null : derived.subType(); } private static T declaredMember(MetaObject from, Class type, String name) { diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java index 371ca36ce..047490fa9 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java @@ -163,7 +163,9 @@ public void everyErrorFixtureYieldsTheReferenceCodeAndMessage() throws IOExcepti expect.put("error-ratio-operand-not-aggregate", new String[]{"ERR_INVALID_MEASURE", "measure 'ratioOfRatio' on entity 'acme::shop::WorkoutEvent': @numerator 'avgDaysPerStarter' is a measure.ratio; a ratio's operands must be measure.aggregate (a ratio of ratios is not supported)."}); expect.put("error-ratio-boolean-operand", new String[]{"ERR_INVALID_MEASURE", - "measure 'errorShare' on entity 'acme::ops::Call': @numerator 'hasError' is an @agg 'any' measure, which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter)."}); + "measure 'errorShare' on entity 'acme::ops::Call': @numerator 'hasError' is a boolean measure; a ratio divides numbers, so its operands must be numeric measures (a count, a sum, an avg, or a min or max of a numeric field)."}); + expect.put("error-ratio-non-numeric-operand", new String[]{"ERR_INVALID_MEASURE", + "measure 'endRate' on entity 'acme::ops::Call': @numerator 'lastEndedAt' is a timestamp measure; a ratio divides numbers, so its operands must be numeric measures (a count, a sum, an avg, or a min or max of a numeric field)."}); expect.put("error-relative-date-bad-duration", new String[]{"ERR_BAD_ATTR_FILTER", "report 'acme::shop::DailyRevenue': @filter on 'purchasedAt' has relative date '90 days', which is not an ISO-8601 duration (e.g. '-P7D', '-PT12H')."}); expect.put("error-relative-date-non-temporal", new String[]{"ERR_BAD_ATTR_FILTER", @@ -840,15 +842,60 @@ public void defaultOnASumAnAvgAMinOfAnIntAndARatioIsFine() throws IOException { assertEquals(List.of(), loadErrors(doc)); } - @Test - public void aRatioDenominatorNamingAnAllMeasureIsRefused() throws IOException { + /** {@code refundShare} = {@code revenue} / {@code purchases}, with {@code attr} renamed onto a + * new measure {@code operand}. */ + private static JsonObject ratioOver(String attr, String agg, String of) throws IOException { JsonObject doc = cleanModel(); childrenOf(doc, "Purchase").add(wrap("measure.aggregate", - "{\"name\":\"refundFlag\",\"@agg\":\"all\",\"@of\":\"Purchase.refunded\"}")); - childrenOf(doc, "Purchase").add(wrap("measure.ratio", - "{\"name\":\"refundShare\",\"@numerator\":\"revenue\",\"@denominator\":\"refundFlag\"}")); - String msg = single(doc, "ERR_INVALID_MEASURE"); - assertTrue(msg, msg.contains("@denominator 'refundFlag' is an @agg 'all' measure")); + "{\"name\":\"operand\",\"@agg\":\"" + agg + "\",\"@of\":\"" + of + "\"}")); + JsonObject ratio = json("{\"name\":\"refundShare\",\"@numerator\":\"revenue\",\"@denominator\":\"purchases\"}"); + ratio.addProperty(attr, "operand"); + JsonObject wrapped = new JsonObject(); + wrapped.add("measure.ratio", ratio); + childrenOf(doc, "Purchase").add(wrapped); + return doc; + } + + private static String m6NotNumeric(String attr, String result) { + return "measure 'refundShare' on entity 'acme::shop::Purchase': @" + attr + " 'operand' is a " + result + + " measure; a ratio divides numbers, so its operands must be numeric measures (a count, a sum, " + + "an avg, or a min or max of a numeric field)."; + } + + @Test + public void aRatioOperandWhoseResultIsABooleanIsRefused() throws IOException { + assertEquals(m6NotNumeric("numerator", "boolean"), + single(ratioOver("@numerator", "any", "Purchase.refunded"), "ERR_INVALID_MEASURE")); + assertEquals(m6NotNumeric("denominator", "boolean"), + single(ratioOver("@denominator", "all", "Purchase.refunded"), "ERR_INVALID_MEASURE")); + } + + @Test + public void aMinOrMaxOverAFieldThatIsNotNumericIsRefusedAsARatioOperand() throws IOException { + String[][] cases = { + {"max", "Purchase.purchasedAt", "timestamp"}, + {"min", "Purchase.purchasedOn", "date"}, + {"max", "Purchase.status", "string"}, + }; + for (String[] c : cases) { + assertEquals(c[1], m6NotNumeric("numerator", c[2]), + single(ratioOver("@numerator", c[0], c[1]), "ERR_INVALID_MEASURE")); + } + } + + @Test + public void aCountASumAnAvgAndAMinOrMaxOverANumericFieldAreLegalRatioOperands() throws IOException { + String[][] cases = { + {"count", "Purchase.id"}, + {"sum", "Purchase.amountCents"}, + {"avg", "Purchase.amountCents"}, + {"min", "Purchase.amountCents"}, + {"max", "Purchase.programId"}, + }; + for (String[] c : cases) { + assertEquals(c[0] + " " + c[1], List.of(), loadErrors(ratioOver("@numerator", c[0], c[1]))); + assertEquals(c[0] + " " + c[1], List.of(), loadErrors(ratioOver("@denominator", c[0], c[1]))); + } } @Test diff --git a/server/python/src/metaobjects/loader/validate_reporting.py b/server/python/src/metaobjects/loader/validate_reporting.py index f4947cc27..5b2ab91f1 100644 --- a/server/python/src/metaobjects/loader/validate_reporting.py +++ b/server/python/src/metaobjects/loader/validate_reporting.py @@ -62,6 +62,7 @@ IDENTITY_REFERENCE_ATTR_REFERENCES, IDENTITY_SUBTYPE_REFERENCE, ) +from ..meta.core.object.meta_object import MetaObject from ..meta.core.object.object_constants import ( OBJECT_REPORT_ATTR_FILTER, OBJECT_REPORT_ATTR_SEGMENT, @@ -84,6 +85,7 @@ report_measure_names, report_spine, ) +from ..meta.core.reporting.report_shape import measure_result_sub_type from ..meta.core.reporting.reporting_constants import ( AGG_AVG, AGG_COUNT, @@ -103,6 +105,7 @@ REPORTING_ATTR_VIA, ) from ..meta.meta_data import MetaData +from ..meta.meta_root import MetaRoot from ..meta.persistence.source.meta_source import MetaSource from ..naming_refs import CHILD_REF_SEP, resolve_object_ref from ..shared.base_types import ( @@ -721,7 +724,7 @@ def _check_aggregate_columns( def _check_ratio_operands(ctx: _MemberCtx, ratio: MetaMeasure, err: Callable[[str], None]) -> None: - """M6 — each operand names a measure.aggregate of the same entity, and not an any / all one.""" + """M6 — each operand names a measure.aggregate of the same entity whose result is a number.""" operands: list[tuple[str, str | None]] = [ (REPORTING_ATTR_NUMERATOR, ratio.numerator()), (REPORTING_ATTR_DENOMINATOR, ratio.denominator()), @@ -737,11 +740,13 @@ def _check_ratio_operands(ctx: _MemberCtx, ratio: MetaMeasure, err: Callable[[st f"@{attr_name} '{ref}' is a measure.{target.sub_type}; a ratio's operands must be measure.aggregate " f"(a ratio of ratios is not supported)." ) - elif isinstance(target, MetaMeasure) and target.agg() in (AGG_ANY, AGG_ALL): - err( - f"@{attr_name} '{ref}' is an @agg '{target.agg()}' measure, which is a boolean; a ratio divides numbers. " - f"Count the matching rows instead (@agg: count with a @filter)." - ) + elif isinstance(target, MetaMeasure) and isinstance(ctx.host, MetaObject) and isinstance(ctx.root, MetaRoot): + result = measure_result_sub_type(target, ctx.host, ctx.root) + if result is not None and result not in _NUMERIC_FIELD_SUBTYPES: + err( + f"@{attr_name} '{ref}' is a {result} measure; a ratio divides numbers, so its operands must be " + f"numeric measures (a count, a sum, an avg, or a min or max of a numeric field)." + ) # --------------------------------------------------------------------------- diff --git a/server/python/src/metaobjects/meta/core/reporting/report_shape.py b/server/python/src/metaobjects/meta/core/reporting/report_shape.py index a47b4a156..e8f38d28e 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_shape.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_shape.py @@ -375,37 +375,54 @@ def _measure_field(item: str, report: MetaObject, from_: MetaObject, root: MetaR m = _declared_member(from_, TYPE_MEASURE, name, MetaMeasure) if not isinstance(m, MetaMeasure): raise _unresolved(report_name, f"measure '{item}' on '{from_.name}'") + derived = _derive_measure(m, from_, root) + if derived is None: + raise _unresolved(report_name, f"measure '{name}' @of") + sub_type, type_source = derived # Table C: a count is never null; any other measure is not null when it has a @default. required = (not m.is_ratio() and m.agg() == AGG_COUNT) or m.default_value() is not None + return ReportField(name, ROLE_MEASURE, sub_type, required, type_source, measure=m) + + +def _derive_measure(m: MetaMeasure, from_: MetaObject, root: MetaRoot) -> tuple[str, MetaField | None] | None: + """Table B for one measure: its subtype, and the ``@of`` field it carries type attrs from + (if any). None when its ``@of`` does not resolve.""" if m.is_ratio(): - return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_DECIMAL, required, measure=m) + return FIELD_SUBTYPE_DECIMAL, None agg = m.agg() if agg == AGG_COUNT: - return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_LONG, required, measure=m) + return FIELD_SUBTYPE_LONG, None # any / all are boolean whatever the source (the loader's M4 refuses a non-boolean @of). if agg in (AGG_ANY, AGG_ALL): - return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_BOOLEAN, required, measure=m) + return FIELD_SUBTYPE_BOOLEAN, None cols = m.of_columns() of = resolve_reporting_field_ref(cols[0] if cols else "", reporting_member_owner(m, from_), root, from_) if of is None: - raise _unresolved(report_name, f"measure '{name}' @of") + return None src = of.sub_type if agg == AGG_SUM: if src == FIELD_SUBTYPE_CURRENCY: - return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_CURRENCY, required, of, measure=m) - sub_type = ( + return FIELD_SUBTYPE_CURRENCY, of + return ( FIELD_SUBTYPE_LONG if src in _SUM_LONG else FIELD_SUBTYPE_DOUBLE if src in _FLOATING else FIELD_SUBTYPE_DECIMAL - ) - return ReportField(name, ROLE_MEASURE, sub_type, required, measure=m) + ), None if agg == AGG_AVG: - sub_type = FIELD_SUBTYPE_DOUBLE if src in _FLOATING else FIELD_SUBTYPE_DECIMAL - return ReportField(name, ROLE_MEASURE, sub_type, required, measure=m) + return (FIELD_SUBTYPE_DOUBLE if src in _FLOATING else FIELD_SUBTYPE_DECIMAL), None # min / max keep the source field's type. - return ReportField(name, ROLE_MEASURE, src, required, of, measure=m) + return src, of + + +def measure_result_sub_type(measure: MetaMeasure, from_: MetaObject, root: MetaRoot) -> str | None: + """Table B's subtype for one measure of ``from_``, whether or not a report lists it (a + ratio's operands are measures a report need not list). The same rule ``report_shape`` + applies to a listed measure. None when its ``@of`` does not resolve, which the loader + (rule M6) reads before the model is known to be valid.""" + derived = _derive_measure(measure, from_, root) + return None if derived is None else derived[0] def report_shape(report: MetaObject, root: MetaRoot) -> ReportShape: diff --git a/server/python/src/metaobjects/spec_metamodel/reporting.json b/server/python/src/metaobjects/spec_metamodel/reporting.json index 8f725ab9e..67f998e83 100644 --- a/server/python/src/metaobjects/spec_metamodel/reporting.json +++ b/server/python/src/metaobjects/spec_metamodel/reporting.json @@ -28,11 +28,11 @@ { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." } ] }, { "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.", + "description": "A named quotient of two numeric 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": "attr", "subType": "string", "name": "numerator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, + { "type": "attr", "subType": "string", "name": "denominator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." } ] }, { "type": "segment", "subType": "filter", diff --git a/server/python/tests/unit/test_reporting_validation_spine_default.py b/server/python/tests/unit/test_reporting_validation_spine_default.py index 46e671cc4..9a420c269 100644 --- a/server/python/tests/unit/test_reporting_validation_spine_default.py +++ b/server/python/tests/unit/test_reporting_validation_spine_default.py @@ -723,34 +723,54 @@ def test_m7_default_on_a_distinct_count_of_a_tuple_is_refused() -> None: assert "@default cannot apply to @agg: count" in msg -def _ratio_over_a_boolean_measure(agg: str, attr: str) -> Model: +def _ratio_over(attr: str, agg: str, of: str) -> Model: + """``refundShare`` = ``revenue`` / ``purchases``, with ``attr`` renamed onto a new measure ``operand``.""" + def fn(x: Model) -> None: - _set_child( - x, - "Purchase", - "refundFlag", - {"measure.aggregate": {"name": "refundFlag", "@agg": agg, "@of": "Purchase.refunded"}}, - ) + _set_child(x, "Purchase", "operand", {"measure.aggregate": {"name": "operand", "@agg": agg, "@of": of}}) ratio = {"name": "refundShare", "@numerator": "revenue", "@denominator": "purchases"} - ratio[attr] = "refundFlag" + ratio[attr] = "operand" _set_child(x, "Purchase", "refundShare", {"measure.ratio": ratio}) return _edit(fn) -def test_m6_a_ratio_operand_naming_an_any_measure_is_refused_naming_the_operand() -> None: - errors = _errors(_ratio_over_a_boolean_measure("any", "@numerator")) - assert [e.code.value for e in errors] == ["ERR_INVALID_MEASURE"] - assert errors[0].message == ( - "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'refundFlag' is an @agg 'any' measure, " - "which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter)." - ) - assert "['measure.ratio']" in _json_path(errors[0]) +_M6_NOT_NUMERIC = ( + "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'operand' is a {result} measure; a ratio " + "divides numbers, so its operands must be numeric measures (a count, a sum, an avg, or a min or max of a " + "numeric field)." +) -def test_m6_a_ratio_operand_naming_an_all_measure_is_refused() -> None: - msg = _single(_ratio_over_a_boolean_measure("all", "@denominator"), "ERR_INVALID_MEASURE") - assert "@denominator 'refundFlag' is an @agg 'all' measure" in msg +def test_m6_a_ratio_operand_whose_result_is_a_boolean_is_refused_on_the_ratio_node() -> None: + errors = _errors(_ratio_over("@numerator", "any", "Purchase.refunded")) + assert [e.code.value for e in errors] == ["ERR_INVALID_MEASURE"] + assert errors[0].message == _M6_NOT_NUMERIC.format(result="boolean") + assert "['measure.ratio']" in _json_path(errors[0]) + msg = _single(_ratio_over("@denominator", "all", "Purchase.refunded"), "ERR_INVALID_MEASURE") + assert "@denominator 'operand' is a boolean measure" in msg + + +def test_m6_a_min_or_max_over_a_field_that_is_not_numeric_is_refused_as_a_ratio_operand() -> None: + for agg, of, result in [ + ("max", "Purchase.purchasedAt", "timestamp"), + ("min", "Purchase.purchasedOn", "date"), + ("max", "Purchase.status", "string"), + ]: + msg = _single(_ratio_over("@numerator", agg, of), "ERR_INVALID_MEASURE") + assert msg == _M6_NOT_NUMERIC.format(result=result), of + + +def test_m6_a_count_a_sum_an_avg_and_a_min_or_max_over_a_numeric_field_are_legal_ratio_operands() -> None: + for agg, of in [ + ("count", "Purchase.id"), + ("sum", "Purchase.amountCents"), + ("avg", "Purchase.amountCents"), + ("min", "Purchase.amountCents"), + ("max", "Purchase.programId"), + ]: + assert _codes(_ratio_over("@numerator", agg, of)) == [], (agg, of) + assert _codes(_ratio_over("@denominator", agg, of)) == [], (agg, of) def test_m8_default_on_a_max_of_a_timestamp_is_refused() -> None: diff --git a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts index 3d5a1ffc0..e931fc749 100644 --- a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts @@ -91,7 +91,7 @@ function reportViews(root: MetaRoot) { /** * `CREATE VIEW` for every report view of `root` (or only those in `only`), exactly as the recipe * shows. The canonical model has grown reports over tables this file's hand-written DDL does not - * create (measurements, sessions, nodes, auths, all_types): the MySQL lane covers the nine + * create (measurements, sessions, nodes, auths, all_types): the MySQL lane covers the eleven * canonical reports it has DDL for, and the rest are gated on Postgres in every port. */ async function createViews(root: MetaRoot, only?: readonly string[]): Promise { @@ -191,7 +191,7 @@ const RELATIVE_MODEL = JSON.stringify({ "metadata.root": { package: "acme", chil { "source.rdb": { "@kind": "view", "@view": "v_up_to_tomorrow" } } ] } }, ]}}); -/** Every view and table this file creates: the canonical nine, the inline models' and the recipe's. */ +/** Every view and table this file creates: the canonical eleven, the inline models' and the recipe's. */ const OWN_VIEWS = [ ...CANONICAL_VIEWS, "v_events_by_grain", "v_last_twelve_hours", "v_last_two_weeks", "v_up_to_tomorrow", diff --git a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts index 1562deb6a..dd2545172 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -411,13 +411,21 @@ function deriveMeasure(m: MetaMeasure, from: MetaObject, root: MetaRoot): Measur /** * Table B's subtype for one measure of `from`, whether or not a report lists it (a * ratio's operands are measures a report need not list). The same rule `reportShape` - * applies to a listed measure. Throws a plain Error naming the measure when its `@of` - * does not resolve (a measure that passed `validateReporting` always resolves). + * applies to a listed measure. Undefined when its `@of` does not resolve, which the + * loader (rule M6) reads before the model is known to be valid. + */ +export function measureResultSubType(measure: MetaMeasure, from: MetaObject, root: MetaRoot): string | undefined { + return deriveMeasure(measure, from, root)?.subType; +} + +/** + * {@link measureResultSubType} for a model that passed `validateReporting`. Throws a plain + * Error naming the measure when its `@of` does not resolve (a valid measure always resolves). */ export function measureDerivedSubType(measure: MetaMeasure, from: MetaObject, root: MetaRoot): string { - const derived = deriveMeasure(measure, from, root); - if (derived === undefined) throw new Error(`measure '${measure.name}' on '${from.name}': @of does not resolve.`); - return derived.subType; + const subType = measureResultSubType(measure, from, root); + if (subType === undefined) throw new Error(`measure '${measure.name}' on '${from.name}': @of does not resolve.`); + return subType; } /** Table B, with Table C's `required`. Throws a plain Error naming the report when a 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 index 3d17f8052..d5c938470 100644 --- a/server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-definition.embedded.ts @@ -143,7 +143,7 @@ export const REPORTING_DEFINITION: ProviderDefinition = { { "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.", + "description": "A named quotient of two numeric 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": [ { @@ -152,7 +152,7 @@ export const REPORTING_DEFINITION: ProviderDefinition = { "name": "numerator", "min": 1, "max": 1, - "description": "Name of a measure.aggregate on the same entity." + "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, { "type": "attr", @@ -160,7 +160,7 @@ export const REPORTING_DEFINITION: ProviderDefinition = { "name": "denominator", "min": 1, "max": 1, - "description": "Name of a measure.aggregate on the same entity." + "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, { "type": "attr", diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index 897b40675..07512eafe 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -76,7 +76,7 @@ import { 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 { isMetaObject, isMetaRoot, isWritableSource } from "../shared/node-guards.js"; import { AGG_ALL, AGG_ANY } from "../persistence/origin/origin-constants.js"; import { FILTER_RELATIVE_NOW, @@ -96,6 +96,7 @@ import { 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 { measureResultSubType } from "../core/reporting/report-shape.js"; import { reportDerivedFieldName, reportDimensionItems, @@ -666,7 +667,7 @@ function checkAggregateColumns( return { field }; } -/** M6 — each operand names a measure.aggregate of the same entity, and not an any / all one. */ +/** M6 — each operand names a measure.aggregate of the same entity whose result is a number. */ function checkRatioOperands(ctx: MemberCtx, ratio: MetaMeasure, err: (message: string) => void): void { const operands: [string, string | undefined][] = [ [REPORTING_ATTR_NUMERATOR, ratio.numerator()], @@ -682,11 +683,14 @@ function checkRatioOperands(ctx: MemberCtx, ratio: MetaMeasure, err: (message: s `@${attr} '${ref}' is a measure.${target.subType}; a ratio's operands must be measure.aggregate ` + `(a ratio of ratios is not supported).`, ); - } else if (target instanceof MetaMeasure && (target.agg() === AGG_ANY || target.agg() === AGG_ALL)) { - err( - `@${attr} '${ref}' is an @agg '${target.agg()}' measure, which is a boolean; a ratio divides numbers. ` + - `Count the matching rows instead (@agg: count with a @filter).`, - ); + } else if (target instanceof MetaMeasure && isMetaObject(ctx.host) && isMetaRoot(ctx.root)) { + const result = measureResultSubType(target, ctx.host, ctx.root); + if (result !== undefined && !NUMERIC_FIELD_SUBTYPES.includes(result)) { + err( + `@${attr} '${ref}' is a ${result} measure; a ratio divides numbers, so its operands must be numeric ` + + `measures (a count, a sum, an avg, or a min or max of a numeric field).`, + ); + } } } } diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index cb7619c36..124150fde 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -535,23 +535,58 @@ describe("validateReporting — measures", () => { expect(msg).toContain("'avgDaysPerStarter'"); }); - test("M6: a ratio operand naming an any / all measure is refused, naming the operand", async () => { - const withOperand = (agg: string, attr: string) => - edit((x) => { - setChild(x, "Purchase", "refundFlag", { - "measure.aggregate": { name: "refundFlag", "@agg": agg, "@of": "Purchase.refunded" }, - }); - setChild(x, "Purchase", "refundShare", { - "measure.ratio": { name: "refundShare", "@numerator": "revenue", "@denominator": "purchases", [attr]: "refundFlag" }, - }); + /** `refundShare` = `revenue` / `purchases`, with `attr` renamed onto a new measure `operand`. */ + const ratioOver = (attr: string, measure: Body, extraField?: Wrapper) => + edit((x) => { + if (extraField !== undefined) childrenOf(x, "Purchase").push(extraField); + setChild(x, "Purchase", "operand", { "measure.aggregate": { name: "operand", ...measure } }); + setChild(x, "Purchase", "refundShare", { + "measure.ratio": { name: "refundShare", "@numerator": "revenue", "@denominator": "purchases", [attr]: "operand" }, }); - expect(await single(withOperand("any", "@numerator"), "ERR_INVALID_MEASURE")).toBe( - "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'refundFlag' is an @agg 'any' measure, " + - "which is a boolean; a ratio divides numbers. Count the matching rows instead (@agg: count with a @filter).", - ); - expect(await single(withOperand("all", "@denominator"), "ERR_INVALID_MEASURE")).toContain( - "@denominator 'refundFlag' is an @agg 'all' measure", + }); + + test("M6: a ratio operand whose result is a boolean is refused, naming the operand and its type", async () => { + const any = ratioOver("@numerator", { "@agg": "any", "@of": "Purchase.refunded" }); + expect(await single(any, "ERR_INVALID_MEASURE")).toBe( + "measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'operand' is a boolean measure; " + + "a ratio divides numbers, so its operands must be numeric measures (a count, a sum, an avg, or a min " + + "or max of a numeric field).", ); + const all = ratioOver("@denominator", { "@agg": "all", "@of": "Purchase.refunded" }); + expect(await single(all, "ERR_INVALID_MEASURE")).toContain("@denominator 'operand' is a boolean measure"); + }); + + test("M6: a min or max over a field that is not numeric is refused as a ratio operand", async () => { + const cases: [agg: string, of: string, result: string, extraField?: Wrapper][] = [ + ["max", "Purchase.purchasedAt", "timestamp"], + ["min", "Purchase.purchasedOn", "date"], + ["max", "Purchase.status", "string"], + ["min", "Purchase.token", "uuid", { "field.uuid": { name: "token" } }], + ["max", "Purchase.tier", "enum", { "field.enum": { name: "tier", "@values": ["basic", "pro"] } }], + ]; + for (const [agg, of, result, extraField] of cases) { + const m = ratioOver("@numerator", { "@agg": agg, "@of": of }, extraField); + expect([of, await single(m, "ERR_INVALID_MEASURE")]).toEqual([ + of, + `measure 'refundShare' on entity 'acme::shop::Purchase': @numerator 'operand' is a ${result} measure; ` + + "a ratio divides numbers, so its operands must be numeric measures (a count, a sum, an avg, or a min " + + "or max of a numeric field).", + ]); + } + }); + + test("M6: a count, a sum, an avg and a min or max over a numeric field are legal ratio operands", async () => { + const operands: Body[] = [ + { "@agg": "count", "@of": "Purchase.id" }, + { "@agg": "sum", "@of": "Purchase.amountCents" }, + { "@agg": "avg", "@of": "Purchase.amountCents" }, + { "@agg": "min", "@of": "Purchase.amountCents" }, + { "@agg": "max", "@of": "Purchase.programId" }, + ]; + for (const operand of operands) { + expect([operand, await codes(ratioOver("@numerator", operand))]).toEqual([operand, []]); + expect([operand, await codes(ratioOver("@denominator", operand))]).toEqual([operand, []]); + } }); test("M6: a ratio operand naming nothing is refused", async () => { diff --git a/site-reference/index.html b/site-reference/index.html index d8a331fc6..32ea3bb53 100644 --- a/site-reference/index.html +++ b/site-reference/index.html @@ -252,7 +252,7 @@ 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. +A named quotient of two numeric measure.aggregate siblings (FR-044), lowered as numerator / NULLIF(denominator, 0) and typed decimal. A zero denominator yields null. types/measure.md#measureratio diff --git a/site-reference/types/measure.html b/site-reference/types/measure.html index ef091ead9..2aaafff92 100644 --- a/site-reference/types/measure.html +++ b/site-reference/types/measure.html @@ -124,7 +124,7 @@

measure.aggregate

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.

+

A named quotient of two numeric 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

@@ -155,7 +155,7 @@

measure.ratio

- + @@ -164,7 +164,7 @@

measure.ratio

- +
—Name of a measure.aggregate on the same entity.Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field.
@numerator —Name of a measure.aggregate on the same entity.Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field.

Allowed children

diff --git a/spec/metamodel/reporting.json b/spec/metamodel/reporting.json index 8f725ab9e..67f998e83 100644 --- a/spec/metamodel/reporting.json +++ b/spec/metamodel/reporting.json @@ -28,11 +28,11 @@ { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null), on min/max over a field that is not numeric, and on @agg any/all unless it is 0 (false) or 1 (true). A ratio's operand carries its own @default into the ratio." } ] }, { "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.", + "description": "A named quotient of two numeric 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": "attr", "subType": "string", "name": "numerator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, + { "type": "attr", "subType": "string", "name": "denominator", "min": 1, "max": 1, "description": "Name of a measure.aggregate on the same entity whose result is numeric: a count, a sum, an avg, or a min or max of a numeric field." }, { "type": "attr", "subType": "int", "name": "default", "min": 0, "max": 1, "description": "Optional integer the measure reads when it would otherwise be null: nothing matched, every matched value is null, or (a ratio) the denominator is zero or null. The derived report field is then never null. Refused on @agg: count (a count is never null) and on min/max over a field that is not numeric. A ratio's operand carries its own @default into the ratio." } ] }, { "type": "segment", "subType": "filter", From 83abce305a148e5ecbe011e7b959fb34dad17b17 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 13:19:50 -0400 Subject: [PATCH 4/8] no-mistakes(review): Cube boolean leaf as max/min; MySQL still returns "1 Assisted-by: no-mistakes:claude:claude-opus-5-5 --- CHANGELOG.md | 4 +- docs/features/cube-export.md | 15 +++-- docs/features/reporting.md | 4 +- ...2026-10-02-fr-044-core-reporting-design.md | 10 ++- fixtures/cube-model/README.md | 4 +- .../model/cubes/ProgramFlagRosterFacts.yml | 18 +++-- .../canonical/expected/model/cubes/Week.yml | 32 ++++++--- .../expected/model/cubes/Call.yml | 32 ++++++--- .../expected/model/cubes/Call.yml | 32 ++++++--- .../codegen-ts/src/cube/build-cube-model.ts | 18 ++--- .../codegen-ts/src/cube/cube-members.ts | 67 ++++++++++--------- .../test/cube/build-cube-model.test.ts | 27 +++++--- .../codegen-ts/test/cube/cube-yaml.test.ts | 50 +++++++++----- .../test/cube/measure-default.test.ts | 32 +++++++++ .../cube-live/cube-live-support.ts | 7 +- 15 files changed, 234 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f958b9f..9decd448e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,7 +244,9 @@ generator catalog and the Atlas evaluation are 1.2 work. `@default` other than `0` or `1` is M9 (both `ERR_INVALID_MEASURE`). An `any` / `all` measure is not a legal `measure.ratio` operand (rule M6, tightened under **Changed**). Postgres writes `bool_or` / `bool_and`; SQLite and MySQL write `MAX` / `MIN` over `0` / `1`. The `cube-model` generator - writes a `type: boolean` measure. See [reporting](docs/features/reporting.md#boolean-measures-any-and-all). + writes a `type: boolean` measure, `{Raw} = 1`, over a private `Raw` that is a `max` (`any`) + or `min` (`all`) of the column as `0` / `1`, so a report with a boolean measure keeps its rollup. + See [reporting](docs/features/reporting.md#boolean-measures-any-and-all). No `metamodelVersion` change: the vocabulary is part of 1.1. - **TypeScript: the `cube-model` reference generator (FR-044 Plan 4).** `cubeModel()` writes the reporting vocabulary as Cube data-model files, `model/cubes/.yml`: a cube for each diff --git a/docs/features/cube-export.md b/docs/features/cube-export.md index baf1d69f9..4373d3288 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -322,10 +322,10 @@ somewhere to go: an `identity.reference` onto an entity that is not a cube makes | `count` with `@distinct` | `type: count_distinct`, `sql: x` | | `count` with `@distinct` over a tuple `x1, x2` | Postgres: `type: count_distinct`, `sql: 'ROW(x1, x2)'`, with one `filters` entry `x1 IS NOT NULL AND x2 IS NOT NULL`, so a tuple with a null component is not counted, as in the view. MySQL: the view's own `COUNT(DISTINCT x1, x2)` as a `type: number` measure (a condition `c` makes it `COUNT(DISTINCT CASE WHEN c THEN x1 END, x2)`); MySQL's multi-argument form skips a tuple with a null component and compares by the columns' collation, which a `JSON_ARRAY(x1, x2)` key does not: on mysql:8.4 under the default `utf8mb4_0900_ai_ci` it counted `'abc'` and `'ABC'` as two tuples where the view counts one | | `sum`, `avg`, `min`, `max` | `type: sum`, `avg`, `min`, `max`, `sql: x` | -| `any`, `all` | `type: boolean`. Postgres: `sql: 'bool_or(x)'` / `'bool_and(x)'`, a condition `c` as `bool_or(x) FILTER (WHERE c)`. MySQL: `sql: 'MAX(x)'` / `'MIN(x)'`, a condition as `MAX(CASE WHEN c THEN x END)`. The aggregate is the view's own, written in `sql` because Postgres has no `MAX(boolean)` and the condition cannot ride in `filters`. | +| `any`, `all` | two members. `Raw` is `type: max` for `any` and `type: min` for `all`, over the column as `0` / `1`, with `public: false`: `sql: 'CAST(x AS INTEGER)'` on Postgres (which has no `MAX(boolean)`) and `sql: x` on MySQL (which stores a boolean as `0` / `1`). `` is `type: boolean`, `sql: '{Raw} = 1'`, and carries the measure's `title` and `description`. A null stays null, so the aggregate ignores it as the view's `bool_or` / `bool_and` do. The leaf is a number because a rollup holds a `max` or a `min` and Cube re-aggregates it; a `boolean` leaf holding `bool_or(x)` cannot be re-aggregated. | | any of the above with a condition `c` | the same, with one `filters` entry `c`. For a Postgres tuple, `c` is ANDed after the not-null terms in that one entry. | | `measure.ratio` | `type: number`. Postgres: `CAST({num} AS NUMERIC) / NULLIF({den}, 0)`. MySQL: `{num} / NULLIF({den}, 0)`. | -| a `measure.aggregate` with `@default: n` | two members. `Raw` is the aggregate as the rows above write it (its `type`, `sql` and `filters`), with `public: false`. `` is `type: number` (`type: boolean` for an `any` or `all`, where `n` is written `TRUE` / `FALSE` on Postgres and `1` / `0` on MySQL), `sql: 'COALESCE({Raw}, n)'`, and carries the measure's `title` and `description`. | +| a `measure.aggregate` with `@default: n` | two members. `Raw` is the aggregate as the rows above write it (its `type`, `sql` and `filters`), with `public: false`. `` is `type: number`, `sql: 'COALESCE({Raw}, n)'`, and carries the measure's `title` and `description`. An `any` or `all` already has its `Raw`, and the default goes inside the comparison: `type: boolean`, `sql: 'COALESCE({Raw}, n) = 1'`, with `n` `0` or `1`. | | a `measure.ratio` with `@default: n` | `type: number`, the quotient above inside `COALESCE(…, n)` | In a ratio, `{num}` and `{den}` are references to the two operand measures, so each operand is @@ -337,9 +337,10 @@ ratio whether or not the ratio declares one, as in the view (`revenue` with `@de `@default` is the value the view reads in place of null (`COALESCE(E, n)`), and Cube reads the same: a defaulted measure is never null, on an empty group, a group whose rows the measure's condition filters out, or a ratio whose denominator is zero. Cube has no nullability to declare -for a measure, so nothing else is written for it. A rollup lists ``, never `Raw`. As a -`number` measure, `` is served from a rollup only when the query's dimensions are the -rollup's own, which is the query a report makes. +for a measure, so nothing else is written for it. A rollup lists ``, never `Raw`, for a +defaulted measure and for an `any` or `all`. As a `number` or `boolean` measure over its +`Raw`, `` is served from a rollup only when the query's dimensions are the rollup's own, +which is the query a report makes. No measure sets `format`: Cube's named formats are display hints and the model carries none. @@ -416,7 +417,7 @@ scope segment: |---|---| | the view | `model/views/.yml`, named after the report, with the report's `title` and `description`. Views and cubes share Cube's one namespace. | | the spine cube | the cube each listed dimension reaches after the spine's hops: the spine entity's own cube, its join-target cube, or the alias cube of the last hop. It gets one `one_to_many` join onto the report's facts cube (or onto its first chain cube), on the columns of the spine hop's reference. | -| `Facts` | a standalone cube, `public: false`, whose `sql` is `SELECT * FROM ` with the report's `@segment` filter and then its `@filter`, ANDed, as its `WHERE` (none when the report has neither; for a TPH subtype, the base table, its discriminator first). The alias is the one the report view gives `@from`, so the condition reads as the view's join condition does. It holds `@from`'s primary key and `@from`'s own definitions of the measures the report lists, the operands of a listed ratio and the `Raw` of a defaulted measure, in `@from`'s order; what the report does not list is `public: false`. | +| `Facts` | a standalone cube, `public: false`, whose `sql` is `SELECT * FROM ` with the report's `@segment` filter and then its `@filter`, ANDed, as its `WHERE` (none when the report has neither; for a TPH subtype, the base table, its discriminator first). The alias is the one the report view gives `@from`, so the condition reads as the view's join condition does. It holds `@from`'s primary key and `@from`'s own definitions of the measures the report lists, the operands of a listed ratio and the `Raw` of a defaulted measure or of an `any` / `all`, in `@from`'s order; what the report does not list is `public: false`. | | `_` | for a spine of more than one hop, a standalone chain cube, `public: false`, for each entity between `@from` and the spine entity: its table, its primary key and one `one_to_many` join onward, towards the facts. `` is the hop that reaches the entity from `@from`'s side, so `Session.fkWeek.fkProgram` gives `ProgramSessions_fkWeek`. | | the view's `cubes` | first `join_path: ` (and, for a dimension past the spine, the join path its own joins take from there), including the member each listed dimension reads, under the dimension's name (`id` as `programKey`) and with the dimension's `title`, `description` and `meta.grains`; last, `join_path: [.].Facts`, including the listed measures. | @@ -453,7 +454,7 @@ view lowering's rows. A view is answered from the tables. | cube name | the entity's name. Two entities of one name in two packages, or an alias, facts or chain cube or a `@spine` report's view named like a cube, is `ERR_CUBE_NAME_COLLISION`, naming both (the message says "cube or view" when one is a view): Cube's cubes and views share one namespace. Rename one, or narrow the generator's `filter` to leave one out; the filter helps only when no `@via` reaches the entity it leaves out, since an entity a `@via` reaches is still written as a join-target cube, unless every hop onto it goes through an alias cube. | | member name | the dimension, measure or segment name as written, so a report field and its Cube member share a name | | a name Cube refuses | Cube names start with a letter, hold only letters, digits and `_`, and are not a Python keyword (`from`, `class`, `in`, `is`, `not`, `and`, `or`, `if`, `else`, `for`, `while`, `with`, `as`, `def`, `return`, `yield`, `import`, `pass`, `global`, `nonlocal`, `lambda`, `del`, `assert`, `break`, `continue`, `try`, `except`, `finally`, `raise`, `async`, `await`, `True`, `False`, `None`, `elif`). That is `ERR_CUBE_INVALID_NAME`, naming the node. The exporter never renames: the name is the report field's. | -| members the exporter adds | primary-key dimensions, reached-column dimensions, the `Raw` measure of a defaulted measure, `Scope` segments, rollups. A name that collides with another member of the cube is `ERR_CUBE_MEMBER_COLLISION`, naming both. The one exception is a declared dimension over a key field under its own name, which is that key dimension (see [Cubes and primary keys](#cubes-and-primary-keys)). | +| members the exporter adds | primary-key dimensions, reached-column dimensions, the `Raw` measure of a defaulted measure or of an `any` / `all`, `Scope` segments, rollups. A name that collides with another member of the cube is `ERR_CUBE_MEMBER_COLLISION`, naming both. The one exception is a declared dimension over a key field under its own name, which is that key dimension (see [Cubes and primary keys](#cubes-and-primary-keys)). | | identifiers | every table, schema and column is quoted: `"…"` on Postgres, backticks on MySQL | | string literals | SQL quoting first (`'` doubled; MySQL also doubles `\`). Cube compiles every `sql` as a template literal, where a backslash is an escape (`\b` a backspace, `\_` a plain `_`, a trailing `\` swallows the closing quote) and `{x}` a member reference. So every `\` is then doubled, and after that `{` becomes `\{` and `}` becomes `\}` (in that order, so a backslash before a brace stays a backslash). Then, when the literal holds a Jinja opener (`{{`, `{%` or `{#`), it is wrapped in `{% raw %}…{% endraw %}` for Jinja, because a backslash does not stop Jinja. A wrapped literal holding `endraw` is `ERR_CUBE_UNESCAPABLE_LITERAL`; one that is not wrapped is never inside a raw block, so `endraw` there is plain text. A column name written with `@column` gets the same treatment. The `cube` lane reads each literal of the `escaping` case back from Cube's `/v1/sql`, where it is the view's own SQL. | | free text | `title` and `description`. Cube reads them as templates too: `{x}` is a member reference, `${x}` an interpolation and a backslash an escape. Each `\` is doubled, each brace escaped, the text raw-wrapped when the original holds `{{`, `{%` or `{#` (the same rule as a SQL literal), and the result is written as a JSON string. Raw-wrapped text holding `endraw` is `ERR_CUBE_UNESCAPABLE_LITERAL`. | diff --git a/docs/features/reporting.md b/docs/features/reporting.md index b20335743..0483d2ddb 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -489,8 +489,8 @@ and the measure is a `boolean` in every port: `true` or `false` on the wire, nev `1`, so they write `MAX(x)` for `any` and `MIN(x)` for `all` (`MAX(CASE WHEN … THEN x END)` for a condition). The runtime reads the column as the boolean it is typed as. A default is `COALESCE(, FALSE)` on Postgres and `COALESCE(, 0)` elsewhere. -- **Cube.** The export writes a `type: boolean` measure holding the same aggregate, see - [cube-export.md](cube-export.md). +- **Cube.** The export writes a `type: boolean` measure over a private `max` / `min` of the + column as `0` / `1`, which a rollup can hold, see [cube-export.md](cube-export.md). ### What the runtime does with it diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index 6b7a08925..d4989bedc 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -517,8 +517,12 @@ existing attribute's closed set, which is a value of `@agg`, not a new attribute **Admission.** RDB as above. A store with no boolean aggregate is capability-gated as for any aggregate it cannot express exactly; none is lowered today. -**Mapping.** Cube: `type: boolean` holding the aggregate in `sql` (Cube's `min` / `max` cannot take -a Postgres boolean, and a condition rides in the SQL, as for a MySQL tuple count). MetricFlow has +**Mapping.** Cube: two members. `Raw` is a private `max` (`any`) or `min` (`all`) over the column +as `0` / `1` (`CAST(x AS INTEGER)` on Postgres, which has no `MAX(boolean)`; the column itself on +MySQL), its condition in `filters`; `` is `type: boolean`, `{Raw} = 1`, with a `@default` +inside the comparison. The leaf is an additive number so that a report with a boolean measure keeps +its rollup: Cube re-aggregates a `max` / `min` it reads from a rollup, and cannot re-aggregate a +`boolean` leaf holding `bool_or(x)`. MetricFlow has no `any` / `all` aggregation (`sum_boolean` counts the trues); the dbt exporter, when it exists, must refuse it by name or map it to `max` / `min` over a 0/1 expression. It is not part of this change. @@ -535,7 +539,7 @@ change. | `measure.aggregate` count + `@distinct` (one column) | `count_distinct` | `count_distinct` | | `measure.aggregate` count + `@distinct` (tuple) | `count_distinct` over `ROW(…)` with a not-null filter on each column (MySQL: the view's own `COUNT(DISTINCT a, b)` as a `number` measure) | `count_distinct` over an expression | | `measure.aggregate` sum/avg/min/max | `sum`/`avg`/`min`/`max` | `sum`/`average`/`min`/`max` | -| `measure.aggregate` any/all | `boolean` measure holding `bool_or(x)` / `bool_and(x)` (MySQL `MAX(x)` / `MIN(x)`) | none yet: `sum_boolean` counts the trues; the dbt exporter refuses it or maps `max` / `min` over a 0/1 expression | +| `measure.aggregate` any/all | `boolean` measure `{Raw} = 1` over a private `max` / `min` of the column as `0` / `1` | none yet: `sum_boolean` counts the trues; the dbt exporter refuses it or maps `max` / `min` over a 0/1 expression | | `measure.ratio` | `number` measure `{a} / NULLIF({b}, 0)`; on Postgres `CAST({a} AS NUMERIC) / NULLIF({b}, 0)`, with no cast on MySQL | `ratio` metric | | `measure.derived` | `number` measure | `derived` metric | | `segment` | segment | metric `filter` / saved query filter | diff --git a/fixtures/cube-model/README.md b/fixtures/cube-model/README.md index 9868680b7..6199dbc89 100644 --- a/fixtures/cube-model/README.md +++ b/fixtures/cube-model/README.md @@ -59,8 +59,8 @@ never copied from it. A golden blessed from the implementation proves nothing. | `measure-ratio` | B: `measure.ratio`; D | `CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0)`, `type: number` | | `measure-mysql` | D (MySQL arms) | backtick quoting; a tuple distinct count is the view's own `COUNT(DISTINCT a, b)` as a `number` measure, a condition as `CASE WHEN … THEN a END` on the first component (a `JSON_ARRAY` key compares bytes, not the column collation, so on mysql:8.4 it counted 6 tuples where the view counts 4); a ratio without the cast | | `measure-default` | B: measure `@default`; the zero-rows / measure-defaults plan, Tables D and E | a `measure.aggregate` with `@default: n` is `Raw` (the Table D aggregate, its condition kept, `public: false`) and `` (`type: number`, `COALESCE({Raw}, n)`), with the measure's description on ``; a negative sentinel (`-1`) on an `avg`; a ratio with `@default` wraps its quotient in `COALESCE`; a ratio whose operand has its own `@default` reads it through the operand's `COALESCE` member, with or without a default of its own (decision 4); the rollup lists ``, never `Raw` | -| `measure-boolean` | B: `measure.aggregate` `@agg: any` / `all`; R10 | a boolean aggregate is `type: boolean` and the aggregate is the view's own, in `sql` (Postgres `bool_or(x)` / `bool_and(x)`; Cube's `min` / `max` cannot take a Postgres boolean): a condition rides in the SQL as `FILTER (WHERE c)`, not in `filters`; `@default: 0` / `1` is `Raw` and `` as for any default but `type: boolean`, `COALESCE({Raw}, FALSE | TRUE)` | -| `measure-boolean-mysql` | B: the same, MySQL | `MAX(x)` / `MIN(x)` over the boolean column, a condition as `MAX(CASE WHEN c THEN x END)`, the default `COALESCE({Raw}, 0 | 1)` | +| `measure-boolean` | B: `measure.aggregate` `@agg: any` / `all`; R10 | a boolean aggregate is two members: `Raw`, a `max` (`any`) or `min` (`all`) over `CAST(x AS INTEGER)` with `public: false` (an additive number a rollup can hold; Postgres has no `MAX(boolean)`), and ``, `type: boolean`, `{Raw} = 1`; a condition is the leaf's one `filters` entry; `@default: 0` / `1` goes inside the comparison, `COALESCE({Raw}, 0 | 1) = 1` | +| `measure-boolean-mysql` | B: the same, MySQL | `Raw` is the `max` / `min` over the boolean column itself, which MySQL stores as `0` / `1` (no cast), the condition in `filters`; `` and its default are as on Postgres | | `segment` | B: `segment.filter` | `=`, `IN (…)`, `LIKE` with `>` ANDed, an `or` group with `IS NULL`; YAML doubles every `'` | | `relative-date` | B: relative filter (Plan 2 Table E) | `(now() - INTERVAL 'P7D')` for an instant, `((now() AT TIME ZONE 'UTC') - INTERVAL 'P7D')` for a naive timestamp, `CAST(… AS DATE)` for a date, `+` for a future offset, and the same SQL in a measure's condition | | `report-rollup` | B: served report; F | a `rollup` named after the report: dimensions and measures in listed order, `segments` with `@segment` then the scope segment `programsByMonthScope`, the one-time-dimension form; a report with no dimensions is a measures-only rollup; rollups are written coarsest first, so `ProgramTotals` precedes `ProgramsByMonth` | diff --git a/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml b/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml index 480837ebc..d0b37367d 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml @@ -12,20 +12,24 @@ cubes: - name: weeks sql: '{CUBE}."id"' type: count + - name: anyDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max + public: false - name: anyDeload - sql: 'bool_or({CUBE}."deload")' + sql: '{anyDeloadRaw} = 1' type: boolean - name: anyDeloadOrFalseRaw - sql: 'bool_or({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' type: boolean - name: allDeloadOrTrueRaw - sql: 'bool_and({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: min public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' type: boolean diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml index 1ba638ba5..843326c62 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml @@ -87,28 +87,42 @@ cubes: type: sum filters: - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' + - name: anyDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max + public: false - name: anyDeload - sql: 'bool_or({CUBE}."deload")' + sql: '{anyDeloadRaw} = 1' type: boolean + - name: allDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: min + public: false - name: allDeload - sql: 'bool_and({CUBE}."deload")' + sql: '{allDeloadRaw} = 1' type: boolean - name: anyDeloadOrFalseRaw - sql: 'bool_or({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' type: boolean - name: allDeloadOrTrueRaw - sql: 'bool_and({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: min public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' type: boolean + - name: anyLongDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max + filters: + - sql: '{CUBE}."durationMinutes" >= 60' + public: false - name: anyLongDeload - sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)' + sql: '{anyLongDeloadRaw} = 1' type: boolean segments: - name: long diff --git a/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml b/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml index ade6e9fe6..be4d7ac76 100644 --- a/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml +++ b/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml @@ -8,26 +8,40 @@ cubes: type: number primary_key: true measures: + - name: anyFailedRaw + sql: '{CUBE}.`failed`' + type: max + public: false - name: anyFailed - sql: 'MAX({CUBE}.`failed`)' + sql: '{anyFailedRaw} = 1' type: boolean + - name: allFailedRaw + sql: '{CUBE}.`failed`' + type: min + public: false - name: allFailed - sql: 'MIN({CUBE}.`failed`)' + sql: '{allFailedRaw} = 1' type: boolean + - name: anySlowFailedRaw + sql: '{CUBE}.`failed`' + type: max + filters: + - sql: '{CUBE}.`duration_ms` >= 1000' + public: false - name: anySlowFailed - sql: 'MAX(CASE WHEN {CUBE}.`duration_ms` >= 1000 THEN {CUBE}.`failed` END)' + sql: '{anySlowFailedRaw} = 1' type: boolean - name: anyFailedOrFalseRaw - sql: 'MAX({CUBE}.`failed`)' - type: boolean + sql: '{CUBE}.`failed`' + type: max public: false - name: anyFailedOrFalse - sql: 'COALESCE({anyFailedOrFalseRaw}, 0)' + sql: 'COALESCE({anyFailedOrFalseRaw}, 0) = 1' type: boolean - name: allFailedOrTrueRaw - sql: 'MIN({CUBE}.`failed`)' - type: boolean + sql: '{CUBE}.`failed`' + type: min public: false - name: allFailedOrTrue - sql: 'COALESCE({allFailedOrTrueRaw}, 1)' + sql: 'COALESCE({allFailedOrTrueRaw}, 1) = 1' type: boolean diff --git a/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml b/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml index 60627dcd7..3990e1f05 100644 --- a/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml +++ b/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml @@ -8,26 +8,40 @@ cubes: type: number primary_key: true measures: + - name: anyFailedRaw + sql: 'CAST({CUBE}."failed" AS INTEGER)' + type: max + public: false - name: anyFailed - sql: 'bool_or({CUBE}."failed")' + sql: '{anyFailedRaw} = 1' type: boolean + - name: allFailedRaw + sql: 'CAST({CUBE}."failed" AS INTEGER)' + type: min + public: false - name: allFailed - sql: 'bool_and({CUBE}."failed")' + sql: '{allFailedRaw} = 1' type: boolean + - name: anySlowFailedRaw + sql: 'CAST({CUBE}."failed" AS INTEGER)' + type: max + filters: + - sql: '{CUBE}."duration_ms" >= 1000' + public: false - name: anySlowFailed - sql: 'bool_or({CUBE}."failed") FILTER (WHERE {CUBE}."duration_ms" >= 1000)' + sql: '{anySlowFailedRaw} = 1' type: boolean - name: anyFailedOrFalseRaw - sql: 'bool_or({CUBE}."failed")' - type: boolean + sql: 'CAST({CUBE}."failed" AS INTEGER)' + type: max public: false - name: anyFailedOrFalse - sql: 'COALESCE({anyFailedOrFalseRaw}, FALSE)' + sql: 'COALESCE({anyFailedOrFalseRaw}, 0) = 1' type: boolean - name: allFailedOrTrueRaw - sql: 'bool_and({CUBE}."failed")' - type: boolean + sql: 'CAST({CUBE}."failed" AS INTEGER)' + type: min public: false - name: allFailedOrTrue - sql: 'COALESCE({allFailedOrTrueRaw}, TRUE)' + sql: 'COALESCE({allFailedOrTrueRaw}, 1) = 1' type: boolean diff --git a/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts b/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts index b4984c6bd..162dff30e 100644 --- a/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts +++ b/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts @@ -78,12 +78,12 @@ import { ERR_CUBE_UNSUPPORTED_DIALECT, } from "./cube-errors.js"; import { - defaultedRawMemberName, dimensionColumn, docOf, grainsOf, measureMembers, memberKey, + rawMemberName, segmentSpec, type MemberContext, } from "./cube-members.js"; @@ -476,10 +476,12 @@ class CubeModelBuilder { draft.declaredOrder.push(dim); if (dim.via() === undefined) draft.declaredDims.set(dim, this.ownDimension(draft, dim, where)); } else if (child.type === TYPE_MEASURE) { - const { raw, measure } = measureMembers(draft.entity, child as MetaMeasure, where, this.mc); + const declared = child as MetaMeasure; + const { raw, measure } = measureMembers(draft.entity, declared, where, this.mc); if (raw !== undefined) { - // A defaulted measure.aggregate: its aggregate is the member the exporter adds, `Raw`. - draft.namespace.add(raw.name, `the measure '${raw.name}' the exporter adds for the @default of ${label}`); + // A defaulted or boolean measure.aggregate: its aggregate is the member the exporter adds, `Raw`. + const reason = declared.defaultValue() !== undefined ? "@default" : "@agg"; + draft.namespace.add(raw.name, `the measure '${raw.name}' the exporter adds for the ${reason} of ${label}`); draft.measures.push(raw); } draft.measures.push(measure); @@ -867,16 +869,16 @@ class CubeModelBuilder { /** * The facts cube's measures: @from's own cube's, in its order, narrowed to those the report lists, - * the operands of a listed ratio and the `Raw` of each defaulted one, so every definition is - * the ordinary cube's. A measure the report does not list is `public: false`. + * the operands of a listed ratio and the `Raw` of each defaulted or boolean one, so every + * definition is the ordinary cube's. A measure the report does not list is `public: false`. */ private factsMeasures(shape: ReportShape, from: CubeDraft, where: string): CubeMeasureSpec[] { const listed = new Set(); const needed = new Set(); const need = (m: MetaMeasure): void => { needed.add(m.name); - // The `Raw` twin is earned by one rule, in `defaultedRawMemberName`, shared with measureMembers. - const raw = defaultedRawMemberName(m); + // The `Raw` twin is earned by one rule, in `rawMemberName`, shared with measureMembers. + const raw = rawMemberName(m); if (raw !== undefined) needed.add(raw); }; for (const f of shape.fields) { diff --git a/server/typescript/packages/codegen-ts/src/cube/cube-members.ts b/server/typescript/packages/codegen-ts/src/cube/cube-members.ts index e10ee05e8..d18ad9e44 100644 --- a/server/typescript/packages/codegen-ts/src/cube/cube-members.ts +++ b/server/typescript/packages/codegen-ts/src/cube/cube-members.ts @@ -50,7 +50,6 @@ import type { CubeSegmentSpec, } from "./cube-model-spec.js"; import { CUBE_SELF, cubeColumn, cubeSqlRenderer, memberRef } from "./cube-sql.js"; -import type { ReportDialect } from "../projection/time-sql.js"; /** What every member mapping reads: the model, the dialect, the column naming strategy. */ export interface MemberContext { @@ -147,34 +146,43 @@ export function dimensionColumn( ); } -/** `Raw`: the member that holds a defaulted measure.aggregate's aggregate (Table G, added members). */ +/** `Raw`: the member that holds a measure.aggregate's aggregate when `` reads it (Table G, added members). */ export function rawMeasureName(measure: string): string { return `${measure}Raw`; } +/** `@agg: any` / `all`: the measure is a boolean (R10). */ +function isBooleanAggregate(m: MetaMeasure): boolean { + // ADR-0039: agg() resolves, so a measure inherited through extends keeps its aggregate. + const agg = m.agg(); + return agg === AGG_ANY || agg === AGG_ALL; +} + /** - * The `Raw` twin a measure earns: only a non-ratio `measure.aggregate` with `@default` gets one. - * `measureMembers` emits under this name and the facts cube keeps it — one rule, two consumers. + * The `Raw` twin a measure earns: a non-ratio `measure.aggregate` with `@default`, or with + * `@agg: any` / `all`, gets one. `measureMembers` emits under this name and the facts cube keeps + * it — one rule, two consumers. */ -export function defaultedRawMemberName(m: MetaMeasure): string | undefined { +export function rawMemberName(m: MetaMeasure): string | undefined { + if (m.isRatio()) return undefined; // ADR-0039: defaultValue() resolves, so a measure inherited through extends keeps its default. - return !m.isRatio() && m.defaultValue() !== undefined ? rawMeasureName(m.name) : undefined; + return m.defaultValue() !== undefined || isBooleanAggregate(m) ? rawMeasureName(m.name) : undefined; } /** - * One measure as Cube members. `raw` is present for a measure.aggregate with `@default`: it is - * the Table D aggregate, `public: false`, under `Raw`, and `measure` reads it through - * `COALESCE` (the zero-rows / measure-defaults plan, Table D: `COALESCE(E, n)`). + * One measure as Cube members. `raw` is the Table D aggregate, `public: false`, under `Raw`, + * and is present in two cases. A measure.aggregate with `@default`: `measure` reads it through + * `COALESCE` (the zero-rows / measure-defaults plan, Table D: `COALESCE(E, n)`). An `@agg: any` / + * `all`: `raw` is a `max` / `min` over the column as `0` / `1`, an additive number a rollup can + * hold, and `measure` is the `boolean` that reads it as `= 1`. */ export interface MeasureMembers { readonly raw?: CubeMeasureSpec; readonly measure: CubeMeasureSpec; } -/** `COALESCE(, n)`: `n` is the measure's integer `@default`. A boolean measure's `@default` is - * 0 or 1, written as a boolean literal on Postgres (the view's own form). */ -function withDefault(sql: string, n: number, boolean: boolean, d: ReportDialect): string { - if (boolean && d === "postgres") return `COALESCE(${sql}, ${n === 1 ? "TRUE" : "FALSE"})`; +/** `COALESCE(, n)`: `n` is the measure's integer `@default`. */ +function withDefault(sql: string, n: number): string { return `COALESCE(${sql}, ${String(n)})`; } @@ -189,20 +197,16 @@ export function measureMembers(entity: MetaObject, m: MetaMeasure, where: string const num = memberRef(ratioOperand(entity, m, m.numerator(), where).name); const den = memberRef(ratioOperand(entity, m, m.denominator(), where).name); const quotient = mc.dialect === "postgres" ? `CAST(${num} AS NUMERIC) / NULLIF(${den}, 0)` : `${num} / NULLIF(${den}, 0)`; - return { measure: { name: m.name, sql: n === undefined ? quotient : withDefault(quotient, n, false, mc.dialect), type: "number", ...docOf(m) } }; + return { measure: { name: m.name, sql: n === undefined ? quotient : withDefault(quotient, n), type: "number", ...docOf(m) } }; } const aggregate = aggregateSpec(entity, m, where, mc); - if (n === undefined) return { measure: { name: m.name, ...aggregate, ...docOf(m) } }; - // Past the ratio branch, this measure satisfies the helper's rule: its twin name exists. - const raw = defaultedRawMemberName(m)!; + const raw = rawMemberName(m); + if (raw === undefined) return { measure: { name: m.name, ...aggregate, ...docOf(m) } }; + const value = n === undefined ? memberRef(raw) : withDefault(memberRef(raw), n); + const boolean = isBooleanAggregate(m); return { raw: { name: raw, ...aggregate, public: false }, - measure: { - name: m.name, - sql: withDefault(memberRef(raw), n, aggregate.type === "boolean", mc.dialect), - type: aggregate.type === "boolean" ? "boolean" : "number", - ...docOf(m), - }, + measure: { name: m.name, sql: boolean ? `${value} = 1` : value, type: boolean ? "boolean" : "number", ...docOf(m) }, }; } @@ -235,20 +239,17 @@ function aggregateSpec( const filter = [...cols.map((x) => `${x} IS NOT NULL`), ...(c === undefined ? [] : [c])].join(" AND "); return { sql: `ROW(${cols.join(", ")})`, type: "count_distinct", filters: [{ sql: filter }] }; } + const filters = c === undefined ? {} : { filters: [{ sql: c }] }; if (agg === AGG_ANY || agg === AGG_ALL) { - // A boolean OR / AND, written as the view writes it (report-ddl-emit.ts) and typed `boolean`. - // Cube's min/max cannot take a boolean column (Postgres has no MAX(boolean)), and the - // condition travels in the SQL: the aggregate is Cube's `boolean` type, which takes none. + // A boolean OR / AND as a `max` / `min` over the column as `0` / `1`: an additive number, so a + // rollup holds it and Cube re-aggregates it, which a `boolean` leaf does not allow. Postgres + // has no MAX(boolean), so the column is cast; MySQL stores a boolean as `0` / `1` already. + // A null stays null, so the aggregate ignores it as `bool_or` / `bool_and` do. const col = cols[0]!; - if (d === "postgres") { - const fn = agg === AGG_ANY ? "bool_or" : "bool_and"; - return { sql: c === undefined ? `${fn}(${col})` : `${fn}(${col}) FILTER (WHERE ${c})`, type: "boolean" }; - } - const fn = agg === AGG_ANY ? "MAX" : "MIN"; - return { sql: `${fn}(${c === undefined ? col : `CASE WHEN ${c} THEN ${col} END`})`, type: "boolean" }; + return { sql: d === "postgres" ? `CAST(${col} AS INTEGER)` : col, type: agg === AGG_ANY ? "max" : "min", ...filters }; } const type: CubeMeasureType = agg === AGG_COUNT ? (distinct ? "count_distinct" : "count") : agg; - return { sql: cols[0]!, type, ...(c === undefined ? {} : { filters: [{ sql: c }] }) }; + return { sql: cols[0]!, type, ...filters }; } /** A `segment.filter`: its filter over `entity`'s fields. */ diff --git a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts index a8cb487fa..48a15a3d7 100644 --- a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts @@ -1482,13 +1482,15 @@ describe("Table H — the canonical model", () => { const totalMinutesOrZero: CubeMeasureSpec = { name: "totalMinutesOrZero", sql: "COALESCE({totalMinutesOrZeroRaw}, 0)", type: "number" }; const longShare: CubeMeasureSpec = { name: "longShare", sql: "CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0)", type: "number" }; const longShareOrZero: CubeMeasureSpec = { name: "longShareOrZero", sql: "COALESCE(CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0), 0)", type: "number" }; - // @agg any / all: Cube's `boolean` measure, the aggregate as the view writes it; a @default is - // the Raw aggregate and a boolean COALESCE over it. - const anyDeload: CubeMeasureSpec = { name: "anyDeload", sql: 'bool_or({CUBE}."deload")', type: "boolean" }; - const anyDeloadOrFalseRaw: CubeMeasureSpec = { name: "anyDeloadOrFalseRaw", sql: 'bool_or({CUBE}."deload")', type: "boolean", public: false }; - const anyDeloadOrFalse: CubeMeasureSpec = { name: "anyDeloadOrFalse", sql: "COALESCE({anyDeloadOrFalseRaw}, FALSE)", type: "boolean" }; - const allDeloadOrTrueRaw: CubeMeasureSpec = { name: "allDeloadOrTrueRaw", sql: 'bool_and({CUBE}."deload")', type: "boolean", public: false }; - const allDeloadOrTrue: CubeMeasureSpec = { name: "allDeloadOrTrue", sql: "COALESCE({allDeloadOrTrueRaw}, TRUE)", type: "boolean" }; + // @agg any / all: Raw is a `max` / `min` over the column as 0 / 1 (an additive number a + // rollup can hold), and is the `boolean` that reads it as `= 1`, its @default inside. + const deloadBit = 'CAST({CUBE}."deload" AS INTEGER)'; + const anyDeloadRaw: CubeMeasureSpec = { name: "anyDeloadRaw", sql: deloadBit, type: "max", public: false }; + const anyDeload: CubeMeasureSpec = { name: "anyDeload", sql: "{anyDeloadRaw} = 1", type: "boolean" }; + const anyDeloadOrFalseRaw: CubeMeasureSpec = { name: "anyDeloadOrFalseRaw", sql: deloadBit, type: "max", public: false }; + const anyDeloadOrFalse: CubeMeasureSpec = { name: "anyDeloadOrFalse", sql: "COALESCE({anyDeloadOrFalseRaw}, 0) = 1", type: "boolean" }; + const allDeloadOrTrueRaw: CubeMeasureSpec = { name: "allDeloadOrTrueRaw", sql: deloadBit, type: "min", public: false }; + const allDeloadOrTrue: CubeMeasureSpec = { name: "allDeloadOrTrue", sql: "COALESCE({allDeloadOrTrueRaw}, 1) = 1", type: "boolean" }; const key: CubeDimensionSpec = { name: "id", sql: '{CUBE}."id"', type: "number", primaryKey: true }; // The other canonical entities (Node, Session, Measurement, the Auth family, AllTypes) and the // join-path cubes carry no report of Table H's; only their presence is asserted here. @@ -1608,14 +1610,17 @@ describe("Table H — the canonical model", () => { name: "longWMinutes", sql: '{CUBE}."durationMinutes"', type: "sum", filters: [{ sql: `({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE 'W%')` }], }, + anyDeloadRaw, anyDeload, - { name: "allDeload", sql: 'bool_and({CUBE}."deload")', type: "boolean" }, + { name: "allDeloadRaw", sql: deloadBit, type: "min", public: false }, + { name: "allDeload", sql: "{allDeloadRaw} = 1", type: "boolean" }, anyDeloadOrFalseRaw, anyDeloadOrFalse, allDeloadOrTrueRaw, allDeloadOrTrue, - // A condition on a boolean aggregate travels in the SQL, as the view writes it. - { name: "anyLongDeload", sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)', type: "boolean" }, + // A condition on a boolean aggregate is the leaf's `filters` entry, as for any aggregate. + { name: "anyLongDeloadRaw", sql: deloadBit, type: "max", filters: [{ sql: '{CUBE}."durationMinutes" >= 60' }], public: false }, + { name: "anyLongDeload", sql: "{anyLongDeloadRaw} = 1", type: "boolean" }, ], segments: [{ name: "long", sql: '{CUBE}."durationMinutes" >= 60' }], // Coarsest first: FitnessTotals and FitnessTotalsFilled group by nothing, so Cube's first @@ -1715,7 +1720,7 @@ describe("Table H — the canonical model", () => { public: false, joins: [], dimensions: [key], - measures: [weeks, anyDeload, anyDeloadOrFalseRaw, anyDeloadOrFalse, allDeloadOrTrueRaw, allDeloadOrTrue], + measures: [weeks, anyDeloadRaw, anyDeload, anyDeloadOrFalseRaw, anyDeloadOrFalse, allDeloadOrTrueRaw, allDeloadOrTrue], segments: [], preAggregations: [], }, diff --git a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts index a05da9aef..411214a5e 100644 --- a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts @@ -279,28 +279,42 @@ cubes: type: sum filters: - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' + - name: anyDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max + public: false - name: anyDeload - sql: 'bool_or({CUBE}."deload")' + sql: '{anyDeloadRaw} = 1' type: boolean + - name: allDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: min + public: false - name: allDeload - sql: 'bool_and({CUBE}."deload")' + sql: '{allDeloadRaw} = 1' type: boolean - name: anyDeloadOrFalseRaw - sql: 'bool_or({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' type: boolean - name: allDeloadOrTrueRaw - sql: 'bool_and({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: min public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' type: boolean + - name: anyLongDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max + filters: + - sql: '{CUBE}."durationMinutes" >= 60' + public: false - name: anyLongDeload - sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)' + sql: '{anyLongDeloadRaw} = 1' type: boolean segments: - name: long @@ -490,22 +504,26 @@ cubes: - name: weeks sql: '{CUBE}."id"' type: count + - name: anyDeloadRaw + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max + public: false - name: anyDeload - sql: 'bool_or({CUBE}."deload")' + sql: '{anyDeloadRaw} = 1' type: boolean - name: anyDeloadOrFalseRaw - sql: 'bool_or({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: max public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' + sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' type: boolean - name: allDeloadOrTrueRaw - sql: 'bool_and({CUBE}."deload")' - type: boolean + sql: 'CAST({CUBE}."deload" AS INTEGER)' + type: min public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' + sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' type: boolean `; diff --git a/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts b/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts index edbcbbaf9..8815210d8 100644 --- a/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts @@ -170,6 +170,38 @@ describe("measure.aggregate with @default", () => { }); }); +describe("measure.aggregate with @agg any / all", () => { + const refunded: Json = { "field.boolean": { name: "refunded" } }; + const anyRefund = (attrs: Json = {}): Json => ({ + "measure.aggregate": { name: "anyRefund", "@agg": "any", "@of": "Sale.refunded", ...attrs }, + }); + + test("a rollup lists the boolean , never its numeric Raw, so the report keeps its rollup", async () => { + const model = await build([sale([refunded, anyRefund()]), report("SaleFlags", ["sales", "anyRefund"])]); + expect(cube(model, "Sale").preAggregations).toEqual([ + { name: "SaleFlags", type: "rollup", measures: ["sales", "anyRefund"], dimensions: [], segments: [] }, + ]); + }); + + test("ERR_CUBE_MEMBER_COLLISION: a declared member named Raw names the @agg as the reason", async () => { + const err = await refusal([ + sale([refunded, anyRefund(), { "measure.aggregate": { name: "anyRefundRaw", "@agg": "count", "@of": "Sale.id" } }]), + ]); + expect(err.code).toBe("ERR_CUBE_MEMBER_COLLISION"); + expect(err.message).toContain( + "the measure 'anyRefundRaw' the exporter adds for the @agg of measure 'acme::shop::Sale.anyRefund' and " + + "measure 'acme::shop::Sale.anyRefundRaw' are both named 'anyRefundRaw'", + ); + }); + + test("with @default the collision still names the @default", async () => { + const err = await refusal([ + sale([refunded, anyRefund({ "@default": 0 }), { "measure.aggregate": { name: "anyRefundRaw", "@agg": "count", "@of": "Sale.id" } }]), + ]); + expect(err.message).toContain("the exporter adds for the @default of measure 'acme::shop::Sale.anyRefund'"); + }); +}); + describe("measure.ratio with @default", () => { const perSale = (attrs: Json = {}): Json => ({ "measure.ratio": { name: "perSale", "@numerator": "revenue", "@denominator": "sales", ...attrs }, diff --git a/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts b/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts index 175cabcfb..d5d1f17bf 100644 --- a/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts +++ b/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts @@ -68,6 +68,8 @@ export interface PlannedColumn { * every port's read returns it). Maps the integer, as text, to its symbol. */ readonly symbols?: Readonly>; + /** A boolean measure (`@agg: any` / `all`): Cube must send a JSON boolean, never `1` / `0`. */ + readonly boolean?: true; } export interface QueryPlan { @@ -96,7 +98,7 @@ export function cubeQueryFor(report: MetaObject, root: MetaRoot): QueryPlan { if (f.measure === undefined) throw new Error(`${report.name}.${f.name}: a measure field without its measure`); const member = `${cube}.${f.measure.name}`; measures.push(member); - columns.push({ field: f.name, cubeKey: member, kind: "measure" }); + columns.push({ field: f.name, cubeKey: member, kind: "measure", ...(f.subType === "boolean" ? { boolean: true as const } : {}) }); } else { if (f.dimension === undefined) throw new Error(`${report.name}.${f.name}: a dimension field without its dimension`); const member = `${cube}.${f.dimension.name}`; @@ -211,6 +213,9 @@ export function fromCubeRow(row: Record, plan: QueryPlan): Norm for (const c of plan.columns) { const key = c.derive?.fromKey ?? c.cubeKey; if (!(key in row)) throw new Error(`Cube's row has no '${key}': ${JSON.stringify(row)}`); + if (c.boolean === true && row[key] !== null && row[key] !== undefined && typeof row[key] !== "boolean") { + throw new Error(`Cube ${key}: a boolean measure came back as ${typeof row[key]} ${JSON.stringify(row[key])}, not a JSON boolean`); + } const value = normalize(c.kind, row[key], `Cube ${key}`); out[c.field] = c.derive === undefined || value === null ? value : coarsen(value, c.derive.grain); } From 93ebc2080c6f61c2eb934ac64ed1046f5aef464a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 14:32:31 -0400 Subject: [PATCH 5/8] no-mistakes(review): revert Cube boolean leaf; assert MySQL "1"/"0" form Assisted-by: no-mistakes:claude:claude-opus-5-5 --- CHANGELOG.md | 6 +- docs/features/cube-export.md | 25 ++++--- docs/features/reporting.md | 4 +- ...2026-10-02-fr-044-core-reporting-design.md | 10 +-- fixtures/cube-model/README.md | 4 +- .../model/cubes/ProgramFlagRosterFacts.yml | 18 ++--- .../canonical/expected/model/cubes/Week.yml | 32 +++------ .../expected/model/cubes/Call.yml | 32 +++------ .../expected/model/cubes/Call.yml | 32 +++------ .../codegen-ts/src/cube/build-cube-model.ts | 18 +++-- .../codegen-ts/src/cube/cube-members.ts | 67 +++++++++---------- .../test/cube/build-cube-model.test.ts | 27 +++----- .../codegen-ts/test/cube/cube-yaml.test.ts | 50 +++++--------- .../test/cube/measure-default.test.ts | 32 --------- .../cube-live/cube-live-support.ts | 18 +++-- .../cube-live/cube-model-mysql.live.ts | 2 +- .../cube-live/cube-model.live.ts | 2 +- 17 files changed, 144 insertions(+), 235 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9decd448e..5a2e46cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,9 +244,9 @@ generator catalog and the Atlas evaluation are 1.2 work. `@default` other than `0` or `1` is M9 (both `ERR_INVALID_MEASURE`). An `any` / `all` measure is not a legal `measure.ratio` operand (rule M6, tightened under **Changed**). Postgres writes `bool_or` / `bool_and`; SQLite and MySQL write `MAX` / `MIN` over `0` / `1`. The `cube-model` generator - writes a `type: boolean` measure, `{Raw} = 1`, over a private `Raw` that is a `max` (`any`) - or `min` (`all`) of the column as `0` / `1`, so a report with a boolean measure keeps its rollup. - See [reporting](docs/features/reporting.md#boolean-measures-any-and-all). + writes a `type: boolean` measure, which Cube returns as a JSON boolean over Postgres and as the + string `"1"` or `"0"` over MySQL (MySQL has no boolean type), so a consumer of the Cube API over + MySQL compares it to `"1"`. See [reporting](docs/features/reporting.md#boolean-measures-any-and-all). No `metamodelVersion` change: the vocabulary is part of 1.1. - **TypeScript: the `cube-model` reference generator (FR-044 Plan 4).** `cubeModel()` writes the reporting vocabulary as Cube data-model files, `model/cubes/.yml`: a cube for each diff --git a/docs/features/cube-export.md b/docs/features/cube-export.md index 4373d3288..67daa528e 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -322,10 +322,10 @@ somewhere to go: an `identity.reference` onto an entity that is not a cube makes | `count` with `@distinct` | `type: count_distinct`, `sql: x` | | `count` with `@distinct` over a tuple `x1, x2` | Postgres: `type: count_distinct`, `sql: 'ROW(x1, x2)'`, with one `filters` entry `x1 IS NOT NULL AND x2 IS NOT NULL`, so a tuple with a null component is not counted, as in the view. MySQL: the view's own `COUNT(DISTINCT x1, x2)` as a `type: number` measure (a condition `c` makes it `COUNT(DISTINCT CASE WHEN c THEN x1 END, x2)`); MySQL's multi-argument form skips a tuple with a null component and compares by the columns' collation, which a `JSON_ARRAY(x1, x2)` key does not: on mysql:8.4 under the default `utf8mb4_0900_ai_ci` it counted `'abc'` and `'ABC'` as two tuples where the view counts one | | `sum`, `avg`, `min`, `max` | `type: sum`, `avg`, `min`, `max`, `sql: x` | -| `any`, `all` | two members. `Raw` is `type: max` for `any` and `type: min` for `all`, over the column as `0` / `1`, with `public: false`: `sql: 'CAST(x AS INTEGER)'` on Postgres (which has no `MAX(boolean)`) and `sql: x` on MySQL (which stores a boolean as `0` / `1`). `` is `type: boolean`, `sql: '{Raw} = 1'`, and carries the measure's `title` and `description`. A null stays null, so the aggregate ignores it as the view's `bool_or` / `bool_and` do. The leaf is a number because a rollup holds a `max` or a `min` and Cube re-aggregates it; a `boolean` leaf holding `bool_or(x)` cannot be re-aggregated. | +| `any`, `all` | `type: boolean`. Postgres: `sql: 'bool_or(x)'` / `'bool_and(x)'`, a condition `c` as `bool_or(x) FILTER (WHERE c)`. MySQL: `sql: 'MAX(x)'` / `'MIN(x)'`, a condition as `MAX(CASE WHEN c THEN x END)`. The aggregate is the view's own, written in `sql` because Postgres has no `MAX(boolean)` and the condition cannot ride in `filters`. | | any of the above with a condition `c` | the same, with one `filters` entry `c`. For a Postgres tuple, `c` is ANDed after the not-null terms in that one entry. | | `measure.ratio` | `type: number`. Postgres: `CAST({num} AS NUMERIC) / NULLIF({den}, 0)`. MySQL: `{num} / NULLIF({den}, 0)`. | -| a `measure.aggregate` with `@default: n` | two members. `Raw` is the aggregate as the rows above write it (its `type`, `sql` and `filters`), with `public: false`. `` is `type: number`, `sql: 'COALESCE({Raw}, n)'`, and carries the measure's `title` and `description`. An `any` or `all` already has its `Raw`, and the default goes inside the comparison: `type: boolean`, `sql: 'COALESCE({Raw}, n) = 1'`, with `n` `0` or `1`. | +| a `measure.aggregate` with `@default: n` | two members. `Raw` is the aggregate as the rows above write it (its `type`, `sql` and `filters`), with `public: false`. `` is `type: number` (`type: boolean` for an `any` or `all`, where `n` is written `TRUE` / `FALSE` on Postgres and `1` / `0` on MySQL), `sql: 'COALESCE({Raw}, n)'`, and carries the measure's `title` and `description`. | | a `measure.ratio` with `@default: n` | `type: number`, the quotient above inside `COALESCE(…, n)` | In a ratio, `{num}` and `{den}` are references to the two operand measures, so each operand is @@ -337,10 +337,19 @@ ratio whether or not the ratio declares one, as in the view (`revenue` with `@de `@default` is the value the view reads in place of null (`COALESCE(E, n)`), and Cube reads the same: a defaulted measure is never null, on an empty group, a group whose rows the measure's condition filters out, or a ratio whose denominator is zero. Cube has no nullability to declare -for a measure, so nothing else is written for it. A rollup lists ``, never `Raw`, for a -defaulted measure and for an `any` or `all`. As a `number` or `boolean` measure over its -`Raw`, `` is served from a rollup only when the query's dimensions are the rollup's own, -which is the query a report makes. +for a measure, so nothing else is written for it. A rollup lists ``, never `Raw`. As a +`number` measure, `` is served from a rollup only when the query's dimensions are the +rollup's own, which is the query a report makes. + +**What Cube returns for an `any` or `all`.** Over Postgres, Cube sends a `type: boolean` +measure as a JSON boolean, `true` or `false`. Over MySQL it sends the string `"1"` or `"0"`: +MySQL has no boolean type, a boolean column is a `TINYINT(1)`, and Cube returns the value the +driver gives it without converting it. This is how Cube reads any boolean-typed measure over +MySQL, from a rollup and from the source table alike, and the exporter does not change it. A +consumer that reads the Cube API over MySQL must compare the value to `"1"`; `"0"` is a +non-empty string, so a truthiness test reads it as true. A measure with nothing to judge is +`null` on both. The live check holds each form exactly: a JSON boolean on Postgres, `"1"` or +`"0"` on MySQL, each equal to the report view's own value. No measure sets `format`: Cube's named formats are display hints and the model carries none. @@ -417,7 +426,7 @@ scope segment: |---|---| | the view | `model/views/.yml`, named after the report, with the report's `title` and `description`. Views and cubes share Cube's one namespace. | | the spine cube | the cube each listed dimension reaches after the spine's hops: the spine entity's own cube, its join-target cube, or the alias cube of the last hop. It gets one `one_to_many` join onto the report's facts cube (or onto its first chain cube), on the columns of the spine hop's reference. | -| `Facts` | a standalone cube, `public: false`, whose `sql` is `SELECT * FROM ` with the report's `@segment` filter and then its `@filter`, ANDed, as its `WHERE` (none when the report has neither; for a TPH subtype, the base table, its discriminator first). The alias is the one the report view gives `@from`, so the condition reads as the view's join condition does. It holds `@from`'s primary key and `@from`'s own definitions of the measures the report lists, the operands of a listed ratio and the `Raw` of a defaulted measure or of an `any` / `all`, in `@from`'s order; what the report does not list is `public: false`. | +| `Facts` | a standalone cube, `public: false`, whose `sql` is `SELECT * FROM ` with the report's `@segment` filter and then its `@filter`, ANDed, as its `WHERE` (none when the report has neither; for a TPH subtype, the base table, its discriminator first). The alias is the one the report view gives `@from`, so the condition reads as the view's join condition does. It holds `@from`'s primary key and `@from`'s own definitions of the measures the report lists, the operands of a listed ratio and the `Raw` of a defaulted measure, in `@from`'s order; what the report does not list is `public: false`. | | `_` | for a spine of more than one hop, a standalone chain cube, `public: false`, for each entity between `@from` and the spine entity: its table, its primary key and one `one_to_many` join onward, towards the facts. `` is the hop that reaches the entity from `@from`'s side, so `Session.fkWeek.fkProgram` gives `ProgramSessions_fkWeek`. | | the view's `cubes` | first `join_path: ` (and, for a dimension past the spine, the join path its own joins take from there), including the member each listed dimension reads, under the dimension's name (`id` as `programKey`) and with the dimension's `title`, `description` and `meta.grains`; last, `join_path: [.].Facts`, including the listed measures. | @@ -454,7 +463,7 @@ view lowering's rows. A view is answered from the tables. | cube name | the entity's name. Two entities of one name in two packages, or an alias, facts or chain cube or a `@spine` report's view named like a cube, is `ERR_CUBE_NAME_COLLISION`, naming both (the message says "cube or view" when one is a view): Cube's cubes and views share one namespace. Rename one, or narrow the generator's `filter` to leave one out; the filter helps only when no `@via` reaches the entity it leaves out, since an entity a `@via` reaches is still written as a join-target cube, unless every hop onto it goes through an alias cube. | | member name | the dimension, measure or segment name as written, so a report field and its Cube member share a name | | a name Cube refuses | Cube names start with a letter, hold only letters, digits and `_`, and are not a Python keyword (`from`, `class`, `in`, `is`, `not`, `and`, `or`, `if`, `else`, `for`, `while`, `with`, `as`, `def`, `return`, `yield`, `import`, `pass`, `global`, `nonlocal`, `lambda`, `del`, `assert`, `break`, `continue`, `try`, `except`, `finally`, `raise`, `async`, `await`, `True`, `False`, `None`, `elif`). That is `ERR_CUBE_INVALID_NAME`, naming the node. The exporter never renames: the name is the report field's. | -| members the exporter adds | primary-key dimensions, reached-column dimensions, the `Raw` measure of a defaulted measure or of an `any` / `all`, `Scope` segments, rollups. A name that collides with another member of the cube is `ERR_CUBE_MEMBER_COLLISION`, naming both. The one exception is a declared dimension over a key field under its own name, which is that key dimension (see [Cubes and primary keys](#cubes-and-primary-keys)). | +| members the exporter adds | primary-key dimensions, reached-column dimensions, the `Raw` measure of a defaulted measure, `Scope` segments, rollups. A name that collides with another member of the cube is `ERR_CUBE_MEMBER_COLLISION`, naming both. The one exception is a declared dimension over a key field under its own name, which is that key dimension (see [Cubes and primary keys](#cubes-and-primary-keys)). | | identifiers | every table, schema and column is quoted: `"…"` on Postgres, backticks on MySQL | | string literals | SQL quoting first (`'` doubled; MySQL also doubles `\`). Cube compiles every `sql` as a template literal, where a backslash is an escape (`\b` a backspace, `\_` a plain `_`, a trailing `\` swallows the closing quote) and `{x}` a member reference. So every `\` is then doubled, and after that `{` becomes `\{` and `}` becomes `\}` (in that order, so a backslash before a brace stays a backslash). Then, when the literal holds a Jinja opener (`{{`, `{%` or `{#`), it is wrapped in `{% raw %}…{% endraw %}` for Jinja, because a backslash does not stop Jinja. A wrapped literal holding `endraw` is `ERR_CUBE_UNESCAPABLE_LITERAL`; one that is not wrapped is never inside a raw block, so `endraw` there is plain text. A column name written with `@column` gets the same treatment. The `cube` lane reads each literal of the `escaping` case back from Cube's `/v1/sql`, where it is the view's own SQL. | | free text | `title` and `description`. Cube reads them as templates too: `{x}` is a member reference, `${x}` an interpolation and a backslash an escape. Each `\` is doubled, each brace escaped, the text raw-wrapped when the original holds `{{`, `{%` or `{#` (the same rule as a SQL literal), and the result is written as a JSON string. Raw-wrapped text holding `endraw` is `ERR_CUBE_UNESCAPABLE_LITERAL`. | diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 0483d2ddb..b20335743 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -489,8 +489,8 @@ and the measure is a `boolean` in every port: `true` or `false` on the wire, nev `1`, so they write `MAX(x)` for `any` and `MIN(x)` for `all` (`MAX(CASE WHEN … THEN x END)` for a condition). The runtime reads the column as the boolean it is typed as. A default is `COALESCE(, FALSE)` on Postgres and `COALESCE(, 0)` elsewhere. -- **Cube.** The export writes a `type: boolean` measure over a private `max` / `min` of the - column as `0` / `1`, which a rollup can hold, see [cube-export.md](cube-export.md). +- **Cube.** The export writes a `type: boolean` measure holding the same aggregate, see + [cube-export.md](cube-export.md). ### What the runtime does with it diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index d4989bedc..6b7a08925 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -517,12 +517,8 @@ existing attribute's closed set, which is a value of `@agg`, not a new attribute **Admission.** RDB as above. A store with no boolean aggregate is capability-gated as for any aggregate it cannot express exactly; none is lowered today. -**Mapping.** Cube: two members. `Raw` is a private `max` (`any`) or `min` (`all`) over the column -as `0` / `1` (`CAST(x AS INTEGER)` on Postgres, which has no `MAX(boolean)`; the column itself on -MySQL), its condition in `filters`; `` is `type: boolean`, `{Raw} = 1`, with a `@default` -inside the comparison. The leaf is an additive number so that a report with a boolean measure keeps -its rollup: Cube re-aggregates a `max` / `min` it reads from a rollup, and cannot re-aggregate a -`boolean` leaf holding `bool_or(x)`. MetricFlow has +**Mapping.** Cube: `type: boolean` holding the aggregate in `sql` (Cube's `min` / `max` cannot take +a Postgres boolean, and a condition rides in the SQL, as for a MySQL tuple count). MetricFlow has no `any` / `all` aggregation (`sum_boolean` counts the trues); the dbt exporter, when it exists, must refuse it by name or map it to `max` / `min` over a 0/1 expression. It is not part of this change. @@ -539,7 +535,7 @@ change. | `measure.aggregate` count + `@distinct` (one column) | `count_distinct` | `count_distinct` | | `measure.aggregate` count + `@distinct` (tuple) | `count_distinct` over `ROW(…)` with a not-null filter on each column (MySQL: the view's own `COUNT(DISTINCT a, b)` as a `number` measure) | `count_distinct` over an expression | | `measure.aggregate` sum/avg/min/max | `sum`/`avg`/`min`/`max` | `sum`/`average`/`min`/`max` | -| `measure.aggregate` any/all | `boolean` measure `{Raw} = 1` over a private `max` / `min` of the column as `0` / `1` | none yet: `sum_boolean` counts the trues; the dbt exporter refuses it or maps `max` / `min` over a 0/1 expression | +| `measure.aggregate` any/all | `boolean` measure holding `bool_or(x)` / `bool_and(x)` (MySQL `MAX(x)` / `MIN(x)`) | none yet: `sum_boolean` counts the trues; the dbt exporter refuses it or maps `max` / `min` over a 0/1 expression | | `measure.ratio` | `number` measure `{a} / NULLIF({b}, 0)`; on Postgres `CAST({a} AS NUMERIC) / NULLIF({b}, 0)`, with no cast on MySQL | `ratio` metric | | `measure.derived` | `number` measure | `derived` metric | | `segment` | segment | metric `filter` / saved query filter | diff --git a/fixtures/cube-model/README.md b/fixtures/cube-model/README.md index 6199dbc89..9868680b7 100644 --- a/fixtures/cube-model/README.md +++ b/fixtures/cube-model/README.md @@ -59,8 +59,8 @@ never copied from it. A golden blessed from the implementation proves nothing. | `measure-ratio` | B: `measure.ratio`; D | `CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0)`, `type: number` | | `measure-mysql` | D (MySQL arms) | backtick quoting; a tuple distinct count is the view's own `COUNT(DISTINCT a, b)` as a `number` measure, a condition as `CASE WHEN … THEN a END` on the first component (a `JSON_ARRAY` key compares bytes, not the column collation, so on mysql:8.4 it counted 6 tuples where the view counts 4); a ratio without the cast | | `measure-default` | B: measure `@default`; the zero-rows / measure-defaults plan, Tables D and E | a `measure.aggregate` with `@default: n` is `Raw` (the Table D aggregate, its condition kept, `public: false`) and `` (`type: number`, `COALESCE({Raw}, n)`), with the measure's description on ``; a negative sentinel (`-1`) on an `avg`; a ratio with `@default` wraps its quotient in `COALESCE`; a ratio whose operand has its own `@default` reads it through the operand's `COALESCE` member, with or without a default of its own (decision 4); the rollup lists ``, never `Raw` | -| `measure-boolean` | B: `measure.aggregate` `@agg: any` / `all`; R10 | a boolean aggregate is two members: `Raw`, a `max` (`any`) or `min` (`all`) over `CAST(x AS INTEGER)` with `public: false` (an additive number a rollup can hold; Postgres has no `MAX(boolean)`), and ``, `type: boolean`, `{Raw} = 1`; a condition is the leaf's one `filters` entry; `@default: 0` / `1` goes inside the comparison, `COALESCE({Raw}, 0 | 1) = 1` | -| `measure-boolean-mysql` | B: the same, MySQL | `Raw` is the `max` / `min` over the boolean column itself, which MySQL stores as `0` / `1` (no cast), the condition in `filters`; `` and its default are as on Postgres | +| `measure-boolean` | B: `measure.aggregate` `@agg: any` / `all`; R10 | a boolean aggregate is `type: boolean` and the aggregate is the view's own, in `sql` (Postgres `bool_or(x)` / `bool_and(x)`; Cube's `min` / `max` cannot take a Postgres boolean): a condition rides in the SQL as `FILTER (WHERE c)`, not in `filters`; `@default: 0` / `1` is `Raw` and `` as for any default but `type: boolean`, `COALESCE({Raw}, FALSE | TRUE)` | +| `measure-boolean-mysql` | B: the same, MySQL | `MAX(x)` / `MIN(x)` over the boolean column, a condition as `MAX(CASE WHEN c THEN x END)`, the default `COALESCE({Raw}, 0 | 1)` | | `segment` | B: `segment.filter` | `=`, `IN (…)`, `LIKE` with `>` ANDed, an `or` group with `IS NULL`; YAML doubles every `'` | | `relative-date` | B: relative filter (Plan 2 Table E) | `(now() - INTERVAL 'P7D')` for an instant, `((now() AT TIME ZONE 'UTC') - INTERVAL 'P7D')` for a naive timestamp, `CAST(… AS DATE)` for a date, `+` for a future offset, and the same SQL in a measure's condition | | `report-rollup` | B: served report; F | a `rollup` named after the report: dimensions and measures in listed order, `segments` with `@segment` then the scope segment `programsByMonthScope`, the one-time-dimension form; a report with no dimensions is a measures-only rollup; rollups are written coarsest first, so `ProgramTotals` precedes `ProgramsByMonth` | diff --git a/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml b/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml index d0b37367d..480837ebc 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/ProgramFlagRosterFacts.yml @@ -12,24 +12,20 @@ cubes: - name: weeks sql: '{CUBE}."id"' type: count - - name: anyDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max - public: false - name: anyDeload - sql: '{anyDeloadRaw} = 1' + sql: 'bool_or({CUBE}."deload")' type: boolean - name: anyDeloadOrFalseRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max + sql: 'bool_or({CUBE}."deload")' + type: boolean public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' type: boolean - name: allDeloadOrTrueRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: min + sql: 'bool_and({CUBE}."deload")' + type: boolean public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' type: boolean diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml index 843326c62..1ba638ba5 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml @@ -87,42 +87,28 @@ cubes: type: sum filters: - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' - - name: anyDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max - public: false - name: anyDeload - sql: '{anyDeloadRaw} = 1' + sql: 'bool_or({CUBE}."deload")' type: boolean - - name: allDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: min - public: false - name: allDeload - sql: '{allDeloadRaw} = 1' + sql: 'bool_and({CUBE}."deload")' type: boolean - name: anyDeloadOrFalseRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max + sql: 'bool_or({CUBE}."deload")' + type: boolean public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' type: boolean - name: allDeloadOrTrueRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: min + sql: 'bool_and({CUBE}."deload")' + type: boolean public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' type: boolean - - name: anyLongDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max - filters: - - sql: '{CUBE}."durationMinutes" >= 60' - public: false - name: anyLongDeload - sql: '{anyLongDeloadRaw} = 1' + sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)' type: boolean segments: - name: long diff --git a/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml b/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml index be4d7ac76..ade6e9fe6 100644 --- a/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml +++ b/fixtures/cube-model/measure-boolean-mysql/expected/model/cubes/Call.yml @@ -8,40 +8,26 @@ cubes: type: number primary_key: true measures: - - name: anyFailedRaw - sql: '{CUBE}.`failed`' - type: max - public: false - name: anyFailed - sql: '{anyFailedRaw} = 1' + sql: 'MAX({CUBE}.`failed`)' type: boolean - - name: allFailedRaw - sql: '{CUBE}.`failed`' - type: min - public: false - name: allFailed - sql: '{allFailedRaw} = 1' + sql: 'MIN({CUBE}.`failed`)' type: boolean - - name: anySlowFailedRaw - sql: '{CUBE}.`failed`' - type: max - filters: - - sql: '{CUBE}.`duration_ms` >= 1000' - public: false - name: anySlowFailed - sql: '{anySlowFailedRaw} = 1' + sql: 'MAX(CASE WHEN {CUBE}.`duration_ms` >= 1000 THEN {CUBE}.`failed` END)' type: boolean - name: anyFailedOrFalseRaw - sql: '{CUBE}.`failed`' - type: max + sql: 'MAX({CUBE}.`failed`)' + type: boolean public: false - name: anyFailedOrFalse - sql: 'COALESCE({anyFailedOrFalseRaw}, 0) = 1' + sql: 'COALESCE({anyFailedOrFalseRaw}, 0)' type: boolean - name: allFailedOrTrueRaw - sql: '{CUBE}.`failed`' - type: min + sql: 'MIN({CUBE}.`failed`)' + type: boolean public: false - name: allFailedOrTrue - sql: 'COALESCE({allFailedOrTrueRaw}, 1) = 1' + sql: 'COALESCE({allFailedOrTrueRaw}, 1)' type: boolean diff --git a/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml b/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml index 3990e1f05..60627dcd7 100644 --- a/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml +++ b/fixtures/cube-model/measure-boolean/expected/model/cubes/Call.yml @@ -8,40 +8,26 @@ cubes: type: number primary_key: true measures: - - name: anyFailedRaw - sql: 'CAST({CUBE}."failed" AS INTEGER)' - type: max - public: false - name: anyFailed - sql: '{anyFailedRaw} = 1' + sql: 'bool_or({CUBE}."failed")' type: boolean - - name: allFailedRaw - sql: 'CAST({CUBE}."failed" AS INTEGER)' - type: min - public: false - name: allFailed - sql: '{allFailedRaw} = 1' + sql: 'bool_and({CUBE}."failed")' type: boolean - - name: anySlowFailedRaw - sql: 'CAST({CUBE}."failed" AS INTEGER)' - type: max - filters: - - sql: '{CUBE}."duration_ms" >= 1000' - public: false - name: anySlowFailed - sql: '{anySlowFailedRaw} = 1' + sql: 'bool_or({CUBE}."failed") FILTER (WHERE {CUBE}."duration_ms" >= 1000)' type: boolean - name: anyFailedOrFalseRaw - sql: 'CAST({CUBE}."failed" AS INTEGER)' - type: max + sql: 'bool_or({CUBE}."failed")' + type: boolean public: false - name: anyFailedOrFalse - sql: 'COALESCE({anyFailedOrFalseRaw}, 0) = 1' + sql: 'COALESCE({anyFailedOrFalseRaw}, FALSE)' type: boolean - name: allFailedOrTrueRaw - sql: 'CAST({CUBE}."failed" AS INTEGER)' - type: min + sql: 'bool_and({CUBE}."failed")' + type: boolean public: false - name: allFailedOrTrue - sql: 'COALESCE({allFailedOrTrueRaw}, 1) = 1' + sql: 'COALESCE({allFailedOrTrueRaw}, TRUE)' type: boolean diff --git a/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts b/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts index 162dff30e..b4984c6bd 100644 --- a/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts +++ b/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts @@ -78,12 +78,12 @@ import { ERR_CUBE_UNSUPPORTED_DIALECT, } from "./cube-errors.js"; import { + defaultedRawMemberName, dimensionColumn, docOf, grainsOf, measureMembers, memberKey, - rawMemberName, segmentSpec, type MemberContext, } from "./cube-members.js"; @@ -476,12 +476,10 @@ class CubeModelBuilder { draft.declaredOrder.push(dim); if (dim.via() === undefined) draft.declaredDims.set(dim, this.ownDimension(draft, dim, where)); } else if (child.type === TYPE_MEASURE) { - const declared = child as MetaMeasure; - const { raw, measure } = measureMembers(draft.entity, declared, where, this.mc); + const { raw, measure } = measureMembers(draft.entity, child as MetaMeasure, where, this.mc); if (raw !== undefined) { - // A defaulted or boolean measure.aggregate: its aggregate is the member the exporter adds, `Raw`. - const reason = declared.defaultValue() !== undefined ? "@default" : "@agg"; - draft.namespace.add(raw.name, `the measure '${raw.name}' the exporter adds for the ${reason} of ${label}`); + // A defaulted measure.aggregate: its aggregate is the member the exporter adds, `Raw`. + draft.namespace.add(raw.name, `the measure '${raw.name}' the exporter adds for the @default of ${label}`); draft.measures.push(raw); } draft.measures.push(measure); @@ -869,16 +867,16 @@ class CubeModelBuilder { /** * The facts cube's measures: @from's own cube's, in its order, narrowed to those the report lists, - * the operands of a listed ratio and the `Raw` of each defaulted or boolean one, so every - * definition is the ordinary cube's. A measure the report does not list is `public: false`. + * the operands of a listed ratio and the `Raw` of each defaulted one, so every definition is + * the ordinary cube's. A measure the report does not list is `public: false`. */ private factsMeasures(shape: ReportShape, from: CubeDraft, where: string): CubeMeasureSpec[] { const listed = new Set(); const needed = new Set(); const need = (m: MetaMeasure): void => { needed.add(m.name); - // The `Raw` twin is earned by one rule, in `rawMemberName`, shared with measureMembers. - const raw = rawMemberName(m); + // The `Raw` twin is earned by one rule, in `defaultedRawMemberName`, shared with measureMembers. + const raw = defaultedRawMemberName(m); if (raw !== undefined) needed.add(raw); }; for (const f of shape.fields) { diff --git a/server/typescript/packages/codegen-ts/src/cube/cube-members.ts b/server/typescript/packages/codegen-ts/src/cube/cube-members.ts index d18ad9e44..e10ee05e8 100644 --- a/server/typescript/packages/codegen-ts/src/cube/cube-members.ts +++ b/server/typescript/packages/codegen-ts/src/cube/cube-members.ts @@ -50,6 +50,7 @@ import type { CubeSegmentSpec, } from "./cube-model-spec.js"; import { CUBE_SELF, cubeColumn, cubeSqlRenderer, memberRef } from "./cube-sql.js"; +import type { ReportDialect } from "../projection/time-sql.js"; /** What every member mapping reads: the model, the dialect, the column naming strategy. */ export interface MemberContext { @@ -146,43 +147,34 @@ export function dimensionColumn( ); } -/** `Raw`: the member that holds a measure.aggregate's aggregate when `` reads it (Table G, added members). */ +/** `Raw`: the member that holds a defaulted measure.aggregate's aggregate (Table G, added members). */ export function rawMeasureName(measure: string): string { return `${measure}Raw`; } -/** `@agg: any` / `all`: the measure is a boolean (R10). */ -function isBooleanAggregate(m: MetaMeasure): boolean { - // ADR-0039: agg() resolves, so a measure inherited through extends keeps its aggregate. - const agg = m.agg(); - return agg === AGG_ANY || agg === AGG_ALL; -} - /** - * The `Raw` twin a measure earns: a non-ratio `measure.aggregate` with `@default`, or with - * `@agg: any` / `all`, gets one. `measureMembers` emits under this name and the facts cube keeps - * it — one rule, two consumers. + * The `Raw` twin a measure earns: only a non-ratio `measure.aggregate` with `@default` gets one. + * `measureMembers` emits under this name and the facts cube keeps it — one rule, two consumers. */ -export function rawMemberName(m: MetaMeasure): string | undefined { - if (m.isRatio()) return undefined; +export function defaultedRawMemberName(m: MetaMeasure): string | undefined { // ADR-0039: defaultValue() resolves, so a measure inherited through extends keeps its default. - return m.defaultValue() !== undefined || isBooleanAggregate(m) ? rawMeasureName(m.name) : undefined; + return !m.isRatio() && m.defaultValue() !== undefined ? rawMeasureName(m.name) : undefined; } /** - * One measure as Cube members. `raw` is the Table D aggregate, `public: false`, under `Raw`, - * and is present in two cases. A measure.aggregate with `@default`: `measure` reads it through - * `COALESCE` (the zero-rows / measure-defaults plan, Table D: `COALESCE(E, n)`). An `@agg: any` / - * `all`: `raw` is a `max` / `min` over the column as `0` / `1`, an additive number a rollup can - * hold, and `measure` is the `boolean` that reads it as `= 1`. + * One measure as Cube members. `raw` is present for a measure.aggregate with `@default`: it is + * the Table D aggregate, `public: false`, under `Raw`, and `measure` reads it through + * `COALESCE` (the zero-rows / measure-defaults plan, Table D: `COALESCE(E, n)`). */ export interface MeasureMembers { readonly raw?: CubeMeasureSpec; readonly measure: CubeMeasureSpec; } -/** `COALESCE(, n)`: `n` is the measure's integer `@default`. */ -function withDefault(sql: string, n: number): string { +/** `COALESCE(, n)`: `n` is the measure's integer `@default`. A boolean measure's `@default` is + * 0 or 1, written as a boolean literal on Postgres (the view's own form). */ +function withDefault(sql: string, n: number, boolean: boolean, d: ReportDialect): string { + if (boolean && d === "postgres") return `COALESCE(${sql}, ${n === 1 ? "TRUE" : "FALSE"})`; return `COALESCE(${sql}, ${String(n)})`; } @@ -197,16 +189,20 @@ export function measureMembers(entity: MetaObject, m: MetaMeasure, where: string const num = memberRef(ratioOperand(entity, m, m.numerator(), where).name); const den = memberRef(ratioOperand(entity, m, m.denominator(), where).name); const quotient = mc.dialect === "postgres" ? `CAST(${num} AS NUMERIC) / NULLIF(${den}, 0)` : `${num} / NULLIF(${den}, 0)`; - return { measure: { name: m.name, sql: n === undefined ? quotient : withDefault(quotient, n), type: "number", ...docOf(m) } }; + return { measure: { name: m.name, sql: n === undefined ? quotient : withDefault(quotient, n, false, mc.dialect), type: "number", ...docOf(m) } }; } const aggregate = aggregateSpec(entity, m, where, mc); - const raw = rawMemberName(m); - if (raw === undefined) return { measure: { name: m.name, ...aggregate, ...docOf(m) } }; - const value = n === undefined ? memberRef(raw) : withDefault(memberRef(raw), n); - const boolean = isBooleanAggregate(m); + if (n === undefined) return { measure: { name: m.name, ...aggregate, ...docOf(m) } }; + // Past the ratio branch, this measure satisfies the helper's rule: its twin name exists. + const raw = defaultedRawMemberName(m)!; return { raw: { name: raw, ...aggregate, public: false }, - measure: { name: m.name, sql: boolean ? `${value} = 1` : value, type: boolean ? "boolean" : "number", ...docOf(m) }, + measure: { + name: m.name, + sql: withDefault(memberRef(raw), n, aggregate.type === "boolean", mc.dialect), + type: aggregate.type === "boolean" ? "boolean" : "number", + ...docOf(m), + }, }; } @@ -239,17 +235,20 @@ function aggregateSpec( const filter = [...cols.map((x) => `${x} IS NOT NULL`), ...(c === undefined ? [] : [c])].join(" AND "); return { sql: `ROW(${cols.join(", ")})`, type: "count_distinct", filters: [{ sql: filter }] }; } - const filters = c === undefined ? {} : { filters: [{ sql: c }] }; if (agg === AGG_ANY || agg === AGG_ALL) { - // A boolean OR / AND as a `max` / `min` over the column as `0` / `1`: an additive number, so a - // rollup holds it and Cube re-aggregates it, which a `boolean` leaf does not allow. Postgres - // has no MAX(boolean), so the column is cast; MySQL stores a boolean as `0` / `1` already. - // A null stays null, so the aggregate ignores it as `bool_or` / `bool_and` do. + // A boolean OR / AND, written as the view writes it (report-ddl-emit.ts) and typed `boolean`. + // Cube's min/max cannot take a boolean column (Postgres has no MAX(boolean)), and the + // condition travels in the SQL: the aggregate is Cube's `boolean` type, which takes none. const col = cols[0]!; - return { sql: d === "postgres" ? `CAST(${col} AS INTEGER)` : col, type: agg === AGG_ANY ? "max" : "min", ...filters }; + if (d === "postgres") { + const fn = agg === AGG_ANY ? "bool_or" : "bool_and"; + return { sql: c === undefined ? `${fn}(${col})` : `${fn}(${col}) FILTER (WHERE ${c})`, type: "boolean" }; + } + const fn = agg === AGG_ANY ? "MAX" : "MIN"; + return { sql: `${fn}(${c === undefined ? col : `CASE WHEN ${c} THEN ${col} END`})`, type: "boolean" }; } const type: CubeMeasureType = agg === AGG_COUNT ? (distinct ? "count_distinct" : "count") : agg; - return { sql: cols[0]!, type, ...filters }; + return { sql: cols[0]!, type, ...(c === undefined ? {} : { filters: [{ sql: c }] }) }; } /** A `segment.filter`: its filter over `entity`'s fields. */ diff --git a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts index 48a15a3d7..a8cb487fa 100644 --- a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts @@ -1482,15 +1482,13 @@ describe("Table H — the canonical model", () => { const totalMinutesOrZero: CubeMeasureSpec = { name: "totalMinutesOrZero", sql: "COALESCE({totalMinutesOrZeroRaw}, 0)", type: "number" }; const longShare: CubeMeasureSpec = { name: "longShare", sql: "CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0)", type: "number" }; const longShareOrZero: CubeMeasureSpec = { name: "longShareOrZero", sql: "COALESCE(CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0), 0)", type: "number" }; - // @agg any / all: Raw is a `max` / `min` over the column as 0 / 1 (an additive number a - // rollup can hold), and is the `boolean` that reads it as `= 1`, its @default inside. - const deloadBit = 'CAST({CUBE}."deload" AS INTEGER)'; - const anyDeloadRaw: CubeMeasureSpec = { name: "anyDeloadRaw", sql: deloadBit, type: "max", public: false }; - const anyDeload: CubeMeasureSpec = { name: "anyDeload", sql: "{anyDeloadRaw} = 1", type: "boolean" }; - const anyDeloadOrFalseRaw: CubeMeasureSpec = { name: "anyDeloadOrFalseRaw", sql: deloadBit, type: "max", public: false }; - const anyDeloadOrFalse: CubeMeasureSpec = { name: "anyDeloadOrFalse", sql: "COALESCE({anyDeloadOrFalseRaw}, 0) = 1", type: "boolean" }; - const allDeloadOrTrueRaw: CubeMeasureSpec = { name: "allDeloadOrTrueRaw", sql: deloadBit, type: "min", public: false }; - const allDeloadOrTrue: CubeMeasureSpec = { name: "allDeloadOrTrue", sql: "COALESCE({allDeloadOrTrueRaw}, 1) = 1", type: "boolean" }; + // @agg any / all: Cube's `boolean` measure, the aggregate as the view writes it; a @default is + // the Raw aggregate and a boolean COALESCE over it. + const anyDeload: CubeMeasureSpec = { name: "anyDeload", sql: 'bool_or({CUBE}."deload")', type: "boolean" }; + const anyDeloadOrFalseRaw: CubeMeasureSpec = { name: "anyDeloadOrFalseRaw", sql: 'bool_or({CUBE}."deload")', type: "boolean", public: false }; + const anyDeloadOrFalse: CubeMeasureSpec = { name: "anyDeloadOrFalse", sql: "COALESCE({anyDeloadOrFalseRaw}, FALSE)", type: "boolean" }; + const allDeloadOrTrueRaw: CubeMeasureSpec = { name: "allDeloadOrTrueRaw", sql: 'bool_and({CUBE}."deload")', type: "boolean", public: false }; + const allDeloadOrTrue: CubeMeasureSpec = { name: "allDeloadOrTrue", sql: "COALESCE({allDeloadOrTrueRaw}, TRUE)", type: "boolean" }; const key: CubeDimensionSpec = { name: "id", sql: '{CUBE}."id"', type: "number", primaryKey: true }; // The other canonical entities (Node, Session, Measurement, the Auth family, AllTypes) and the // join-path cubes carry no report of Table H's; only their presence is asserted here. @@ -1610,17 +1608,14 @@ describe("Table H — the canonical model", () => { name: "longWMinutes", sql: '{CUBE}."durationMinutes"', type: "sum", filters: [{ sql: `({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE 'W%')` }], }, - anyDeloadRaw, anyDeload, - { name: "allDeloadRaw", sql: deloadBit, type: "min", public: false }, - { name: "allDeload", sql: "{allDeloadRaw} = 1", type: "boolean" }, + { name: "allDeload", sql: 'bool_and({CUBE}."deload")', type: "boolean" }, anyDeloadOrFalseRaw, anyDeloadOrFalse, allDeloadOrTrueRaw, allDeloadOrTrue, - // A condition on a boolean aggregate is the leaf's `filters` entry, as for any aggregate. - { name: "anyLongDeloadRaw", sql: deloadBit, type: "max", filters: [{ sql: '{CUBE}."durationMinutes" >= 60' }], public: false }, - { name: "anyLongDeload", sql: "{anyLongDeloadRaw} = 1", type: "boolean" }, + // A condition on a boolean aggregate travels in the SQL, as the view writes it. + { name: "anyLongDeload", sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)', type: "boolean" }, ], segments: [{ name: "long", sql: '{CUBE}."durationMinutes" >= 60' }], // Coarsest first: FitnessTotals and FitnessTotalsFilled group by nothing, so Cube's first @@ -1720,7 +1715,7 @@ describe("Table H — the canonical model", () => { public: false, joins: [], dimensions: [key], - measures: [weeks, anyDeloadRaw, anyDeload, anyDeloadOrFalseRaw, anyDeloadOrFalse, allDeloadOrTrueRaw, allDeloadOrTrue], + measures: [weeks, anyDeload, anyDeloadOrFalseRaw, anyDeloadOrFalse, allDeloadOrTrueRaw, allDeloadOrTrue], segments: [], preAggregations: [], }, diff --git a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts index 411214a5e..a05da9aef 100644 --- a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts @@ -279,42 +279,28 @@ cubes: type: sum filters: - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' - - name: anyDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max - public: false - name: anyDeload - sql: '{anyDeloadRaw} = 1' + sql: 'bool_or({CUBE}."deload")' type: boolean - - name: allDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: min - public: false - name: allDeload - sql: '{allDeloadRaw} = 1' + sql: 'bool_and({CUBE}."deload")' type: boolean - name: anyDeloadOrFalseRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max + sql: 'bool_or({CUBE}."deload")' + type: boolean public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' type: boolean - name: allDeloadOrTrueRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: min + sql: 'bool_and({CUBE}."deload")' + type: boolean public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' type: boolean - - name: anyLongDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max - filters: - - sql: '{CUBE}."durationMinutes" >= 60' - public: false - name: anyLongDeload - sql: '{anyLongDeloadRaw} = 1' + sql: 'bool_or({CUBE}."deload") FILTER (WHERE {CUBE}."durationMinutes" >= 60)' type: boolean segments: - name: long @@ -504,26 +490,22 @@ cubes: - name: weeks sql: '{CUBE}."id"' type: count - - name: anyDeloadRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max - public: false - name: anyDeload - sql: '{anyDeloadRaw} = 1' + sql: 'bool_or({CUBE}."deload")' type: boolean - name: anyDeloadOrFalseRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: max + sql: 'bool_or({CUBE}."deload")' + type: boolean public: false - name: anyDeloadOrFalse - sql: 'COALESCE({anyDeloadOrFalseRaw}, 0) = 1' + sql: 'COALESCE({anyDeloadOrFalseRaw}, FALSE)' type: boolean - name: allDeloadOrTrueRaw - sql: 'CAST({CUBE}."deload" AS INTEGER)' - type: min + sql: 'bool_and({CUBE}."deload")' + type: boolean public: false - name: allDeloadOrTrue - sql: 'COALESCE({allDeloadOrTrueRaw}, 1) = 1' + sql: 'COALESCE({allDeloadOrTrueRaw}, TRUE)' type: boolean `; diff --git a/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts b/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts index 8815210d8..edbcbbaf9 100644 --- a/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/measure-default.test.ts @@ -170,38 +170,6 @@ describe("measure.aggregate with @default", () => { }); }); -describe("measure.aggregate with @agg any / all", () => { - const refunded: Json = { "field.boolean": { name: "refunded" } }; - const anyRefund = (attrs: Json = {}): Json => ({ - "measure.aggregate": { name: "anyRefund", "@agg": "any", "@of": "Sale.refunded", ...attrs }, - }); - - test("a rollup lists the boolean , never its numeric Raw, so the report keeps its rollup", async () => { - const model = await build([sale([refunded, anyRefund()]), report("SaleFlags", ["sales", "anyRefund"])]); - expect(cube(model, "Sale").preAggregations).toEqual([ - { name: "SaleFlags", type: "rollup", measures: ["sales", "anyRefund"], dimensions: [], segments: [] }, - ]); - }); - - test("ERR_CUBE_MEMBER_COLLISION: a declared member named Raw names the @agg as the reason", async () => { - const err = await refusal([ - sale([refunded, anyRefund(), { "measure.aggregate": { name: "anyRefundRaw", "@agg": "count", "@of": "Sale.id" } }]), - ]); - expect(err.code).toBe("ERR_CUBE_MEMBER_COLLISION"); - expect(err.message).toContain( - "the measure 'anyRefundRaw' the exporter adds for the @agg of measure 'acme::shop::Sale.anyRefund' and " + - "measure 'acme::shop::Sale.anyRefundRaw' are both named 'anyRefundRaw'", - ); - }); - - test("with @default the collision still names the @default", async () => { - const err = await refusal([ - sale([refunded, anyRefund({ "@default": 0 }), { "measure.aggregate": { name: "anyRefundRaw", "@agg": "count", "@of": "Sale.id" } }]), - ]); - expect(err.message).toContain("the exporter adds for the @default of measure 'acme::shop::Sale.anyRefund'"); - }); -}); - describe("measure.ratio with @default", () => { const perSale = (attrs: Json = {}): Json => ({ "measure.ratio": { name: "perSale", "@numerator": "revenue", "@denominator": "sales", ...attrs }, diff --git a/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts b/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts index d5d1f17bf..ad7e4a6a5 100644 --- a/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts +++ b/server/typescript/packages/integration-tests/cube-live/cube-live-support.ts @@ -68,7 +68,10 @@ export interface PlannedColumn { * every port's read returns it). Maps the integer, as text, to its symbol. */ readonly symbols?: Readonly>; - /** A boolean measure (`@agg: any` / `all`): Cube must send a JSON boolean, never `1` / `0`. */ + /** + * A boolean measure (`@agg: any` / `all`). Over Postgres Cube must send a JSON boolean. Over + * MySQL, which has no boolean type, Cube sends the string `"1"` or `"0"`, and nothing else. + */ readonly boolean?: true; } @@ -208,15 +211,20 @@ function coarsen(day: string, grain: string): string { } } -export function fromCubeRow(row: Record, plan: QueryPlan): NormalRow { +export function fromCubeRow(row: Record, plan: QueryPlan, dialect: "postgres" | "mysql"): NormalRow { const out: NormalRow = {}; for (const c of plan.columns) { const key = c.derive?.fromKey ?? c.cubeKey; if (!(key in row)) throw new Error(`Cube's row has no '${key}': ${JSON.stringify(row)}`); - if (c.boolean === true && row[key] !== null && row[key] !== undefined && typeof row[key] !== "boolean") { - throw new Error(`Cube ${key}: a boolean measure came back as ${typeof row[key]} ${JSON.stringify(row[key])}, not a JSON boolean`); + const raw = row[key]; + if (c.boolean === true && raw !== null && raw !== undefined) { + const wellFormed = dialect === "postgres" ? typeof raw === "boolean" : raw === "1" || raw === "0"; + if (!wellFormed) { + const want = dialect === "postgres" ? "a JSON boolean" : 'the string "1" or "0"'; + throw new Error(`Cube ${key}: a boolean measure came back as ${typeof raw} ${JSON.stringify(raw)}, not ${want}`); + } } - const value = normalize(c.kind, row[key], `Cube ${key}`); + const value = normalize(c.kind, raw, `Cube ${key}`); out[c.field] = c.derive === undefined || value === null ? value : coarsen(value, c.derive.grain); } return out; diff --git a/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts b/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts index 54501cd70..edc925fea 100644 --- a/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts +++ b/server/typescript/packages/integration-tests/cube-live/cube-model-mysql.live.ts @@ -189,7 +189,7 @@ describe(`cube-model MySQL live check (${CUBE_IMAGE} over ${CUBE_MYSQL_IMAGE}, d const t0 = Date.now(); const result = await load(s, plan.query); const used = Object.keys(result.usedPreAggregations ?? {}).sort(); - const cubeRows = sortRows(result.data.map((row) => fromCubeRow(row, plan)), plan); + const cubeRows = sortRows(result.data.map((row) => fromCubeRow(row, plan, GEN_CONFIG.dialect)), plan); const viewRows = sortRows(selectView(s, want.view).map((row) => fromViewRow(row, plan, want.view)), plan); timings.push(`${want.report}: ${cubeRows.length} row(s) compared, served from ${used[0] ?? "the source table"}, load ${Date.now() - t0} ms`); expect(viewRows.length).toBe(want.rows); diff --git a/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts b/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts index 2e7c2d90a..357c0faee 100644 --- a/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts +++ b/server/typescript/packages/integration-tests/cube-live/cube-model.live.ts @@ -282,7 +282,7 @@ describe(`cube-model live check (${CUBE_IMAGE}, development mode)`, () => { const loadMs = Date.now() - t0; // Rows first, so a rollup mismatch can say whether the numbers were right anyway. - const cubeRows = sortRows(result.data.map((row) => fromCubeRow(row, plan)), plan); + const cubeRows = sortRows(result.data.map((row) => fromCubeRow(row, plan, GEN_CONFIG.dialect)), plan); const viewRows = sortRows(selectView(s, want.view).map((row) => fromViewRow(row, plan, want.view)), plan); const rowsMatch = Bun.deepEquals(cubeRows, viewRows); const used = Object.keys(result.usedPreAggregations ?? {}).sort(); From ea002eaf5e0b7bc806f92eb520f7502e2128aeea Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 20:21:27 -0400 Subject: [PATCH 6/8] test(reporting): name the boolean-measure reports in the TypeScript and Java compile gates; declare the field the any/all doc example reads --- docs/features/reporting.md | 1 + .../generator/spring/CodegenCompileConformanceTest.java | 6 +++--- .../codegen-ts/test/codegen-compile-conformance.test.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/features/reporting.md b/docs/features/reporting.md index b20335743..09dc46db3 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -463,6 +463,7 @@ and the measure is a `boolean` in every port: `true` or `false` on the wire, nev ```jsonc { "field.boolean": { "name": "failed" } }, +{ "field.boolean": { "name": "succeeded" } }, { "measure.aggregate": { "name": "hasError", "@agg": "any", "@of": "Call.failed" } }, { "measure.aggregate": { "name": "allSucceeded", "@agg": "all", "@of": "Call.succeeded", "@default": 1 } } ``` diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java index c5f693f39..1c3ecc3d0 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java @@ -219,16 +219,16 @@ public void theSpringWebTierCompiles() throws Exception { selection.put("repository", PLAIN); selection.put("filter-allowlist", PLAIN); selection.put("names", PLAIN); - // FR-044 Plan 3: every view-backed report of the corpus (twenty-one today) is served, + // FR-044 Plan 3: every view-backed report of the corpus (twenty-three today) is served, // so its row DTOs, repository seams and filter allowlists are in this compile. Named, // so a gate that stops admitting a report fails here instead of compiling less. // ProgramRoster, ProgramLongWeeks and FitnessTotalsFilled carry @spine / a measure - // @default; the rest reach the vocabulary the first nine do not. + // @default; the rest reach the vocabulary the first eleven do not. // ProgramMinutes derives eleven filterable fields, one past Map.of's ten pairs. generateAndCompile("web", selection, 17, "ProgramMinutesDto", "FitnessTotalsDto", "ProgramsByMonthDto", "ProgramsByWeekDto", "RecentProgramsDto", "AssetActivityDto", "ProgramRosterDto", "ProgramLongWeeksDto", - "FitnessTotalsFilledDto", + "ProgramFlagsDto", "ProgramFlagRosterDto", "FitnessTotalsFilledDto", "MeasurementStatsDto", "MeasurementStatsFilledDto", "AssetDaysDto", "AllTypesBreakdownDto", "ProgramsOverTimeDto", "LiveIntroProgramsDto", "ProgramsEmptyScopeDto", "WeekShapesDto", "SessionsByProgramDto", "ProgramSessionsDto", "NodesByParentDto", "AuthsByKindDto", "ProgramMinutesRepository", "ProgramMinutesFilterAllowlist"); diff --git a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts index 7ac07a4f9..53c0ebfbe 100644 --- a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts +++ b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts @@ -114,7 +114,7 @@ const DEFAULT_OPTIONS: ts.CompilerOptions = { /** The corpus's view-backed reports (FR-044). */ const SERVED_REPORTS = [ "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity", - "ProgramRoster", "ProgramLongWeeks", "FitnessTotalsFilled", + "ProgramRoster", "ProgramLongWeeks", "ProgramFlags", "ProgramFlagRoster", "FitnessTotalsFilled", "MeasurementStats", "MeasurementStatsFilled", "AssetDays", "AllTypesBreakdown", "ProgramsOverTime", "LiveIntroPrograms", "ProgramsEmptyScope", "WeekShapes", "SessionsByProgram", "ProgramSessions", "NodesByParent", "AuthsByKind", ] as const; From 42b7148d0067670f41c6d591a3e1dd5ae50df54e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 21:38:38 -0400 Subject: [PATCH 7/8] no-mistakes(document): Add any/all to authoring skill aggregate list --- agent-context/skills/metaobjects-authoring/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 304bae183..703bd84e6 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -686,7 +686,7 @@ and an `object.report` combines them by name. Four node kinds: - `dimension.attribute` / `dimension.time` — what to group by (`@of: Entity.field`; a time dimension lists the `@grains` it supports: `hour`, `day`, `week`, `month`, `quarter`, `year`); -- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`) and `measure.ratio` +- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`, `any`, `all`) and `measure.ratio` (`@numerator` / `@denominator`, both measures of the entity); - `segment.filter` — a named, reusable `@filter` ("active purchase"); - `object.report` — a top-level object: `@from` an entity, `@dimensions` (`name` or From f11549a27442a8c1bb6e444c14b872696cc61c51 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 21:43:16 -0400 Subject: [PATCH 8/8] docs(cube-export): the corpus holds three MySQL mapping cases, not two --- docs/CONFORMANCE.md | 6 +++--- docs/features/cube-export.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 481b357a3..3f0ed77fd 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -135,7 +135,7 @@ manifest contains without moving these boundaries. - *Cube accepts the output — queried on Postgres, compiled in both dialects.* The `cube` lane (`scripts/ci-local.sh --only cube`, which needs Docker) loads the canonical golden and the 36 cases that hold a tree into a pinned Cube (`cubejs/cube:v1.7.43`) and requires each to - compile, the two MySQL cases included: compiling a model runs no SQL, so the lane's Postgres + compile, the three MySQL cases included: compiling a model runs no SQL, so the lane's Postgres data source serves them too. For the canonical reports it compares the Cube query with the report view (a `@spine` report through its Cube view, its empty spine rows included), and for the `escaping` and `measure-default` cases it reads the SQL back from Cube's `/v1/sql`. @@ -143,8 +143,8 @@ manifest contains without moving these boundaries. generated with `dialect: "mysql"` is loaded into the same pinned Cube over a real MySQL 8.4 holding the report views `buildReportViews` lowers (`fixtures/cube-model/canonical/seed.mysql.sql`), and each of the twenty-three served reports' Cube query is compared with `SELECT *` from its view, with the - pre-aggregation used asserted. That is the smaller of the two honest options: the two MySQL - *cases* (escaping, a quoted identifier) are still compiled, not executed, and the goldens hold + pre-aggregation used asserted. That is the smaller of the two honest options: the three MySQL + *cases* (`dimension-time-mysql`, `measure-mysql`, `measure-boolean-mysql`) are still compiled, not executed, and the goldens hold their SQL. The lane runs Cube in development mode, so Cube's production mode with a separate Cube Store is not gated. - *The lane is a gate, not a second corpus.* Like the codegen-compile gate, it reuses diff --git a/docs/features/cube-export.md b/docs/features/cube-export.md index 67daa528e..5b1f68151 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -645,7 +645,7 @@ the table names above are the development-mode form. refuses the model with `ERR_CUBE_AMBIGUOUS_PATH`, naming both. The lossless form needs a nested alias for each path. That is more machinery than a rare model is worth yet, so the exporter errors rather than guess. -- **The two MySQL mapping cases are compiled, never executed.** The corpus pins their SQL byte +- **The three MySQL mapping cases are compiled, never executed.** The corpus pins their SQL byte for byte and Cube compiles them, but no MySQL database runs them. The MySQL path runs only the served reports. The tuple distinct count's MySQL form was compared with the view's on mysql:8.4 by hand when it was built.