diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index d0bce8e3c..c12553bec 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -33,8 +33,8 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 15 | ✓ | ✓ | 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/) | 42 (36 query + 6 migration) | all 42 (the 9 `report-*` also on SQLite, MySQL and D1) | 36 query (migrations TS-only, ADR-0015); the 9 `report-*` also on MySQL | 36 query (via Exposed); the 9 `report-*` also on SQLite and MySQL | 36 query; the 9 `report-*` also on SQLite and MySQL | 36 query (Postgres only: pg8000) | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 82 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 12 projection + 16 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/) | 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/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) | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -136,13 +136,13 @@ manifest contains without moving these boundaries. (`scripts/ci-local.sh --only cube`, which needs Docker) loads the canonical golden and the 34 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 nine canonical reports it compares the Cube query with the + 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`. 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 nine reports' Cube query is compared with `SELECT *` from its view, with the + and each of the twenty-one 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 @@ -298,12 +298,12 @@ trailing-newline preservation, and unicode multibyte handling. All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md) (template drift section — `Renderer.verify`). -### `fixtures/persistence-conformance/` (42 — 36 query + 6 migration) +### `fixtures/persistence-conformance/` (52 — 46 query + 6 migration) - `migrations/*` (6) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section) -- `queries/*` (36) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) +- `queries/*` (46) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) -**Engines beyond Postgres (FR-044 reports).** The nine `queries/report-*.yaml` scenarios also +**Engines beyond Postgres (FR-044 reports).** The nineteen `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 @@ -317,9 +317,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/` (82) +### `fixtures/api-contract-conformance/` (88) -All 82 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port +All 88 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` @@ -337,7 +337,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/` (16, +it, whether its identity names `@fields` or derives them; decimal and float fields filter) and `report/` (22, 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, @@ -523,7 +523,7 @@ 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 31 -+ render 15 + persistence 42 + api-contract 82 + source-resolution 25 + scope 10 + ++ render 15 + persistence 52 + api-contract 88 + 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/api-contract.md b/docs/features/api-contract.md index 5e1caedd2..3883b2a30 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/) -(16 scenarios), **generated lane only, on all five ports**, for the reason `projection/` +(22 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/cube-export.md b/docs/features/cube-export.md index 2fc4cbdcd..b9308bc08 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -556,6 +556,8 @@ there. | Grains | Cube offers every granularity on a time dimension. | `@grains` is carried as `meta.grains`, not enforced. | | Ad-hoc queries | Cube lets a caller pick any members. | Nothing here checks the numbers for a combination no report declares. | | Rollup freshness | Cube refreshes a rollup on its refresh key, and the exporter writes none, so Cube's default applies. | A rollup can trail the table until it refreshes. The view is never stale. A report with a relative date gets no rollup for this reason. | +| Empty scope | A report with no dimension whose scope reads no rows returns one row from the view: the count `0` and each measure's `@default`. Cube answers from the rollup, which is empty, and sums it to null for every measure, the count and the default included. | `ProgramsEmptyScope` is the one canonical report where the rows differ; the live check requires both answers, so the difference stays an executed check. | +| Several grains | A report that lists one time dimension at several grains gets a rollup naming that dimension once per grain. A Cube query names a time dimension once, at one granularity, so Cube 1.7.43 matches no query to that rollup. | The rollup is declared and never used; Cube answers from the source table and the rows equal the view's. `ProgramsOverTime` is the canonical case. | | Encodings | Cube's REST API sends numbers as strings and time buckets as `YYYY-MM-DDTHH:MM:SS.sss`. Where a rollup sends `60`, the view sends `60.0000000000000000`, and where Cube sends `2026-05-01T00:00:00.000`, the view sends `2026-05-01`. | Clients normalize them. The live check does, before it compares rows. | ## Checking it @@ -584,13 +586,13 @@ container and the network are removed on every exit path. It checks six things o 1. The generated canonical model is the reviewed golden. 2. Cube compiles it and lists every cube and member the files declare. -3. For each of the nine canonical reports, the Cube query above, answered from the report's +3. For each of the canonical reports, the Cube query above, answered from the report's rollup when the model has one (the lane asserts the rollup name in `usedPreAggregations`), returns the rows of `SELECT * FROM `, after the normalization the Encodings row above - describes. The two `@spine` reports, `ProgramRoster` and `ProgramLongWeeks`, are queried - through their Cube views, and their 7 rows each include the programs with no weeks (and, for - `ProgramLongWeeks`, the two whose weeks are all short); `FitnessTotalsFilled`, a report of - defaulted measures, is answered from its rollup. + describes. The three `@spine` reports, `ProgramRoster`, `ProgramLongWeeks` and + `ProgramSessions`, are queried through their Cube views, and their 11 rows each include the + programs with no weeks (and, for `ProgramLongWeeks`, the two whose weeks are all short); + `FitnessTotalsFilled`, a report of defaulted measures, is answered from its rollup. 4. Every case of the mapping corpus that has an expected tree (34 of the 44: 32 Postgres and 2 MySQL) compiles in the same Cube, and each `title` and `description` it declares comes back from `/v1/meta` as declared. Compiling runs no SQL, so the MySQL cases compile against the diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 4187d603b..47565b62c 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -621,11 +621,11 @@ the anti-join by hand, and make two reports. ### What the corpus gates -Nine shared scenarios under `fixtures/persistence-conformance/queries/report-*.yaml` read the +Nineteen 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 -over an empty table). The derived columns are pinned by +over an empty table; and, beyond those, every `@agg` kind over int, long, float, double, decimal, currency, date and instant columns, a nullable int-backed enum, a boolean and a uuid as dimensions, `day`, `quarter` and `year` grains together, every filter operator on a measure, a report `@segment` and `@filter` together, a two-hop `@via`, a self-referencing dimension, a report declared from a TPH base, and a negative `@default`). The derived columns are pinned by `fixtures/persistence-conformance/report-shapes.json`, produced by TypeScript and byte-matched by every port. The SQL is produced by TypeScript only, so the other ports read the view the TypeScript migrate engine produced and never lower a report themselves. TypeScript's own value @@ -633,7 +633,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 nine scenarios run on more than Postgres, wherever a port's runtime has the engine: +The nineteen report scenarios run on more than Postgres, wherever a port's runtime has the engine: | | Postgres | SQLite | MySQL 8.4 | D1 (local runtime) | |---|---|---|---|---| @@ -651,21 +651,23 @@ 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 sixteen REST scenarios through Miniflare's local D1 (`meta migrate +scenarios and the same twenty-two 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 sixteen scenarios under +The REST surface is gated by twenty-two 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 totals report with the `withCount` envelope, a filter on a dimension and on a measure, a sort on a measure and an enum dimension, paging over groups, the three field-naming `400` envelopes, `405` on `POST`, `404` on every verb at `/{id}`, and a `@spine` report whose product with no -sale keeps its row, with a filter and a sort on its defaulted measure. The corpus model +sale keeps its row, with a filter and a sort on its defaulted measure, a dimension reached through +two references, one time dimension listed at two grains, a filter on a string (`like`), a boolean and a +uuid dimension, and a filter on a measure that carries its own `@filter`. The corpus model carries one sourceless report, so a port that serves every report it finds fails. No scenario asserts a decimal's spelling or a timestamp literal. TypeScript and C# run the scenarios against the real views on Postgres, SQLite and MySQL (`report/schema.sqlite.sql`, `report/schema.mysql.sql`), and diff --git a/fixtures/api-contract-conformance/report/README.md b/fixtures/api-contract-conformance/report/README.md index db7728a86..faf7a85d7 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 -Three entities (`Invoice`; `Product` and its child `Sale`) and five reports, four served -and one sourceless on purpose. +Four entities (`Invoice`; `Category`, `Product` and their child `Sale`) and nine reports, +eight served and one sourceless on purpose. | Report | Route | View | Derived fields | |---|---|---|---| @@ -72,6 +72,10 @@ and one sourceless on purpose. | `InvoiceTotals` | `/api/invoice_totals` | `v_invoice_totals` | `invoices`, `totalCents`, `paidShare` | | `InvoiceDays` | none | none | `issuedOnDay`, `invoices` | | `ProductRevenue` | `/api/product_revenues` | `v_product_revenue` | `productId`, `productName`, `sales`, `revenueCents`, `revenueOrZero` | +| `InvoiceCalendar` | `/api/invoice_calendars` | `v_invoice_calendar` | one time dimension at two grains, `invoices` | +| `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` | `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 @@ -87,9 +91,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, Product, Sale + five object.report nodes (four served, one sourceless) -├── seed.json # the base tables (`invoices`, `products`, `sales`) and `reports` (what the views return) -├── schema.postgres.sql # TypeScript-produced: the three tables and the four views +├── meta.json # Invoice, Category, Product, Sale + nine object.report nodes (eight 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.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/ @@ -108,7 +112,13 @@ report/ ├── no-item-route.yaml # GET/PATCH/PUT/DELETE on /{id} -> 404, no body assertion ├── list-spine.yaml # @spine: the product with no sale has a row; withCount counts it ├── filter-on-defaulted-measure.yaml # eq=0 on a defaulted sum, isNull on the plain sum, gt=0 on the count - └── sort-on-defaulted-measure.yaml # a defaulted sum sorts its empty row as 0, both directions + ├── sort-on-defaulted-measure.yaml # a defaulted sum sorts its empty row as 0, both directions + ├── list-multi-hop.yaml # a dimension reached through two references + ├── list-two-grains.yaml # one time dimension listed at two grains + ├── 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 ``` ### Why `seed.json` has two halves @@ -116,7 +126,7 @@ report/ Every top-level key but `reports` is a **base table**, named by its table name and listed **parents first** (`products` before `sales`, which references it): a lane inserts the keys in file order and every foreign key holds. The full-stack lanes (TypeScript, C#) -insert them and let the real views derive the report rows. `reports` is what the four +insert them and let the real views derive the report rows. `reports` is what the views return for those rows, and it is what the **seam lanes** (Java, Kotlin, Python) serve: their in-memory repository or H2 table stands in for the view behind the consumer seam. A TypeScript test holds the two halves together, so the seam lanes cannot drift @@ -144,11 +154,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` (16 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 16 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` (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) | | 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` (16 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` (16 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` (16 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` (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` | 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/api-contract-conformance/report/meta.json b/fixtures/api-contract-conformance/report/meta.json index 10e48b85e..009c3d12f 100644 --- a/fixtures/api-contract-conformance/report/meta.json +++ b/fixtures/api-contract-conformance/report/meta.json @@ -11,14 +11,17 @@ { "field.enum": { "name": "status", "@required": true, "@values": ["OPEN", "PAID", "VOID"] } }, { "field.long": { "name": "amountCents", "@required": true } }, { "field.date": { "name": "issuedOn", "@required": true } }, + { "field.boolean": { "name": "disputed", "@required": true } }, { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, { "segment.filter": { "name": "paid", "@filter": { "status": "PAID" } } }, { "dimension.attribute": { "name": "status", "@of": "Invoice.status" } }, + { "dimension.attribute": { "name": "disputed", "@of": "Invoice.disputed" } }, { "dimension.time": { "name": "issuedOn", "@of": "Invoice.issuedOn", "@grains": ["day", "month"] } }, { "measure.aggregate": { "name": "invoices", "@agg": "count", "@of": "Invoice.id" } }, { "measure.aggregate": { "name": "paidInvoices", "@agg": "count", "@of": "Invoice.id", "@segment": "paid" } }, { "measure.aggregate": { "name": "totalCents", "@agg": "sum", "@of": "Invoice.amountCents" } }, { "measure.aggregate": { "name": "paidCents", "@agg": "sum", "@of": "Invoice.amountCents", "@segment": "paid" } }, + { "measure.aggregate": { "name": "bigInvoices", "@agg": "count", "@of": "Invoice.id", "@filter": { "amountCents": { "gte": 60000 } } } }, { "measure.ratio": { "name": "paidShare", "@numerator": "paidInvoices", "@denominator": "invoices" } } ] }}, @@ -42,19 +45,45 @@ "@measures": ["invoices", "totalCents", "paidShare"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_totals" } } ] }}, + { "object.report": { + "name": "InvoiceCalendar", + "@from": "Invoice", + "@dimensions": ["issuedOn:day", "issuedOn:month"], + "@measures": ["invoices", "totalCents"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_calendar" } } ] + }}, + { "object.report": { + "name": "InvoiceDisputes", + "@from": "Invoice", + "@dimensions": ["disputed"], + "@measures": ["invoices", "bigInvoices"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_disputes" } } ] + }}, { "object.report": { "name": "InvoiceDays", "@from": "Invoice", "@dimensions": ["issuedOn:day"], "@measures": ["invoices"] }}, + { "object.entity": { + "name": "Category", + "children": [ + { "source.rdb": { "@table": "categories" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } } + ] + }}, { "object.entity": { "name": "Product", "children": [ { "source.rdb": { "@table": "products" } }, { "field.long": { "name": "id" } }, { "field.string": { "name": "name", "@required": true } }, - { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } } + { "field.uuid": { "name": "sku" } }, + { "field.long": { "name": "categoryId" } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, + { "identity.reference": { "name": "fkCategory", "@fields": "categoryId", "@references": "Category" } } ] }}, { "object.entity": { @@ -68,7 +97,11 @@ { "identity.reference": { "name": "fkProduct", "@fields": "productId", "@references": "Product" } }, { "dimension.attribute": { "name": "productId", "@of": "Product.id", "@via": "Sale.fkProduct" } }, { "dimension.attribute": { "name": "productName", "@of": "Product.name", "@via": "Sale.fkProduct" } }, + { "dimension.attribute": { "name": "categoryName", "@of": "Category.name", "@via": "Sale.fkProduct.fkCategory" } }, + { "dimension.attribute": { "name": "productSku", "@of": "Product.sku", "@via": "Sale.fkProduct" } }, { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } }, + { "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 } } ] @@ -80,6 +113,20 @@ "@dimensions": ["productId", "productName"], "@measures": ["sales", "revenueCents", "revenueOrZero"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_product_revenue" } } ] + }}, + { "object.report": { + "name": "SalesByCategory", + "@from": "Sale", + "@dimensions": ["categoryName"], + "@measures": ["sales", "revenueCents", "maxCents", "largeSales"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_sales_by_category" } } ] + }}, + { "object.report": { + "name": "SalesBySku", + "@from": "Sale", + "@dimensions": ["productSku"], + "@measures": ["sales", "revenueCents"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_sales_by_sku" } } ] }} ] } diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-boolean-dimension.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-boolean-dimension.yaml new file mode 100644 index 000000000..1c38068cd --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/filter-boolean-dimension.yaml @@ -0,0 +1,31 @@ +name: report-filter-boolean-dimension +description: > + A boolean dimension (Invoice.disputed) is a derived field that takes `eq`. It reaches the + wire as a JSON boolean, and `?filter[disputed][eq]=true` parses to one on every port. + `bigInvoices` is a count with an inline filter (amountCents >= 60000). +requests: + - id: r1 + method: GET + path: /api/invoice_disputes?sort=disputed:asc + expect: + status: 200 + body: + equals: + - { disputed: false, invoices: 3, bigInvoices: 2 } + - { disputed: true, invoices: 2, bigInvoices: 1 } + - id: r2 + method: GET + path: /api/invoice_disputes?filter[disputed][eq]=true + expect: + status: 200 + body: + equals: + - { disputed: true, invoices: 2, bigInvoices: 1 } + - id: r3 + method: GET + path: /api/invoice_disputes?filter[disputed][eq]=false + expect: + status: 200 + body: + equals: + - { disputed: false, invoices: 3, bigInvoices: 2 } diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-on-filtered-measure.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-on-filtered-measure.yaml new file mode 100644 index 000000000..bb04a9f83 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/filter-on-filtered-measure.yaml @@ -0,0 +1,31 @@ +name: report-filter-on-filtered-measure +description: > + A count with an inline measure @filter is an ordinary long: it is 0 (not null) for a + group none of whose rows pass, so ?filter[largeSales][eq]=0 matches that group and + ?filter[largeSales][gt]=0 matches the other. `maxCents` filters with the numeric + operators like any long. +requests: + - id: r1 + method: GET + path: /api/sales_by_categories?filter[largeSales][eq]=0 + expect: + status: 200 + body: + equals: + - { categoryName: "Toys", sales: 1, revenueCents: 4500, maxCents: 4500, largeSales: 0 } + - id: r2 + method: GET + path: /api/sales_by_categories?filter[largeSales][gt]=0 + expect: + status: 200 + body: + equals: + - { categoryName: "Tools", sales: 2, revenueCents: 30000, maxCents: 18000, largeSales: 2 } + - id: r3 + method: GET + path: /api/sales_by_categories?filter[maxCents][gte]=18000 + expect: + status: 200 + body: + equals: + - { categoryName: "Tools", sales: 2, revenueCents: 30000, maxCents: 18000, largeSales: 2 } diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-on-string-dimension.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-on-string-dimension.yaml new file mode 100644 index 000000000..16eb68d88 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/filter-on-string-dimension.yaml @@ -0,0 +1,21 @@ +name: report-filter-on-string-dimension +description: > + ?filter[categoryName][like]=Too% — a string dimension takes `like` as well as `eq`. + The pattern is anchored, as on any list route. +requests: + - id: r1 + method: GET + path: /api/sales_by_categories?filter[categoryName][like]=Too%25 + expect: + status: 200 + body: + equals: + - { categoryName: "Tools", sales: 2, revenueCents: 30000, maxCents: 18000, largeSales: 2 } + - id: r2 + method: GET + path: /api/sales_by_categories?filter[categoryName][eq]=Toys + expect: + status: 200 + body: + equals: + - { categoryName: "Toys", sales: 1, revenueCents: 4500, maxCents: 4500, largeSales: 0 } diff --git a/fixtures/api-contract-conformance/report/scenarios/filter-uuid-dimension.yaml b/fixtures/api-contract-conformance/report/scenarios/filter-uuid-dimension.yaml new file mode 100644 index 000000000..f82ee9657 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/filter-uuid-dimension.yaml @@ -0,0 +1,39 @@ +name: report-filter-uuid-dimension +description: > + A uuid dimension (Product.sku, reached through Sale.fkProduct) takes `eq`, `ne` and `in` + and reaches the wire as its lowercase hyphenated string. Cinder has a sku but no sale, and + SalesBySku declares no @spine, so it has no row. +requests: + - id: r1 + method: GET + path: /api/sales_by_skus?sort=revenueCents:desc + expect: + status: 200 + body: + equals: + - { productSku: "11111111-1111-4111-8111-111111111111", sales: 2, revenueCents: 30000 } + - { productSku: "22222222-2222-4222-8222-222222222222", sales: 1, revenueCents: 4500 } + - id: r2 + method: GET + path: /api/sales_by_skus?filter[productSku][eq]=22222222-2222-4222-8222-222222222222 + expect: + status: 200 + body: + equals: + - { productSku: "22222222-2222-4222-8222-222222222222", sales: 1, revenueCents: 4500 } + - id: r3 + method: GET + path: /api/sales_by_skus?filter[productSku][ne]=22222222-2222-4222-8222-222222222222 + expect: + status: 200 + body: + equals: + - { productSku: "11111111-1111-4111-8111-111111111111", sales: 2, revenueCents: 30000 } + - id: r4 + method: GET + path: /api/sales_by_skus?filter[productSku][in]=11111111-1111-4111-8111-111111111111,33333333-3333-4333-8333-333333333333 + expect: + status: 200 + body: + equals: + - { productSku: "11111111-1111-4111-8111-111111111111", sales: 2, revenueCents: 30000 } diff --git a/fixtures/api-contract-conformance/report/scenarios/list-multi-hop.yaml b/fixtures/api-contract-conformance/report/scenarios/list-multi-hop.yaml new file mode 100644 index 000000000..590686c1d --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/list-multi-hop.yaml @@ -0,0 +1,18 @@ +name: report-list-multi-hop +description: > + GET /api/sales_by_categories — SalesByCategory groups Sale by categoryName, a dimension + reached through TWO references (Sale.fkProduct.fkCategory). The segment is the report + name snake_cased and then pluralized: a name ending in a consonant plus `y` takes `ies`. + Beacon is in Toys, Atlas in Tools; Cinder has no category and no sale. `maxCents` is a + max, and `largeSales` is a count carrying an inline measure filter (amountCents >= 10000), + which is 0, not null, for a group none of whose sales pass it. +requests: + - id: r1 + method: GET + path: /api/sales_by_categories?sort=categoryName:asc + expect: + status: 200 + body: + equals: + - { categoryName: "Tools", sales: 2, revenueCents: 30000, maxCents: 18000, largeSales: 2 } + - { categoryName: "Toys", sales: 1, revenueCents: 4500, maxCents: 4500, largeSales: 0 } diff --git a/fixtures/api-contract-conformance/report/scenarios/list-two-grains.yaml b/fixtures/api-contract-conformance/report/scenarios/list-two-grains.yaml new file mode 100644 index 000000000..96fc8a57f --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/list-two-grains.yaml @@ -0,0 +1,28 @@ +name: report-list-two-grains +description: > + GET /api/invoice_calendars — InvoiceCalendar lists ONE time dimension at TWO grains, so + the row carries issuedOnDay and issuedOnMonth, both typed date and spelled YYYY-MM-DD. + Three of the five days fall in May. A filter on the coarser grain keeps those three, and + withCount counts groups. +requests: + - id: r1 + method: GET + path: /api/invoice_calendars?sort=issuedOnDay:asc + expect: + status: 200 + body: + equals: + - { issuedOnDay: "2026-04-30", issuedOnMonth: "2026-04-01", invoices: 1, totalCents: 125000 } + - { issuedOnDay: "2026-05-01", issuedOnMonth: "2026-05-01", invoices: 1, totalCents: 40000 } + - { issuedOnDay: "2026-05-17", issuedOnMonth: "2026-05-01", invoices: 1, totalCents: 90500 } + - { issuedOnDay: "2026-05-31", issuedOnMonth: "2026-05-01", invoices: 1, totalCents: 0 } + - { issuedOnDay: "2026-06-01", issuedOnMonth: "2026-06-01", invoices: 1, totalCents: 60000 } + - id: r2 + method: GET + path: /api/invoice_calendars?filter[issuedOnMonth][eq]=2026-05-01&sort=issuedOnDay:desc&withCount=1 + expect: + status: 200 + body: + envelope: true + rowsLength: 3 + total: 3 diff --git a/fixtures/api-contract-conformance/report/schema.mysql.sql b/fixtures/api-contract-conformance/report/schema.mysql.sql index fc15902b9..759e80a0c 100644 --- a/fixtures/api-contract-conformance/report/schema.mysql.sql +++ b/fixtures/api-contract-conformance/report/schema.mysql.sql @@ -9,14 +9,23 @@ CREATE TABLE `invoices` ( `reference` VARCHAR(40) NOT NULL, `status` VARCHAR(20) NOT NULL, `amountCents` BIGINT NOT NULL, - `issuedOn` DATE NOT NULL + `issuedOn` DATE NOT NULL, + `disputed` BOOLEAN NOT NULL ); -CREATE TABLE `products` ( +CREATE TABLE `categories` ( `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(200) NOT NULL ); +CREATE TABLE `products` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL, + `sku` VARCHAR(36), + `categoryId` BIGINT, + FOREIGN KEY (`categoryId`) REFERENCES `categories` (`id`) +); + CREATE TABLE `sales` ( `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `productId` BIGINT NOT NULL, @@ -48,6 +57,23 @@ CREATE VIEW `v_invoice_totals` AS COUNT(CASE WHEN i.`status` = 'PAID' THEN i.`id` END) / NULLIF(COUNT(i.`id`), 0) AS `paidShare` FROM `invoices` i; +CREATE VIEW `v_invoice_calendar` AS + SELECT + DATE(i.`issuedOn`) AS `issuedOnDay`, + DATE(DATE_FORMAT(i.`issuedOn`, '%Y-%m-01')) AS `issuedOnMonth`, + COUNT(i.`id`) AS `invoices`, + CAST(SUM(i.`amountCents`) AS SIGNED) AS `totalCents` + FROM `invoices` i + GROUP BY DATE(i.`issuedOn`), DATE(DATE_FORMAT(i.`issuedOn`, '%Y-%m-01')); + +CREATE VIEW `v_invoice_disputes` AS + SELECT + i.`disputed` AS `disputed`, + COUNT(i.`id`) AS `invoices`, + COUNT(CASE WHEN i.`amountCents` >= 60000 THEN i.`id` END) AS `bigInvoices` + FROM `invoices` i + GROUP BY i.`disputed`; + CREATE VIEW `v_product_revenue` AS SELECT p.`id` AS `productId`, @@ -58,3 +84,24 @@ CREATE VIEW `v_product_revenue` AS FROM `products` p LEFT OUTER JOIN `sales` s ON p.`id` = s.`productId` GROUP BY p.`id`, p.`name`; + +CREATE VIEW `v_sales_by_category` AS + SELECT + c.`name` AS `categoryName`, + COUNT(s.`id`) AS `sales`, + CAST(SUM(s.`amountCents`) AS SIGNED) AS `revenueCents`, + MAX(s.`amountCents`) AS `maxCents`, + COUNT(CASE WHEN s.`amountCents` >= 10000 THEN s.`id` END) AS `largeSales` + FROM `sales` s + INNER JOIN `products` p ON p.`id` = s.`productId` + LEFT OUTER JOIN `categories` c ON c.`id` = p.`categoryId` + GROUP BY c.`name`; + +CREATE VIEW `v_sales_by_sku` AS + SELECT + p.`sku` AS `productSku`, + COUNT(s.`id`) AS `sales`, + CAST(SUM(s.`amountCents`) AS SIGNED) AS `revenueCents` + FROM `sales` s + INNER JOIN `products` p ON p.`id` = s.`productId` + GROUP BY p.`sku`; diff --git a/fixtures/api-contract-conformance/report/schema.postgres.sql b/fixtures/api-contract-conformance/report/schema.postgres.sql index 7d67f523d..01c228bf9 100644 --- a/fixtures/api-contract-conformance/report/schema.postgres.sql +++ b/fixtures/api-contract-conformance/report/schema.postgres.sql @@ -9,13 +9,22 @@ CREATE TABLE "invoices" ( "status" TEXT NOT NULL, "amountCents" BIGINT NOT NULL, "issuedOn" DATE NOT NULL, + "disputed" BOOLEAN NOT NULL, CONSTRAINT "invoices_pkey" PRIMARY KEY ("id"), CONSTRAINT "invoices_status_chk" CHECK ("status" IN ('OPEN', 'PAID', 'VOID')) ); +CREATE TABLE "categories" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" TEXT NOT NULL, + CONSTRAINT "categories_pkey" PRIMARY KEY ("id") +); + CREATE TABLE "products" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "name" TEXT NOT NULL, + "sku" UUID, + "categoryId" BIGINT, CONSTRAINT "products_pkey" PRIMARY KEY ("id") ); @@ -26,6 +35,8 @@ CREATE TABLE "sales" ( CONSTRAINT "sales_pkey" PRIMARY KEY ("id") ); +ALTER TABLE "products" ADD CONSTRAINT "products_categoryId_fk" FOREIGN KEY ("categoryId") REFERENCES "categories" ("id"); + ALTER TABLE "sales" ADD CONSTRAINT "sales_productId_fk" FOREIGN KEY ("productId") REFERENCES "products" ("id"); CREATE VIEW "v_invoice_status_totals" AS @@ -55,6 +66,25 @@ CREATE VIEW "v_invoice_totals" AS FROM "invoices" i; COMMENT ON VIEW "v_invoice_totals" IS 'metaobjects:v1:sha256:d99e43a93181f09bf2af44297ae4bf3f0f52dc5fc1dd73279b2ab76fab12a5fb'; +CREATE VIEW "v_invoice_calendar" AS + SELECT + i."issuedOn" AS "issuedOnDay", + CAST(date_trunc('month', CAST(i."issuedOn" AS TIMESTAMP)) AS DATE) AS "issuedOnMonth", + COUNT(i."id") AS "invoices", + CAST(SUM(i."amountCents") AS BIGINT) AS "totalCents" + FROM "invoices" i + GROUP BY i."issuedOn", CAST(date_trunc('month', CAST(i."issuedOn" AS TIMESTAMP)) AS DATE); +COMMENT ON VIEW "v_invoice_calendar" IS 'metaobjects:v1:sha256:bb09a6e3477ccb38cd7f38df162d5621f5bd50d457b80492dd2f6800ee07e91c'; + +CREATE VIEW "v_invoice_disputes" AS + SELECT + i."disputed" AS "disputed", + COUNT(i."id") AS "invoices", + COUNT(i."id") FILTER (WHERE i."amountCents" >= 60000) AS "bigInvoices" + FROM "invoices" i + GROUP BY i."disputed"; +COMMENT ON VIEW "v_invoice_disputes" IS 'metaobjects:v1:sha256:728e28e8265487a06e3e24606e0cb1e18812f038f5da8b23313c2e6578b47992'; + CREATE VIEW "v_product_revenue" AS SELECT p."id" AS "productId", @@ -66,3 +96,26 @@ CREATE VIEW "v_product_revenue" AS LEFT OUTER JOIN "sales" s ON p."id" = s."productId" GROUP BY p."id", p."name"; COMMENT ON VIEW "v_product_revenue" IS 'metaobjects:v1:sha256:4591ac33c76e8bd7078de8aa46d3f790deec87c7653d3d52d1b8ce93ee740949'; + +CREATE VIEW "v_sales_by_category" AS + SELECT + c."name" AS "categoryName", + COUNT(s."id") AS "sales", + CAST(SUM(s."amountCents") AS BIGINT) AS "revenueCents", + MAX(s."amountCents") AS "maxCents", + COUNT(s."id") FILTER (WHERE s."amountCents" >= 10000) AS "largeSales" + FROM "sales" s + INNER JOIN "products" p ON p."id" = s."productId" + LEFT OUTER JOIN "categories" c ON c."id" = p."categoryId" + GROUP BY c."name"; +COMMENT ON VIEW "v_sales_by_category" IS 'metaobjects:v1:sha256:02e75adfdefcc1605e134fb92ac35fb2a1cc827d99c47c876e120c11ccbf4b0f'; + +CREATE VIEW "v_sales_by_sku" AS + SELECT + p."sku" AS "productSku", + COUNT(s."id") AS "sales", + CAST(SUM(s."amountCents") AS BIGINT) AS "revenueCents" + FROM "sales" s + 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'; diff --git a/fixtures/api-contract-conformance/report/schema.sqlite.sql b/fixtures/api-contract-conformance/report/schema.sqlite.sql index 39c986ad8..a9ebcd26e 100644 --- a/fixtures/api-contract-conformance/report/schema.sqlite.sql +++ b/fixtures/api-contract-conformance/report/schema.sqlite.sql @@ -9,14 +9,23 @@ CREATE TABLE "invoices" ( "status" TEXT NOT NULL, "amountCents" INTEGER NOT NULL, "issuedOn" TEXT NOT NULL, + "disputed" INTEGER NOT NULL, CONSTRAINT "invoices_status_chk" CHECK ("status" IN ('OPEN', 'PAID', 'VOID')) ); -CREATE TABLE "products" ( +CREATE TABLE "categories" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL ); +CREATE TABLE "products" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "sku" TEXT, + "categoryId" INTEGER, + FOREIGN KEY ("categoryId") REFERENCES "categories" ("id") +); + CREATE TABLE "sales" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "productId" INTEGER NOT NULL, @@ -48,6 +57,23 @@ CREATE VIEW "v_invoice_totals" AS CAST(COUNT(CASE WHEN i."status" = 'PAID' THEN i."id" END) AS REAL) / NULLIF(COUNT(i."id"), 0) AS "paidShare" FROM "invoices" i; +CREATE VIEW "v_invoice_calendar" AS + SELECT + date(i."issuedOn") AS "issuedOnDay", + date(i."issuedOn", 'start of month') AS "issuedOnMonth", + COUNT(i."id") AS "invoices", + SUM(i."amountCents") AS "totalCents" + FROM "invoices" i + GROUP BY date(i."issuedOn"), date(i."issuedOn", 'start of month'); + +CREATE VIEW "v_invoice_disputes" AS + SELECT + i."disputed" AS "disputed", + COUNT(i."id") AS "invoices", + COUNT(CASE WHEN i."amountCents" >= 60000 THEN i."id" END) AS "bigInvoices" + FROM "invoices" i + GROUP BY i."disputed"; + CREATE VIEW "v_product_revenue" AS SELECT p."id" AS "productId", @@ -58,3 +84,24 @@ CREATE VIEW "v_product_revenue" AS FROM "products" p LEFT OUTER JOIN "sales" s ON p."id" = s."productId" GROUP BY p."id", p."name"; + +CREATE VIEW "v_sales_by_category" AS + SELECT + c."name" AS "categoryName", + COUNT(s."id") AS "sales", + SUM(s."amountCents") AS "revenueCents", + MAX(s."amountCents") AS "maxCents", + COUNT(CASE WHEN s."amountCents" >= 10000 THEN s."id" END) AS "largeSales" + FROM "sales" s + INNER JOIN "products" p ON p."id" = s."productId" + LEFT OUTER JOIN "categories" c ON c."id" = p."categoryId" + GROUP BY c."name"; + +CREATE VIEW "v_sales_by_sku" AS + SELECT + p."sku" AS "productSku", + COUNT(s."id") AS "sales", + SUM(s."amountCents") AS "revenueCents" + FROM "sales" s + INNER JOIN "products" p ON p."id" = s."productId" + GROUP BY p."sku"; diff --git a/fixtures/api-contract-conformance/report/seed.json b/fixtures/api-contract-conformance/report/seed.json index a2c5e8a53..c44218a43 100644 --- a/fixtures/api-contract-conformance/report/seed.json +++ b/fixtures/api-contract-conformance/report/seed.json @@ -1,15 +1,19 @@ { "invoices": [ - { "id": 1, "reference": "INV-1001", "status": "PAID", "amountCents": 125000, "issuedOn": "2026-04-30" }, - { "id": 2, "reference": "INV-1002", "status": "OPEN", "amountCents": 40000, "issuedOn": "2026-05-01" }, - { "id": 3, "reference": "INV-1003", "status": "OPEN", "amountCents": 90500, "issuedOn": "2026-05-17" }, - { "id": 4, "reference": "INV-1004", "status": "VOID", "amountCents": 0, "issuedOn": "2026-05-31" }, - { "id": 5, "reference": "INV-1005", "status": "PAID", "amountCents": 60000, "issuedOn": "2026-06-01" } + { "id": 1, "reference": "INV-1001", "status": "PAID", "amountCents": 125000, "issuedOn": "2026-04-30", "disputed": false }, + { "id": 2, "reference": "INV-1002", "status": "OPEN", "amountCents": 40000, "issuedOn": "2026-05-01", "disputed": true }, + { "id": 3, "reference": "INV-1003", "status": "OPEN", "amountCents": 90500, "issuedOn": "2026-05-17", "disputed": true }, + { "id": 4, "reference": "INV-1004", "status": "VOID", "amountCents": 0, "issuedOn": "2026-05-31", "disputed": false }, + { "id": 5, "reference": "INV-1005", "status": "PAID", "amountCents": 60000, "issuedOn": "2026-06-01", "disputed": false } + ], + "categories": [ + { "id": 1, "name": "Tools" }, + { "id": 2, "name": "Toys" } ], "products": [ - { "id": 1, "name": "Atlas" }, - { "id": 2, "name": "Beacon" }, - { "id": 3, "name": "Cinder" } + { "id": 1, "name": "Atlas", "sku": "11111111-1111-4111-8111-111111111111", "categoryId": 1 }, + { "id": 2, "name": "Beacon", "sku": "22222222-2222-4222-8222-222222222222", "categoryId": 2 }, + { "id": 3, "name": "Cinder", "sku": "33333333-3333-4333-8333-333333333333", "categoryId": null } ], "sales": [ { "id": 1, "productId": 1, "amountCents": 12000 }, @@ -30,6 +34,25 @@ "InvoiceTotals": [ { "invoices": 5, "totalCents": 315500, "paidShare": "0.4" } ], + "InvoiceCalendar": [ + { "issuedOnDay": "2026-04-30", "issuedOnMonth": "2026-04-01", "invoices": 1, "totalCents": 125000 }, + { "issuedOnDay": "2026-05-01", "issuedOnMonth": "2026-05-01", "invoices": 1, "totalCents": 40000 }, + { "issuedOnDay": "2026-05-17", "issuedOnMonth": "2026-05-01", "invoices": 1, "totalCents": 90500 }, + { "issuedOnDay": "2026-05-31", "issuedOnMonth": "2026-05-01", "invoices": 1, "totalCents": 0 }, + { "issuedOnDay": "2026-06-01", "issuedOnMonth": "2026-06-01", "invoices": 1, "totalCents": 60000 } + ], + "InvoiceDisputes": [ + { "disputed": false, "invoices": 3, "bigInvoices": 2 }, + { "disputed": true, "invoices": 2, "bigInvoices": 1 } + ], + "SalesByCategory": [ + { "categoryName": "Tools", "sales": 2, "revenueCents": 30000, "maxCents": 18000, "largeSales": 2 }, + { "categoryName": "Toys", "sales": 1, "revenueCents": 4500, "maxCents": 4500, "largeSales": 0 } + ], + "SalesBySku": [ + { "productSku": "11111111-1111-4111-8111-111111111111", "sales": 2, "revenueCents": 30000 }, + { "productSku": "22222222-2222-4222-8222-222222222222", "sales": 1, "revenueCents": 4500 } + ], "ProductRevenue": [ { "productId": 1, "productName": "Atlas", "sales": 2, "revenueCents": 30000, "revenueOrZero": 30000 }, { "productId": 2, "productName": "Beacon", "sales": 1, "revenueCents": 4500, "revenueOrZero": 4500 }, diff --git a/fixtures/cube-model/canonical/expected/model/cubes/AllTypes.yml b/fixtures/cube-model/canonical/expected/model/cubes/AllTypes.yml new file mode 100644 index 000000000..012fd556d --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/AllTypes.yml @@ -0,0 +1,48 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: AllTypes + sql_table: '"all_types"' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: string + primary_key: true + - name: flag + sql: '{CUBE}."bVal"' + type: boolean + - name: level + sql: '{CUBE}."enumVal"' + type: string + - name: legacyStatus + sql: 'CASE {CUBE}."intEnumVal" WHEN 0 THEN ''DRAFT'' WHEN 5 THEN ''PUBLISHED'' WHEN 9 THEN ''ARCHIVED'' END' + type: string + - name: dateKey + sql: 'CAST({CUBE}."dateVal" AS TIMESTAMP)' + type: time + - name: uuidKey + sql: '{CUBE}."uuidVal"' + type: string + measures: + - name: allRows + sql: '{CUBE}."id"' + type: count + - name: totalInts + sql: '{CUBE}."iVal"' + type: sum + - name: avgDec + sql: '{CUBE}."decVal"' + type: avg + - name: maxMoney + sql: '{CUBE}."moneyVal"' + type: max + - name: minLong + sql: '{CUBE}."lVal"' + type: min + - name: highRows + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."enumVal" IN (''MEDIUM'', ''HIGH'')' + segments: + - name: allTypesBreakdownScope + sql: '({CUBE}."tsVal" < ((now() AT TIME ZONE ''UTC'') - INTERVAL ''PT1H'') AND {CUBE}."iVal" <> 99)' diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Asset.yml b/fixtures/cube-model/canonical/expected/model/cubes/Asset.yml index 8cc6584a6..d072223cd 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/Asset.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/Asset.yml @@ -21,7 +21,27 @@ cubes: - name: assets sql: '{CUBE}."id"' type: count + - name: owners + sql: '{CUBE}."ownerId"' + type: count_distinct + - name: firstRecorded + sql: '{CUBE}."recordedAt"' + type: min + - name: lastObserved + sql: '{CUBE}."observedAt"' + type: max + - name: lastAsOf + sql: '{CUBE}."asOfDate"' + type: max pre_aggregations: + - name: AssetDays + type: rollup + measures: [CUBE.assets, CUBE.owners, CUBE.firstRecorded, CUBE.lastObserved, CUBE.lastAsOf] + time_dimensions: + - dimension: CUBE.recordedAt + granularity: day + - dimension: CUBE.asOfDate + granularity: month - name: AssetActivity type: rollup measures: [CUBE.assets] diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Auth.yml b/fixtures/cube-model/canonical/expected/model/cubes/Auth.yml new file mode 100644 index 000000000..f049a5524 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/Auth.yml @@ -0,0 +1,28 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: Auth + sql_table: '"auths"' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: kind + sql: '{CUBE}."type"' + type: string + measures: + - name: auths + sql: '{CUBE}."id"' + type: count + - name: distinctRefs + sql: '{CUBE}."reference"' + type: count_distinct + segments: + - name: authsByKindScope + sql: '{CUBE}."type" <> ''PriorAuth''' + pre_aggregations: + - name: AuthsByKind + type: rollup + measures: [CUBE.auths, CUBE.distinctRefs] + dimensions: [CUBE.kind] + segments: [CUBE.authsByKindScope] diff --git a/fixtures/cube-model/canonical/expected/model/cubes/BridgeAuth.yml b/fixtures/cube-model/canonical/expected/model/cubes/BridgeAuth.yml new file mode 100644 index 000000000..394291ca5 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/BridgeAuth.yml @@ -0,0 +1,19 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: BridgeAuth + sql: 'SELECT * FROM "auths" WHERE "type" = ''Bridge''' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: kind + sql: '{CUBE}."type"' + type: string + measures: + - name: auths + sql: '{CUBE}."id"' + type: count + - name: distinctRefs + sql: '{CUBE}."reference"' + type: count_distinct diff --git a/fixtures/cube-model/canonical/expected/model/cubes/CopayAuth.yml b/fixtures/cube-model/canonical/expected/model/cubes/CopayAuth.yml new file mode 100644 index 000000000..6ca62bd76 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/CopayAuth.yml @@ -0,0 +1,19 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: CopayAuth + sql: 'SELECT * FROM "auths" WHERE "type" = ''Copay''' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: kind + sql: '{CUBE}."type"' + type: string + measures: + - name: auths + sql: '{CUBE}."id"' + type: count + - name: distinctRefs + sql: '{CUBE}."reference"' + type: count_distinct diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Measurement.yml b/fixtures/cube-model/canonical/expected/model/cubes/Measurement.yml new file mode 100644 index 000000000..6485335a0 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/Measurement.yml @@ -0,0 +1,60 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: Measurement + sql_table: '"measurements"' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + measures: + - name: readings + sql: '{CUBE}."id"' + type: count + - name: totalMassKg + sql: '{CUBE}."massKg"' + type: sum + - name: avgMassKg + sql: '{CUBE}."massKg"' + type: avg + - name: maxTempC + sql: '{CUBE}."tempC"' + type: max + - name: minTempC + sql: '{CUBE}."tempC"' + type: min + - name: totalPreciseKg + sql: '{CUBE}."preciseKg"' + type: sum + - name: avgPreciseKg + sql: '{CUBE}."preciseKg"' + type: avg + - name: maxPreciseKg + sql: '{CUBE}."preciseKg"' + type: max + - name: heavyReadings + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."massKg" >= 10' + - name: avgPreciseKgOrMinusOneRaw + sql: '{CUBE}."preciseKg"' + type: avg + public: false + - name: avgPreciseKgOrMinusOne + sql: 'COALESCE({avgPreciseKgOrMinusOneRaw}, -1)' + type: number + - name: maxPreciseKgOrZeroRaw + sql: '{CUBE}."preciseKg"' + type: max + public: false + - name: maxPreciseKgOrZero + sql: 'COALESCE({maxPreciseKgOrZeroRaw}, 0)' + type: number + pre_aggregations: + - name: MeasurementStatsFilled + type: rollup + measures: [CUBE.readings, CUBE.avgPreciseKgOrMinusOne, CUBE.maxPreciseKgOrZero] + - name: MeasurementStats + type: rollup + measures: [CUBE.readings, CUBE.totalMassKg, CUBE.avgMassKg, CUBE.maxTempC, CUBE.minTempC, CUBE.totalPreciseKg, CUBE.avgPreciseKg, CUBE.maxPreciseKg, CUBE.heavyReadings] diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Node.yml b/fixtures/cube-model/canonical/expected/model/cubes/Node.yml new file mode 100644 index 000000000..4e771ae33 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/Node.yml @@ -0,0 +1,25 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: Node + sql_table: '"nodes"' + joins: + - name: Node_fkParent + relationship: many_to_one + sql: '{CUBE}."parentId" = {Node_fkParent}."id"' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: parentLabel + sql: '{Node_fkParent.label}' + type: string + measures: + - name: nodes + sql: '{CUBE}."id"' + type: count + pre_aggregations: + - name: NodesByParent + type: rollup + measures: [CUBE.nodes] + dimensions: [CUBE.parentLabel] diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Node_fkParent.yml b/fixtures/cube-model/canonical/expected/model/cubes/Node_fkParent.yml new file mode 100644 index 000000000..1ecf1c143 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/Node_fkParent.yml @@ -0,0 +1,14 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: Node_fkParent + sql_table: '"nodes"' + public: false + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: label + sql: '{CUBE}."label"' + type: string + public: false diff --git a/fixtures/cube-model/canonical/expected/model/cubes/PriorAuthAuth.yml b/fixtures/cube-model/canonical/expected/model/cubes/PriorAuthAuth.yml new file mode 100644 index 000000000..057b00c80 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/PriorAuthAuth.yml @@ -0,0 +1,19 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: PriorAuthAuth + sql: 'SELECT * FROM "auths" WHERE "type" = ''PriorAuth''' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: kind + sql: '{CUBE}."type"' + type: string + measures: + - name: auths + sql: '{CUBE}."id"' + type: count + - name: distinctRefs + sql: '{CUBE}."reference"' + type: count_distinct diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Program.yml b/fixtures/cube-model/canonical/expected/model/cubes/Program.yml index f9bce78d3..b86199bfe 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: ProgramSessions_fkWeek + relationship: one_to_many + sql: '{CUBE}."id" = {ProgramSessions_fkWeek}."programId"' dimensions: - name: id sql: '{CUBE}."id"' @@ -35,12 +38,50 @@ cubes: - name: programs sql: '{CUBE}."id"' type: count + - name: notDrafts + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."status" <> ''DRAFT''' + - name: avgPrice + sql: '{CUBE}."priceCents"' + type: avg + - name: cheapest + sql: '{CUBE}."priceCents"' + type: min + - name: priciest + sql: '{CUBE}."priceCents"' + type: max + - name: avgPriceOrMinusOneRaw + sql: '{CUBE}."priceCents"' + type: avg + public: false + - name: avgPriceOrMinusOne + sql: 'COALESCE({avgPriceOrMinusOneRaw}, -1)' + type: number segments: - name: published sql: '{CUBE}."status" = ''PUBLISHED''' + - name: live + sql: '{CUBE}."status" IN (''DRAFT'', ''PUBLISHED'')' + - name: introOrFree + sql: '({CUBE}."title" LIKE ''Intro%'' OR {CUBE}."priceCents" = 0)' - name: recentProgramsScope sql: '{CUBE}."created_ts" >= ((now() AT TIME ZONE ''UTC'') - INTERVAL ''P30D'')' + - name: liveIntroProgramsScope + sql: '({CUBE}."status" IN (''DRAFT'', ''PUBLISHED'') AND {CUBE}."priceCents" < 5000)' + - name: programsEmptyScopeScope + sql: '{CUBE}."priceCents" > 1000000' pre_aggregations: + - name: ProgramsEmptyScope + type: rollup + measures: [CUBE.programs, CUBE.avgPriceOrMinusOne] + segments: [CUBE.live, CUBE.programsEmptyScopeScope] + - name: LiveIntroPrograms + type: rollup + measures: [CUBE.programs, CUBE.notDrafts, CUBE.listValue] + dimensions: [CUBE.status] + segments: [CUBE.introOrFree, CUBE.liveIntroProgramsScope] - name: ProgramsByWeek type: rollup measures: [CUBE.programs] @@ -53,3 +94,13 @@ cubes: dimensions: [CUBE.status] time_dimension: CUBE.createdAt granularity: month + - name: ProgramsOverTime + type: rollup + measures: [CUBE.programs, CUBE.avgPrice, CUBE.cheapest, CUBE.priciest] + time_dimensions: + - dimension: CUBE.createdAt + granularity: day + - dimension: CUBE.createdAt + granularity: quarter + - dimension: CUBE.createdAt + granularity: year diff --git a/fixtures/cube-model/canonical/expected/model/cubes/ProgramSessionsFacts.yml b/fixtures/cube-model/canonical/expected/model/cubes/ProgramSessionsFacts.yml new file mode 100644 index 000000000..b400de45d --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/ProgramSessionsFacts.yml @@ -0,0 +1,24 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: ProgramSessionsFacts + sql: 'SELECT * FROM "sessions" s' + public: false + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + measures: + - name: sessions + sql: '{CUBE}."id"' + type: count + - name: sessionMinutes + sql: '{CUBE}."minutes"' + type: sum + - name: sessionMinutesOrZeroRaw + sql: '{CUBE}."minutes"' + type: sum + public: false + - name: sessionMinutesOrZero + sql: 'COALESCE({sessionMinutesOrZeroRaw}, 0)' + type: number diff --git a/fixtures/cube-model/canonical/expected/model/cubes/ProgramSessions_fkWeek.yml b/fixtures/cube-model/canonical/expected/model/cubes/ProgramSessions_fkWeek.yml new file mode 100644 index 000000000..05d36959c --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/ProgramSessions_fkWeek.yml @@ -0,0 +1,14 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: ProgramSessions_fkWeek + sql_table: '"weeks"' + public: false + joins: + - name: ProgramSessionsFacts + relationship: one_to_many + sql: '{CUBE}."id" = {ProgramSessionsFacts}."weekId"' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true diff --git a/fixtures/cube-model/canonical/expected/model/cubes/ReferralAuth.yml b/fixtures/cube-model/canonical/expected/model/cubes/ReferralAuth.yml new file mode 100644 index 000000000..fc249f6c1 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/ReferralAuth.yml @@ -0,0 +1,19 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: ReferralAuth + sql: 'SELECT * FROM "auths" WHERE "type" = ''Referral''' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: kind + sql: '{CUBE}."type"' + type: string + measures: + - name: auths + sql: '{CUBE}."id"' + type: count + - name: distinctRefs + sql: '{CUBE}."reference"' + type: count_distinct diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Session.yml b/fixtures/cube-model/canonical/expected/model/cubes/Session.yml new file mode 100644 index 000000000..b7c69ce18 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/cubes/Session.yml @@ -0,0 +1,41 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +cubes: + - name: Session + sql_table: '"sessions"' + joins: + - name: Week + relationship: many_to_one + sql: '{CUBE}."weekId" = {Week}."id"' + dimensions: + - name: id + sql: '{CUBE}."id"' + type: number + primary_key: true + - name: weekKey + sql: '{Week.id}' + type: number + - name: programKey + sql: '{Program.id}' + type: number + - name: programTitle + sql: '{Program.title}' + type: string + measures: + - name: sessions + sql: '{CUBE}."id"' + type: count + - name: sessionMinutes + sql: '{CUBE}."minutes"' + type: sum + - name: sessionMinutesOrZeroRaw + sql: '{CUBE}."minutes"' + type: sum + public: false + - name: sessionMinutesOrZero + sql: 'COALESCE({sessionMinutesOrZeroRaw}, 0)' + type: number + pre_aggregations: + - name: SessionsByProgram + type: rollup + measures: [CUBE.sessions, CUBE.sessionMinutes] + dimensions: [CUBE.programTitle, CUBE.weekKey] diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml index d3c5e971f..183c8e6fc 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/Week.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/Week.yml @@ -62,6 +62,31 @@ cubes: - name: longShareOrZero sql: 'COALESCE(CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0), 0)' type: number + - name: unlabelled + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."label" IS NULL' + - name: labelledMinutes + sql: '{CUBE}."durationMinutes"' + type: sum + filters: + - sql: '{CUBE}."label" IS NOT NULL' + - name: shortWeeks + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."durationMinutes" < 30' + - name: midWeeks + sql: '{CUBE}."id"' + type: count + filters: + - sql: '({CUBE}."durationMinutes" > 30 AND {CUBE}."durationMinutes" <= 60)' + - name: longWMinutes + sql: '{CUBE}."durationMinutes"' + type: sum + filters: + - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' segments: - name: long sql: '{CUBE}."durationMinutes" >= 60' @@ -72,6 +97,10 @@ cubes: - name: FitnessTotalsFilled type: rollup measures: [CUBE.weeks, CUBE.totalMinutesOrZero, CUBE.longShareOrZero] + - name: WeekShapes + type: rollup + measures: [CUBE.weeks, CUBE.unlabelled, CUBE.labelledMinutes, CUBE.shortWeeks, CUBE.midWeeks, CUBE.longWMinutes] + dimensions: [CUBE.programTitle] - name: ProgramMinutes type: rollup measures: [CUBE.weeks, CUBE.longWeeks, CUBE.labels, CUBE.slots, CUBE.totalMinutes, CUBE.avgMinutes, CUBE.minMinutes, CUBE.maxMinutes, CUBE.longShare] diff --git a/fixtures/cube-model/canonical/expected/model/views/ProgramSessions.yml b/fixtures/cube-model/canonical/expected/model/views/ProgramSessions.yml new file mode 100644 index 000000000..e0b6f43c9 --- /dev/null +++ b/fixtures/cube-model/canonical/expected/model/views/ProgramSessions.yml @@ -0,0 +1,15 @@ +# @generated by @metaobjectsdev/codegen-ts — cube-model +views: + - name: ProgramSessions + cubes: + - join_path: Program + includes: + - name: id + alias: programKey + - name: title + alias: programTitle + - join_path: Program.ProgramSessions_fkWeek.ProgramSessionsFacts + includes: + - name: sessions + - name: sessionMinutes + - name: sessionMinutesOrZero diff --git a/fixtures/cube-model/canonical/seed.mysql.sql b/fixtures/cube-model/canonical/seed.mysql.sql index 5c7b84640..2bf4251ac 100644 --- a/fixtures/cube-model/canonical/seed.mysql.sql +++ b/fixtures/cube-model/canonical/seed.mysql.sql @@ -11,7 +11,11 @@ INSERT INTO `programs` (`id`,`title`,`priceCents`,`status`,`created_ts`) VALUES (4, 'Legacy', 700, 'ARCHIVED', '2026-05-31 23:59:59'), (5, 'Monday', 300, 'PUBLISHED', '2026-05-18 00:00:00'), (6, 'Fresh', 1000, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 3 DAY), - (7, 'Stale', 2000, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 60 DAY); + (7, 'Stale', 2000, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 60 DAY), + (8, 'Intro Flow', 1500, 'DRAFT', '2026-06-01 08:00:00'), + (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), @@ -23,3 +27,43 @@ INSERT INTO `assets` (`id`,`ownerId`,`externalId`,`payload`,`recordedAt`,`observ ('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'), ('66666666-6666-4666-8666-666666666666','77777777-7777-4777-8777-777777777777','88888888-8888-4888-8888-888888888888','{"k": 3}','2026-05-04 04:10:00','2026-05-04 04:10:00','2026-05-04','04:10:00'); +INSERT INTO `measurements` (`id`,`tempC`,`massKg`,`preciseKg`) VALUES + (1, 1.5, 8.5, 1.25), + (2, -2.25, 12.5, 2.5), + (3, 4.5, 20.25, 0.75), + (4, 0.5, 7.5, 3.5); +INSERT INTO `sessions` (`id`,`weekId`,`minutes`) VALUES + (100, 10, 30), + (101, 10, 40), + (102, 11, 50), + (200, 20, 25); +INSERT INTO `nodes` (`id`,`label`,`parentId`) VALUES + (1, 'root', NULL), + (2, 'a', 1), + (3, 'b', 1), + (4, 'c', 2), + (5, 'top', NULL); +INSERT INTO `auths` (`id`,`type`,`reference`,`quantity`,`urgency`) VALUES + (1, 'Bridge', 'A-1', 2, NULL), + (2, 'Bridge', 'A-1', 3, NULL), + (3, 'Copay', 'C-1', NULL, NULL), + (4, 'PriorAuth', 'P-1', NULL, NULL), + (5, 'Referral', 'R-1', NULL, 'routine'), + (6, 'Referral', 'R-2', NULL, 'urgent'), + (7, NULL, 'N-1', NULL, NULL); +INSERT INTO `all_types` ( + `id`,`sVal`,`iVal`,`lVal`,`dVal`,`fVal`,`decVal`,`bVal`,`dateVal`,`timeVal`,`tsVal`,`tsTzVal`, + `moneyVal`,`enumVal`,`intEnumVal`,`uuidVal`,`uriVal`,`inetVal`,`inet6Val` +) VALUES + ('00000000-0000-4000-8000-000000000001','r1',10,7,0.5,0.5,1.5,true,'2026-05-01','00:00:00', + '2026-05-01 00:00:00','2026-05-01 00:00:00',1000,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:1','10.0.0.1','fe80::1'), + ('00000000-0000-4000-8000-000000000002','r2',20,3,0.5,0.5,2.5,true,'2026-05-01','00:00:00', + '2026-05-01 00:00:00','2026-05-01 00:00:00',3000,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:2','10.0.0.2','fe80::2'), + ('00000000-0000-4000-8000-000000000003','r3',5,9,0.5,0.5,0.5,false,'2026-05-02','00:00:00', + '2026-05-02 00:00:00','2026-05-02 00:00:00',200,'LOW',NULL, + 'b2222222-2222-4222-8222-222222222222','urn:x:3','10.0.0.3','fe80::3'), + ('00000000-0000-4000-8000-000000000005','r5',99,1,0.5,0.5,9,true,'2026-05-01','00:00:00', + '2026-05-01 00:00:00','2026-05-01 00:00:00',9999,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:5','10.0.0.5','fe80::5'); diff --git a/fixtures/cube-model/canonical/seed.sql b/fixtures/cube-model/canonical/seed.sql index 627c55738..b9cc97355 100644 --- a/fixtures/cube-model/canonical/seed.sql +++ b/fixtures/cube-model/canonical/seed.sql @@ -4,7 +4,11 @@ -- programs, weeks and assets. The rows are the union of the persistence corpus's report-scenario -- seeds, plus one week for program 5 (a group with a null label and a short week) and two -- programs created RELATIVE TO THE DATABASE CLOCK, 3 and 60 days before now(), so the relative --- `@filter` of RecentPrograms keeps exactly one of them whenever the lane runs. +-- `@filter` of RecentPrograms keeps exactly one of them whenever the lane runs. Programs 8-11 are +-- the `introOrFree` segment of LiveIntroPrograms: a DRAFT 'Intro' program and a free PUBLISHED one +-- in its scope, and an ARCHIVED and a 5000-or-more 'Intro' program its `@filter` drops. Each falls +-- on a day, week and month an earlier program already has, so the time-grain reports keep their +-- row counts. 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'), @@ -12,7 +16,11 @@ INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES (4, 'Legacy', 700, 'ARCHIVED', '2026-05-31T23:59:59'), (5, 'Monday', 300, 'PUBLISHED', '2026-05-18T00:00:00'), (6, 'Fresh', 1000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '3 days'), - (7, 'Stale', 2000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '60 days'); + (7, 'Stale', 2000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '60 days'), + (8, 'Intro Flow', 1500, 'DRAFT', '2026-06-01T08:00:00'), + (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), @@ -24,3 +32,46 @@ INSERT INTO "assets" ("id","ownerId","externalId","payload","recordedAt","observ ('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'), ('66666666-6666-4666-8666-666666666666','77777777-7777-4777-8777-777777777777','88888888-8888-4888-8888-888888888888','{"k": 3}','2026-05-04T04:10:00Z','2026-05-04T04:10:00','2026-05-04','04:10:00'); +-- The tables the later reports read (FR-044 coverage reports): measurements, sessions, nodes, +-- auths and all_types. Values are the dyadic floats normalization.md requires. The programs and +-- weeks above give the sessions their parents; programs 3-11 have no sessions (spine rows). +INSERT INTO "measurements" ("id","tempC","massKg","preciseKg") VALUES + (1, 1.5, 8.5, 1.25), + (2, -2.25, 12.5, 2.5), + (3, 4.5, 20.25, 0.75), + (4, 0.5, 7.5, 3.5); +INSERT INTO "sessions" ("id","weekId","minutes") VALUES + (100, 10, 30), + (101, 10, 40), + (102, 11, 50), + (200, 20, 25); +INSERT INTO "nodes" ("id","label","parentId") VALUES + (1, 'root', NULL), + (2, 'a', 1), + (3, 'b', 1), + (4, 'c', 2), + (5, 'top', NULL); +INSERT INTO "auths" ("id","type","reference","quantity","urgency") VALUES + (1, 'Bridge', 'A-1', 2, NULL), + (2, 'Bridge', 'A-1', 3, NULL), + (3, 'Copay', 'C-1', NULL, NULL), + (4, 'PriorAuth', 'P-1', NULL, NULL), + (5, 'Referral', 'R-1', NULL, 'routine'), + (6, 'Referral', 'R-2', NULL, 'urgent'), + (7, NULL, 'N-1', NULL, NULL); +INSERT INTO "all_types" ( + "id","sVal","iVal","lVal","dVal","fVal","decVal","bVal","dateVal","timeVal","tsVal","tsTzVal", + "moneyVal","enumVal","intEnumVal","uuidVal","uriVal","inetVal","inet6Val" +) VALUES + ('00000000-0000-4000-8000-000000000001','r1',10,7,0.5,0.5,1.5,true,'2026-05-01','00:00:00', + '2026-05-01T00:00:00','2026-05-01T00:00:00Z',1000,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:1','10.0.0.1','fe80::1'), + ('00000000-0000-4000-8000-000000000002','r2',20,3,0.5,0.5,2.5,true,'2026-05-01','00:00:00', + '2026-05-01T00:00:00','2026-05-01T00:00:00Z',3000,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:2','10.0.0.2','fe80::2'), + ('00000000-0000-4000-8000-000000000003','r3',5,9,0.5,0.5,0.5,false,'2026-05-02','00:00:00', + '2026-05-02T00:00:00','2026-05-02T00:00:00Z',200,'LOW',NULL, + 'b2222222-2222-4222-8222-222222222222','urn:x:3','10.0.0.3','fe80::3'), + ('00000000-0000-4000-8000-000000000005','r5',99,1,0.5,0.5,9,true,'2026-05-01','00:00:00', + '2026-05-01T00:00:00','2026-05-01T00:00:00Z',9999,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:5','10.0.0.5','fe80::5'); diff --git a/fixtures/persistence-conformance/README.md b/fixtures/persistence-conformance/README.md index 7776357a8..4a25ead0f 100644 --- a/fixtures/persistence-conformance/README.md +++ b/fixtures/persistence-conformance/README.md @@ -318,7 +318,7 @@ Because M:N membership is a **set**, the runner compares `relate` results ### Report scenarios (FR-044) -Nine `queries/report-*.yaml` scenarios read a view-backed `object.report` through the +Nineteen `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 @@ -349,6 +349,16 @@ then measures in `@measures` order; a time dimension is named ``, f | `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-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 | +| `report-measure-filters` | an inline measure `@filter` on `isNull: true`, `isNull: false`, `lt`, a two-operator range and a `like` ANDed with a segment | +| `report-multi-hop` | a two-hop `@via` dimension, and a `@spine` whose rows come from the program at the end of that path | +| `report-negative-default` | `@default: -1` on an `avg` and `@default: 0` on a decimal `max` over an empty table, against the undefaulted twin | +| `report-numeric-types` | `sum`, `avg`, `min` and `max` over float, double and decimal columns; a measure filter on a double | +| `report-self-reference` | a dimension reached through the entity's own reference; a root row lands in the null group | +| `report-time-grains-extended` | `day`, `quarter` and `year` grains on one time dimension at once; the year and quarter boundaries; `avg`, `min` and `max` of a currency | +| `report-time-min-max` | day and month buckets on an instant and a date; `min` and `max` of a `TIMESTAMPTZ`, a naive `TIMESTAMP` and a `DATE`; a distinct count of a uuid | +| `report-tph-base-filter` | a report declared from a TPH base with an `@filter` on the discriminator | Wire shapes follow the view's column types and [`normalization.md`](./normalization.md): `count` and the integral `sum` are `BIGINT` (string), `min`/`max` of an int are `INTEGER` diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 8d35b31db..510c37350 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -19,7 +19,14 @@ { "measure.aggregate": { "name": "listValue", "@agg": "sum", "@of": "Program.priceCents", "@segment": "published" } }, { "segment.filter": { "name": "published", "@filter": { "status": "PUBLISHED" } } }, { "dimension.attribute": { "name": "status", "@of": "Program.status" } }, - { "measure.aggregate": { "name": "programs", "@agg": "count", "@of": "Program.id" } } + { "measure.aggregate": { "name": "programs", "@agg": "count", "@of": "Program.id" } }, + { "segment.filter": { "name": "live", "@filter": { "status": { "in": ["DRAFT", "PUBLISHED"] } } } }, + { "segment.filter": { "name": "introOrFree", "@filter": { "or": [ { "title": { "like": "Intro%" } }, { "priceCents": 0 } ] } } }, + { "measure.aggregate": { "name": "notDrafts", "@agg": "count", "@of": "Program.id", "@filter": { "status": { "ne": "DRAFT" } } } }, + { "measure.aggregate": { "name": "avgPrice", "@agg": "avg", "@of": "Program.priceCents" } }, + { "measure.aggregate": { "name": "cheapest", "@agg": "min", "@of": "Program.priceCents" } }, + { "measure.aggregate": { "name": "priciest", "@agg": "max", "@of": "Program.priceCents" } }, + { "measure.aggregate": { "name": "avgPriceOrMinusOne", "@agg": "avg", "@of": "Program.priceCents", "@default": -1 } } ] }}, { "object.entity": { @@ -47,7 +54,12 @@ { "measure.ratio": { "name": "longShare", "@numerator": "longWeeks", "@denominator": "weeks" } }, { "dimension.attribute": { "name": "programKey", "@of": "Program.id", "@via": "Week.fkProgram" } }, { "measure.aggregate": { "name": "totalMinutesOrZero", "@agg": "sum", "@of": "Week.durationMinutes", "@default": 0 } }, - { "measure.ratio": { "name": "longShareOrZero", "@numerator": "longWeeks", "@denominator": "weeks", "@default": 0 } } + { "measure.ratio": { "name": "longShareOrZero", "@numerator": "longWeeks", "@denominator": "weeks", "@default": 0 } }, + { "measure.aggregate": { "name": "unlabelled", "@agg": "count", "@of": "Week.id", "@filter": { "label": { "isNull": true } } } }, + { "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%" } } } } ] }}, { "object.entity": { @@ -58,7 +70,26 @@ { "field.string": { "name": "label", "@required": true, "@maxLength": 80 } }, { "field.long": { "name": "parentId" } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, - { "identity.reference": { "name": "fkParent", "@fields": "parentId", "@references": "Node" } } + { "identity.reference": { "name": "fkParent", "@fields": "parentId", "@references": "Node" } }, + { "dimension.attribute": { "name": "parentLabel", "@of": "Node.label", "@via": "Node.fkParent" } }, + { "measure.aggregate": { "name": "nodes", "@agg": "count", "@of": "Node.id" } } + ] + }}, + { "object.entity": { + "name": "Session", + "children": [ + { "source.rdb": { "@table": "sessions" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "weekId", "@required": true } }, + { "field.int": { "name": "minutes", "@required": true } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, + { "identity.reference": { "name": "fkWeek", "@fields": "weekId", "@references": "Week" } }, + { "dimension.attribute": { "name": "weekKey", "@of": "Week.id", "@via": "Session.fkWeek" } }, + { "dimension.attribute": { "name": "programKey", "@of": "Program.id", "@via": "Session.fkWeek.fkProgram" } }, + { "dimension.attribute": { "name": "programTitle", "@of": "Program.title", "@via": "Session.fkWeek.fkProgram" } }, + { "measure.aggregate": { "name": "sessions", "@agg": "count", "@of": "Session.id" } }, + { "measure.aggregate": { "name": "sessionMinutes", "@agg": "sum", "@of": "Session.minutes" } }, + { "measure.aggregate": { "name": "sessionMinutesOrZero", "@agg": "sum", "@of": "Session.minutes", "@default": 0 } } ] }}, { "object.entity": { @@ -69,7 +100,18 @@ { "field.float": { "name": "tempC", "@required": true } }, { "field.double": { "name": "massKg", "@required": true } }, { "field.decimal": { "name": "preciseKg", "@required": true, "@precision": 9, "@scale": 4 } }, - { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { "name": "readings", "@agg": "count", "@of": "Measurement.id" } }, + { "measure.aggregate": { "name": "totalMassKg", "@agg": "sum", "@of": "Measurement.massKg" } }, + { "measure.aggregate": { "name": "avgMassKg", "@agg": "avg", "@of": "Measurement.massKg" } }, + { "measure.aggregate": { "name": "maxTempC", "@agg": "max", "@of": "Measurement.tempC" } }, + { "measure.aggregate": { "name": "minTempC", "@agg": "min", "@of": "Measurement.tempC" } }, + { "measure.aggregate": { "name": "totalPreciseKg", "@agg": "sum", "@of": "Measurement.preciseKg" } }, + { "measure.aggregate": { "name": "avgPreciseKg", "@agg": "avg", "@of": "Measurement.preciseKg" } }, + { "measure.aggregate": { "name": "maxPreciseKg", "@agg": "max", "@of": "Measurement.preciseKg" } }, + { "measure.aggregate": { "name": "heavyReadings", "@agg": "count", "@of": "Measurement.id", "@filter": { "massKg": { "gte": 10 } } } }, + { "measure.aggregate": { "name": "avgPreciseKgOrMinusOne", "@agg": "avg", "@of": "Measurement.preciseKg", "@default": -1 } }, + { "measure.aggregate": { "name": "maxPreciseKgOrZero", "@agg": "max", "@of": "Measurement.preciseKg", "@default": 0 } } ] }}, { "object.entity": { @@ -87,7 +129,11 @@ { "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } }, { "dimension.time": { "name": "recordedAt", "@of": "Asset.recordedAt", "@grains": ["hour", "day"] } }, { "dimension.time": { "name": "asOfDate", "@of": "Asset.asOfDate", "@grains": ["week", "month"] } }, - { "measure.aggregate": { "name": "assets", "@agg": "count", "@of": "Asset.id" } } + { "measure.aggregate": { "name": "assets", "@agg": "count", "@of": "Asset.id" } }, + { "measure.aggregate": { "name": "owners", "@agg": "count", "@distinct": true, "@of": "Asset.ownerId" } }, + { "measure.aggregate": { "name": "firstRecorded", "@agg": "min", "@of": "Asset.recordedAt" } }, + { "measure.aggregate": { "name": "lastObserved", "@agg": "max", "@of": "Asset.observedAt" } }, + { "measure.aggregate": { "name": "lastAsOf", "@agg": "max", "@of": "Asset.asOfDate" } } ] }}, { "object.projection": { @@ -206,7 +252,10 @@ { "field.long": { "name": "id" } }, { "field.enum": { "name": "type", "@values": ["Bridge", "Copay", "PriorAuth", "Referral"] } }, { "field.string": { "name": "reference", "@required": true, "@maxLength": 80 } }, - { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { "name": "kind", "@of": "Auth.type" } }, + { "measure.aggregate": { "name": "auths", "@agg": "count", "@of": "Auth.id" } }, + { "measure.aggregate": { "name": "distinctRefs", "@agg": "count", "@distinct": true, "@of": "Auth.reference" } } ] }}, { "object.entity": { @@ -294,7 +343,18 @@ { "field.inet": { "name": "inet6Val", "@required": true } }, { "field.object": { "name": "settings", "@objectRef": "Settings", "@storage": "jsonb" } }, { "field.object": { "name": "labels", "@objectRef": "Label", "@storage": "jsonb", "isArray": true } }, - { "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } } + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } }, + { "dimension.attribute": { "name": "flag", "@of": "AllTypes.bVal" } }, + { "dimension.attribute": { "name": "level", "@of": "AllTypes.enumVal" } }, + { "dimension.attribute": { "name": "legacyStatus", "@of": "AllTypes.intEnumVal" } }, + { "dimension.attribute": { "name": "dateKey", "@of": "AllTypes.dateVal" } }, + { "dimension.attribute": { "name": "uuidKey", "@of": "AllTypes.uuidVal" } }, + { "measure.aggregate": { "name": "allRows", "@agg": "count", "@of": "AllTypes.id" } }, + { "measure.aggregate": { "name": "totalInts", "@agg": "sum", "@of": "AllTypes.iVal" } }, + { "measure.aggregate": { "name": "avgDec", "@agg": "avg", "@of": "AllTypes.decVal" } }, + { "measure.aggregate": { "name": "maxMoney", "@agg": "max", "@of": "AllTypes.moneyVal" } }, + { "measure.aggregate": { "name": "minLong", "@agg": "min", "@of": "AllTypes.lVal" } }, + { "measure.aggregate": { "name": "highRows", "@agg": "count", "@of": "AllTypes.id", "@filter": { "enumVal": { "in": ["MEDIUM", "HIGH"] } } } } ] }}, @@ -354,6 +414,49 @@ "@measures": ["weeks", "totalMinutesOrZero", "longShareOrZero"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_fitness_totals_filled" } } ] } }, + { "object.report": { "name": "MeasurementStats", "@from": "Measurement", + "@measures": ["readings", "totalMassKg", "avgMassKg", "maxTempC", "minTempC", "totalPreciseKg", "avgPreciseKg", "maxPreciseKg", "heavyReadings"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_measurement_stats" } } ] } }, + { "object.report": { "name": "MeasurementStatsFilled", "@from": "Measurement", + "@measures": ["readings", "avgPreciseKgOrMinusOne", "maxPreciseKgOrZero"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_measurement_stats_filled" } } ] } }, + { "object.report": { "name": "AssetDays", "@from": "Asset", + "@dimensions": ["recordedAt:day", "asOfDate:month"], + "@measures": ["assets", "owners", "firstRecorded", "lastObserved", "lastAsOf"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_asset_days" } } ] } }, + { "object.report": { "name": "AllTypesBreakdown", "@from": "AllTypes", + "@dimensions": ["flag", "level", "legacyStatus", "dateKey", "uuidKey"], + "@measures": ["allRows", "totalInts", "avgDec", "maxMoney", "minLong", "highRows"], + "@filter": { "tsVal": { "lt": { "now": "-PT1H" } }, "iVal": { "ne": 99 } }, + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_all_types_breakdown" } } ] } }, + { "object.report": { "name": "ProgramsOverTime", "@from": "Program", + "@dimensions": ["createdAt:day", "createdAt:quarter", "createdAt:year"], + "@measures": ["programs", "avgPrice", "cheapest", "priciest"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_programs_over_time" } } ] } }, + { "object.report": { "name": "LiveIntroPrograms", "@from": "Program", + "@dimensions": ["status"], "@measures": ["programs", "notDrafts", "listValue"], + "@segment": "introOrFree", "@filter": { "status": { "in": ["DRAFT", "PUBLISHED"] }, "priceCents": { "lt": 5000 } }, + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_live_intro_programs" } } ] } }, + { "object.report": { "name": "ProgramsEmptyScope", "@from": "Program", + "@measures": ["programs", "avgPriceOrMinusOne"], "@segment": "live", "@filter": { "priceCents": { "gt": 1000000 } }, + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_programs_empty_scope" } } ] } }, + { "object.report": { "name": "WeekShapes", "@from": "Week", + "@dimensions": ["programTitle"], + "@measures": ["weeks", "unlabelled", "labelledMinutes", "shortWeeks", "midWeeks", "longWMinutes"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_week_shapes" } } ] } }, + { "object.report": { "name": "SessionsByProgram", "@from": "Session", + "@dimensions": ["programTitle", "weekKey"], "@measures": ["sessions", "sessionMinutes"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_sessions_by_program" } } ] } }, + { "object.report": { "name": "ProgramSessions", "@from": "Session", "@spine": "Session.fkWeek.fkProgram", + "@dimensions": ["programKey", "programTitle"], "@measures": ["sessions", "sessionMinutes", "sessionMinutesOrZero"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_sessions" } } ] } }, + { "object.report": { "name": "NodesByParent", "@from": "Node", + "@dimensions": ["parentLabel"], "@measures": ["nodes"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_nodes_by_parent" } } ] } }, + { "object.report": { "name": "AuthsByKind", "@from": "Auth", + "@dimensions": ["kind"], "@measures": ["auths", "distinctRefs"], "@filter": { "type": { "ne": "PriorAuth" } }, + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_auths_by_kind" } } ] } }, + { "template.prompt": { "name": "coachNote", "@payloadRef": "ProgramBrief", diff --git a/fixtures/persistence-conformance/canonical/schema.mysql.sql b/fixtures/persistence-conformance/canonical/schema.mysql.sql index c84254109..a8a4910a9 100644 --- a/fixtures/persistence-conformance/canonical/schema.mysql.sql +++ b/fixtures/persistence-conformance/canonical/schema.mysql.sql @@ -32,6 +32,61 @@ CREATE TABLE `assets` ( `atTime` TIME(3) NOT NULL ); +CREATE TABLE `nodes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `label` VARCHAR(80) NOT NULL, + `parentId` BIGINT, + FOREIGN KEY (`parentId`) REFERENCES `nodes` (`id`) +); + +CREATE TABLE `sessions` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `weekId` BIGINT NOT NULL, + `minutes` INT NOT NULL, + FOREIGN KEY (`weekId`) REFERENCES `weeks` (`id`) +); + +CREATE TABLE `measurements` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `tempC` FLOAT NOT NULL, + `massKg` DOUBLE NOT NULL, + `preciseKg` DECIMAL(9,4) NOT NULL +); + +CREATE TABLE `auths` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `type` VARCHAR(16), + `reference` VARCHAR(80) NOT NULL, + `quantity` INT, + `copayAmount` DECIMAL(10,2), + `approver` VARCHAR(80), + `urgency` VARCHAR(16) +); + +CREATE TABLE `all_types` ( + `id` VARCHAR(36) NOT NULL DEFAULT (UUID()) PRIMARY KEY, + `sVal` VARCHAR(200) NOT NULL, + `iVal` INT NOT NULL, + `lVal` BIGINT NOT NULL, + `dVal` DOUBLE NOT NULL, + `fVal` FLOAT NOT NULL, + `decVal` DECIMAL(18,6) NOT NULL, + `bVal` BOOLEAN NOT NULL, + `dateVal` DATE NOT NULL, + `timeVal` TIME(3) NOT NULL, + `tsVal` DATETIME(3) NOT NULL, + `tsTzVal` DATETIME(3) NOT NULL, + `moneyVal` BIGINT NOT NULL, + `enumVal` VARCHAR(6) NOT NULL, + `intEnumVal` INT, + `uuidVal` VARCHAR(36) NOT NULL, + `uriVal` VARCHAR(255) NOT NULL, + `inetVal` VARCHAR(45) NOT NULL, + `inet6Val` VARCHAR(45) NOT NULL, + `settings` JSON, + `labels` JSON +); + CREATE VIEW `v_program_minutes` AS SELECT w.`programId` AS `program`, @@ -115,3 +170,134 @@ CREATE VIEW `v_fitness_totals_filled` AS COALESCE(CAST(SUM(w.`durationMinutes`) AS SIGNED), 0) AS `totalMinutesOrZero`, COALESCE(COUNT(CASE WHEN w.`durationMinutes` >= 60 THEN w.`id` END) / NULLIF(COUNT(w.`id`), 0), 0) AS `longShareOrZero` FROM `weeks` w; + +CREATE VIEW `v_measurement_stats` AS + SELECT + COUNT(m.`id`) AS `readings`, + SUM(m.`massKg`) AS `totalMassKg`, + AVG(m.`massKg`) AS `avgMassKg`, + MAX(m.`tempC`) AS `maxTempC`, + MIN(m.`tempC`) AS `minTempC`, + SUM(m.`preciseKg`) AS `totalPreciseKg`, + AVG(m.`preciseKg`) AS `avgPreciseKg`, + MAX(m.`preciseKg`) AS `maxPreciseKg`, + COUNT(CASE WHEN m.`massKg` >= 10 THEN m.`id` END) AS `heavyReadings` + FROM `measurements` m; + +CREATE VIEW `v_measurement_stats_filled` AS + SELECT + COUNT(m.`id`) AS `readings`, + COALESCE(AVG(m.`preciseKg`), -1) AS `avgPreciseKgOrMinusOne`, + COALESCE(MAX(m.`preciseKg`), 0) AS `maxPreciseKgOrZero` + FROM `measurements` m; + +CREATE VIEW `v_asset_days` AS + SELECT + DATE(a.`recordedAt`) AS `recordedAtDay`, + DATE(DATE_FORMAT(a.`asOfDate`, '%Y-%m-01')) AS `asOfDateMonth`, + COUNT(a.`id`) AS `assets`, + COUNT(DISTINCT a.`ownerId`) AS `owners`, + MIN(a.`recordedAt`) AS `firstRecorded`, + MAX(a.`observedAt`) AS `lastObserved`, + MAX(a.`asOfDate`) AS `lastAsOf` + FROM `assets` a + GROUP BY DATE(a.`recordedAt`), DATE(DATE_FORMAT(a.`asOfDate`, '%Y-%m-01')); + +CREATE VIEW `v_all_types_breakdown` AS + SELECT + a.`bVal` AS `flag`, + a.`enumVal` AS `level`, + a.`intEnumVal` AS `legacyStatus`, + a.`dateVal` AS `dateKey`, + a.`uuidVal` AS `uuidKey`, + COUNT(a.`id`) AS `allRows`, + CAST(SUM(a.`iVal`) AS SIGNED) AS `totalInts`, + AVG(a.`decVal`) AS `avgDec`, + MAX(a.`moneyVal`) AS `maxMoney`, + MIN(a.`lVal`) AS `minLong`, + COUNT(CASE WHEN a.`enumVal` IN ('MEDIUM', 'HIGH') THEN a.`id` END) AS `highRows` + FROM `all_types` a + WHERE (a.`tsVal` < (UTC_TIMESTAMP(3) - INTERVAL 1 HOUR) AND a.`iVal` <> 99) + GROUP BY a.`bVal`, a.`enumVal`, a.`intEnumVal`, a.`dateVal`, a.`uuidVal`; + +CREATE VIEW `v_programs_over_time` AS + SELECT + DATE(p.`created_ts`) AS `createdAtDay`, + MAKEDATE(YEAR(p.`created_ts`), 1) + INTERVAL (QUARTER(p.`created_ts`) - 1) QUARTER AS `createdAtQuarter`, + MAKEDATE(YEAR(p.`created_ts`), 1) AS `createdAtYear`, + COUNT(p.`id`) AS `programs`, + AVG(p.`priceCents`) AS `avgPrice`, + MIN(p.`priceCents`) AS `cheapest`, + MAX(p.`priceCents`) AS `priciest` + FROM `programs` p + GROUP BY DATE(p.`created_ts`), MAKEDATE(YEAR(p.`created_ts`), 1) + INTERVAL (QUARTER(p.`created_ts`) - 1) QUARTER, MAKEDATE(YEAR(p.`created_ts`), 1); + +CREATE VIEW `v_live_intro_programs` AS + SELECT + p.`status` AS `status`, + COUNT(p.`id`) AS `programs`, + COUNT(CASE WHEN p.`status` <> 'DRAFT' THEN p.`id` END) AS `notDrafts`, + CAST(SUM(CASE WHEN p.`status` = 'PUBLISHED' THEN p.`priceCents` END) AS SIGNED) AS `listValue` + FROM `programs` p + WHERE ((p.`title` LIKE 'Intro%' OR p.`priceCents` = 0) AND (p.`status` IN ('DRAFT', 'PUBLISHED') AND p.`priceCents` < 5000)) + GROUP BY p.`status`; + +CREATE VIEW `v_programs_empty_scope` AS + SELECT + COUNT(p.`id`) AS `programs`, + COALESCE(AVG(p.`priceCents`), -1) AS `avgPriceOrMinusOne` + FROM `programs` p + WHERE (p.`status` IN ('DRAFT', 'PUBLISHED') AND p.`priceCents` > 1000000); + +CREATE VIEW `v_week_shapes` AS + SELECT + p.`title` AS `programTitle`, + COUNT(w.`id`) AS `weeks`, + COUNT(CASE WHEN w.`label` IS NULL THEN w.`id` END) AS `unlabelled`, + CAST(SUM(CASE WHEN w.`label` IS NOT NULL THEN w.`durationMinutes` END) AS SIGNED) AS `labelledMinutes`, + COUNT(CASE WHEN w.`durationMinutes` < 30 THEN w.`id` END) AS `shortWeeks`, + COUNT(CASE WHEN (w.`durationMinutes` > 30 AND w.`durationMinutes` <= 60) THEN w.`id` END) AS `midWeeks`, + CAST(SUM(CASE WHEN (w.`durationMinutes` >= 60 AND w.`label` LIKE 'W%') THEN w.`durationMinutes` END) AS SIGNED) AS `longWMinutes` + FROM `weeks` w + INNER JOIN `programs` p ON p.`id` = w.`programId` + GROUP BY p.`title`; + +CREATE VIEW `v_sessions_by_program` AS + SELECT + p.`title` AS `programTitle`, + w.`id` AS `weekKey`, + COUNT(s.`id`) AS `sessions`, + CAST(SUM(s.`minutes`) AS SIGNED) AS `sessionMinutes` + FROM `sessions` s + INNER JOIN `weeks` w ON w.`id` = s.`weekId` + INNER JOIN `programs` p ON p.`id` = w.`programId` + GROUP BY p.`title`, w.`id`; + +CREATE VIEW `v_program_sessions` AS + SELECT + p.`id` AS `programKey`, + p.`title` AS `programTitle`, + COUNT(s.`id`) AS `sessions`, + CAST(SUM(s.`minutes`) AS SIGNED) AS `sessionMinutes`, + COALESCE(CAST(SUM(s.`minutes`) AS SIGNED), 0) AS `sessionMinutesOrZero` + FROM `programs` p + LEFT OUTER JOIN `weeks` w ON p.`id` = w.`programId` + LEFT OUTER JOIN `sessions` s ON w.`id` = s.`weekId` + GROUP BY p.`id`, p.`title`; + +CREATE VIEW `v_nodes_by_parent` AS + SELECT + n0.`label` AS `parentLabel`, + COUNT(n.`id`) AS `nodes` + FROM `nodes` n + LEFT OUTER JOIN `nodes` n0 ON n0.`id` = n.`parentId` + GROUP BY n0.`label`; + +CREATE VIEW `v_auths_by_kind` AS + SELECT + a.`type` AS `kind`, + COUNT(a.`id`) AS `auths`, + COUNT(DISTINCT a.`reference`) AS `distinctRefs` + FROM `auths` a + WHERE a.`type` <> 'PriorAuth' + GROUP BY a.`type`; diff --git a/fixtures/persistence-conformance/canonical/schema.postgres.sql b/fixtures/persistence-conformance/canonical/schema.postgres.sql index 182a0fccd..bbef4aceb 100644 --- a/fixtures/persistence-conformance/canonical/schema.postgres.sql +++ b/fixtures/persistence-conformance/canonical/schema.postgres.sql @@ -30,6 +30,13 @@ CREATE TABLE "nodes" ( CONSTRAINT "nodes_pkey" PRIMARY KEY ("id") ); +CREATE TABLE "sessions" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "weekId" BIGINT NOT NULL, + "minutes" INTEGER NOT NULL, + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + CREATE TABLE "measurements" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "tempC" REAL NOT NULL, @@ -139,6 +146,8 @@ ALTER TABLE "weeks" ADD CONSTRAINT "weeks_programId_fk" FOREIGN KEY ("programId" ALTER TABLE "nodes" ADD CONSTRAINT "nodes_parentId_fk" FOREIGN KEY ("parentId") REFERENCES "nodes" ("id"); +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_weekId_fk" FOREIGN KEY ("weekId") REFERENCES "weeks" ("id"); + ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_postId_fk" FOREIGN KEY ("postId") REFERENCES "posts" ("id"); ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_tagId_fk" FOREIGN KEY ("tagId") REFERENCES "tags" ("id"); @@ -268,3 +277,146 @@ CREATE VIEW "v_fitness_totals_filled" AS COALESCE(CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0), 0) AS "longShareOrZero" FROM "weeks" w; COMMENT ON VIEW "v_fitness_totals_filled" IS 'metaobjects:v1:sha256:b3ea9f392eb506a9d70ffa39f7b66e1e7f987b5913b0f3e74d0b84a4d6c299a9'; + +CREATE VIEW "v_measurement_stats" AS + SELECT + COUNT(m."id") AS "readings", + CAST(SUM(m."massKg") AS DOUBLE PRECISION) AS "totalMassKg", + AVG(m."massKg") AS "avgMassKg", + MAX(m."tempC") AS "maxTempC", + MIN(m."tempC") AS "minTempC", + SUM(m."preciseKg") AS "totalPreciseKg", + AVG(m."preciseKg") AS "avgPreciseKg", + MAX(m."preciseKg") AS "maxPreciseKg", + COUNT(m."id") FILTER (WHERE m."massKg" >= 10) AS "heavyReadings" + FROM "measurements" m; +COMMENT ON VIEW "v_measurement_stats" IS 'metaobjects:v1:sha256:8e6d5c3fc357a038917d6df20153b9d066487e9fa692afd696d86b5062ce43be'; + +CREATE VIEW "v_measurement_stats_filled" AS + SELECT + COUNT(m."id") AS "readings", + COALESCE(AVG(m."preciseKg"), -1) AS "avgPreciseKgOrMinusOne", + COALESCE(MAX(m."preciseKg"), 0) AS "maxPreciseKgOrZero" + FROM "measurements" m; +COMMENT ON VIEW "v_measurement_stats_filled" IS 'metaobjects:v1:sha256:56ba8806e78aa0d43b374454a9a7bfb0a2e0bb015f288a8281418d31b2be194a'; + +CREATE VIEW "v_asset_days" AS + SELECT + CAST(date_trunc('day', a."recordedAt" AT TIME ZONE 'UTC') AS DATE) AS "recordedAtDay", + CAST(date_trunc('month', CAST(a."asOfDate" AS TIMESTAMP)) AS DATE) AS "asOfDateMonth", + COUNT(a."id") AS "assets", + COUNT(DISTINCT a."ownerId") AS "owners", + MIN(a."recordedAt") AS "firstRecorded", + MAX(a."observedAt") AS "lastObserved", + MAX(a."asOfDate") AS "lastAsOf" + FROM "assets" a + GROUP BY CAST(date_trunc('day', a."recordedAt" AT TIME ZONE 'UTC') AS DATE), CAST(date_trunc('month', CAST(a."asOfDate" AS TIMESTAMP)) AS DATE); +COMMENT ON VIEW "v_asset_days" IS 'metaobjects:v1:sha256:2ecfc341adfc2080203a284dcd646ae32ef609a69a0c3880788b5ffb049a2543'; + +CREATE VIEW "v_all_types_breakdown" AS + SELECT + a."bVal" AS "flag", + a."enumVal" AS "level", + a."intEnumVal" AS "legacyStatus", + a."dateVal" AS "dateKey", + a."uuidVal" AS "uuidKey", + COUNT(a."id") AS "allRows", + CAST(SUM(a."iVal") AS BIGINT) AS "totalInts", + AVG(a."decVal") AS "avgDec", + MAX(a."moneyVal") AS "maxMoney", + MIN(a."lVal") AS "minLong", + COUNT(a."id") FILTER (WHERE a."enumVal" IN ('MEDIUM', 'HIGH')) AS "highRows" + FROM "all_types" a + WHERE (a."tsVal" < ((now() AT TIME ZONE 'UTC') - INTERVAL 'PT1H') AND a."iVal" <> 99) + GROUP BY a."bVal", a."enumVal", a."intEnumVal", a."dateVal", a."uuidVal"; +COMMENT ON VIEW "v_all_types_breakdown" IS 'metaobjects:v1:sha256:beac13c76688e1dd5ae66845abecd41a8d2e434e3fbfe819494341cc7e473f0d'; + +CREATE VIEW "v_programs_over_time" AS + SELECT + CAST(date_trunc('day', p."created_ts") AS DATE) AS "createdAtDay", + CAST(date_trunc('quarter', p."created_ts") AS DATE) AS "createdAtQuarter", + CAST(date_trunc('year', p."created_ts") AS DATE) AS "createdAtYear", + COUNT(p."id") AS "programs", + AVG(p."priceCents") AS "avgPrice", + MIN(p."priceCents") AS "cheapest", + MAX(p."priceCents") AS "priciest" + FROM "programs" p + GROUP BY CAST(date_trunc('day', p."created_ts") AS DATE), CAST(date_trunc('quarter', p."created_ts") AS DATE), CAST(date_trunc('year', p."created_ts") AS DATE); +COMMENT ON VIEW "v_programs_over_time" IS 'metaobjects:v1:sha256:aadf4509dae744682a2cf21e7cc02fa2ebfce16b6dd2e391e53a3bb4e8c0df19'; + +CREATE VIEW "v_live_intro_programs" AS + SELECT + p."status" AS "status", + COUNT(p."id") AS "programs", + COUNT(p."id") FILTER (WHERE p."status" <> 'DRAFT') AS "notDrafts", + CAST(SUM(p."priceCents") FILTER (WHERE p."status" = 'PUBLISHED') AS BIGINT) AS "listValue" + FROM "programs" p + WHERE ((p."title" LIKE 'Intro%' OR p."priceCents" = 0) AND (p."status" IN ('DRAFT', 'PUBLISHED') AND p."priceCents" < 5000)) + GROUP BY p."status"; +COMMENT ON VIEW "v_live_intro_programs" IS 'metaobjects:v1:sha256:2f4ea71ca39cc90b5293ceb4e353efcf6a0be3e8bdf3a8f587895abfb861e5f7'; + +CREATE VIEW "v_programs_empty_scope" AS + SELECT + COUNT(p."id") AS "programs", + COALESCE(AVG(p."priceCents"), -1) AS "avgPriceOrMinusOne" + FROM "programs" p + WHERE (p."status" IN ('DRAFT', 'PUBLISHED') AND p."priceCents" > 1000000); +COMMENT ON VIEW "v_programs_empty_scope" IS 'metaobjects:v1:sha256:b0d7aa6674edaf37d83feeac3fae8b5e7571637cd91ba2c7ef25125e78b1378b'; + +CREATE VIEW "v_week_shapes" AS + SELECT + p."title" AS "programTitle", + COUNT(w."id") AS "weeks", + COUNT(w."id") FILTER (WHERE w."label" IS NULL) AS "unlabelled", + CAST(SUM(w."durationMinutes") FILTER (WHERE w."label" IS NOT NULL) AS BIGINT) AS "labelledMinutes", + COUNT(w."id") FILTER (WHERE w."durationMinutes" < 30) AS "shortWeeks", + COUNT(w."id") FILTER (WHERE (w."durationMinutes" > 30 AND w."durationMinutes" <= 60)) AS "midWeeks", + CAST(SUM(w."durationMinutes") FILTER (WHERE (w."durationMinutes" >= 60 AND w."label" LIKE 'W%')) AS BIGINT) AS "longWMinutes" + FROM "weeks" w + INNER JOIN "programs" p ON p."id" = w."programId" + GROUP BY p."title"; +COMMENT ON VIEW "v_week_shapes" IS 'metaobjects:v1:sha256:d45ae0c03233df8ba4109d7c45a132e60185a96aaacdefe6e61de445286f6840'; + +CREATE VIEW "v_sessions_by_program" AS + SELECT + p."title" AS "programTitle", + w."id" AS "weekKey", + COUNT(s."id") AS "sessions", + CAST(SUM(s."minutes") AS BIGINT) AS "sessionMinutes" + FROM "sessions" s + INNER JOIN "weeks" w ON w."id" = s."weekId" + INNER JOIN "programs" p ON p."id" = w."programId" + GROUP BY p."title", w."id"; +COMMENT ON VIEW "v_sessions_by_program" IS 'metaobjects:v1:sha256:2df2051342861240b2ddc4d2d1cbdab012a4a255f0191c35a863b9cfa5754e14'; + +CREATE VIEW "v_program_sessions" AS + SELECT + p."id" AS "programKey", + p."title" AS "programTitle", + COUNT(s."id") AS "sessions", + CAST(SUM(s."minutes") AS BIGINT) AS "sessionMinutes", + COALESCE(CAST(SUM(s."minutes") AS BIGINT), 0) AS "sessionMinutesOrZero" + FROM "programs" p + LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" + LEFT OUTER JOIN "sessions" s ON w."id" = s."weekId" + GROUP BY p."id", p."title"; +COMMENT ON VIEW "v_program_sessions" IS 'metaobjects:v1:sha256:b6ecd744d937fc2cb0da4067281d062c060d06561f024640beb2bb3ca735bf4f'; + +CREATE VIEW "v_nodes_by_parent" AS + SELECT + n0."label" AS "parentLabel", + COUNT(n."id") AS "nodes" + FROM "nodes" n + LEFT OUTER JOIN "nodes" n0 ON n0."id" = n."parentId" + GROUP BY n0."label"; +COMMENT ON VIEW "v_nodes_by_parent" IS 'metaobjects:v1:sha256:a39a2061d1b52009c0f930de6c443430e8987501cbe40d03bebd5fe583864591'; + +CREATE VIEW "v_auths_by_kind" AS + SELECT + a."type" AS "kind", + COUNT(a."id") AS "auths", + COUNT(DISTINCT a."reference") AS "distinctRefs" + FROM "auths" a + WHERE a."type" <> 'PriorAuth' + GROUP BY a."type"; +COMMENT ON VIEW "v_auths_by_kind" IS 'metaobjects:v1:sha256:600fcf3a54c8a8f51b2771850689073c7715193fe2528cd7af77bc2e97d22168'; diff --git a/fixtures/persistence-conformance/canonical/schema.sqlite.sql b/fixtures/persistence-conformance/canonical/schema.sqlite.sql index 463fc70c7..feb3416ee 100644 --- a/fixtures/persistence-conformance/canonical/schema.sqlite.sql +++ b/fixtures/persistence-conformance/canonical/schema.sqlite.sql @@ -29,6 +29,13 @@ CREATE TABLE "nodes" ( FOREIGN KEY ("parentId") REFERENCES "nodes" ("id") ); +CREATE TABLE "sessions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "weekId" INTEGER NOT NULL, + "minutes" INTEGER NOT NULL, + FOREIGN KEY ("weekId") REFERENCES "weeks" ("id") +); + CREATE TABLE "measurements" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "tempC" REAL NOT NULL, @@ -237,3 +244,134 @@ CREATE VIEW "v_fitness_totals_filled" AS COALESCE(SUM(w."durationMinutes"), 0) AS "totalMinutesOrZero", COALESCE(CAST(COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS REAL) / NULLIF(COUNT(w."id"), 0), 0.0) AS "longShareOrZero" FROM "weeks" w; + +CREATE VIEW "v_measurement_stats" AS + SELECT + COUNT(m."id") AS "readings", + SUM(m."massKg") AS "totalMassKg", + AVG(m."massKg") AS "avgMassKg", + MAX(m."tempC") AS "maxTempC", + MIN(m."tempC") AS "minTempC", + SUM(m."preciseKg") AS "totalPreciseKg", + AVG(m."preciseKg") AS "avgPreciseKg", + MAX(m."preciseKg") AS "maxPreciseKg", + COUNT(CASE WHEN m."massKg" >= 10 THEN m."id" END) AS "heavyReadings" + FROM "measurements" m; + +CREATE VIEW "v_measurement_stats_filled" AS + SELECT + COUNT(m."id") AS "readings", + COALESCE(AVG(m."preciseKg"), -1.0) AS "avgPreciseKgOrMinusOne", + COALESCE(MAX(m."preciseKg"), 0.0) AS "maxPreciseKgOrZero" + FROM "measurements" m; + +CREATE VIEW "v_asset_days" AS + SELECT + date(a."recordedAt") AS "recordedAtDay", + date(a."asOfDate", 'start of month') AS "asOfDateMonth", + COUNT(a."id") AS "assets", + COUNT(DISTINCT a."ownerId") AS "owners", + MIN(a."recordedAt") AS "firstRecorded", + MAX(a."observedAt") AS "lastObserved", + MAX(a."asOfDate") AS "lastAsOf" + FROM "assets" a + GROUP BY date(a."recordedAt"), date(a."asOfDate", 'start of month'); + +CREATE VIEW "v_all_types_breakdown" AS + SELECT + a."bVal" AS "flag", + a."enumVal" AS "level", + a."intEnumVal" AS "legacyStatus", + a."dateVal" AS "dateKey", + a."uuidVal" AS "uuidKey", + COUNT(a."id") AS "allRows", + SUM(a."iVal") AS "totalInts", + AVG(a."decVal") AS "avgDec", + MAX(a."moneyVal") AS "maxMoney", + MIN(a."lVal") AS "minLong", + COUNT(CASE WHEN a."enumVal" IN ('MEDIUM', 'HIGH') THEN a."id" END) AS "highRows" + FROM "all_types" a + WHERE (a."tsVal" < strftime('%Y-%m-%dT%H:%M:%f', 'now', '-1 hours') AND a."iVal" <> 99) + GROUP BY a."bVal", a."enumVal", a."intEnumVal", a."dateVal", a."uuidVal"; + +CREATE VIEW "v_programs_over_time" AS + SELECT + date(p."created_ts") AS "createdAtDay", + date(p."created_ts", 'start of month', '-' || ((CAST(strftime('%m', p."created_ts") AS INTEGER) - 1) % 3) || ' months') AS "createdAtQuarter", + date(p."created_ts", 'start of year') AS "createdAtYear", + COUNT(p."id") AS "programs", + AVG(p."priceCents") AS "avgPrice", + MIN(p."priceCents") AS "cheapest", + MAX(p."priceCents") AS "priciest" + FROM "programs" p + GROUP BY date(p."created_ts"), date(p."created_ts", 'start of month', '-' || ((CAST(strftime('%m', p."created_ts") AS INTEGER) - 1) % 3) || ' months'), date(p."created_ts", 'start of year'); + +CREATE VIEW "v_live_intro_programs" AS + SELECT + p."status" AS "status", + COUNT(p."id") AS "programs", + COUNT(CASE WHEN p."status" <> 'DRAFT' THEN p."id" END) AS "notDrafts", + SUM(CASE WHEN p."status" = 'PUBLISHED' THEN p."priceCents" END) AS "listValue" + FROM "programs" p + WHERE ((p."title" LIKE 'Intro%' OR p."priceCents" = 0) AND (p."status" IN ('DRAFT', 'PUBLISHED') AND p."priceCents" < 5000)) + GROUP BY p."status"; + +CREATE VIEW "v_programs_empty_scope" AS + SELECT + COUNT(p."id") AS "programs", + COALESCE(AVG(p."priceCents"), -1.0) AS "avgPriceOrMinusOne" + FROM "programs" p + WHERE (p."status" IN ('DRAFT', 'PUBLISHED') AND p."priceCents" > 1000000); + +CREATE VIEW "v_week_shapes" AS + SELECT + p."title" AS "programTitle", + COUNT(w."id") AS "weeks", + COUNT(CASE WHEN w."label" IS NULL THEN w."id" END) AS "unlabelled", + SUM(CASE WHEN w."label" IS NOT NULL THEN w."durationMinutes" END) AS "labelledMinutes", + COUNT(CASE WHEN w."durationMinutes" < 30 THEN w."id" END) AS "shortWeeks", + COUNT(CASE WHEN (w."durationMinutes" > 30 AND w."durationMinutes" <= 60) THEN w."id" END) AS "midWeeks", + SUM(CASE WHEN (w."durationMinutes" >= 60 AND w."label" LIKE 'W%') THEN w."durationMinutes" END) AS "longWMinutes" + FROM "weeks" w + INNER JOIN "programs" p ON p."id" = w."programId" + GROUP BY p."title"; + +CREATE VIEW "v_sessions_by_program" AS + SELECT + p."title" AS "programTitle", + w."id" AS "weekKey", + COUNT(s."id") AS "sessions", + SUM(s."minutes") AS "sessionMinutes" + FROM "sessions" s + INNER JOIN "weeks" w ON w."id" = s."weekId" + INNER JOIN "programs" p ON p."id" = w."programId" + GROUP BY p."title", w."id"; + +CREATE VIEW "v_program_sessions" AS + SELECT + p."id" AS "programKey", + p."title" AS "programTitle", + COUNT(s."id") AS "sessions", + SUM(s."minutes") AS "sessionMinutes", + COALESCE(SUM(s."minutes"), 0) AS "sessionMinutesOrZero" + FROM "programs" p + LEFT OUTER JOIN "weeks" w ON p."id" = w."programId" + LEFT OUTER JOIN "sessions" s ON w."id" = s."weekId" + GROUP BY p."id", p."title"; + +CREATE VIEW "v_nodes_by_parent" AS + SELECT + n0."label" AS "parentLabel", + COUNT(n."id") AS "nodes" + FROM "nodes" n + LEFT OUTER JOIN "nodes" n0 ON n0."id" = n."parentId" + GROUP BY n0."label"; + +CREATE VIEW "v_auths_by_kind" AS + SELECT + a."type" AS "kind", + COUNT(a."id") AS "auths", + COUNT(DISTINCT a."reference") AS "distinctRefs" + FROM "auths" a + WHERE a."type" <> 'PriorAuth' + GROUP BY a."type"; diff --git a/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml b/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml index a58883a31..457c81ffe 100644 --- a/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml +++ b/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml @@ -79,5 +79,7 @@ expect: - { table_name: "post_tags" } - { table_name: "posts" } - { table_name: "programs" } + # sessions: the fact table the multi-hop report scenarios read through two references. + - { table_name: "sessions" } - { table_name: "tags" } - { table_name: "weeks" } diff --git a/fixtures/persistence-conformance/queries/report-all-types-breakdown.yaml b/fixtures/persistence-conformance/queries/report-all-types-breakdown.yaml new file mode 100644 index 000000000..d071b9cb8 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-all-types-breakdown.yaml @@ -0,0 +1,61 @@ +name: report-all-types-breakdown +description: | + AllTypesBreakdown groups AllTypes by a boolean, an enum, a NULLABLE int-backed enum + (read as its member symbol), a date and a uuid, and aggregates an int sum, a decimal avg, + a currency max, a long min and a count with an inline `in` @filter. Its report @filter + mixes a relative timestamp with a PT duration (`tsVal < now - PT1H`) and a `ne`. + + r1 true HIGH PUBLISHED(5) 2026-05-01 U1 iVal 10 dec 1.5 money 1000 long 7 + r2 true HIGH PUBLISHED(5) 2026-05-01 U1 iVal 20 dec 2.5 money 3000 long 3 (same group as r1) + r3 false LOW null 2026-05-02 U2 iVal 5 dec 0.5 money 200 long 9 + r4 true MEDIUM DRAFT(0) 2026-05-01 U3 iVal 1 dec 4.25 money 50 long 2 + r5 iVal 99 -> out of scope (ne) + r6 tsVal in the year 2999 -> out of scope (relative bound; a literal, so the seed is the same SQL on every engine) +seed-data: | + INSERT INTO "all_types" ( + "id","sVal","iVal","lVal","dVal","fVal","decVal","bVal","dateVal","timeVal","tsVal","tsTzVal", + "moneyVal","enumVal","intEnumVal","uuidVal","uriVal","inetVal","inet6Val" + ) VALUES + ('00000000-0000-4000-8000-000000000001','r1',10,7,0.5,0.5,1.5,true,'2026-05-01','00:00:00', + '2026-05-01T00:00:00','2026-05-01T00:00:00Z',1000,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:1','10.0.0.1','fe80::1'), + ('00000000-0000-4000-8000-000000000002','r2',20,3,0.5,0.5,2.5,true,'2026-05-01','00:00:00', + '2026-05-01T00:00:00','2026-05-01T00:00:00Z',3000,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:2','10.0.0.2','fe80::2'), + ('00000000-0000-4000-8000-000000000003','r3',5,9,0.5,0.5,0.5,false,'2026-05-02','00:00:00', + '2026-05-02T00:00:00','2026-05-02T00:00:00Z',200,'LOW',NULL, + 'b2222222-2222-4222-8222-222222222222','urn:x:3','10.0.0.3','fe80::3'), + ('00000000-0000-4000-8000-000000000004','r4',1,2,0.5,0.5,4.25,true,'2026-05-01','00:00:00', + '2026-05-01T00:00:00','2026-05-01T00:00:00Z',50,'MEDIUM',0, + 'c3333333-3333-4333-8333-333333333333','urn:x:4','10.0.0.4','fe80::4'), + ('00000000-0000-4000-8000-000000000005','r5',99,1,0.5,0.5,9,true,'2026-05-01','00:00:00', + '2026-05-01T00:00:00','2026-05-01T00:00:00Z',9999,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:5','10.0.0.5','fe80::5'), + ('00000000-0000-4000-8000-000000000006','r6',2,1,0.5,0.5,9,true,'2026-05-01','00:00:00', + '2999-01-01T00:00:00','2026-05-01T00:00:00Z',9999,'HIGH',5, + 'a1111111-1111-4111-8111-111111111111','urn:x:6','10.0.0.6','fe80::6'); +queries: + - name: every-dimension-type + op: list + entity: AllTypesBreakdown + sort: [{ field: dateKey, dir: asc }, { field: level, dir: asc }] + expect: + - { flag: true, level: "HIGH", legacyStatus: "PUBLISHED", dateKey: "2026-05-01", + uuidKey: "a1111111-1111-4111-8111-111111111111", allRows: "2", totalInts: "30", avgDec: "2", + maxMoney: "3000", minLong: "3", highRows: "2" } + - { flag: true, level: "MEDIUM", legacyStatus: "DRAFT", dateKey: "2026-05-01", + uuidKey: "c3333333-3333-4333-8333-333333333333", allRows: "1", totalInts: "1", avgDec: "4.25", + maxMoney: "50", minLong: "2", highRows: "1" } + - { flag: false, level: "LOW", legacyStatus: null, dateKey: "2026-05-02", + uuidKey: "b2222222-2222-4222-8222-222222222222", allRows: "1", totalInts: "5", avgDec: "0.5", + maxMoney: "200", minLong: "9", highRows: "0" } + - name: filter-on-a-boolean-dimension + op: count + entity: AllTypesBreakdown + filter: { flag: { eq: true } } + expect: 2 + - name: filter-isnull-on-a-nullable-enum-dimension + op: count + entity: AllTypesBreakdown + filter: { legacyStatus: { isNull: true } } + expect: 1 diff --git a/fixtures/persistence-conformance/queries/report-filter-operators.yaml b/fixtures/persistence-conformance/queries/report-filter-operators.yaml new file mode 100644 index 000000000..dc5f43af9 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-filter-operators.yaml @@ -0,0 +1,56 @@ +name: report-filter-operators +description: | + LiveIntroPrograms combines a report @segment (introOrFree: an `or` of a `like` and an + equality) with a report @filter (an `in` list and a `lt`), and ANDs them. Its `notDrafts` + measure carries an inline `ne` @filter. ProgramsEmptyScope scopes by segment `live` (an + `in` list) and a filter no row meets, so it reads zero rows and its avg @default: -1 + applies. + + Seven programs: + 1 'Intro to Lifting' DRAFT 0 in scope (intro, in list, < 5000) + 2 'Intro to Running' PUBLISHED 3000 in scope + 3 'Intro Pro' PUBLISHED 9000 out: priced 5000 or more + 4 'Advanced' PUBLISHED 0 in scope (free) + 5 'Advanced Plus' ARCHIVED 2000 out: neither intro nor free + 6 'Intro Archive' ARCHIVED 100 out: status is not in the list + 7 'Free Draft' DRAFT 0 in scope (free) + DRAFT: 2 programs, 0 not drafts, listValue null (no published row). PUBLISHED: 2 programs, + 2 not drafts, listValue 3000 + 0. +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Intro to Lifting', 0, 'DRAFT', '2026-01-01T00:00:00'), + (2, 'Intro to Running', 3000, 'PUBLISHED', '2026-01-01T00:00:00'), + (3, 'Intro Pro', 9000, 'PUBLISHED', '2026-01-01T00:00:00'), + (4, 'Advanced', 0, 'PUBLISHED', '2026-01-01T00:00:00'), + (5, 'Advanced Plus', 2000, 'ARCHIVED', '2026-01-01T00:00:00'), + (6, 'Intro Archive', 100, 'ARCHIVED', '2026-01-01T00:00:00'), + (7, 'Free Draft', 0, 'DRAFT', '2026-01-01T00:00:00'); +queries: + - name: segment-and-filter-are-anded + op: list + entity: LiveIntroPrograms + sort: [{ field: status, dir: asc }] + expect: + - { status: "DRAFT", programs: "2", notDrafts: "0", listValue: null } + - { status: "PUBLISHED", programs: "2", notDrafts: "2", listValue: "3000" } + - name: filter-on-the-dimension + op: list + entity: LiveIntroPrograms + filter: { status: { eq: "DRAFT" } } + expect: + - { status: "DRAFT", programs: "2", notDrafts: "0", listValue: null } + - name: filter-isnull-on-a-measure + op: list + entity: LiveIntroPrograms + filter: { listValue: { isNull: true } } + expect: + - { status: "DRAFT", programs: "2", notDrafts: "0", listValue: null } + - name: count-groups + op: count + entity: LiveIntroPrograms + expect: 2 + - name: negative-default-over-an-empty-scope + op: list + entity: ProgramsEmptyScope + expect: + - { programs: "0", avgPriceOrMinusOne: "-1" } diff --git a/fixtures/persistence-conformance/queries/report-measure-filters.yaml b/fixtures/persistence-conformance/queries/report-measure-filters.yaml new file mode 100644 index 000000000..4ff6312b8 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-measure-filters.yaml @@ -0,0 +1,41 @@ +name: report-measure-filters +description: | + WeekShapes puts one inline measure @filter on each of the operators the other report + scenarios skip: `isNull: true`, `isNull: false`, `lt`, a two-operator range (`gt` and + `lte` on one field), and a `like` ANDed with the `long` segment. + + Alpha: W1/20, W2/45, null/60, Wrap/90 + unlabelled 1; labelledMinutes 20 + 45 + 90 = 155; shortWeeks (< 30) 1; + midWeeks (> 30 and <= 60) 2 (45, 60); longWMinutes (>= 60 and label like 'W%') + 90 (the 60-minute week has no label, so it is not counted). + Beta: X1/30, null/25 + unlabelled 1; labelledMinutes 30; shortWeeks 1 (25; 30 is not < 30); midWeeks 0; + longWMinutes null (no row passes). +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Alpha', 1000, 'PUBLISHED', '2026-01-01T00:00:00'), + (2, 'Beta', 2000, 'PUBLISHED', '2026-01-01T00:00:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes") VALUES + (10, 1, 'W1', 20), + (11, 1, 'W2', 45), + (12, 1, NULL, 60), + (13, 1, 'Wrap', 90), + (20, 2, 'X1', 30), + (21, 2, NULL, 25); +queries: + - name: every-filter-operator + op: list + entity: WeekShapes + sort: [{ field: programTitle, dir: asc }] + expect: + - { programTitle: "Alpha", weeks: "4", unlabelled: "1", labelledMinutes: "155", shortWeeks: "1", + midWeeks: "2", longWMinutes: "90" } + - { programTitle: "Beta", weeks: "2", unlabelled: "1", labelledMinutes: "30", shortWeeks: "1", + midWeeks: "0", longWMinutes: null } + - name: filter-isnull-on-a-measure + op: list + entity: WeekShapes + filter: { longWMinutes: { isNull: true } } + expect: + - { programTitle: "Beta", weeks: "2", unlabelled: "1", labelledMinutes: "30", shortWeeks: "1", + midWeeks: "0", longWMinutes: null } diff --git a/fixtures/persistence-conformance/queries/report-multi-hop.yaml b/fixtures/persistence-conformance/queries/report-multi-hop.yaml new file mode 100644 index 000000000..a75d4911a --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-multi-hop.yaml @@ -0,0 +1,59 @@ +name: report-multi-hop +description: | + Two-hop @via. Session reaches its program through Session.fkWeek.fkProgram, so + SessionsByProgram joins weeks and programs, and ProgramSessions takes its rows from the + program at the end of that two-hop path (@spine). + + Alpha (1): week 10 holds sessions 30 and 40, week 11 holds 50 + Beta (2): week 20 holds 25 + Gamma (3): no weeks + Delta (4): week 40 holds no sessions + + ProgramSessions keeps Gamma and Delta with `sessions` 0, `sessionMinutes` null and the + @default: 0 measure 0. The undefaulted sum is null, the defaulted one is 0. +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Alpha', 1000, 'PUBLISHED', '2026-01-01T00:00:00'), + (2, 'Beta', 2000, 'PUBLISHED', '2026-01-01T00:00:00'), + (3, 'Gamma', 3000, 'DRAFT', '2026-01-01T00:00:00'), + (4, 'Delta', 4000, 'DRAFT', '2026-01-01T00:00:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes") VALUES + (10, 1, 'W1', 30), + (11, 1, 'W2', 30), + (20, 2, 'W1', 30), + (40, 4, 'W1', 30); + INSERT INTO "sessions" ("id","weekId","minutes") VALUES + (100, 10, 30), + (101, 10, 40), + (102, 11, 50), + (200, 20, 25); +queries: + - name: grouped-by-a-dimension-two-hops-away + op: list + entity: SessionsByProgram + sort: [{ field: weekKey, dir: asc }] + expect: + - { programTitle: "Alpha", weekKey: "10", sessions: "2", sessionMinutes: "70" } + - { programTitle: "Alpha", weekKey: "11", sessions: "1", sessionMinutes: "50" } + - { programTitle: "Beta", weekKey: "20", sessions: "1", sessionMinutes: "25" } + - name: spine-over-a-two-hop-path + op: list + entity: ProgramSessions + sort: [{ field: programKey, dir: asc }] + expect: + - { programKey: "1", programTitle: "Alpha", sessions: "3", sessionMinutes: "120", sessionMinutesOrZero: "120" } + - { programKey: "2", programTitle: "Beta", sessions: "1", sessionMinutes: "25", sessionMinutesOrZero: "25" } + - { programKey: "3", programTitle: "Gamma", sessions: "0", sessionMinutes: null, sessionMinutesOrZero: "0" } + - { programKey: "4", programTitle: "Delta", sessions: "0", sessionMinutes: null, sessionMinutesOrZero: "0" } + - name: count-spine-rows + op: count + entity: ProgramSessions + expect: 4 + - name: filter-on-the-defaulted-measure + op: list + entity: ProgramSessions + filter: { sessionMinutesOrZero: { eq: 0 } } + sort: [{ field: programKey, dir: asc }] + expect: + - { programKey: "3", programTitle: "Gamma", sessions: "0", sessionMinutes: null, sessionMinutesOrZero: "0" } + - { programKey: "4", programTitle: "Delta", sessions: "0", sessionMinutes: null, sessionMinutesOrZero: "0" } diff --git a/fixtures/persistence-conformance/queries/report-negative-default.yaml b/fixtures/persistence-conformance/queries/report-negative-default.yaml new file mode 100644 index 000000000..83b14e00b --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-negative-default.yaml @@ -0,0 +1,17 @@ +name: report-negative-default +description: | + MeasurementStatsFilled over an EMPTY measurements table. The avg carries @default: -1 and + the decimal max @default: 0, so the single totals row reads -1 (a double) and 0 (a + decimal) where an undefaulted avg and max read null. The count stays 0. +queries: + - name: negative-and-zero-defaults-over-an-empty-table + op: list + entity: MeasurementStatsFilled + expect: + - { readings: "0", avgPreciseKgOrMinusOne: "-1", maxPreciseKgOrZero: "0" } + - name: undefaulted-twin-reads-null + op: list + entity: MeasurementStats + expect: + - { readings: "0", totalMassKg: null, avgMassKg: null, maxTempC: null, minTempC: null, + totalPreciseKg: null, avgPreciseKg: null, maxPreciseKg: null, heavyReadings: "0" } diff --git a/fixtures/persistence-conformance/queries/report-numeric-types.yaml b/fixtures/persistence-conformance/queries/report-numeric-types.yaml new file mode 100644 index 000000000..f72e41166 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-numeric-types.yaml @@ -0,0 +1,31 @@ +name: report-numeric-types +description: | + MeasurementStats is a no-dimension report over Measurement: sum, avg, min and max over a + float, a double and a decimal column, plus a count carrying an inline measure @filter + (massKg >= 10). Every value is dyadic (see normalization.md) so each port renders the + same plain-decimal string. + + Four readings: massKg 8.5 / 12.5 / 20.25 / 7.5 (sum 48.75, avg 12.1875), tempC 1.5 / -2.25 / + 4.5 / 0.5 (REAL: max 4.5, min -2.25), preciseKg 1.25 / 2.5 / 0.75 / 3.5 (NUMERIC(9,4): sum 8, + avg 2, max 3.5). Two readings weigh 10 or more. + + MeasurementStatsFilled carries @default on an avg (-1) and on a decimal max (0). Here the + table has data, so neither default applies; report-negative-default reads the empty table. +seed-data: | + INSERT INTO "measurements" ("id","tempC","massKg","preciseKg") VALUES + (1, 1.5, 8.5, 1.25), + (2, -2.25, 12.5, 2.5), + (3, 4.5, 20.25, 0.75), + (4, 0.5, 7.5, 3.5); +queries: + - name: float-double-decimal-aggregates + op: list + entity: MeasurementStats + expect: + - { readings: "4", totalMassKg: "48.75", avgMassKg: "12.1875", maxTempC: "4.5", minTempC: "-2.25", + totalPreciseKg: "8", avgPreciseKg: "2", maxPreciseKg: "3.5", heavyReadings: "2" } + - name: defaults-do-not-apply-to-a-present-value + op: list + entity: MeasurementStatsFilled + expect: + - { readings: "4", avgPreciseKgOrMinusOne: "2", maxPreciseKgOrZero: "3.5" } diff --git a/fixtures/persistence-conformance/queries/report-self-reference.yaml b/fixtures/persistence-conformance/queries/report-self-reference.yaml new file mode 100644 index 000000000..be8319043 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-self-reference.yaml @@ -0,0 +1,40 @@ +name: report-self-reference +description: | + NodesByParent groups Node by a dimension reached through the entity's own reference + (Node.fkParent), so the view joins nodes to nodes. A root has a null parent and lands in + the null group (the join is a LEFT OUTER JOIN, because parentId is optional). + + 1 root (no parent) 2 a (parent root) + 3 b (parent root) 4 c (parent a) + 5 top (no parent) 6 lone (no parent) + null group: root, top and lone (3); 'root' group: a and b (2); 'a' group: c (1). + The three counts differ so the scenario sorts on the count alone: where a NULL group sorts + among the others is the engine's choice (Postgres last, SQLite first), so no scenario may + rest on it. +seed-data: | + INSERT INTO "nodes" ("id","label","parentId") VALUES + (1, 'root', NULL), + (2, 'a', 1), + (3, 'b', 1), + (4, 'c', 2), + (5, 'top', NULL), + (6, 'lone', NULL); +queries: + - name: group-by-the-parents-label + op: list + entity: NodesByParent + sort: [{ field: nodes, dir: asc }] + expect: + - { parentLabel: "a", nodes: "1" } + - { parentLabel: "root", nodes: "2" } + - { parentLabel: null, nodes: "3" } + - name: filter-isnull-on-the-self-joined-dimension + op: list + entity: NodesByParent + filter: { parentLabel: { isNull: true } } + expect: + - { parentLabel: null, nodes: "3" } + - name: count-groups + op: count + entity: NodesByParent + expect: 3 diff --git a/fixtures/persistence-conformance/queries/report-time-grains-extended.yaml b/fixtures/persistence-conformance/queries/report-time-grains-extended.yaml new file mode 100644 index 000000000..729f09c13 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-time-grains-extended.yaml @@ -0,0 +1,43 @@ +name: report-time-grains-extended +description: | + ProgramsOverTime groups Program.createdAt at three grains at once (day, quarter, year), + with avg, min and max over a currency column. The seed straddles a year boundary + (2025-12-31 23:59:59 vs 2026-01-01 00:00:00) and a quarter boundary (2026-03-31 23:00:00 + vs 2026-04-01 00:00:00), and puts two programs on 2026-01-01 so one group holds two rows. + Dates are "YYYY-MM-DD"; the quarter bucket is the first day of the quarter and the year + bucket is January 1st. avg of a BIGINT is NUMERIC (canonical decimal string). +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'P1', 1000, 'PUBLISHED', '2025-12-31T23:59:59'), + (2, 'P2', 2000, 'PUBLISHED', '2026-01-01T00:00:00'), + (3, 'P3', 4000, 'PUBLISHED', '2026-01-01T12:00:00'), + (4, 'P4', 3000, 'PUBLISHED', '2026-03-31T23:00:00'), + (5, 'P5', 5000, 'PUBLISHED', '2026-04-01T00:00:00'); +queries: + - name: day-quarter-year + op: list + entity: ProgramsOverTime + sort: [{ field: createdAtDay, dir: asc }] + expect: + - { createdAtDay: "2025-12-31", createdAtQuarter: "2025-10-01", createdAtYear: "2025-01-01", + programs: "1", avgPrice: "1000", cheapest: "1000", priciest: "1000" } + - { createdAtDay: "2026-01-01", createdAtQuarter: "2026-01-01", createdAtYear: "2026-01-01", + programs: "2", avgPrice: "3000", cheapest: "2000", priciest: "4000" } + - { createdAtDay: "2026-03-31", createdAtQuarter: "2026-01-01", createdAtYear: "2026-01-01", + programs: "1", avgPrice: "3000", cheapest: "3000", priciest: "3000" } + - { createdAtDay: "2026-04-01", createdAtQuarter: "2026-04-01", createdAtYear: "2026-01-01", + programs: "1", avgPrice: "5000", cheapest: "5000", priciest: "5000" } + - name: filter-on-a-quarter-bucket + op: list + entity: ProgramsOverTime + filter: { createdAtQuarter: { eq: "2026-01-01" } } + sort: [{ field: createdAtDay, dir: asc }] + expect: + - { createdAtDay: "2026-01-01", createdAtQuarter: "2026-01-01", createdAtYear: "2026-01-01", + programs: "2", avgPrice: "3000", cheapest: "2000", priciest: "4000" } + - { createdAtDay: "2026-03-31", createdAtQuarter: "2026-01-01", createdAtYear: "2026-01-01", + programs: "1", avgPrice: "3000", cheapest: "3000", priciest: "3000" } + - name: count-groups + op: count + entity: ProgramsOverTime + expect: 4 diff --git a/fixtures/persistence-conformance/queries/report-time-min-max.yaml b/fixtures/persistence-conformance/queries/report-time-min-max.yaml new file mode 100644 index 000000000..12b7f4b44 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-time-min-max.yaml @@ -0,0 +1,35 @@ +name: report-time-min-max +description: | + AssetDays buckets Asset.recordedAt by day (an instant, bucketed in UTC) and + Asset.asOfDate by month (a date), and aggregates min/max over a TIMESTAMPTZ, a naive + TIMESTAMP and a DATE, plus a distinct count of a uuid. + + a1 owner A recorded 2026-05-04T03:30Z observed 03:30 asOf 2026-05-03 + a2 owner B recorded 2026-05-04T22:10Z observed 22:10 asOf 2026-05-20 + a3 owner B recorded 2026-05-04T23:30Z observed 23:30 asOf 2026-06-01 + a4 owner A recorded 2026-05-05T00:15Z observed 00:15 asOf 2026-04-30 + a1 and a2 share (2026-05-04, 2026-05-01) with two distinct owners. +seed-data: | + INSERT INTO "assets" + ("id","ownerId","externalId","payload","recordedAt","observedAt","asOfDate","atTime") + VALUES + ('11111111-1111-4111-8111-111111111111', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + NULL, NULL, '2026-05-04T03:30:00.000Z', '2026-05-04 03:30:00', '2026-05-03', '03:30:00'), + ('22222222-2222-4222-8222-222222222222', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + NULL, NULL, '2026-05-04T22:10:00.000Z', '2026-05-04 22:10:00', '2026-05-20', '22:10:00'), + ('33333333-3333-4333-8333-333333333333', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + NULL, NULL, '2026-05-04T23:30:00.000Z', '2026-05-04 23:30:00', '2026-06-01', '23:30:00'), + ('44444444-4444-4444-8444-444444444444', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + NULL, NULL, '2026-05-05T00:15:00.000Z', '2026-05-05 00:15:00', '2026-04-30', '00:15:00'); +queries: + - name: min-max-over-temporal-columns + op: list + entity: AssetDays + sort: [{ field: recordedAtDay, dir: asc }, { field: asOfDateMonth, dir: asc }] + expect: + - { recordedAtDay: "2026-05-04", asOfDateMonth: "2026-05-01", assets: "2", owners: "2", + firstRecorded: "2026-05-04T03:30:00Z", lastObserved: "2026-05-04T22:10:00", lastAsOf: "2026-05-20" } + - { recordedAtDay: "2026-05-04", asOfDateMonth: "2026-06-01", assets: "1", owners: "1", + firstRecorded: "2026-05-04T23:30:00Z", lastObserved: "2026-05-04T23:30:00", lastAsOf: "2026-06-01" } + - { recordedAtDay: "2026-05-05", asOfDateMonth: "2026-04-01", assets: "1", owners: "1", + firstRecorded: "2026-05-05T00:15:00Z", lastObserved: "2026-05-05T00:15:00", lastAsOf: "2026-04-30" } diff --git a/fixtures/persistence-conformance/queries/report-tph-base-filter.yaml b/fixtures/persistence-conformance/queries/report-tph-base-filter.yaml new file mode 100644 index 000000000..7085fd46c --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-tph-base-filter.yaml @@ -0,0 +1,33 @@ +name: report-tph-base-filter +description: | + AuthsByKind is declared from the TPH base (Auth), the supported way to report on a + subset of subtypes: an @filter on the discriminator (`type <> 'PriorAuth'`). A row whose + type is null is dropped by the comparison, as in any SQL. + + 1 Bridge A-1 2 Bridge A-1 3 Copay C-1 + 4 PriorAuth P-1 5 Referral R-1 6 Referral R-2 7 (no type) N-1 + Bridge: 2 rows, 1 distinct reference. Copay: 1, 1. Referral: 2, 2. +seed-data: | + INSERT INTO "auths" ("id","type","reference","quantity","urgency") VALUES + (1, 'Bridge', 'A-1', 2, NULL), + (2, 'Bridge', 'A-1', 3, NULL), + (3, 'Copay', 'C-1', NULL, NULL), + (4, 'PriorAuth', 'P-1', NULL, NULL), + (5, 'Referral', 'R-1', NULL, 'routine'), + (6, 'Referral', 'R-2', NULL, 'urgent'), + (7, NULL, 'N-1', NULL, NULL); +queries: + - name: discriminator-filter-and-distinct-count + op: list + entity: AuthsByKind + sort: [{ field: kind, dir: asc }] + expect: + - { kind: "Bridge", auths: "2", distinctRefs: "1" } + - { kind: "Copay", auths: "1", distinctRefs: "1" } + - { kind: "Referral", auths: "2", distinctRefs: "2" } + - name: filter-on-the-discriminator-dimension + op: list + entity: AuthsByKind + filter: { kind: { eq: "Referral" } } + expect: + - { kind: "Referral", auths: "2", distinctRefs: "2" } diff --git a/fixtures/persistence-conformance/report-shapes.json b/fixtures/persistence-conformance/report-shapes.json index bb2cdf3d2..ee239f73d 100644 --- a/fixtures/persistence-conformance/report-shapes.json +++ b/fixtures/persistence-conformance/report-shapes.json @@ -321,6 +321,538 @@ "typeSource": null } ] + }, + { + "report": "fitness::MeasurementStats", + "from": "fitness::Measurement", + "view": "v_measurement_stats", + "fields": [ + { + "name": "readings", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "totalMassKg", + "role": "measure", + "subType": "double", + "required": false, + "typeSource": null + }, + { + "name": "avgMassKg", + "role": "measure", + "subType": "double", + "required": false, + "typeSource": null + }, + { + "name": "maxTempC", + "role": "measure", + "subType": "float", + "required": false, + "typeSource": "fitness::Measurement.tempC" + }, + { + "name": "minTempC", + "role": "measure", + "subType": "float", + "required": false, + "typeSource": "fitness::Measurement.tempC" + }, + { + "name": "totalPreciseKg", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": null + }, + { + "name": "avgPreciseKg", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": null + }, + { + "name": "maxPreciseKg", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": "fitness::Measurement.preciseKg" + }, + { + "name": "heavyReadings", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] + }, + { + "report": "fitness::MeasurementStatsFilled", + "from": "fitness::Measurement", + "view": "v_measurement_stats_filled", + "fields": [ + { + "name": "readings", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "avgPreciseKgOrMinusOne", + "role": "measure", + "subType": "decimal", + "required": true, + "typeSource": null + }, + { + "name": "maxPreciseKgOrZero", + "role": "measure", + "subType": "decimal", + "required": true, + "typeSource": "fitness::Measurement.preciseKg" + } + ] + }, + { + "report": "fitness::AssetDays", + "from": "fitness::Asset", + "view": "v_asset_days", + "fields": [ + { + "name": "recordedAtDay", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "asOfDateMonth", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "assets", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "owners", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "firstRecorded", + "role": "measure", + "subType": "timestamp", + "required": false, + "typeSource": "fitness::Asset.recordedAt" + }, + { + "name": "lastObserved", + "role": "measure", + "subType": "timestamp", + "required": false, + "typeSource": "fitness::Asset.observedAt" + }, + { + "name": "lastAsOf", + "role": "measure", + "subType": "date", + "required": false, + "typeSource": "fitness::Asset.asOfDate" + } + ] + }, + { + "report": "fitness::AllTypesBreakdown", + "from": "fitness::AllTypes", + "view": "v_all_types_breakdown", + "fields": [ + { + "name": "flag", + "role": "dimension", + "subType": "boolean", + "required": true, + "typeSource": "fitness::AllTypes.bVal" + }, + { + "name": "level", + "role": "dimension", + "subType": "enum", + "required": true, + "typeSource": "fitness::AllTypes.enumVal" + }, + { + "name": "legacyStatus", + "role": "dimension", + "subType": "enum", + "required": false, + "typeSource": "fitness::AllTypes.intEnumVal" + }, + { + "name": "dateKey", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": "fitness::AllTypes.dateVal" + }, + { + "name": "uuidKey", + "role": "dimension", + "subType": "uuid", + "required": true, + "typeSource": "fitness::AllTypes.uuidVal" + }, + { + "name": "allRows", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "totalInts", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": null + }, + { + "name": "avgDec", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": null + }, + { + "name": "maxMoney", + "role": "measure", + "subType": "currency", + "required": false, + "typeSource": "fitness::AllTypes.moneyVal" + }, + { + "name": "minLong", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": "fitness::AllTypes.lVal" + }, + { + "name": "highRows", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] + }, + { + "report": "fitness::ProgramsOverTime", + "from": "fitness::Program", + "view": "v_programs_over_time", + "fields": [ + { + "name": "createdAtDay", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "createdAtQuarter", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "createdAtYear", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "programs", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "avgPrice", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": null + }, + { + "name": "cheapest", + "role": "measure", + "subType": "currency", + "required": false, + "typeSource": "fitness::Program.priceCents" + }, + { + "name": "priciest", + "role": "measure", + "subType": "currency", + "required": false, + "typeSource": "fitness::Program.priceCents" + } + ] + }, + { + "report": "fitness::LiveIntroPrograms", + "from": "fitness::Program", + "view": "v_live_intro_programs", + "fields": [ + { + "name": "status", + "role": "dimension", + "subType": "enum", + "required": true, + "typeSource": "fitness::Program.status" + }, + { + "name": "programs", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "notDrafts", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "listValue", + "role": "measure", + "subType": "currency", + "required": false, + "typeSource": "fitness::Program.priceCents" + } + ] + }, + { + "report": "fitness::ProgramsEmptyScope", + "from": "fitness::Program", + "view": "v_programs_empty_scope", + "fields": [ + { + "name": "programs", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "avgPriceOrMinusOne", + "role": "measure", + "subType": "decimal", + "required": true, + "typeSource": null + } + ] + }, + { + "report": "fitness::WeekShapes", + "from": "fitness::Week", + "view": "v_week_shapes", + "fields": [ + { + "name": "programTitle", + "role": "dimension", + "subType": "string", + "required": false, + "typeSource": "fitness::Program.title" + }, + { + "name": "weeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "unlabelled", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "labelledMinutes", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": null + }, + { + "name": "shortWeeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "midWeeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "longWMinutes", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": null + } + ] + }, + { + "report": "fitness::SessionsByProgram", + "from": "fitness::Session", + "view": "v_sessions_by_program", + "fields": [ + { + "name": "programTitle", + "role": "dimension", + "subType": "string", + "required": false, + "typeSource": "fitness::Program.title" + }, + { + "name": "weekKey", + "role": "dimension", + "subType": "long", + "required": false, + "typeSource": "fitness::Week.id" + }, + { + "name": "sessions", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "sessionMinutes", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": null + } + ] + }, + { + "report": "fitness::ProgramSessions", + "from": "fitness::Session", + "view": "v_program_sessions", + "fields": [ + { + "name": "programKey", + "role": "dimension", + "subType": "long", + "required": true, + "typeSource": "fitness::Program.id" + }, + { + "name": "programTitle", + "role": "dimension", + "subType": "string", + "required": true, + "typeSource": "fitness::Program.title" + }, + { + "name": "sessions", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "sessionMinutes", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": null + }, + { + "name": "sessionMinutesOrZero", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] + }, + { + "report": "fitness::NodesByParent", + "from": "fitness::Node", + "view": "v_nodes_by_parent", + "fields": [ + { + "name": "parentLabel", + "role": "dimension", + "subType": "string", + "required": false, + "typeSource": "fitness::Node.label" + }, + { + "name": "nodes", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] + }, + { + "report": "fitness::AuthsByKind", + "from": "fitness::Auth", + "view": "v_auths_by_kind", + "fields": [ + { + "name": "kind", + "role": "dimension", + "subType": "enum", + "required": false, + "typeSource": "fitness::Auth.type" + }, + { + "name": "auths", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "distinctRefs", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] } ] } diff --git a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs index 8325875b6..24d7d12f1 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs @@ -146,15 +146,19 @@ 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 — the corpus's nine view-backed reports each generate 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. The last three carry @spine / a measure @default. + // FR-044 — every view-backed report of the corpus (twenty-one 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 + // @spine / a measure @default; the rest reach the numeric, temporal, boolean/uuid/ + // int-enum, multi-hop, self-reference and empty-scope vocabulary the first nine do not. foreach (var report in new[] { "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity", "ProgramRoster", "ProgramLongWeeks", "FitnessTotalsFilled", + "MeasurementStats", "MeasurementStatsFilled", "AssetDays", "AllTypesBreakdown", "ProgramsOverTime", "LiveIntroPrograms", + "ProgramsEmptyScope", "WeekShapes", "SessionsByProgram", "ProgramSessions", "NodesByParent", "AuthsByKind", }) { Assert.True(files.Any(f => f.Path == report + ".g.cs"), $"no row class was generated for report {report}"); diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs index 385aec4b8..2111299e2 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs @@ -48,13 +48,15 @@ public void Derived_shapes_byte_match_the_committed_artifact() } [Fact] - public void The_artifact_covers_the_nine_canonical_reports() + public void The_artifact_covers_the_canonical_reports() { // Else the byte comparison could pass over an empty list on both sides. 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", "FitnessTotalsFilled", + "MeasurementStats", "MeasurementStatsFilled", "AssetDays", "AllTypesBreakdown", "ProgramsOverTime", "LiveIntroPrograms", + "ProgramsEmptyScope", "WeekShapes", "SessionsByProgram", "ProgramSessions", "NodesByParent", "AuthsByKind"], reports); } diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs index 881f2a23a..f99cea627 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs @@ -2,17 +2,18 @@ // corpus. // // Runs the real MetaObjects.Codegen generators (Entity + DbContext + FilterAllowlist -// + Routes + Names) on the report model (the Invoice, Product and Sale tables, four -// view-backed reports — one of them, ProductRevenue, a @spine report with a defaulted -// measure — and one sourceless report), Roslyn-compiles the emitted sources in-memory, and -// hosts them on Kestrel against Testcontainers Postgres (or, for StartSqliteAsync / -// StartMySqlAsync, a SQLite file / a MySQL database) with the four views present. A -// failing scenario is a real generator bug (RoutesGenerator / DbContextGenerator / -// FilterAllowlistGenerator / ReportRows), never something fixed by hand-editing emitted code. +// + Routes + Names) on the report model (its base tables and view-backed reports — one of +// them, ProductRevenue, a @spine report with a defaulted measure — and one sourceless +// report), Roslyn-compiles the emitted sources in-memory, and hosts them on Kestrel against +// Testcontainers Postgres (or, for StartSqliteAsync / StartMySqlAsync, a SQLite file / a +// MySQL database) with the views present. The exact sets are `ExpectedRoutedNames` +// and `SourcelessReport` below, not this prose. A failing scenario is a real generator bug +// (RoutesGenerator / DbContextGenerator / FilterAllowlistGenerator / ReportRows), never +// something fixed by hand-editing emitted code. // // Mirrors ProjectionGeneratedServerFactory. The routes to mount are chosen by -// RoutesGenerator.AppliesTo over the DECLARED nodes, and must be exactly the three entities -// and the four served reports: the sourceless InvoiceDays sits in the model so that a +// RoutesGenerator.AppliesTo over the DECLARED nodes, and must be exactly the entities +// and served reports listed below: the sourceless InvoiceDays sits in the model so that a // generator which serves every report it finds fails here. // // No JSON options are configured on the host. The date wire format under test @@ -41,7 +42,8 @@ internal sealed class ReportGeneratedServerFactory : IAsyncDisposable // Ordinal order. InvoiceDays is in the model and declares no source. private static readonly string[] ExpectedRoutedNames = - ["Invoice", "InvoiceStatusTotals", "InvoiceTotals", "InvoicesByMonth", "Product", "ProductRevenue", "Sale"]; + ["Category", "Invoice", "InvoiceCalendar", "InvoiceDisputes", "InvoiceStatusTotals", "InvoiceTotals", + "InvoicesByMonth", "Product", "ProductRevenue", "Sale", "SalesByCategory", "SalesBySku"]; private const string SourcelessReport = "InvoiceDays"; private readonly ScenarioEngine _engine; @@ -93,8 +95,8 @@ private static async Task StartAsync(ScenarioEngin var app = builder.Build(); - // Mount every generated MapRoutes(app, "/api") — the three entities' writable - // sets and the four served reports' read-only ones. The entities are mounted so the + // Mount every generated MapRoutes(app, "/api") — the base entities' writable + // sets and the served reports' read-only ones. The entities are mounted so the // lane also proves the two coexist; only the reports' routes carry scenarios. foreach (var name in routedNames) { @@ -127,9 +129,9 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen string.Join("; ", loadResult.Errors.Select(e => e.ToString()))); var root = loadResult.Root; - // Asked of the DECLARED nodes: the three entities and the four served reports, and not - // the sourceless InvoiceDays. Exactly these, so a generator that serves every report it - // finds (or none) fails here by name instead of as a run of unexplained 404s. + // Asked of the DECLARED nodes: exactly `ExpectedRoutedNames` — the base entities and + // the served reports, and not the sourceless InvoiceDays. So a generator that serves + // every report it finds (or none) fails here by name instead of as a run of 404s. var routedNames = root.Objects() .Where(o => RoutesGenerator.AppliesTo(o, root)) .Select(o => CSharpNaming.Pascal(o.Name)) diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypesBreakdown.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypesBreakdown.g.cs new file mode 100644 index 000000000..3e2b4c241 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypesBreakdown.g.cs @@ -0,0 +1,37 @@ +// +// 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 AllTypesBreakdown +{ + public enum AllTypesBreakdownLevel { LOW, MEDIUM, HIGH } + public enum AllTypesBreakdownLegacyStatus { DRAFT, PUBLISHED, ARCHIVED } + [Column("flag")] + public bool Flag { get; set; } + [Column("level")] + public AllTypesBreakdownLevel Level { get; set; } + [Column("legacyStatus")] + public AllTypesBreakdownLegacyStatus? LegacyStatus { get; set; } + [Column("dateKey")] + public DateOnly DateKey { get; set; } + [Column("uuidKey")] + public Guid UuidKey { get; set; } + [Column("allRows")] + public long AllRows { get; set; } + [Column("totalInts")] + public long? TotalInts { get; set; } + [Column("avgDec")] + public decimal? AvgDec { get; set; } + [Column("maxMoney")] + public long? MaxMoney { get; set; } + [Column("minLong")] + public long? MinLong { get; set; } + [Column("highRows")] + public long HighRows { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs index 5acfa3fec..1a37e5134 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs @@ -10,15 +10,22 @@ public class AppDbContext : DbContext public AppDbContext(DbContextOptions options) : base(options) { } public DbSet AllTypes { get; set; } = default!; + public DbSet AllTypesBreakdowns { get; set; } = default!; public DbSet Assets { get; set; } = default!; public DbSet AssetActivities { get; set; } = default!; + public DbSet AssetDays { get; set; } = default!; public DbSet Auths { get; set; } = default!; + public DbSet AuthsByKinds { get; set; } = default!; public DbSet FitnessTotals { get; set; } = default!; public DbSet FitnessTotalsFilleds { get; set; } = default!; public DbSet Follows { get; set; } = default!; public DbSet Friendships { get; set; } = default!; + public DbSet LiveIntroPrograms { get; set; } = default!; public DbSet Measurements { get; set; } = default!; + public DbSet MeasurementStats { get; set; } = default!; + public DbSet MeasurementStatsFilleds { get; set; } = default!; public DbSet Nodes { get; set; } = default!; + public DbSet NodesByParents { get; set; } = default!; public DbSet Persons { get; set; } = default!; public DbSet Posts { get; set; } = default!; public DbSet PostReferrals { get; set; } = default!; @@ -27,29 +34,51 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet ProgramLongWeeks { get; set; } = default!; public DbSet ProgramMinutes { get; set; } = default!; public DbSet ProgramRosters { get; set; } = default!; + public DbSet ProgramSessions { get; set; } = default!; public DbSet ProgramStats { get; set; } = default!; public DbSet ProgramViews { get; set; } = default!; public DbSet ProgramsByMonths { get; set; } = default!; public DbSet ProgramsByWeeks { get; set; } = default!; + public DbSet ProgramsEmptyScopes { get; set; } = default!; + public DbSet ProgramsOverTimes { get; set; } = default!; public DbSet RecentPrograms { get; set; } = default!; + public DbSet Sessions { get; set; } = default!; + public DbSet SessionsByPrograms { get; set; } = default!; public DbSet Tags { get; set; } = default!; public DbSet Weeks { get; set; } = default!; + public DbSet WeekShapes { get; set; } = default!; protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasNoKey().ToView("v_all_types_breakdown"); + modelBuilder.Entity().Property(x => x.Level).HasConversion(); + modelBuilder.Entity().Property(x => x.LegacyStatus).HasConversion(v => v == AllTypesBreakdown.AllTypesBreakdownLegacyStatus.DRAFT ? 0 : v == AllTypesBreakdown.AllTypesBreakdownLegacyStatus.PUBLISHED ? 5 : 9, v => v == 0 ? AllTypesBreakdown.AllTypesBreakdownLegacyStatus.DRAFT : v == 5 ? AllTypesBreakdown.AllTypesBreakdownLegacyStatus.PUBLISHED : v == 9 ? AllTypesBreakdown.AllTypesBreakdownLegacyStatus.ARCHIVED : UnmappedEnumValue(v, "legacyStatus")); modelBuilder.Entity().HasNoKey().ToView("v_asset_activity"); + modelBuilder.Entity().HasNoKey().ToView("v_asset_days"); + modelBuilder.Entity().HasNoKey().ToView("v_auths_by_kind"); + modelBuilder.Entity().Property(x => x.Kind).HasConversion(); modelBuilder.Entity().HasNoKey().ToView("v_fitness_totals"); modelBuilder.Entity().HasNoKey().ToView("v_fitness_totals_filled"); + modelBuilder.Entity().HasNoKey().ToView("v_live_intro_programs"); + modelBuilder.Entity().Property(x => x.Status).HasConversion(); + 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_long_weeks"); modelBuilder.Entity().HasNoKey().ToView("v_program_minutes"); modelBuilder.Entity().HasNoKey().ToView("v_program_roster"); + modelBuilder.Entity().HasNoKey().ToView("v_program_sessions"); modelBuilder.Entity().ToView(ProgramStatNames.SourcePrimaryView); modelBuilder.Entity().ToView(ProgramViewNames.SourcePrimaryView); modelBuilder.Entity().Property(x => x.Status).HasConversion(); modelBuilder.Entity().HasNoKey().ToView("v_programs_by_month"); modelBuilder.Entity().Property(x => x.Status).HasConversion(); modelBuilder.Entity().HasNoKey().ToView("v_programs_by_week"); + modelBuilder.Entity().HasNoKey().ToView("v_programs_empty_scope"); + modelBuilder.Entity().HasNoKey().ToView("v_programs_over_time"); modelBuilder.Entity().HasNoKey().ToView("v_recent_programs"); + modelBuilder.Entity().HasNoKey().ToView("v_sessions_by_program"); + modelBuilder.Entity().HasNoKey().ToView("v_week_shapes"); modelBuilder.Entity().OwnsOne(x => x.Settings, b => b.ToJson(AllTypesNames.SettingsColumn)); modelBuilder.Entity().OwnsMany(x => x.Labels, b => b.ToJson(AllTypesNames.LabelsColumn)); modelBuilder.Entity().Property(x => x.EnumVal).HasConversion(); @@ -76,6 +105,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasMany(x => x.Referrals).WithMany().UsingEntity(l => l.HasOne().WithMany().HasForeignKey(j => j.ReferralId), r => r.HasOne().WithMany().HasForeignKey(j => j.PostId)); modelBuilder.Entity().Property(x => x.Status).HasConversion(); modelBuilder.Entity().Property(x => x.CreatedAt).HasColumnType("timestamp without time zone"); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.WeekId).OnDelete(DeleteBehavior.NoAction); modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.ProgramId).OnDelete(DeleteBehavior.Cascade); } diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AssetDays.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AssetDays.g.cs new file mode 100644 index 000000000..bc57703c4 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AssetDays.g.cs @@ -0,0 +1,27 @@ +// +// 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 AssetDays +{ + [Column("recordedAtDay")] + public DateOnly RecordedAtDay { get; set; } + [Column("asOfDateMonth")] + public DateOnly AsOfDateMonth { get; set; } + [Column("assets")] + public long Assets { get; set; } + [Column("owners")] + public long Owners { get; set; } + [Column("firstRecorded")] + public DateTimeOffset? FirstRecorded { get; set; } + [Column("lastObserved")] + public DateTime? LastObserved { get; set; } + [Column("lastAsOf")] + public DateOnly? LastAsOf { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AuthsByKind.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AuthsByKind.g.cs new file mode 100644 index 000000000..b232b1d9e --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AuthsByKind.g.cs @@ -0,0 +1,20 @@ +// +// 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 AuthsByKind +{ + public enum AuthsByKindKind { Bridge, Copay, PriorAuth, Referral } + [Column("kind")] + public AuthsByKindKind? Kind { get; set; } + [Column("auths")] + public long Auths { get; set; } + [Column("distinctRefs")] + public long DistinctRefs { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/LiveIntroPrograms.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/LiveIntroPrograms.g.cs new file mode 100644 index 000000000..4d27accc4 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/LiveIntroPrograms.g.cs @@ -0,0 +1,22 @@ +// +// 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 LiveIntroPrograms +{ + public enum LiveIntroProgramsStatus { DRAFT, PUBLISHED, ARCHIVED } + [Column("status")] + public LiveIntroProgramsStatus Status { get; set; } + [Column("programs")] + public long Programs { get; set; } + [Column("notDrafts")] + public long NotDrafts { get; set; } + [Column("listValue")] + public long? ListValue { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/MeasurementStats.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/MeasurementStats.g.cs new file mode 100644 index 000000000..fe138355a --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/MeasurementStats.g.cs @@ -0,0 +1,31 @@ +// +// 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 MeasurementStats +{ + [Column("readings")] + public long Readings { get; set; } + [Column("totalMassKg")] + public double? TotalMassKg { get; set; } + [Column("avgMassKg")] + public double? AvgMassKg { get; set; } + [Column("maxTempC")] + public float? MaxTempC { get; set; } + [Column("minTempC")] + public float? MinTempC { get; set; } + [Column("totalPreciseKg")] + public decimal? TotalPreciseKg { get; set; } + [Column("avgPreciseKg")] + public decimal? AvgPreciseKg { get; set; } + [Column("maxPreciseKg")] + public decimal? MaxPreciseKg { get; set; } + [Column("heavyReadings")] + public long HeavyReadings { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/MeasurementStatsFilled.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/MeasurementStatsFilled.g.cs new file mode 100644 index 000000000..d94e29b25 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/MeasurementStatsFilled.g.cs @@ -0,0 +1,19 @@ +// +// 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 MeasurementStatsFilled +{ + [Column("readings")] + public long Readings { get; set; } + [Column("avgPreciseKgOrMinusOne")] + public decimal AvgPreciseKgOrMinusOne { get; set; } + [Column("maxPreciseKgOrZero")] + public decimal MaxPreciseKgOrZero { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/NodesByParent.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/NodesByParent.g.cs new file mode 100644 index 000000000..e89f09a23 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/NodesByParent.g.cs @@ -0,0 +1,18 @@ +// +// 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 NodesByParent +{ + [Column("parentLabel")] + [MaxLength(80)] + public string? ParentLabel { get; set; } + [Column("nodes")] + public long Nodes { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramSessions.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramSessions.g.cs new file mode 100644 index 000000000..3270f5311 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramSessions.g.cs @@ -0,0 +1,26 @@ +// +// 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 ProgramSessions +{ + [Column("programKey")] + public long ProgramKey { get; set; } + [Column("programTitle")] + [Required(AllowEmptyStrings = true)] + [MaxLength(200)] + [MinLength(1)] + public string ProgramTitle { get; set; } = default!; + [Column("sessions")] + public long Sessions { get; set; } + [Column("sessionMinutes")] + public long? SessionMinutes { get; set; } + [Column("sessionMinutesOrZero")] + public long SessionMinutesOrZero { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsEmptyScope.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsEmptyScope.g.cs new file mode 100644 index 000000000..cf7368c83 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsEmptyScope.g.cs @@ -0,0 +1,17 @@ +// +// 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 ProgramsEmptyScope +{ + [Column("programs")] + public long Programs { get; set; } + [Column("avgPriceOrMinusOne")] + public decimal AvgPriceOrMinusOne { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsOverTime.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsOverTime.g.cs new file mode 100644 index 000000000..d26cdacec --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsOverTime.g.cs @@ -0,0 +1,27 @@ +// +// 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 ProgramsOverTime +{ + [Column("createdAtDay")] + public DateOnly CreatedAtDay { get; set; } + [Column("createdAtQuarter")] + public DateOnly CreatedAtQuarter { get; set; } + [Column("createdAtYear")] + public DateOnly CreatedAtYear { get; set; } + [Column("programs")] + public long Programs { get; set; } + [Column("avgPrice")] + public decimal? AvgPrice { get; set; } + [Column("cheapest")] + public long? Cheapest { get; set; } + [Column("priciest")] + public long? Priciest { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/Session.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/Session.g.cs new file mode 100644 index 000000000..26e7d1d1e --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/Session.g.cs @@ -0,0 +1,21 @@ +// +// 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; + +[Table(SessionNames.SourcePrimaryTable)] +public class Session +{ + [Key] + [Column(SessionNames.IdColumn)] + public long Id { get; set; } + [Column(SessionNames.WeekIdColumn)] + public long WeekId { get; set; } + [Column(SessionNames.MinutesColumn)] + public int Minutes { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/SessionNames.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/SessionNames.g.cs new file mode 100644 index 000000000..76280bac2 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/SessionNames.g.cs @@ -0,0 +1,42 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace MetaObjects.IntegrationTests.Generated; + +/// +/// GENERATED — per-object physical database names for Session (spec A1/A2/A6). +/// +public abstract class SessionNames +{ + public const string Type = "object"; + public const string SubType = "entity"; + public const string Name = "Session"; + + public const string SourcePrimaryType = "source"; + public const string SourcePrimarySubType = "rdb"; + public const string SourcePrimaryKind = "table"; + public const string SourcePrimaryTable = "sessions"; + + public const string IdField = "id"; + public const string IdColumn = "id"; + public const string MinutesField = "minutes"; + public const string MinutesColumn = "minutes"; + public const string WeekIdField = "weekId"; + public const string WeekIdColumn = "weekId"; + + public const string IdentityFkWeekType = "identity"; + public const string IdentityFkWeekSubType = "reference"; + public const string IdentityFkWeekName = "fkWeek"; + public const string IdentityIdType = "identity"; + public const string IdentityIdSubType = "primary"; + public const string IdentityIdName = "id"; + + public static readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["minutes"] = MinutesColumn, + ["weekId"] = WeekIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/SessionQueries.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/SessionQueries.g.cs new file mode 100644 index 000000000..23ada26b5 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/SessionQueries.g.cs @@ -0,0 +1,24 @@ +// +// Generated by MetaObjects entity-generator (reverse FK finders, ADR-0038). Do not edit by hand. +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace MetaObjects.IntegrationTests.Generated; + +/// Reverse-relationship FK finders for Session (ADR-0038): explicit, +/// single-query reads — no lazy navigation, no N+1. +public static class SessionQueries +{ + /// The Session rows whose WeekId equals (one indexed query). + public static async System.Threading.Tasks.Task> FindSessionsByWeek(AppDbContext db, long weekId) => + await db.Sessions.AsNoTracking().Where(e => e.WeekId == weekId).ToListAsync(); + + /// The Session rows whose WeekId is in (one batched IN query; anti-N+1). + public static async System.Threading.Tasks.Task> FindSessionsByWeekIn(AppDbContext db, IReadOnlyCollection weekIds) + { + if (weekIds.Count == 0) return new List(); + return await db.Sessions.AsNoTracking().Where(e => weekIds.Contains(e.WeekId)).ToListAsync(); + } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/SessionsByProgram.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/SessionsByProgram.g.cs new file mode 100644 index 000000000..257778100 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/SessionsByProgram.g.cs @@ -0,0 +1,22 @@ +// +// 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 SessionsByProgram +{ + [Column("programTitle")] + [MaxLength(200)] + public string? ProgramTitle { get; set; } + [Column("weekKey")] + public long? WeekKey { get; set; } + [Column("sessions")] + public long Sessions { get; set; } + [Column("sessionMinutes")] + public long? SessionMinutes { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekShapes.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekShapes.g.cs new file mode 100644 index 000000000..5ea76d960 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekShapes.g.cs @@ -0,0 +1,28 @@ +// +// 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 WeekShapes +{ + [Column("programTitle")] + [MaxLength(200)] + public string? ProgramTitle { get; set; } + [Column("weeks")] + public long Weeks { get; set; } + [Column("unlabelled")] + public long Unlabelled { get; set; } + [Column("labelledMinutes")] + public long? LabelledMinutes { get; set; } + [Column("shortWeeks")] + public long ShortWeeks { get; set; } + [Column("midWeeks")] + public long MidWeeks { get; set; } + [Column("longWMinutes")] + public long? LongWMinutes { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/DbContextAdapter.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/DbContextAdapter.cs index cb50ea596..955b7ce39 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Runner/DbContextAdapter.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Runner/DbContextAdapter.cs @@ -362,6 +362,10 @@ private static IQueryable ApplySort(IQueryable queryable, IReadOnlyList // not IConvertible, so Convert.ChangeType cannot handle it. if (underlying == typeof(Guid)) return Guid.Parse(raw.ToString()!); + // A field.date filter value (a report's date-grain dimension) arrives as an ISO calendar + // date string; DateOnly is not IConvertible either. Same parse the write codec uses. + if (underlying == typeof(DateOnly)) + return WriteCoercion.ParseDate(raw.ToString()!); return Convert.ChangeType(raw, underlying, System.Globalization.CultureInfo.InvariantCulture); } diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs index 2e0f63aa4..9caf1ccd3 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs @@ -87,7 +87,7 @@ public static PropertyInfo Property(Type entity, string fieldName) => if (underlying == typeof(double)) return double.Parse(raw, NumberStyles.Float, CultureInfo.InvariantCulture); if (underlying == typeof(float)) return float.Parse(raw, NumberStyles.Float, CultureInfo.InvariantCulture); if (underlying == typeof(decimal)) return decimal.Parse(raw, NumberStyles.Float, CultureInfo.InvariantCulture); - if (underlying == typeof(DateOnly)) return DateOnly.ParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture); + if (underlying == typeof(DateOnly)) return ParseDate(raw); if (underlying == typeof(TimeOnly)) return ParseTime(raw); if (underlying == typeof(DateTime)) return ParseDateTime(raw); // ADR-0036 Wave 2 — a default field.timestamp binds to DateTimeOffset (an absolute @@ -118,6 +118,14 @@ public static PropertyInfo Property(Type entity, string fieldName) => ?.GetGenericArguments()[0]; } + /// + /// The corpus authoring form of a `field.date`: an ISO calendar date. One parse, because + /// the write codec here and the filter-operand coercion in DbContextAdapter must answer + /// the SAME authoring string with the SAME DateOnly — restated, they can only drift. + /// + public static DateOnly ParseDate(string raw) => + DateOnly.ParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture); + private static bool ParseBool(string raw) => raw switch { "true" or "True" or "TRUE" => true, 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 fe60b14b7..2ec311da3 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 @@ -199,15 +199,18 @@ public void theSpringWebTierCompiles() throws Exception { selection.put("repository", PLAIN); selection.put("filter-allowlist", PLAIN); selection.put("names", PLAIN); - // FR-044 Plan 3: the corpus's nine view-backed reports are served, so their 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. The - // last three carry @spine / a measure @default. + // FR-044 Plan 3: every view-backed report of the corpus (twenty-one 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. // 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", + "MeasurementStatsDto", "MeasurementStatsFilledDto", "AssetDaysDto", "AllTypesBreakdownDto", "ProgramsOverTimeDto", "LiveIntroProgramsDto", + "ProgramsEmptyScopeDto", "WeekShapesDto", "SessionsByProgramDto", "ProgramSessionsDto", "NodesByParentDto", "AuthsByKindDto", "ProgramMinutesRepository", "ProgramMinutesFilterAllowlist"); } 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 3d7fef8e5..80537b780 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 @@ -1,17 +1,8 @@ package com.metaobjects.integration.kotlin import com.metaobjects.generator.kotlin.KotlinExposedTableGenerator -import com.metaobjects.integration.kotlin.tables.AssetActivityView -import com.metaobjects.integration.kotlin.tables.FitnessTotalsFilledView -import com.metaobjects.integration.kotlin.tables.FitnessTotalsView -import com.metaobjects.integration.kotlin.tables.ProgramLongWeeksView -import com.metaobjects.integration.kotlin.tables.ProgramMinutesView -import com.metaobjects.integration.kotlin.tables.ProgramRosterView -import com.metaobjects.integration.kotlin.tables.ProgramsByMonthView -import com.metaobjects.integration.kotlin.tables.ProgramsByWeekView -import com.metaobjects.integration.kotlin.tables.RecentProgramsView +import com.metaobjects.integration.kotlin.tables.ReportViews import com.metaobjects.loader.MetaDataLoader -import org.jetbrains.exposed.sql.Table import org.junit.jupiter.api.Test import java.io.File import java.nio.file.Files @@ -202,6 +193,132 @@ internal class KotlinCodegenMatchesReferenceTest { ), report = "v_fitness_totals_filled", ), + // 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). + "MeasurementStats" to EntityExpectation( + columns = listOf( + ExpectedColumn("readings", families = setOf("long"), nullable = false), + ExpectedColumn("totalMassKg", families = setOf("double"), nullable = true), + ExpectedColumn("avgMassKg", families = setOf("double"), nullable = true), + ExpectedColumn("maxTempC", families = setOf("float"), nullable = true), + ExpectedColumn("minTempC", families = setOf("float"), nullable = true), + ExpectedColumn("totalPreciseKg", families = setOf("decimal"), nullable = true), + ExpectedColumn("avgPreciseKg", families = setOf("decimal"), nullable = true), + ExpectedColumn("maxPreciseKg", families = setOf("decimal"), nullable = true), + ExpectedColumn("heavyReadings", families = setOf("long"), nullable = false), + ), + report = "v_measurement_stats", + ), + "MeasurementStatsFilled" to EntityExpectation( + columns = listOf( + ExpectedColumn("readings", families = setOf("long"), nullable = false), + ExpectedColumn("avgPreciseKgOrMinusOne", families = setOf("decimal"), nullable = false), + ExpectedColumn("maxPreciseKgOrZero", families = setOf("decimal"), nullable = false), + ), + report = "v_measurement_stats_filled", + ), + "AssetDays" to EntityExpectation( + columns = listOf( + ExpectedColumn("recordedAtDay", families = setOf("date"), nullable = false), + ExpectedColumn("asOfDateMonth", families = setOf("date"), nullable = false), + ExpectedColumn("assets", families = setOf("long"), nullable = false), + ExpectedColumn("owners", families = setOf("long"), nullable = false), + ExpectedColumn("firstRecorded", families = setOf("instantWithTimeZone"), nullable = true), + ExpectedColumn("lastObserved", families = setOf("datetime"), nullable = true), + ExpectedColumn("lastAsOf", families = setOf("date"), nullable = true), + ), + report = "v_asset_days", + ), + "AllTypesBreakdown" to EntityExpectation( + columns = listOf( + ExpectedColumn("flag", families = setOf("bool"), nullable = false), + ExpectedColumn("level", families = setOf("varchar", "enumerationByName"), nullable = false), + ExpectedColumn("legacyStatus", families = setOf("customEnumeration"), nullable = true), + ExpectedColumn("dateKey", families = setOf("date"), nullable = false), + ExpectedColumn("uuidKey", families = setOf("uuid"), nullable = false), + ExpectedColumn("allRows", families = setOf("long"), nullable = false), + ExpectedColumn("totalInts", families = setOf("long"), nullable = true), + ExpectedColumn("avgDec", families = setOf("decimal"), nullable = true), + ExpectedColumn("maxMoney", families = setOf("long"), nullable = true), + ExpectedColumn("minLong", families = setOf("long"), nullable = true), + ExpectedColumn("highRows", families = setOf("long"), nullable = false), + ), + report = "v_all_types_breakdown", + ), + "ProgramsOverTime" to EntityExpectation( + columns = listOf( + ExpectedColumn("createdAtDay", families = setOf("date"), nullable = false), + ExpectedColumn("createdAtQuarter", families = setOf("date"), nullable = false), + ExpectedColumn("createdAtYear", families = setOf("date"), nullable = false), + ExpectedColumn("programs", families = setOf("long"), nullable = false), + ExpectedColumn("avgPrice", families = setOf("decimal"), nullable = true), + ExpectedColumn("cheapest", families = setOf("long"), nullable = true), + ExpectedColumn("priciest", families = setOf("long"), nullable = true), + ), + report = "v_programs_over_time", + ), + "LiveIntroPrograms" to EntityExpectation( + columns = listOf( + ExpectedColumn("status", families = setOf("varchar", "enumerationByName"), nullable = false), + ExpectedColumn("programs", families = setOf("long"), nullable = false), + ExpectedColumn("notDrafts", families = setOf("long"), nullable = false), + ExpectedColumn("listValue", families = setOf("long"), nullable = true), + ), + report = "v_live_intro_programs", + ), + "ProgramsEmptyScope" to EntityExpectation( + columns = listOf( + ExpectedColumn("programs", families = setOf("long"), nullable = false), + ExpectedColumn("avgPriceOrMinusOne", families = setOf("decimal"), nullable = false), + ), + report = "v_programs_empty_scope", + ), + "WeekShapes" to EntityExpectation( + columns = listOf( + ExpectedColumn("programTitle", families = setOf("varchar"), nullable = true), + ExpectedColumn("weeks", families = setOf("long"), nullable = false), + ExpectedColumn("unlabelled", families = setOf("long"), nullable = false), + ExpectedColumn("labelledMinutes", families = setOf("long"), nullable = true), + ExpectedColumn("shortWeeks", families = setOf("long"), nullable = false), + ExpectedColumn("midWeeks", families = setOf("long"), nullable = false), + ExpectedColumn("longWMinutes", families = setOf("long"), nullable = true), + ), + report = "v_week_shapes", + ), + "SessionsByProgram" to EntityExpectation( + columns = listOf( + ExpectedColumn("programTitle", families = setOf("varchar"), nullable = true), + ExpectedColumn("weekKey", families = setOf("long"), nullable = true), + ExpectedColumn("sessions", families = setOf("long"), nullable = false), + ExpectedColumn("sessionMinutes", families = setOf("long"), nullable = true), + ), + report = "v_sessions_by_program", + ), + "ProgramSessions" to EntityExpectation( + columns = listOf( + ExpectedColumn("programKey", families = setOf("long"), nullable = false), + ExpectedColumn("programTitle", families = setOf("varchar"), nullable = false), + ExpectedColumn("sessions", families = setOf("long"), nullable = false), + ExpectedColumn("sessionMinutes", families = setOf("long"), nullable = true), + ExpectedColumn("sessionMinutesOrZero", families = setOf("long"), nullable = false), + ), + report = "v_program_sessions", + ), + "NodesByParent" to EntityExpectation( + columns = listOf( + ExpectedColumn("parentLabel", families = setOf("varchar"), nullable = true), + ExpectedColumn("nodes", families = setOf("long"), nullable = false), + ), + report = "v_nodes_by_parent", + ), + "AuthsByKind" to EntityExpectation( + columns = listOf( + ExpectedColumn("kind", families = setOf("varchar", "enumerationByName"), nullable = true), + ExpectedColumn("auths", families = setOf("long"), nullable = false), + ExpectedColumn("distinctRefs", families = setOf("long"), nullable = false), + ), + report = "v_auths_by_kind", + ), ) @Test @@ -237,17 +354,9 @@ internal class KotlinCodegenMatchesReferenceTest { */ @Test fun `the hand-written report reference tables match the same expectations`() { - val references: Map = mapOf( - "ProgramMinutes" to ProgramMinutesView, - "FitnessTotals" to FitnessTotalsView, - "ProgramsByMonth" to ProgramsByMonthView, - "ProgramsByWeek" to ProgramsByWeekView, - "RecentPrograms" to RecentProgramsView, - "AssetActivity" to AssetActivityView, - "ProgramRoster" to ProgramRosterView, - "ProgramLongWeeks" to ProgramLongWeeksView, - "FitnessTotalsFilled" to FitnessTotalsFilledView, - ) + // The SAME registry the persistence lane's `tableFor` consults — one name list, two + // consumers, so the lanes cannot silently cover different report sets. + val references = ReportViews.BY_NAME assertEquals(expectations.filterValues { it.report != null }.keys, references.keys) for ((report, table) in references) { val expected = expectations.getValue(report) diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt index a262ead8b..84e0510d9 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt @@ -16,19 +16,13 @@ import com.metaobjects.integration.kotlin.tables.ProgramStatView import com.metaobjects.integration.kotlin.tables.ProgramTable import com.metaobjects.integration.kotlin.tables.ProgramView import com.metaobjects.integration.kotlin.tables.WeekTable -import com.metaobjects.integration.kotlin.tables.AssetActivityView -import com.metaobjects.integration.kotlin.tables.FitnessTotalsFilledView -import com.metaobjects.integration.kotlin.tables.FitnessTotalsView -import com.metaobjects.integration.kotlin.tables.ProgramLongWeeksView -import com.metaobjects.integration.kotlin.tables.ProgramMinutesView -import com.metaobjects.integration.kotlin.tables.ProgramRosterView -import com.metaobjects.integration.kotlin.tables.ProgramsByMonthView -import com.metaobjects.integration.kotlin.tables.ProgramsByWeekView -import com.metaobjects.integration.kotlin.tables.RecentProgramsView +import com.metaobjects.integration.kotlin.tables.ReportViews +import com.metaobjects.integration.kotlin.tables.SessionTable import org.jetbrains.exposed.sql.AndOp import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.javatime.JavaLocalDateColumnType import org.jetbrains.exposed.sql.Query import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SortOrder @@ -522,31 +516,24 @@ object QueryScenarioRunner { root.getChildren(MetaObject::class.java, false).firstOrNull { it.shortName == name } ?: error("Entity '$name' not found in canonical metadata root") - private fun tableFor(entity: String): Table = when (entity) { - "Program" -> ProgramTable - "Week" -> WeekTable - "Node" -> NodeTable - "Measurement" -> MeasurementTable - "ProgramStat" -> ProgramStatView - "ProgramView" -> ProgramView - // FR-044: a view-backed report is read through the view its lowering created. It has - // no primary key, so only `list` and `count` reach these. - "ProgramMinutes" -> ProgramMinutesView - "FitnessTotals" -> FitnessTotalsView - "ProgramsByMonth" -> ProgramsByMonthView - "ProgramsByWeek" -> ProgramsByWeekView - "RecentPrograms" -> RecentProgramsView - "AssetActivity" -> AssetActivityView - // A @spine report (rows from the spine entity) and a report of defaulted measures. - "ProgramRoster" -> ProgramRosterView - "ProgramLongWeeks" -> ProgramLongWeeksView - "FitnessTotalsFilled" -> FitnessTotalsFilledView - "Asset" -> AssetTable - "AllTypes" -> AllTypesTable - // FR-017 TPH: the discriminator base + all its subtypes share the single `auths` table. - "Auth", "BridgeAuth", "CopayAuth", "PriorAuthAuth" -> AuthTable - else -> error("No Exposed Table registered for entity '$entity' — extend QueryScenarioRunner.tableFor") - } + private fun tableFor(entity: String): Table = + // FR-044: a view-backed report is read through the view its lowering created — from the + // same registry the codegen cross-check iterates, so the two lanes cannot name different + // sets. It has no primary key, so only `list` and `count` reach these. + ReportViews.BY_NAME[entity] ?: when (entity) { + "Program" -> ProgramTable + "Week" -> WeekTable + "Node" -> NodeTable + "Measurement" -> MeasurementTable + "ProgramStat" -> ProgramStatView + "ProgramView" -> ProgramView + "Session" -> SessionTable + "Asset" -> AssetTable + "AllTypes" -> AllTypesTable + // FR-017 TPH: the discriminator base + all its subtypes share the single `auths` table. + "Auth", "BridgeAuth", "CopayAuth", "PriorAuthAuth" -> AuthTable + else -> error("No Exposed Table registered for entity '$entity' — extend QueryScenarioRunner.tableFor or tables/ReportViews") + } // ----------------------------------------------------------------------- // Filter / by / sort @@ -693,6 +680,10 @@ object QueryScenarioRunner { type == "uuid" -> if (raw is UUID) raw else UUID.fromString(raw.toString().lowercase()) type.contains("bigint") || type.contains("int8") -> (raw as? Number)?.toLong() ?: raw.toString().toLong() type.contains("int") -> (raw as? Number)?.toInt() ?: raw.toString().toInt() + // A filter on a DATE column (a report's date dimension) supplies the day as text. + // Keyed off the column type too: SQLite spells a DATE column's SQL type as TEXT. + type == "date" || col.columnType is JavaLocalDateColumnType -> + if (raw is LocalDate) raw else LocalDate.parse(raw.toString()) else -> raw } } 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 d13047948..de04149a8 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 @@ -21,8 +21,8 @@ import java.util.stream.Stream /** * FR-044 Plan 3: the Kotlin GENERATED-controller lane for the view-backed-report - * api-contract sub-corpus. Drives the four generated read-only report controllers over an - * embedded Tomcat: GET list with the FR-009 filter and sort allowlists over the report's + * api-contract sub-corpus. Drives the corpus's served reports' generated read-only + * controllers over an embedded Tomcat: GET list with the FR-009 filter and sort allowlists over the report's * DERIVED fields, `POST` answering `405 {"error": "method_not_allowed"}`, and no `/{id}`. * * Generated lane ONLY, on purpose and on every port (see the sub-corpus README): what is @@ -46,9 +46,9 @@ internal class ReportGeneratedApiContractConformanceTest { } @Test - fun `the corpus has its sixteen scenarios`() { + fun `the corpus has its twenty-two scenarios`() { // A lane that silently ran fewer would still be green. - assertEquals(16, scenarios().count()) + assertEquals(22, 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 1c79b67bd..820d54ef4 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 @@ -163,10 +163,14 @@ class GeneratedReportControllerHarness( const val ENTITY_PKG = "acme.sales" /** - * The four reports of the corpus that declare a view. `InvoiceDays` declares none. - * `ProductRevenue` declares `@spine` and a measure with `@default`. + * The reports of the corpus that declare a view. `InvoiceDays` declares none. + * `ProductRevenue` declares `@spine` and a measure with `@default`; `InvoiceCalendar` lists + * one time dimension at two grains; `SalesByCategory` reaches a dimension through two references. */ - val SERVED = listOf("InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "ProductRevenue") + val SERVED = listOf( + "InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", + "ProductRevenue", "SalesByCategory", "SalesBySku", + ) private fun snakeCase(s: String): String = buildString { for (c in s) { diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesBreakdownView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesBreakdownView.kt new file mode 100644 index 000000000..065f74781 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesBreakdownView.kt @@ -0,0 +1,32 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.date + +/** + * Hand-written reference Exposed Table mapping the `AllTypesBreakdown` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_all_types_breakdown`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types; an int-backed enum dimension carries the member SYMBOL, as + * [AllTypesTable] does for the base column. + * + * 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 AllTypesBreakdownView : Table("v_all_types_breakdown") { + val flag = bool("flag") + val level = varchar("level", 64) + val legacyStatus = intBackedEnum("legacyStatus", INT_ENUM_MEMBERS).nullable() + val dateKey = date("dateKey") + val uuidKey = uuid("uuidKey") + val allRows = long("allRows") + val totalInts = long("totalInts").nullable() + val avgDec = decimal("avgDec", 38, 18).nullable() + val maxMoney = long("maxMoney").nullable() + val minLong = long("minLong").nullable() + val highRows = long("highRows") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesTable.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesTable.kt index 9b29270d4..763935979 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesTable.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AllTypesTable.kt @@ -70,8 +70,7 @@ object AllTypesTable : Table("all_types") { // wire format does not. Nullable to match the canonical DDL (`"intEnumVal" INTEGER`, no // NOT NULL). See [IntBackedEnumColumnType] for why this is a Column here while the // GENERATED form binds a real enum through customEnumeration. - val intEnumVal = - intBackedEnum("intEnumVal", mapOf("DRAFT" to 0, "PUBLISHED" to 5, "ARCHIVED" to 9)).nullable() + val intEnumVal = intBackedEnum("intEnumVal", INT_ENUM_MEMBERS).nullable() val uuidVal = uuid("uuidVal") // `field.uri` → plain `text` column carrying the verbatim URI string (Postgres has no uri // type). See [MetaUriColumnType] — round-trips the URI unchanged. diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetDaysView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetDaysView.kt new file mode 100644 index 000000000..17ecc6c38 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetDaysView.kt @@ -0,0 +1,28 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.date +import org.jetbrains.exposed.sql.javatime.datetime + +/** + * Hand-written reference Exposed Table mapping the `AssetDays` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_asset_days`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 AssetDaysView : Table("v_asset_days") { + val recordedAtDay = date("recordedAtDay") + val asOfDateMonth = date("asOfDateMonth") + val assets = long("assets") + val owners = long("owners") + val firstRecorded = instantWithTimeZone("firstRecorded").nullable() + val lastObserved = datetime("lastObserved").nullable() + val lastAsOf = date("lastAsOf").nullable() +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AuthsByKindView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AuthsByKindView.kt new file mode 100644 index 000000000..142c2e6b2 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AuthsByKindView.kt @@ -0,0 +1,22 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `AuthsByKind` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_auths_by_kind`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 AuthsByKindView : Table("v_auths_by_kind") { + val kind = varchar("kind", 64).nullable() + val auths = long("auths") + val distinctRefs = long("distinctRefs") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/IntBackedEnumColumnType.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/IntBackedEnumColumnType.kt index 2dd0d6d0a..6801af4f6 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/IntBackedEnumColumnType.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/IntBackedEnumColumnType.kt @@ -58,3 +58,10 @@ internal class IntBackedEnumColumnType( */ internal fun Table.intBackedEnum(name: String, intByMember: Map): Column = registerColumn(name, IntBackedEnumColumnType(intByMember)) + +/** + * The `@intValueMap` of the canonical fixture's one int-backed enum — `AllTypes.intEnumVal`, + * whose `legacyStatus` dimension reads through the same map. Stated once because both columns + * must carry the fixture's map exactly (`fixtures/persistence-conformance/canonical/meta.fitness.json`). + */ +internal val INT_ENUM_MEMBERS: Map = mapOf("DRAFT" to 0, "PUBLISHED" to 5, "ARCHIVED" to 9) diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/LiveIntroProgramsView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/LiveIntroProgramsView.kt new file mode 100644 index 000000000..76405cc59 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/LiveIntroProgramsView.kt @@ -0,0 +1,23 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `LiveIntroPrograms` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_live_intro_programs`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 LiveIntroProgramsView : Table("v_live_intro_programs") { + val status = varchar("status", 64) + val programs = long("programs") + val notDrafts = long("notDrafts") + val listValue = long("listValue").nullable() +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/MeasurementStatsFilledView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/MeasurementStatsFilledView.kt new file mode 100644 index 000000000..c54ae0e16 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/MeasurementStatsFilledView.kt @@ -0,0 +1,22 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `MeasurementStatsFilled` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_measurement_stats_filled`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 MeasurementStatsFilledView : Table("v_measurement_stats_filled") { + val readings = long("readings") + val avgPreciseKgOrMinusOne = decimal("avgPreciseKgOrMinusOne", 38, 18) + val maxPreciseKgOrZero = decimal("maxPreciseKgOrZero", 38, 18) +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/MeasurementStatsView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/MeasurementStatsView.kt new file mode 100644 index 000000000..ac0cecab9 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/MeasurementStatsView.kt @@ -0,0 +1,28 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `MeasurementStats` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_measurement_stats`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 MeasurementStatsView : Table("v_measurement_stats") { + val readings = long("readings") + val totalMassKg = double("totalMassKg").nullable() + val avgMassKg = double("avgMassKg").nullable() + val maxTempC = float("maxTempC").nullable() + val minTempC = float("minTempC").nullable() + val totalPreciseKg = decimal("totalPreciseKg", 38, 18).nullable() + val avgPreciseKg = decimal("avgPreciseKg", 38, 18).nullable() + val maxPreciseKg = decimal("maxPreciseKg", 38, 18).nullable() + val heavyReadings = long("heavyReadings") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/NodesByParentView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/NodesByParentView.kt new file mode 100644 index 000000000..e539177ac --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/NodesByParentView.kt @@ -0,0 +1,21 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `NodesByParent` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_nodes_by_parent`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 NodesByParentView : Table("v_nodes_by_parent") { + val parentLabel = varchar("parentLabel", 80).nullable() + val nodes = long("nodes") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramSessionsView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramSessionsView.kt new file mode 100644 index 000000000..946a03a78 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramSessionsView.kt @@ -0,0 +1,24 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `ProgramSessions` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_program_sessions`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 ProgramSessionsView : Table("v_program_sessions") { + val programKey = long("programKey") + val programTitle = varchar("programTitle", 200) + val sessions = long("sessions") + val sessionMinutes = long("sessionMinutes").nullable() + val sessionMinutesOrZero = long("sessionMinutesOrZero") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsEmptyScopeView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsEmptyScopeView.kt new file mode 100644 index 000000000..1ff8b1012 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsEmptyScopeView.kt @@ -0,0 +1,21 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `ProgramsEmptyScope` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_programs_empty_scope`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 ProgramsEmptyScopeView : Table("v_programs_empty_scope") { + val programs = long("programs") + val avgPriceOrMinusOne = decimal("avgPriceOrMinusOne", 38, 18) +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsOverTimeView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsOverTimeView.kt new file mode 100644 index 000000000..476d2d09c --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsOverTimeView.kt @@ -0,0 +1,27 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.date + +/** + * Hand-written reference Exposed Table mapping the `ProgramsOverTime` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_programs_over_time`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 ProgramsOverTimeView : Table("v_programs_over_time") { + val createdAtDay = date("createdAtDay") + val createdAtQuarter = date("createdAtQuarter") + val createdAtYear = date("createdAtYear") + val programs = long("programs") + val avgPrice = decimal("avgPrice", 38, 18).nullable() + val cheapest = long("cheapest").nullable() + val priciest = long("priciest").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 new file mode 100644 index 000000000..b84926a0c --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ReportViews.kt @@ -0,0 +1,43 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * ONE registry mapping each FR-044 report's name to the hand-written reference Exposed Table + * that reads its view. It lives in the tables package because both consumers must read the + * same set: the persistence lane resolves a scenario's entity to a table through it + * (`QueryScenarioRunner.tableFor`), and the codegen cross-check iterates it to hold every + * reference table to the same column expectations as the generator (`KotlinCodegenMatchesReferenceTest`). + * Restating the names in both leaves drift caught only by a runtime + * `else -> error(...)` in the OTHER lane's test. + * + * A report has no primary key, so only `list` and `count` reach these. + */ +internal object ReportViews { + val BY_NAME: Map = mapOf( + "ProgramMinutes" to ProgramMinutesView, + "FitnessTotals" to FitnessTotalsView, + "ProgramsByMonth" to ProgramsByMonthView, + "ProgramsByWeek" to ProgramsByWeekView, + "RecentPrograms" to RecentProgramsView, + "AssetActivity" to AssetActivityView, + // A @spine report (rows from the spine entity) and a report of defaulted measures. + "ProgramRoster" to ProgramRosterView, + "ProgramLongWeeks" to ProgramLongWeeksView, + "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. + "MeasurementStats" to MeasurementStatsView, + "MeasurementStatsFilled" to MeasurementStatsFilledView, + "AssetDays" to AssetDaysView, + "AllTypesBreakdown" to AllTypesBreakdownView, + "ProgramsOverTime" to ProgramsOverTimeView, + "LiveIntroPrograms" to LiveIntroProgramsView, + "ProgramsEmptyScope" to ProgramsEmptyScopeView, + "WeekShapes" to WeekShapesView, + "SessionsByProgram" to SessionsByProgramView, + "ProgramSessions" to ProgramSessionsView, + "NodesByParent" to NodesByParentView, + "AuthsByKind" to AuthsByKindView, + ) +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/SessionTable.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/SessionTable.kt new file mode 100644 index 000000000..67732f346 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/SessionTable.kt @@ -0,0 +1,18 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mirroring `Session` from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`: the fact table the + * multi-hop report scenarios (`SessionsByProgram`, `ProgramSessions`) read through the + * `fkWeek` reference and, from the week, `fkProgram`. It is only seeded here; the reports are + * read through their views. + */ +object SessionTable : Table("sessions") { + val id = long("id").autoIncrement() + val weekId = long("weekId").references(WeekTable.id) + val minutes = integer("minutes") + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/SessionsByProgramView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/SessionsByProgramView.kt new file mode 100644 index 000000000..b702a9be2 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/SessionsByProgramView.kt @@ -0,0 +1,23 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `SessionsByProgram` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_sessions_by_program`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 SessionsByProgramView : Table("v_sessions_by_program") { + val programTitle = varchar("programTitle", 200).nullable() + val weekKey = long("weekKey").nullable() + val sessions = long("sessions") + val sessionMinutes = long("sessionMinutes").nullable() +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/WeekShapesView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/WeekShapesView.kt new file mode 100644 index 000000000..34ecef5f5 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/WeekShapesView.kt @@ -0,0 +1,26 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `WeekShapes` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_week_shapes`, 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. Column types and + * nullability follow `fixtures/persistence-conformance/report-shapes.json` and the view's + * physical column types. + * + * 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 WeekShapesView : Table("v_week_shapes") { + val programTitle = varchar("programTitle", 200).nullable() + val weeks = long("weeks") + val unlabelled = long("unlabelled") + val labelledMinutes = long("labelledMinutes").nullable() + val shortWeeks = long("shortWeeks") + val midWeeks = long("midWeeks") + val longWMinutes = long("longWMinutes").nullable() +} diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java index f3d77b8f6..01e814098 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java @@ -69,7 +69,7 @@ static Object execute(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc return executeDelete(omdb, conn, mc, spec); } - Expression filter = buildFilter(spec.by(), spec.filter()); + Expression filter = buildFilter(spec.by(), spec.filter(), omdb.readObjectFor(mc)); if ("count".equals(spec.op())) { return omdb.getObjectsCount(conn, mc, filter); @@ -108,7 +108,7 @@ interface ColumnTypeProbe { private static Object executeRelate(ObjectManagerDB omdb, ObjectConnection conn, MetaObject sourceMeta, QuerySpec spec, MetaRoot root, ColumnTypeProbe columnTypeProbe) throws Exception { // 1. Load the single source object identified by `by`. - Expression byFilter = buildFilter(spec.by(), null); + Expression byFilter = buildFilter(spec.by(), null, null); QueryOptions opts = new QueryOptions(); if (byFilter != null) opts.setExpression(byFilter); Collection sources = omdb.getObjects(conn, sourceMeta, opts); @@ -226,7 +226,7 @@ private static Object executeDelete(ObjectManagerDB omdb, ObjectConnection conn, // set-based delete-by-key — so we do NOT load-then-delete: the seed row's raw-SQL jsonb // (no @type discriminator) would fail the metadata-driven deserialize on load. The count // is the boolean outcome (>0 = a row was deleted). - Expression byFilter = buildFilter(spec.by(), null); + Expression byFilter = buildFilter(spec.by(), null, null); int deleted = omdb.deleteObjects(conn, mc, byFilter); return deleted > 0; } @@ -234,7 +234,7 @@ private static Object executeDelete(ObjectManagerDB omdb, ObjectConnection conn, /** Load a single object by an exact-match key map, or {@code null} when no row matches. */ private static Object loadByKey(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc, Map key) throws Exception { - Expression filter = buildFilter(key, null); + Expression filter = buildFilter(key, null, null); QueryOptions opts = new QueryOptions(); if (filter != null) opts.setExpression(filter); Collection rows = omdb.getObjects(conn, mc, opts); @@ -267,22 +267,47 @@ private static MetaObject mustGetEntity(MetaRoot root, String name) { * Combine {@code by} (op:get shortcut) and {@code filter} into one * Expression tree. {@code by} maps field→value (each becomes an EQUAL * clause); {@code filter} understands the cross-port DSL plus the - * top-level {@code and:} combinator. + * top-level {@code and:} combinator. {@code filterMeta}, when given, is + * the object {@code filter} addresses and lets each operand be coerced + * to its column's native type. */ - private static Expression buildFilter(Map by, Map filter) { + private static Expression buildFilter(Map by, Map filter, MetaObject filterMeta) { Expression acc = null; if (by != null) { for (Map.Entry e : by.entrySet()) acc = and(acc, new Expression(e.getKey(), e.getValue())); } if (filter != null) { - Expression filterExp = compileFilter(filter); + Expression filterExp = compileFilter(filterMeta, filter); if (filterExp != null) acc = and(acc, filterExp); } return acc; } + /** + * Coerce a corpus operand to a {@code field.date} column's native type. The corpus spells a + * date operand as the ISO calendar date and the column binds a {@link java.time.LocalDate} + * (or a {@link java.util.Date}), so this parses it at the operator — where field and operand + * are already in hand — the way every other port's runner coerces an operand to its column's + * type (Kotlin's {@code coerce(raw, col)}, the C# converter). A filter walk needs no separate + * pre-pass: a new operator or combinator reaches the coercion through the same dispatch that + * compiles it. Non-date fields and non-date-shaped operands pass through. + */ + private static Object dateOperand(MetaObject meta, String field, Object value) { + if (!isDateField(meta, field)) return value; + if (value instanceof String s) return java.time.LocalDate.parse(s); + if (value instanceof List list) { + return list.stream().map(x -> x instanceof String s ? (Object) java.time.LocalDate.parse(s) : x).toList(); + } + return value; + } + + private static boolean isDateField(MetaObject meta, String field) { + return meta.getMetaFieldsStream().anyMatch(f -> + f.getName().equals(field) && com.metaobjects.field.DateField.SUBTYPE_DATE.equals(f.getSubType())); + } + @SuppressWarnings("unchecked") - private static Expression compileFilter(Map filter) { + private static Expression compileFilter(MetaObject meta, Map filter) { // Top-level `and: [filter, filter, ...]` combinator. Object andValue = filter.get("and"); if (andValue instanceof List list) { @@ -293,7 +318,7 @@ private static Expression compileFilter(Map filter) { throw new IllegalArgumentException("`and` list elements must be filter maps (got: " + (child == null ? "null" : child.getClass().getSimpleName()) + ")"); } - combined = and(combined, compileFilter(childMap)); + combined = and(combined, compileFilter(meta, childMap)); } return combined; } @@ -307,27 +332,31 @@ private static Expression compileFilter(Map filter) { (e.getValue() == null ? "null" : e.getValue().getClass().getSimpleName()) + ")"); } for (Map.Entry op : ops.entrySet()) { - acc = and(acc, buildOp(e.getKey(), op.getKey(), op.getValue())); + acc = and(acc, buildOp(meta, e.getKey(), op.getKey(), op.getValue())); } } return acc; } - private static Expression buildOp(String field, String op, Object value) { + private static Expression buildOp(MetaObject meta, String field, String op, Object value) { + // `isNull` carries a boolean and `like` a pattern: neither names a date VALUE, so only + // the comparison ops and `in` get the operand coerced (see dateOperand). + Object v = meta == null || "isNull".equals(op) || "like".equals(op) + ? value : dateOperand(meta, field, value); return switch (op) { - case "eq" -> new Expression(field, value, Expression.EQUAL); - case "ne" -> new Expression(field, value, Expression.NOT_EQUAL); - case "gt" -> new Expression(field, value, Expression.GREATER); - case "gte" -> new Expression(field, value, Expression.EQUAL_GREATER); - case "lt" -> new Expression(field, value, Expression.LESSER); - case "lte" -> new Expression(field, value, Expression.EQUAL_LESSER); + case "eq" -> new Expression(field, v, Expression.EQUAL); + case "ne" -> new Expression(field, v, Expression.NOT_EQUAL); + case "gt" -> new Expression(field, v, Expression.GREATER); + case "gte" -> new Expression(field, v, Expression.EQUAL_GREATER); + case "lt" -> new Expression(field, v, Expression.LESSER); + case "lte" -> new Expression(field, v, Expression.EQUAL_LESSER); case "isNull" -> { boolean wantNull = value instanceof Boolean b ? b : value instanceof String s ? Boolean.parseBoolean(s) : false; // Expression(field, null, EQUAL|NOT_EQUAL) is the Java convention for IS NULL / IS NOT NULL. yield new Expression(field, null, wantNull ? Expression.EQUAL : Expression.NOT_EQUAL); } - case "in" -> compileIn(field, value); + case "in" -> compileIn(field, v); case "like" -> compileLike(field, value); default -> throw new IllegalArgumentException("Unsupported filter op '" + op + "' on field '" + field + "'"); }; 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 6187d75e9..3a70edfa3 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 @@ -17,7 +17,7 @@ *

{@code seed.json} has two halves. Every top-level key but {@code reports} is a base * table ({@code invoices}, {@code products}, {@code sales}), used by the full-stack lanes. * This lane is a SEAM lane: its in-memory repository stands in for the view, so it is - * seeded from {@code reports} alone, which is what the four views return for those rows. + * seeded from {@code reports} alone, which is what the served views return for those rows. * No SQL for a report is produced or run here (ADR-0015: view SQL is TypeScript's). A * TypeScript test holds the two halves together.

*/ @@ -26,10 +26,13 @@ private ReportCorpus() {} private static final ObjectMapper MAPPER = new ObjectMapper(); - /** The four served reports, in declaration order. {@code ProductRevenue} declares - * {@code @spine} and a measure with {@code @default}. */ + /** 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. */ static final List SERVED = - List.of("InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "ProductRevenue"); + List.of("InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", + "ProductRevenue", "SalesByCategory", "SalesBySku"); /** The report that declares no view and must generate nothing. */ static final String SOURCELESS = "InvoiceDays"; @@ -42,19 +45,37 @@ static Path scenariosDir() { return root().resolve("scenarios"); } - /** The rows {@code report}'s view returns: {@code reports.} from {@code report/seed.json}. */ + /** + * The seed's {@code reports} half, parsed ONCE: a per-report reader would re-read and + * re-parse the whole file once per served report. Sibling corpus classes in this package + * parse once into a static; this keeps the whole {@code reports} map as the index. + */ + private static final Map>> REPORT_ROWS = readReportRows(); + @SuppressWarnings("unchecked") - static List> seedRows(String report) { + private static Map>> readReportRows() { try { String text = Files.readString(root().resolve("seed.json"), StandardCharsets.UTF_8); Map parsed = MAPPER.readValue(text, Map.class); if (!(parsed.get("reports") instanceof Map reports)) throw new IllegalStateException("report/seed.json: missing 'reports' object"); - if (!(reports.get(report) instanceof List rows)) - throw new IllegalStateException("report/seed.json: missing 'reports." + report + "' array"); - return (List>) rows; + Map>> out = new java.util.LinkedHashMap<>(); + reports.forEach((name, rows) -> { + if (!(rows instanceof List list)) + throw new IllegalStateException("report/seed.json: 'reports." + name + "' is not an array"); + out.put(String.valueOf(name), (List>) list); + }); + return out; } catch (IOException e) { throw new UncheckedIOException(e); } } + + /** The rows {@code report}'s view returns: {@code reports.} from {@code report/seed.json}. */ + static List> seedRows(String report) { + List> rows = REPORT_ROWS.get(report); + if (rows == null) + throw new IllegalStateException("report/seed.json: missing 'reports." + report + "' array"); + return rows; + } } 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 8ca551be7..206ff0477 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 @@ -23,9 +23,11 @@ /** * FR-044 Plan 3 — the Java GENERATED-controller lane for the view-backed-report - * api-contract sub-corpus. Boots the four GENERATED Spring report controllers on one - * embedded Tomcat, each behind an in-memory repository seam seeded with what its view - * returns, and drives all sixteen scenarios. + * 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 + * {@link ReportCorpus#SERVED}, not in prose.) * *

Generated lane ONLY, on purpose and on every port (see the sub-corpus README). What * is under test is whether the port's GENERATOR emits a read route for a served report, @@ -61,9 +63,9 @@ static Stream scenarios() { } @Test - void theCorpusCarriesItsSixteenScenarios() { + void theCorpusCarriesItsTwentyTwoScenarios() { // A scenarios directory that resolved to nothing would leave this lane green and empty. - assertEquals(16, SCENARIOS.size()); + assertEquals(22, SCENARIOS.size()); } @ParameterizedTest(name = "{0}") diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java index f78d3fdc1..6b28c99a5 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java @@ -113,6 +113,7 @@ private static Comparable operand(String field, String raw) { else if (type == BigDecimal.class) value = new BigDecimal(raw); else if (type == LocalDate.class) value = LocalDate.parse(raw); else if (type == Boolean.class) value = Boolean.valueOf(raw); + else if (type == java.util.UUID.class) value = java.util.UUID.fromString(raw); else if (type == String.class) value = raw; else if (type.isEnum()) value = enumConstant(type, raw); else throw new IllegalStateException("no operand coercion for " + type.getName() + " (" + field + ")"); 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 c09b7cf21..d8a378b77 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(9, models.size()); + assertEquals(21, models.size()); for (ReportReadModel model : models) { assertNull("never attached", model.getParent()); diff --git a/server/python/tests/integration/generated_report_app.py b/server/python/tests/integration/generated_report_app.py index 65b6883a7..956aba45e 100644 --- a/server/python/tests/integration/generated_report_app.py +++ b/server/python/tests/integration/generated_report_app.py @@ -2,7 +2,7 @@ Peer of ``generated_projection_app.py``, for the ``report/`` api-contract corpus (``fixtures/api-contract-conformance/report/``). Runs the REAL generation path -(``run_gen``, the one ``metaobjects gen`` takes) for the four served reports, writes the +(``run_gen``, the one ``metaobjects gen`` takes) for the served reports, writes the emitted package to a temp dir, imports each generated router UNMODIFIED, and mounts it behind an in-memory repository. @@ -11,7 +11,7 @@ Python emits no SQL for a report (ADR-0015: view SQL is TypeScript-only), so the in-memory repository stands in for the view. It is seeded with the ``reports`` half of the corpus -``seed.json``, which is what the four views return for the base-table rows (the other +``seed.json``, which is what those views return for the base-table rows (the other top-level keys, which this lane does not load: it has no tables). Only ``list`` / ``count`` exist, matching the generated ``Protocol``: a report has no ``find_by_id``. @@ -36,13 +36,19 @@ from metaobjects.codegen.runner import run_gen from metaobjects.codegen.runtime.filter_parser import FilterPredicate -#: The four served reports, as (report name, generated module stem). ``ProductRevenue`` +from .generated_router_app import _like_to_regex + +#: The served reports, as (report name, generated module stem). ``ProductRevenue`` #: declares ``@spine``: its seeded rows include the product with no sale. SERVED_REPORTS: dict[str, str] = { "InvoiceStatusTotals": "invoice_status_totals", "InvoicesByMonth": "invoices_by_month", "InvoiceTotals": "invoice_totals", + "InvoiceCalendar": "invoice_calendar", + "InvoiceDisputes": "invoice_disputes", "ProductRevenue": "product_revenue", + "SalesByCategory": "sales_by_category", + "SalesBySku": "sales_by_sku", } #: The sourceless report: generated nowhere, mounted nowhere. UNSERVED_REPORT = "InvoiceDays" @@ -168,6 +174,10 @@ def _matches(self, row: dict[str, Any], p: FilterPredicate) -> bool: return actual == want if p.op == "ne": return actual != want + if p.op == "like": + # One SQL-LIKE translation for the port: the helper the generated-router seam + # uses. A second in-memory seam re-deriving it here would let the two drift. + return _like_to_regex(str(want)).match(str(actual)) is not None if p.op == "gt": return actual > want if p.op == "gte": diff --git a/server/python/tests/integration/test_api_contract_report.py b/server/python/tests/integration/test_api_contract_report.py index 735ebdaf6..1d688432f 100644 --- a/server/python/tests/integration/test_api_contract_report.py +++ b/server/python/tests/integration/test_api_contract_report.py @@ -1,7 +1,7 @@ """FR-044 — cross-port API contract conformance for a VIEW-BACKED ``object.report``. Drives ``fixtures/api-contract-conformance/report/`` against the GENERATED routers for the -four served reports (the deployed artifact), with a read-only in-memory repo behind each +served reports (the deployed artifact), with a read-only in-memory repo behind each generated consumer seam, seeded from ``seed.json``'s ``reports`` half. Generated lane only, by design — see the corpus README. The thing under test is whether @@ -60,7 +60,7 @@ def _load_scenarios() -> list[tuple[str, dict[str, Any]]]: def _load_seed_reports() -> dict[str, list[dict[str, Any]]]: - """The seam lane's half of the seed: what the four views return for the base rows.""" + """The seam lane's half of the seed: what the views return for the base rows.""" parsed = json.loads((_CORPUS / "seed.json").read_text()) reports = parsed.get("reports") if not isinstance(reports, dict): @@ -79,14 +79,14 @@ 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) == 4 + assert len(SERVED_REPORTS) == 8 for report, stem in SERVED_REPORTS.items(): assert f"{report}.py" in _EMITTED assert f"{stem}_filter_allowlist.py" in _EMITTED assert f"{stem}_router.py" in _EMITTED assert not any(UNSERVED_REPORT in f or "invoice_days" in f for f in _EMITTED), _EMITTED # The base entities were not asked for, so no writable router sits beside the reports. - for base in ("invoice", "product", "sale"): + for base in ("category", "invoice", "product", "sale"): assert f"{base}_router.py" not in _EMITTED diff --git a/server/python/tests/runtime/test_object_manager_report.py b/server/python/tests/runtime/test_object_manager_report.py index 8b6508b16..4d551ef89 100644 --- a/server/python/tests/runtime/test_object_manager_report.py +++ b/server/python/tests/runtime/test_object_manager_report.py @@ -5,7 +5,7 @@ ``_require_entity``, so the column list, filter/sort resolution, read coercion and table resolution all see an ordinary view-backed object. These tests use a recording driver (no database): they pin the SQL, the refusals, and that the loaded tree is -never touched. The rows themselves are proven by the nine shared ``report-*`` persistence +never touched. The rows themselves are proven by the shared ``report-*`` persistence scenarios against Postgres (``tests/integration/test_query_scenarios.py``). """ from __future__ import annotations diff --git a/server/python/tests/test_report_shape.py b/server/python/tests/test_report_shape.py index 3ea208463..7cefee7f8 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_nine_reports() -> None: +def test_the_canonical_model_has_twenty_one_reports() -> None: reports = [c for c in _root().children() if c.type == TYPE_OBJECT and c.sub_type == OBJECT_SUBTYPE_REPORT] - assert len(reports) == 9 + assert len(reports) == 21 assert all(isinstance(r, MetaObject) for r in reports) 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 f587c0517..7ac07a4f9 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 @@ -115,6 +115,8 @@ const DEFAULT_OPTIONS: ts.CompilerOptions = { const SERVED_REPORTS = [ "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity", "ProgramRoster", "ProgramLongWeeks", "FitnessTotalsFilled", + "MeasurementStats", "MeasurementStatsFilled", "AssetDays", "AllTypesBreakdown", "ProgramsOverTime", "LiveIntroPrograms", + "ProgramsEmptyScope", "WeekShapes", "SessionsByProgram", "ProgramSessions", "NodesByParent", "AuthsByKind", ] as const; const PROFILES: ReadonlyArray = [ @@ -198,7 +200,8 @@ describe("codegen-compile conformance — the shared fitness corpus", () => { "ProgramBrief.ts", "ProgramVerdict.ts", "WeekLabel.ts", - // FR-044 Plan 3: the nine view-backed reports' entity, names and queries files. + // FR-044 Plan 3: every view-backed report's entity, names and queries files + // (the count is SERVED_REPORTS's, not this prose's). ...SERVED_REPORTS.flatMap((r) => [`${r}.ts`, `${r}.names.ts`, `${r}.queries.ts`]), ]) { expect([...emitted]).toContain(expected); 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 d5311fa3c..c89c117dc 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 @@ -1429,7 +1429,7 @@ describe("Table H — the canonical model", () => { "fixtures", "persistence-conformance", "canonical", "meta.fitness.json", ); - test("Program, Week and Asset are Table H's cubes; the @spine reports add facts cubes and views", async () => { + test("Program, Week and Asset are Table H's cubes; the @spine reports add facts cubes and views; the other canonical entities add theirs", async () => { const result = await loadUris([pathToFileURL(CANONICAL).href]); expect(result.errors.map((e) => e.message)).toEqual([]); const model = buildCubeModel(result.root, { dialect: "postgres", columnNamingStrategy: "literal" }); @@ -1454,6 +1454,17 @@ describe("Table H — the canonical model", () => { { joinPath: "Program.ProgramLongWeeksFacts", includes: [{ name: "weeks" }, { name: "totalMinutesOrZero" }] }, ], }, + { + // The third @spine report: its facts cube is reached through the Session -> Week reference. + name: "ProgramSessions", + cubes: [ + { joinPath: "Program", includes: [{ name: "id", alias: "programKey" }, { name: "title", alias: "programTitle" }] }, + { + joinPath: "Program.ProgramSessions_fkWeek.ProgramSessionsFacts", + includes: [{ name: "sessions" }, { name: "sessionMinutes" }, { name: "sessionMinutesOrZero" }], + }, + ], + }, ]); const weeks: CubeMeasureSpec = { name: "weeks", sql: '{CUBE}."id"', type: "count" }; const longWeeks: CubeMeasureSpec = { name: "longWeeks", sql: '{CUBE}."id"', type: "count", filters: [{ sql: '{CUBE}."durationMinutes" >= 60' }] }; @@ -1462,7 +1473,15 @@ describe("Table H — the canonical model", () => { 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" }; const key: CubeDimensionSpec = { name: "id", sql: '{CUBE}."id"', type: "number", primaryKey: true }; - expect(model.cubes).toEqual([ + // 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", + ]); + const tableH = new Set(["Program", "Week", "Asset", "ProgramRosterFacts", "ProgramLongWeeksFacts"]); + expect(model.cubes.filter((c) => tableH.has(c.name))).toEqual([ { name: "Program", sqlTable: '"programs"', @@ -1470,6 +1489,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: "ProgramSessions_fkWeek", relationship: "one_to_many", sql: '{CUBE}."id" = {ProgramSessions_fkWeek}."programId"' }, ], dimensions: [ { name: "id", sql: '{CUBE}."id"', type: "number", primaryKey: true }, @@ -1486,14 +1506,32 @@ describe("Table H — the canonical model", () => { filters: [{ sql: `{CUBE}."status" = 'PUBLISHED'` }], }, { name: "programs", sql: '{CUBE}."id"', type: "count" }, + { name: "notDrafts", sql: '{CUBE}."id"', type: "count", filters: [{ sql: `{CUBE}."status" <> 'DRAFT'` }] }, + { name: "avgPrice", sql: '{CUBE}."priceCents"', type: "avg" }, + { name: "cheapest", sql: '{CUBE}."priceCents"', type: "min" }, + { name: "priciest", sql: '{CUBE}."priceCents"', type: "max" }, + { name: "avgPriceOrMinusOneRaw", sql: '{CUBE}."priceCents"', type: "avg", public: false }, + { name: "avgPriceOrMinusOne", sql: "COALESCE({avgPriceOrMinusOneRaw}, -1)", type: "number" }, ], segments: [ { name: "published", sql: `{CUBE}."status" = 'PUBLISHED'` }, + { name: "live", sql: `{CUBE}."status" IN ('DRAFT', 'PUBLISHED')` }, + { name: "introOrFree", sql: `({CUBE}."title" LIKE 'Intro%' OR {CUBE}."priceCents" = 0)` }, // RecentPrograms' relative @filter: a scope segment, and no rollup. { name: "recentProgramsScope", sql: `{CUBE}."created_ts" >= ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D')` }, + { name: "liveIntroProgramsScope", sql: `({CUBE}."status" IN ('DRAFT', 'PUBLISHED') AND {CUBE}."priceCents" < 5000)` }, + { name: "programsEmptyScopeScope", sql: `{CUBE}."priceCents" > 1000000` }, ], // Coarsest first: ProgramsByWeek groups by one column, ProgramsByMonth by two. preAggregations: [ + { + name: "ProgramsEmptyScope", type: "rollup", measures: ["programs", "avgPriceOrMinusOne"], dimensions: [], + segments: ["live", "programsEmptyScopeScope"], + }, + { + name: "LiveIntroPrograms", type: "rollup", measures: ["programs", "notDrafts", "listValue"], dimensions: ["status"], + segments: ["introOrFree", "liveIntroProgramsScope"], + }, { name: "ProgramsByWeek", type: "rollup", measures: ["programs"], dimensions: [], segments: ["published"], timeDimension: "createdAt", granularity: "week", @@ -1502,6 +1540,15 @@ describe("Table H — the canonical model", () => { name: "ProgramsByMonth", type: "rollup", measures: ["programs", "listValue"], dimensions: ["status"], segments: [], timeDimension: "createdAt", granularity: "month", }, + { + name: "ProgramsOverTime", type: "rollup", measures: ["programs", "avgPrice", "cheapest", "priciest"], + dimensions: [], segments: [], + timeDimensions: [ + { dimension: "createdAt", granularity: "day" }, + { dimension: "createdAt", granularity: "quarter" }, + { dimension: "createdAt", granularity: "year" }, + ], + }, ], }, { @@ -1532,6 +1579,17 @@ describe("Table H — the canonical model", () => { totalMinutesOrZeroRaw, totalMinutesOrZero, longShareOrZero, + { name: "unlabelled", sql: '{CUBE}."id"', type: "count", filters: [{ sql: '{CUBE}."label" IS NULL' }] }, + { name: "labelledMinutes", sql: '{CUBE}."durationMinutes"', type: "sum", filters: [{ sql: '{CUBE}."label" IS NOT NULL' }] }, + { name: "shortWeeks", sql: '{CUBE}."id"', type: "count", filters: [{ sql: '{CUBE}."durationMinutes" < 30' }] }, + { + name: "midWeeks", sql: '{CUBE}."id"', type: "count", + filters: [{ sql: '({CUBE}."durationMinutes" > 30 AND {CUBE}."durationMinutes" <= 60)' }], + }, + { + name: "longWMinutes", sql: '{CUBE}."durationMinutes"', type: "sum", + filters: [{ sql: `({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE 'W%')` }], + }, ], segments: [{ name: "long", sql: '{CUBE}."durationMinutes" >= 60' }], // Coarsest first: FitnessTotals and FitnessTotalsFilled group by nothing, so Cube's first @@ -1543,6 +1601,11 @@ describe("Table H — the canonical model", () => { name: "FitnessTotalsFilled", type: "rollup", measures: ["weeks", "totalMinutesOrZero", "longShareOrZero"], dimensions: [], segments: [], }, + { + name: "WeekShapes", type: "rollup", + measures: ["weeks", "unlabelled", "labelledMinutes", "shortWeeks", "midWeeks", "longWMinutes"], + dimensions: ["programTitle"], segments: [], + }, { name: "ProgramMinutes", type: "rollup", measures: ["weeks", "longWeeks", "labels", "slots", "totalMinutes", "avgMinutes", "minMinutes", "maxMinutes", "longShare"], @@ -1562,9 +1625,23 @@ describe("Table H — the canonical model", () => { meta: { grains: ["week", "month"] }, }, ], - measures: [{ name: "assets", sql: '{CUBE}."id"', type: "count" }], + measures: [ + { name: "assets", sql: '{CUBE}."id"', type: "count" }, + { name: "owners", sql: '{CUBE}."ownerId"', type: "count_distinct" }, + { name: "firstRecorded", sql: '{CUBE}."recordedAt"', type: "min" }, + { name: "lastObserved", sql: '{CUBE}."observedAt"', type: "max" }, + { name: "lastAsOf", sql: '{CUBE}."asOfDate"', type: "max" }, + ], segments: [], preAggregations: [ + { + name: "AssetDays", type: "rollup", measures: ["assets", "owners", "firstRecorded", "lastObserved", "lastAsOf"], + dimensions: [], segments: [], + timeDimensions: [ + { dimension: "recordedAt", granularity: "day" }, + { dimension: "asOfDate", granularity: "month" }, + ], + }, { name: "AssetActivity", type: "rollup", measures: ["assets"], dimensions: [], segments: [], timeDimensions: [ 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 ad4160320..6616a5df1 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 @@ -12,15 +12,28 @@ import { } from "../../scripts/gen-cube-model-canonical.js"; describe("the cube-model canonical golden", () => { - test("is Table H's three cubes, the two @spine reports' facts cubes and views, and nothing else", () => { + test("is Table H's three cubes, the cubes the other canonical entities and the three @spine reports add, and nothing else", () => { expect([...readTree(CANONICAL_EXPECTED_DIR).keys()]).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/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", ]); }); 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 d616d364b..797e37ba8 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: ProgramSessions_fkWeek + relationship: one_to_many + sql: '{CUBE}."id" = {ProgramSessions_fkWeek}."programId"' dimensions: - name: id sql: '{CUBE}."id"' @@ -116,12 +119,50 @@ cubes: - name: programs sql: '{CUBE}."id"' type: count + - name: notDrafts + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."status" <> ''DRAFT''' + - name: avgPrice + sql: '{CUBE}."priceCents"' + type: avg + - name: cheapest + sql: '{CUBE}."priceCents"' + type: min + - name: priciest + sql: '{CUBE}."priceCents"' + type: max + - name: avgPriceOrMinusOneRaw + sql: '{CUBE}."priceCents"' + type: avg + public: false + - name: avgPriceOrMinusOne + sql: 'COALESCE({avgPriceOrMinusOneRaw}, -1)' + type: number segments: - name: published sql: '{CUBE}."status" = ''PUBLISHED''' + - name: live + sql: '{CUBE}."status" IN (''DRAFT'', ''PUBLISHED'')' + - name: introOrFree + sql: '({CUBE}."title" LIKE ''Intro%'' OR {CUBE}."priceCents" = 0)' - name: recentProgramsScope sql: '{CUBE}."created_ts" >= ((now() AT TIME ZONE ''UTC'') - INTERVAL ''P30D'')' + - name: liveIntroProgramsScope + sql: '({CUBE}."status" IN (''DRAFT'', ''PUBLISHED'') AND {CUBE}."priceCents" < 5000)' + - name: programsEmptyScopeScope + sql: '{CUBE}."priceCents" > 1000000' pre_aggregations: + - name: ProgramsEmptyScope + type: rollup + measures: [CUBE.programs, CUBE.avgPriceOrMinusOne] + segments: [CUBE.live, CUBE.programsEmptyScopeScope] + - name: LiveIntroPrograms + type: rollup + measures: [CUBE.programs, CUBE.notDrafts, CUBE.listValue] + dimensions: [CUBE.status] + segments: [CUBE.introOrFree, CUBE.liveIntroProgramsScope] - name: ProgramsByWeek type: rollup measures: [CUBE.programs] @@ -134,6 +175,16 @@ cubes: dimensions: [CUBE.status] time_dimension: CUBE.createdAt granularity: month + - name: ProgramsOverTime + type: rollup + measures: [CUBE.programs, CUBE.avgPrice, CUBE.cheapest, CUBE.priciest] + time_dimensions: + - dimension: CUBE.createdAt + granularity: day + - dimension: CUBE.createdAt + granularity: quarter + - dimension: CUBE.createdAt + granularity: year `; const WEEK_YML = `${HEADER} @@ -200,6 +251,31 @@ cubes: - name: longShareOrZero sql: 'COALESCE(CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0), 0)' type: number + - name: unlabelled + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."label" IS NULL' + - name: labelledMinutes + sql: '{CUBE}."durationMinutes"' + type: sum + filters: + - sql: '{CUBE}."label" IS NOT NULL' + - name: shortWeeks + sql: '{CUBE}."id"' + type: count + filters: + - sql: '{CUBE}."durationMinutes" < 30' + - name: midWeeks + sql: '{CUBE}."id"' + type: count + filters: + - sql: '({CUBE}."durationMinutes" > 30 AND {CUBE}."durationMinutes" <= 60)' + - name: longWMinutes + sql: '{CUBE}."durationMinutes"' + type: sum + filters: + - sql: '({CUBE}."durationMinutes" >= 60 AND {CUBE}."label" LIKE ''W%'')' segments: - name: long sql: '{CUBE}."durationMinutes" >= 60' @@ -210,6 +286,10 @@ cubes: - name: FitnessTotalsFilled type: rollup measures: [CUBE.weeks, CUBE.totalMinutesOrZero, CUBE.longShareOrZero] + - name: WeekShapes + type: rollup + measures: [CUBE.weeks, CUBE.unlabelled, CUBE.labelledMinutes, CUBE.shortWeeks, CUBE.midWeeks, CUBE.longWMinutes] + dimensions: [CUBE.programTitle] - name: ProgramMinutes type: rollup measures: [CUBE.weeks, CUBE.longWeeks, CUBE.labels, CUBE.slots, CUBE.totalMinutes, CUBE.avgMinutes, CUBE.minMinutes, CUBE.maxMinutes, CUBE.longShare] @@ -239,7 +319,27 @@ cubes: - name: assets sql: '{CUBE}."id"' type: count + - name: owners + sql: '{CUBE}."ownerId"' + type: count_distinct + - name: firstRecorded + sql: '{CUBE}."recordedAt"' + type: min + - name: lastObserved + sql: '{CUBE}."observedAt"' + type: max + - name: lastAsOf + sql: '{CUBE}."asOfDate"' + type: max pre_aggregations: + - name: AssetDays + type: rollup + measures: [CUBE.assets, CUBE.owners, CUBE.firstRecorded, CUBE.lastObserved, CUBE.lastAsOf] + time_dimensions: + - dimension: CUBE.recordedAt + granularity: day + - dimension: CUBE.asOfDate + granularity: month - name: AssetActivity type: rollup measures: [CUBE.assets] @@ -346,6 +446,23 @@ views: - name: totalMinutesOrZero `; +const PROGRAM_SESSIONS_VIEW_YML = `${HEADER} +views: + - name: ProgramSessions + cubes: + - join_path: Program + includes: + - name: id + alias: programKey + - name: title + alias: programTitle + - join_path: Program.ProgramSessions_fkWeek.ProgramSessionsFacts + includes: + - name: sessions + - name: sessionMinutes + - name: sessionMinutesOrZero +`; + // test → codegen-ts → packages → typescript → server → repo root const CANONICAL = resolve( import.meta.dir, "..", "..", "..", "..", "..", "..", @@ -377,15 +494,15 @@ describe("Table H — the canonical model's files", () => { expect(render(await canonicalModel(), "Asset")).toBe(ASSET_YML); }); - test("the two @spine reports' facts cubes", async () => { + test("the @spine reports' facts cubes", async () => { const model = await canonicalModel(); expect(render(model, "ProgramRosterFacts")).toBe(PROGRAM_ROSTER_FACTS_YML); expect(render(model, "ProgramLongWeeksFacts")).toBe(PROGRAM_LONG_WEEKS_FACTS_YML); }); - test("the two @spine reports' views", async () => { + test("the three @spine reports' views", async () => { const model = await canonicalModel(); - expect(model.views.map(renderCubeViewYaml)).toEqual([PROGRAM_ROSTER_VIEW_YML, PROGRAM_LONG_WEEKS_VIEW_YML]); + expect(model.views.map(renderCubeViewYaml)).toEqual([PROGRAM_ROSTER_VIEW_YML, PROGRAM_LONG_WEEKS_VIEW_YML, PROGRAM_SESSIONS_VIEW_YML]); }); test("rendering twice gives the same bytes", async () => { @@ -949,7 +1066,11 @@ function parsed(c: CubeSpec): unknown { describe("parse oracle", () => { test("the canonical cubes parse to the model's values", async () => { const model = await canonicalModel(); - expect(model.cubes.map((c) => c.name)).toEqual(["Program", "Week", "Asset", "ProgramRosterFacts", "ProgramLongWeeksFacts"]); + 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", + ]); for (const c of model.cubes) expect(parsed(c)).toEqual({ cubes: [expectedCube(c)] }); }); 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 28981f212..7f69fd65d 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 @@ -706,12 +706,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 two @spine reports are views, each with its - // facts cube. + // A gate over an empty emit passes trivially. The three @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/Asset.yml", "model/cubes/Program.yml", "model/cubes/ProgramLongWeeksFacts.yml", - "model/cubes/ProgramRosterFacts.yml", "model/cubes/Week.yml", - "model/views/ProgramLongWeeks.yml", "model/views/ProgramRoster.yml", + "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/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", ]); expect(b).toEqual(a); }); @@ -767,18 +771,21 @@ 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 - // two facts cubes, and with every cube they read written the two views are too. + // three facts cubes (ProgramSessionsFacts through the Session -> Week join-path cube), and with + // every cube they read written the three views are too. expect(a.map(([p]) => p)).toEqual([ "model/cubes/Program.yml", "model/cubes/ProgramLongWeeksFacts.yml", "model/cubes/ProgramRosterFacts.yml", - "model/cubes/Week.yml", "model/views/ProgramLongWeeks.yml", "model/views/ProgramRoster.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", ]); expect(await run(refCubeModel)).toEqual(a); }); test("the dialect option beats a sqlite config, and a filter narrows the BUILD, in both", async () => { - // Week is filtered out. Program.yml's `title` dimension exists only because Week's @via reads - // it, so a copy that built the whole model and merely hid Week's file would still write it. - const opts = { dialect: "mysql", filter: (o: { name: string }) => o.name !== "Week" } as const; + // Only Asset and Program are kept (Week, and Session which also reaches Week, are filtered out). + // Program.yml's `title` dimension exists only because Week's @via reads it, so a copy that built + // the whole model and merely hid the other files would still write it. + const opts = { dialect: "mysql", filter: (o: { name: string }) => o.name === "Asset" || o.name === "Program" } as const; const a = await runTree(() => builtinCubeModel(opts), {}, { dialect: "sqlite" }); expect(a.map(([p]) => p)).toEqual(["model/cubes/Asset.yml", "model/cubes/Program.yml"]); expect(a.every(([, text]) => text.includes("`"))).toBe(true); 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 337bd07af..4b8ba1bb5 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 @@ -4,6 +4,7 @@ import { expect } from "bun:test"; import { + FIELD_ATTR_INT_VALUE_MAP, GRAIN_HOUR, OBJECT_REPORT_ATTR_FILTER, OBJECT_REPORT_ATTR_SEGMENT, @@ -45,6 +46,9 @@ export interface CubeQuery { readonly timezone: "UTC"; } +/** The grains one Cube query can ask together, finest first: each bucket lies inside the next. */ +const NESTING_ORDER: readonly string[] = ["day", "month", "quarter", "year"]; + export type ColumnKind = "attribute" | "time" | "hour" | "measure"; /** One report field: its view column and the Cube row key that carries it. */ @@ -52,6 +56,18 @@ export interface PlannedColumn { readonly field: string; readonly cubeKey: string; readonly kind: ColumnKind; + /** + * A coarser grain of a dimension the query already asks at a finer one. Cube answers a time + * dimension at ONE granularity per query, so the report's other grains are read off that + * bucket's start: day, month, quarter and year nest, and the group the finest grain makes is + * the group they make. + */ + readonly derive?: { readonly fromKey: string; readonly grain: string }; + /** + * An int-backed enum: the view holds the stored integer, Cube's `CASE` the member symbol (as + * every port's read returns it). Maps the integer, as text, to its symbol. + */ + readonly symbols?: Readonly>; } export interface QueryPlan { @@ -86,10 +102,29 @@ export function cubeQueryFor(report: MetaObject, root: MetaRoot): QueryPlan { const member = `${cube}.${f.dimension.name}`; if (f.grain === undefined) { dimensions.push(member); - columns.push({ field: f.name, cubeKey: member, kind: "attribute" }); + const intMap = f.typeSource?.attr(FIELD_ATTR_INT_VALUE_MAP); + const symbols = + typeof intMap === "object" && intMap !== null + ? Object.fromEntries(Object.entries(intMap as Record).map(([symbol, n]) => [String(n), symbol])) + : undefined; + // A date dimension is a Cube `time` member: it sends the day as a midnight timestamp. + columns.push({ field: f.name, cubeKey: member, kind: f.subType === "date" ? "time" : "attribute", ...(symbols === undefined ? {} : { symbols }) }); } else { - timeDimensions.push({ dimension: member, granularity: f.grain }); - columns.push({ field: f.name, cubeKey: `${member}.${f.grain}`, kind: f.grain === GRAIN_HOUR ? "hour" : "time" }); + // One granularity per time dimension per Cube query: ask the finest grain the report + // lists, and derive the coarser ones from that bucket (see PlannedColumn.derive). + const dimName = f.dimension.name; + const grains = shape.fields.filter((g) => g.dimension?.name === dimName && g.grain !== undefined).map((g) => g.grain as string); + const rank = (g: string): number => NESTING_ORDER.indexOf(g); + if (grains.length > 1 && grains.some((g) => rank(g) < 0)) { + throw new Error(`${report.name}.${f.name}: ${member} at ${grains.join(" and ")} does not nest, so one Cube query cannot ask both`); + } + const fine = grains.slice().sort((x, y) => rank(x) - rank(y))[0] as string; + if (!timeDimensions.some((t) => t.dimension === member)) timeDimensions.push({ dimension: member, granularity: fine }); + columns.push( + f.grain === fine + ? { field: f.name, cubeKey: `${member}.${fine}`, kind: f.grain === GRAIN_HOUR ? "hour" : "time" } + : { field: f.name, cubeKey: `${member}.${f.grain}`, kind: "time", derive: { fromKey: `${member}.${fine}`, grain: f.grain } }, + ); } } } @@ -113,35 +148,68 @@ export function canonicalDecimal(s: string): string { return s.includes(".") ? s.replace(/\.?0+$/, "") : s; } +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; +const TEMPORAL_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)?$/; + /** True when the string already names its offset (`Z`, `+00:00`, `-0500`). */ export const HAS_ZONE_RE = /(Z|[+-]\d{2}(:?\d{2})?)$/; +/** + * Read a temporal string as a UTC instant: an explicit offset wins, a date-only value is + * midnight, and a naive wall clock (`2026-05-01T08:30:00.000` — Cube's spelling with the + * query pinned to UTC) is read as UTC. One zone-handling site for every temporal column. + */ +function isoInstant(s: string, where: string): string { + const d = new Date(DATE_ONLY_RE.test(s) ? `${s}T00:00:00Z` : HAS_ZONE_RE.test(s) ? s : `${s}Z`); + if (Number.isNaN(d.getTime())) throw new Error(`${where}: '${s}' is not a timestamp`); + return d.toISOString(); +} + 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") throw new Error(`${where}: unexpected value ${JSON.stringify(raw)}`); + 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": // A day, week, month, quarter or year bucket: Cube sends `2026-05-01T00:00:00.000`, the view a DATE. return s.slice(0, 10); - case "hour": { + case "hour": // An instant. Cube sends the wall clock in the query time zone (UTC, fixed by the query); // the view sends a timestamptz with its offset. - const d = new Date(HAS_ZONE_RE.test(s) ? s : `${s}Z`); - if (Number.isNaN(d.getTime())) throw new Error(`${where}: '${s}' is not a timestamp`); - return d.toISOString(); - } + return isoInstant(s, where); case "attribute": + return DECIMAL_RE.test(s) ? canonicalDecimal(s) : s; case "measure": + // A min or max over a date or a timestamp is a temporal value: Cube sends every one as + // `YYYY-MM-DDTHH:MM:SS.sss` (a date at midnight), the view as the column's own text, with an + // offset for a timestamptz. Both are read as UTC instants and compared as such. + if (TEMPORAL_RE.test(s)) return isoInstant(s, where); return DECIMAL_RE.test(s) ? canonicalDecimal(s) : s; } } +/** The start of the month, quarter or year that holds `day` (`YYYY-MM-DD`). */ +function coarsen(day: string, grain: string): string { + const [y, m] = day.split("-") as [string, string]; + switch (grain) { + case "month": + return `${y}-${m}-01`; + case "quarter": + return `${y}-${String(Math.floor((Number(m) - 1) / 3) * 3 + 1).padStart(2, "0")}-01`; + case "year": + return `${y}-01-01`; + default: + throw new Error(`cannot derive a ${grain} bucket from a day`); + } +} + export function fromCubeRow(row: Record, plan: QueryPlan): NormalRow { const out: NormalRow = {}; for (const c of plan.columns) { - if (!(c.cubeKey in row)) throw new Error(`Cube's row has no '${c.cubeKey}': ${JSON.stringify(row)}`); - out[c.field] = normalize(c.kind, row[c.cubeKey], `Cube ${c.cubeKey}`); + const key = c.derive?.fromKey ?? c.cubeKey; + if (!(key in row)) throw new Error(`Cube's row has no '${key}': ${JSON.stringify(row)}`); + const value = normalize(c.kind, row[key], `Cube ${key}`); + out[c.field] = c.derive === undefined || value === null ? value : coarsen(value, c.derive.grain); } return out; } @@ -150,7 +218,10 @@ export function fromViewRow(row: Record, plan: QueryPlan, const want = plan.columns.map((c) => c.field).sort(); expect(Object.keys(row).sort()).toEqual(want); const out: NormalRow = {}; - for (const c of plan.columns) out[c.field] = normalize(c.kind, row[c.field], `${view}.${c.field}`); + for (const c of plan.columns) { + const raw = row[c.field]; + out[c.field] = normalize(c.kind, raw !== null && raw !== undefined && c.symbols?.[raw] !== undefined ? c.symbols[raw] : raw, `${view}.${c.field}`); + } return out; } @@ -290,3 +361,31 @@ export async function cubeSql(s: CubeStack, query: CubeQuery): Promise { } } +/** + * Table I: what each served canonical report must produce. `preAggregation` is the table Cube + * names in `usedPreAggregations`: development mode's schema `dev_pre_aggregations`, then + * `__` in snake case, TWO underscores (executed on 1.7.43); undefined means Table F + * emits no rollup and Cube reads the source tables (a relative date, or a `@spine` report, which + * is a Cube view). `rows` is the row count on the lane's seed (canonical/seed.sql, or seed.mysql.sql on MySQL): a `@spine` report has one row + * per program, the eight with no weeks (and, for ProgramLongWeeks, the two whose weeks are all + * short) included. + */ +export interface ExpectedReport { + readonly report: string; + readonly view: string; + readonly preAggregation: string | undefined; + readonly rows: number; + /** + * A known difference (docs/features/cube-export.md, "Where a Cube query and the view differ"): + * the rows Cube answers where the view's differ. The test requires Cube to send exactly these + * and the view NOT to, so the difference stays an executed check rather than a skipped one. + */ + readonly cubeRows?: readonly Record[]; + /** + * The model declares a rollup for this report and Cube never matches it, so it answers from the + * source table (`preAggregation` is then undefined). Today that is a report listing ONE time + * dimension at several grains: the `time_dimensions` entry repeats the dimension and Cube 1.7.43 + * matches no query to it (a query names a time dimension once, at one granularity). + */ + readonly unusedRollup?: true; +} 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 269f78c26..c3b61a831 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 @@ -39,7 +39,7 @@ import { import { loadMetadataFile } from "../src/load-metadata.ts"; import { CANONICAL_DIR } from "../src/paths.ts"; import { readCanonicalMysqlSchemaSql } from "../src/canonical-schema-mysql.ts"; -import { cubeQueryFor, fromCubeRow, fromViewRow, getMeta, load, sortRows } from "./cube-live-support.ts"; +import { cubeQueryFor, type ExpectedReport, fromCubeRow, fromViewRow, getMeta, load, sortRows } from "./cube-live-support.ts"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", ".."); const CANONICAL_MODEL = join(CANONICAL_DIR, "meta.fitness.json"); @@ -51,16 +51,28 @@ const TEST_TIMEOUT_MS = 600_000; * Each served canonical report, its view, the rollup Cube answers from (the same table Table I * names on Postgres; undefined = the source tables) and its row count on seed.mysql.sql. */ -const REPORTS: readonly { readonly report: string; readonly view: string; readonly preAggregation: string | undefined; readonly rows: number }[] = [ +const REPORTS: readonly ExpectedReport[] = [ { report: "ProgramMinutes", view: "v_program_minutes", preAggregation: "dev_pre_aggregations.week__program_minutes", rows: 3 }, { report: "FitnessTotals", view: "v_fitness_totals", preAggregation: "dev_pre_aggregations.week__fitness_totals", rows: 1 }, { report: "ProgramsByMonth", view: "v_programs_by_month", preAggregation: "dev_pre_aggregations.program__programs_by_month", rows: 5 }, { report: "ProgramsByWeek", view: "v_programs_by_week", preAggregation: "dev_pre_aggregations.program__programs_by_week", rows: 5 }, { report: "RecentPrograms", view: "v_recent_programs", preAggregation: undefined, rows: 1 }, { report: "AssetActivity", view: "v_asset_activity", preAggregation: "dev_pre_aggregations.asset__asset_activity", rows: 2 }, - { report: "ProgramRoster", view: "v_program_roster", preAggregation: undefined, rows: 7 }, - { report: "ProgramLongWeeks", view: "v_program_long_weeks", preAggregation: undefined, rows: 7 }, + { report: "ProgramRoster", view: "v_program_roster", preAggregation: undefined, rows: 11 }, + { report: "ProgramLongWeeks", view: "v_program_long_weeks", 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 }, + { report: "AssetDays", view: "v_asset_days", preAggregation: "dev_pre_aggregations.asset__asset_days", rows: 1 }, + { report: "AllTypesBreakdown", view: "v_all_types_breakdown", preAggregation: undefined, rows: 2 }, + { report: "ProgramsOverTime", view: "v_programs_over_time", preAggregation: undefined, rows: 7 }, + { report: "LiveIntroPrograms", view: "v_live_intro_programs", preAggregation: "dev_pre_aggregations.program__live_intro_programs", rows: 2 }, + { report: "ProgramsEmptyScope", view: "v_programs_empty_scope", preAggregation: "dev_pre_aggregations.program__programs_empty_scope", rows: 1, cubeRows: [{ avgPriceOrMinusOne: null, programs: null }] }, + { report: "WeekShapes", view: "v_week_shapes", preAggregation: "dev_pre_aggregations.week__week_shapes", rows: 3 }, + { report: "SessionsByProgram", view: "v_sessions_by_program", preAggregation: "dev_pre_aggregations.session__sessions_by_program", rows: 3 }, + { report: "ProgramSessions", view: "v_program_sessions", preAggregation: undefined, rows: 11 }, + { report: "NodesByParent", view: "v_nodes_by_parent", preAggregation: "dev_pre_aggregations.node__nodes_by_parent", rows: 3 }, + { report: "AuthsByKind", view: "v_auths_by_kind", preAggregation: "dev_pre_aggregations.auth__auths_by_kind", rows: 3 }, ]; const unavailable = dockerUnavailableReason(); @@ -161,11 +173,11 @@ describe(`cube-model MySQL live check (${CUBE_IMAGE} over ${CUBE_MYSQL_IMAGE}, d for (const want of REPORTS) expect(reportReadSource(findReport(want.report))?.physicalName).toBe(want.view); }); - test.skipIf(skip)("Cube compiles the MySQL model and lists the cubes over the three tables the reports read", async () => { + test.skipIf(skip)("Cube compiles the MySQL model and lists the cubes over the tables the reports read", async () => { const meta = await getMeta(requireStack()); expect(meta.error).toBeUndefined(); const names = new Set(meta.cubes.map((c) => c.name)); - for (const cube of ["Program", "Week", "Asset"]) expect(names.has(cube)).toBe(true); + for (const cube of ["Program", "Week", "Asset", "Session", "Node", "Auth", "Measurement"]) expect(names.has(cube)).toBe(true); }, TEST_TIMEOUT_MS); for (const want of REPORTS) { @@ -179,7 +191,13 @@ describe(`cube-model MySQL live check (${CUBE_IMAGE} over ${CUBE_MYSQL_IMAGE}, d 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); - expect(cubeRows).toEqual(viewRows); + if (want.cubeRows !== undefined) { + // A known difference (docs/features/cube-export.md): Cube sends exactly these rows, the view does not. + expect(cubeRows).toEqual([...want.cubeRows]); + expect(cubeRows).not.toEqual(viewRows); + } else { + expect(cubeRows).toEqual(viewRows); + } expect(used).toEqual(want.preAggregation === undefined ? [] : [want.preAggregation]); }, TEST_TIMEOUT_MS); } 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 791acbd81..cae078f1c 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 @@ -39,6 +39,7 @@ import { JOIN_PATH_SEPARATOR, cubeModel, defineConfig, runGen, servedReport } fr import { DOC_ATTR_DESCRIPTION, DOC_ATTR_TITLE, + FIELD_ATTR_INT_VALUE_MAP, GRAIN_HOUR, OBJECT_REPORT_ATTR_FILTER, OBJECT_REPORT_ATTR_SEGMENT, @@ -74,6 +75,7 @@ import { generatedSql, fromCubeRow, fromViewRow, + type ExpectedReport, getJson, getMeta, load, @@ -103,21 +105,6 @@ const CORPUS_CASES = corpusCasesWithTree(); /** The corpus pass's one budget, which every case's deadline is drawn from. */ const CORPUS_BUDGET_MS = CORPUS_CASES.length * CASE_BUDGET_MS; -/** - * Table I: what each served canonical report must produce. `preAggregation` is the table Cube - * names in `usedPreAggregations`: development mode's schema `dev_pre_aggregations`, then - * `__` in snake case, TWO underscores (executed on 1.7.43); undefined means Table F - * emits no rollup and Cube reads the source tables (a relative date, or a `@spine` report, which - * is a Cube view). `rows` is the row count on canonical/seed.sql: a `@spine` report has one row - * per program, the four with no weeks (and, for ProgramLongWeeks, the two whose weeks are all - * short) included. - */ -interface ExpectedReport { - readonly report: string; - readonly view: string; - readonly preAggregation: string | undefined; - readonly rows: number; -} const TABLE_I: readonly ExpectedReport[] = [ { report: "ProgramMinutes", view: "v_program_minutes", preAggregation: "dev_pre_aggregations.week__program_minutes", rows: 3 }, @@ -126,9 +113,23 @@ const TABLE_I: readonly ExpectedReport[] = [ { report: "ProgramsByWeek", view: "v_programs_by_week", preAggregation: "dev_pre_aggregations.program__programs_by_week", rows: 5 }, { report: "RecentPrograms", view: "v_recent_programs", preAggregation: undefined, rows: 1 }, { report: "AssetActivity", view: "v_asset_activity", preAggregation: "dev_pre_aggregations.asset__asset_activity", rows: 2 }, - { report: "ProgramRoster", view: "v_program_roster", preAggregation: undefined, rows: 7 }, - { report: "ProgramLongWeeks", view: "v_program_long_weeks", preAggregation: undefined, rows: 7 }, + { report: "ProgramRoster", view: "v_program_roster", preAggregation: undefined, rows: 11 }, + { report: "ProgramLongWeeks", view: "v_program_long_weeks", 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 }, + { report: "AssetDays", view: "v_asset_days", preAggregation: "dev_pre_aggregations.asset__asset_days", rows: 1 }, + { report: "AllTypesBreakdown", view: "v_all_types_breakdown", preAggregation: undefined, rows: 2 }, + { report: "ProgramsOverTime", view: "v_programs_over_time", preAggregation: undefined, rows: 7, unusedRollup: true }, + { report: "LiveIntroPrograms", view: "v_live_intro_programs", preAggregation: "dev_pre_aggregations.program__live_intro_programs", rows: 2 }, + // No dimension, no row in scope: the view reads one row with the count 0 and the default; Cube + // sums the (empty) rollup, which is null for every measure, the count and the default included. + { report: "ProgramsEmptyScope", view: "v_programs_empty_scope", preAggregation: "dev_pre_aggregations.program__programs_empty_scope", rows: 1, cubeRows: [{ avgPriceOrMinusOne: null, programs: null }] }, + { report: "WeekShapes", view: "v_week_shapes", preAggregation: "dev_pre_aggregations.week__week_shapes", rows: 3 }, + { report: "SessionsByProgram", view: "v_sessions_by_program", preAggregation: "dev_pre_aggregations.session__sessions_by_program", rows: 3 }, + { report: "ProgramSessions", view: "v_program_sessions", preAggregation: undefined, rows: 11 }, + { report: "NodesByParent", view: "v_nodes_by_parent", preAggregation: "dev_pre_aggregations.node__nodes_by_parent", rows: 3 }, + { report: "AuthsByKind", view: "v_auths_by_kind", preAggregation: "dev_pre_aggregations.auth__auths_by_kind", rows: 3 }, ]; // --------------------------------------------------------------------------------------------- @@ -254,15 +255,17 @@ describe(`cube-model live check (${CUBE_IMAGE}, development mode)`, () => { test.skipIf(skip)("Table I covers exactly the canonical model's served reports", () => { const served = requireCanonical().objects().filter(servedReport).map((r) => r.name).sort(); expect(served).toEqual(TABLE_I.map((e) => e.report).sort()); + // Loop-invariant: walk and parse the golden tree ONCE, not once per report. + const golden = readTree(CANONICAL_EXPECTED_DIR); + const views = declaredViews(golden).map((v) => String(v.name)); for (const want of TABLE_I) { const report = findReport(want.report); expect(reportReadSource(report)?.physicalName).toBe(want.view); // Table I's rollup column agrees with the golden: a rollup named after the report on its // `@from` cube exactly when Table I expects Cube to answer from one. const cube = reportShape(report, requireCanonical()).from.name; - expect(declaredRollups(readTree(CANONICAL_EXPECTED_DIR), cube).includes(want.report)).toBe(want.preAggregation !== undefined); + expect(declaredRollups(golden, cube).includes(want.report)).toBe(want.preAggregation !== undefined || want.unusedRollup === true); // And a Cube view named after the report exactly when it declares @spine. - const views = declaredViews(readTree(CANONICAL_EXPECTED_DIR)).map((v) => String(v.name)); expect(views.includes(want.report)).toBe(reportSpine(report) !== undefined); } }); @@ -292,10 +295,15 @@ describe(`cube-model live check (${CUBE_IMAGE}, development mode)`, () => { ); } expect(viewRows.length).toBe(want.rows); - if (!rowsMatch) { + if (!rowsMatch && want.cubeRows === undefined) { console.error(`${want.report}: Cube's SQL for ${JSON.stringify(plan.query)}:\n${await cubeSql(s, plan.query)}`); } - expect(cubeRows).toEqual(viewRows); + if (want.cubeRows !== undefined) { + expect(cubeRows).toEqual([...want.cubeRows]); + expect(cubeRows).not.toEqual(viewRows); + } else { + expect(cubeRows).toEqual(viewRows); + } }, TEST_TIMEOUT_MS); } 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 bf2e66e02..05f7b442b 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 @@ -1,15 +1,16 @@ // api-contract-report-generated-server.ts — boots the GENERATED report routes (the // deployed artifact) over HTTP and drives them against the FR-044 report corpus. // -// The model is three writable entities (`Invoice`; `Product` and `Sale`, a parent and its -// child) and five `object.report` nodes; four declare `source.rdb @kind:view` and are -// served, `InvoiceDays` declares no source and must emit nothing. It: +// The model is the corpus's writable entities (`Invoice`; `Product` and `Sale`, a parent +// and its child; the sales categories) and its `object.report` nodes: every served report +// declares `source.rdb @kind:view`, and `InvoiceDays` declares no source and must emit +// nothing (the counts live in `SERVED_REPORTS` and the seed, not in this prose). It: // 1. runs the real codegen (runGen) over report/meta.json into a temp dir and fails // if a routes file was emitted for the sourceless report; // 2. provisions the base tables by hand in the EMITTED snake_case spelling, then // creates each view from buildReportViews (the real lowering, the real view SQL) // under the emitted names, so the route reads through the real view; -// 3. imports the four EMITTED .routes.ts files unmodified and mounts them. +// 3. imports each EMITTED .routes.ts file unmodified and mounts them. // // Generated lane only, and on every port — see the corpus README. @@ -105,7 +106,16 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ "reference" varchar(40) NOT NULL, "status" varchar(20) NOT NULL, "amount_cents" bigint NOT NULL, - "issued_on" date NOT NULL + "issued_on" date NOT NULL, + "disputed" boolean NOT NULL + );`, + }, + { + name: "categories", + ddl: ` + CREATE TABLE IF NOT EXISTS "categories" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL );`, }, { @@ -113,7 +123,9 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ ddl: ` CREATE TABLE IF NOT EXISTS "products" ( "id" bigserial PRIMARY KEY, - "name" text NOT NULL + "name" text NOT NULL, + "sku" uuid, + "category_id" bigint REFERENCES "categories" ("id") );`, }, { @@ -139,7 +151,11 @@ export const SERVED_REPORTS = [ { name: "InvoiceStatusTotals", registrar: "invoiceStatusTotalsRoutes" }, { name: "InvoicesByMonth", registrar: "invoicesByMonthRoutes" }, { name: "InvoiceTotals", registrar: "invoiceTotalsRoutes" }, + { name: "InvoiceCalendar", registrar: "invoiceCalendarRoutes" }, + { name: "InvoiceDisputes", registrar: "invoiceDisputesRoutes" }, { name: "ProductRevenue", registrar: "productRevenueRoutes" }, + { name: "SalesByCategory", registrar: "salesByCategoryRoutes" }, + { name: "SalesBySku", registrar: "salesBySkuRoutes" }, ] 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 4d47839d0..234650460 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 @@ -27,11 +27,19 @@ const TABLES = [ \`reference\` VARCHAR(40) NOT NULL, \`status\` VARCHAR(20) NOT NULL, \`amountCents\` BIGINT NOT NULL, - \`issuedOn\` DATE NOT NULL + \`issuedOn\` DATE NOT NULL, + \`disputed\` BOOLEAN NOT NULL )`, - `CREATE TABLE \`products\` ( + `CREATE TABLE \`categories\` ( \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, \`name\` VARCHAR(200) NOT NULL +)`, + `CREATE TABLE \`products\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`name\` VARCHAR(200) NOT NULL, + \`sku\` VARCHAR(36), + \`categoryId\` BIGINT, + FOREIGN KEY (\`categoryId\`) REFERENCES \`categories\` (\`id\`) )`, `CREATE TABLE \`sales\` ( \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, @@ -41,6 +49,7 @@ const TABLES = [ )`, ]; + /** The bytes of report/schema.mysql.sql for a loaded report/meta.json. */ export function generateReportApiMysqlSchemaSql(root: MetaRoot): string { return renderMysqlSchemaArtifact(HEADER, TABLES, root); 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 e288fedef..a4ef3d8da 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 @@ -33,14 +33,25 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ \`reference\` VARCHAR(40) NOT NULL, \`status\` VARCHAR(20) NOT NULL, \`amount_cents\` BIGINT NOT NULL, - \`issued_on\` DATE NOT NULL + \`issued_on\` DATE NOT NULL, + \`disputed\` BOOLEAN NOT NULL + )`, + }, + { + name: "categories", + ddl: `CREATE TABLE \`categories\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`name\` VARCHAR(200) NOT NULL )`, }, { name: "products", ddl: `CREATE TABLE \`products\` ( \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - \`name\` VARCHAR(200) NOT NULL + \`name\` VARCHAR(200) NOT NULL, + \`sku\` VARCHAR(36), + \`category_id\` BIGINT, + FOREIGN KEY (\`category_id\`) REFERENCES \`categories\` (\`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 986380151..9fdde4fbf 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 @@ -48,7 +48,16 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ "reference" TEXT NOT NULL, "status" TEXT NOT NULL, "amount_cents" INTEGER NOT NULL, - "issued_on" TEXT NOT NULL + "issued_on" TEXT NOT NULL, + "disputed" INTEGER NOT NULL + )`, + }, + { + name: "categories", + ddl: ` + CREATE TABLE "categories" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL )`, }, { @@ -56,7 +65,9 @@ const BASE_TABLES: ReadonlyArray<{ name: string; ddl: string }> = [ ddl: ` CREATE TABLE "products" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "name" TEXT NOT NULL + "name" TEXT NOT NULL, + "sku" TEXT, + "category_id" INTEGER REFERENCES "categories" ("id") )`, }, { 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 ff8c96d32..600864e16 100644 --- a/server/typescript/packages/integration-tests/src/canonical-schema-mysql.ts +++ b/server/typescript/packages/integration-tests/src/canonical-schema-mysql.ts @@ -4,8 +4,8 @@ // TABLES here are the adopter's hand-written DDL, exactly as docs/recipes/mysql.md tells an // adopter to write them. The VIEWS are not hand-written: each is `buildReportViews(root, // { dialect: "mysql" })`, the one producer of report SQL, wrapped in the recipe's -// `CREATE VIEW`. Only the tables the report scenarios read are declared (programs, weeks, -// assets) — the rest of the canonical model gates the entity runtime on Postgres. +// `CREATE VIEW`. Only the tables the report scenarios read are declared (programs, weeks, nodes, +// sessions, measurements, assets, auths, all_types) — the rest of the canonical model gates the entity runtime on Postgres. // // Committed as fixtures/persistence-conformance/canonical/schema.mysql.sql and drift-checked // by test/schema-artifact.test.ts, so a port that runs the report scenarios on MySQL executes @@ -55,6 +55,56 @@ const TABLES = [ \`observedAt\` DATETIME(3) NOT NULL, \`asOfDate\` DATE NOT NULL, \`atTime\` TIME(3) NOT NULL +)`, + `CREATE TABLE \`nodes\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`label\` VARCHAR(80) NOT NULL, + \`parentId\` BIGINT, + FOREIGN KEY (\`parentId\`) REFERENCES \`nodes\` (\`id\`) +)`, + `CREATE TABLE \`sessions\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`weekId\` BIGINT NOT NULL, + \`minutes\` INT NOT NULL, + FOREIGN KEY (\`weekId\`) REFERENCES \`weeks\` (\`id\`) +)`, + `CREATE TABLE \`measurements\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`tempC\` FLOAT NOT NULL, + \`massKg\` DOUBLE NOT NULL, + \`preciseKg\` DECIMAL(9,4) NOT NULL +)`, + `CREATE TABLE \`auths\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`type\` VARCHAR(16), + \`reference\` VARCHAR(80) NOT NULL, + \`quantity\` INT, + \`copayAmount\` DECIMAL(10,2), + \`approver\` VARCHAR(80), + \`urgency\` VARCHAR(16) +)`, + `CREATE TABLE \`all_types\` ( + \`id\` VARCHAR(36) NOT NULL DEFAULT (UUID()) PRIMARY KEY, + \`sVal\` VARCHAR(200) NOT NULL, + \`iVal\` INT NOT NULL, + \`lVal\` BIGINT NOT NULL, + \`dVal\` DOUBLE NOT NULL, + \`fVal\` FLOAT NOT NULL, + \`decVal\` DECIMAL(18,6) NOT NULL, + \`bVal\` BOOLEAN NOT NULL, + \`dateVal\` DATE NOT NULL, + \`timeVal\` TIME(3) NOT NULL, + \`tsVal\` DATETIME(3) NOT NULL, + \`tsTzVal\` DATETIME(3) NOT NULL, + \`moneyVal\` BIGINT NOT NULL, + \`enumVal\` VARCHAR(6) NOT NULL, + \`intEnumVal\` INT, + \`uuidVal\` VARCHAR(36) NOT NULL, + \`uriVal\` VARCHAR(255) NOT NULL, + \`inetVal\` VARCHAR(45) NOT NULL, + \`inet6Val\` VARCHAR(45) NOT NULL, + \`settings\` JSON, + \`labels\` JSON )`, ]; diff --git a/server/typescript/packages/integration-tests/src/drift-residue.ts b/server/typescript/packages/integration-tests/src/drift-residue.ts new file mode 100644 index 000000000..9ee34b453 --- /dev/null +++ b/server/typescript/packages/integration-tests/src/drift-residue.ts @@ -0,0 +1,33 @@ +import type { Change } from "@metaobjectsdev/migrate-ts"; + +/** A change that touches a managed VIEW: created, dropped, or recreated around base-table drift. */ +export function isViewChange(c: Change): boolean { + return c.kind === "create-view" || c.kind === "drop-view" || c.kind === "replace-view"; +} + +/** The `all_types` columns whose lack of a SQLite storage class produces the residue below. */ +export const INET_COLUMNS: ReadonlySet = new Set(["inetVal", "inet6Val"]); + +/** + * The KNOWN residue the SQLite lanes tolerate — named once, because the two tests that + * filter it (`report-views-sqlite.test.ts`, `view-recreate-drift-sqlite.test.ts`) otherwise + * drift from each other on the next shift, as they did when this widened. + * + * `field.inet` has no SQLite storage class of its own: the canonical model's `all_types` + * table declares two inet columns, SQLite introspects them back as TEXT, and the diff + * reports a blocked `text -> inet` change on every run. That is a property of the table, + * not of any report — and the one report view that reads `all_types` (`AllTypesBreakdown`) + * is recreated around that blocked table change, which the diff says so (reason + * `unchanged`, on `all_types`): it is residue of the same table drift, not a view that + * differs. + * + * Named, not swallowed: a column-type change on any OTHER table, or a view recreated with + * any other reason, fails the lane that filters this. + */ +export function isInetResidue(c: Change): boolean { + return ( + (c.kind === "change-column-type" && c.table === "all_types" && INET_COLUMNS.has(c.column) && + c.to.kind === "inet") || + (isViewChange(c) && "reason" in c && c.reason?.kind === "unchanged" && c.reason.tables.join() === "all_types") + ); +} diff --git a/server/typescript/packages/integration-tests/src/engine-wire.ts b/server/typescript/packages/integration-tests/src/engine-wire.ts index fceaf4218..1bb331b46 100644 --- a/server/typescript/packages/integration-tests/src/engine-wire.ts +++ b/server/typescript/packages/integration-tests/src/engine-wire.ts @@ -18,18 +18,47 @@ // of a report row; an unknown entity, a non-report row and a null pass through untouched. import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { canonicalDecimal, canonicalFloat, DECIMAL_RE } from "./normalization.ts"; -import { REPORT_SHAPES_PATH } from "./paths.ts"; +import { CANONICAL_DIR, REPORT_SHAPES_PATH } from "./paths.ts"; export type WireEngine = "sqlite" | "mysql"; -interface ShapeField { readonly name: string; readonly subType: string } +interface ShapeField { readonly name: string; readonly subType: string; readonly typeSource: string | null } -let shapes: Map> | undefined; +interface WireField { readonly subType: string; readonly localTime: boolean } -/** report entity simple name -> (field name -> subType), from the committed report-shapes.json. */ -function reportShapes(): Map> { +let shapes: Map> | undefined; +let localTimeFields: Set | undefined; + +/** + * `Entity.field` of every `@localTime` timestamp in the canonical model: a naive TIMESTAMP, whose + * wire form carries no zone (`2026-05-04T22:10:00`), unlike a TIMESTAMPTZ's `...Z`. Read from the + * model's JSON rather than re-declared here. + */ +function localTimeTimestamps(): Set { + if (localTimeFields) return localTimeFields; + const out = new Set(); + const root = JSON.parse(readFileSync(join(CANONICAL_DIR, "meta.fitness.json"), "utf8")) as { "metadata.root": { children?: unknown[] } }; + for (const node of root["metadata.root"].children ?? []) { + const [, body] = Object.entries(node as Record)[0] ?? []; + for (const child of body?.children ?? []) { + const [type, attrs] = Object.entries(child as Record>)[0] ?? []; + if (type === "field.timestamp" && attrs?.["@localTime"] === true) out.add(`${body?.name}.${attrs["name"]}`); + } + } + localTimeFields = out; + return out; +} + +/** True when the field's type comes from a `@localTime` timestamp of the model. */ +function localTimeOf(f: ShapeField): boolean { + return f.typeSource !== null && localTimeTimestamps().has(f.typeSource.slice(f.typeSource.lastIndexOf(":") + 1)); +} + +/** report entity simple name -> (field name -> subType and zone-ness), from the committed report-shapes.json. */ +function reportShapes(): Map> { if (shapes) return shapes; const parsed = JSON.parse(readFileSync(REPORT_SHAPES_PATH, "utf8")) as { reports: ReadonlyArray<{ report: string; fields: ReadonlyArray }>; @@ -37,7 +66,7 @@ function reportShapes(): Map> { shapes = new Map( parsed.reports.map((r) => [ r.report.slice(r.report.lastIndexOf(":") + 1), - new Map(r.fields.map((f) => [f.name, f.subType])), + new Map(r.fields.map((f) => [f.name, { subType: f.subType, localTime: localTimeOf(f) }])), ]), ); return shapes; @@ -55,18 +84,23 @@ function decimalText(v: unknown): unknown { const pad = (n: number): string => n.toString().padStart(2, "0"); -/** An instant -> `YYYY-MM-DDTHH:MM:SS[.fff]Z` in UTC, the corpus' TIMESTAMPTZ spelling. */ -function instantText(engine: WireEngine, v: unknown): unknown { +/** + * An instant -> `YYYY-MM-DDTHH:MM:SS[.fff]Z` in UTC, the corpus' TIMESTAMPTZ spelling; a naive + * (`@localTime`) timestamp keeps the wall clock and carries no `Z`, as it does on Postgres. + */ +function instantText(engine: WireEngine, v: unknown, localTime: boolean): unknown { if (typeof v !== "string") return v; - // MySQL DATETIME has no zone: the lane holds the UTC wall clock, so read it as UTC. - const iso = engine === "mysql" && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(v) ? `${v.replace(" ", "T")}Z` : v; + // MySQL DATETIME has no zone: the lane holds the UTC wall clock, so read it as UTC. A naive + // value from any engine is read as UTC too, so the host's zone cannot move its wall clock. + const zoned = /[zZ]|[+-]\d{2}:?\d{2}$/.test(v); + const iso = (engine === "mysql" || localTime) && !zoned ? `${v.replace(" ", "T")}Z` : v; const d = new Date(iso); if (Number.isNaN(d.getTime())) return v; const ms = d.getUTCMilliseconds(); const frac = ms === 0 ? "" : `.${String(ms).padStart(3, "0").replace(/0+$/, "")}`; return ( `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` + - `T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}${frac}Z` + `T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}${frac}${localTime ? "" : "Z"}` ); } @@ -77,7 +111,8 @@ export function toWireRow(engine: WireEngine, entity: string, row: Record = {}; for (const [name, value] of Object.entries(row)) { if (value === null || value === undefined) { out[name] = value; continue; } - switch (fields.get(name)) { + const field = fields.get(name); + switch (field?.subType) { case "long": case "currency": // integral minor units: a BIGINT on the wire, like a count out[name] = typeof value === "number" || typeof value === "bigint" ? String(value) : value; @@ -86,7 +121,7 @@ export function toWireRow(engine: WireEngine, entity: string, row: Record>> => { }; describe("api-contract report corpus", () => { - test("the model loads and four of its five reports are view-backed", async () => { + test("the model loads and all but one of its reports are view-backed", async () => { const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); const reports = root.objects().filter((o) => o.subType === OBJECT_SUBTYPE_REPORT); expect(reports.map((r) => r.name)).toEqual( - ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceDays", "ProductRevenue"]); + ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", "InvoiceDays", + "ProductRevenue", "SalesByCategory", "SalesBySku"]); expect(reports.filter((r) => reportReadSource(r) !== undefined).map((r) => r.name)).toEqual( - ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "ProductRevenue"]); + ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", + "ProductRevenue", "SalesByCategory", "SalesBySku"]); }); 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", "ProductRevenue"].map(segment)).toEqual( - ["invoice_status_totals", "invoices_by_months", "invoice_totals", "product_revenues"]); - expect(root.objects().length).toBe(8); + expect(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", + "ProductRevenue", "SalesByCategory", "SalesBySku"].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); }); test("each seeded report row has exactly the report's derived fields", async () => { @@ -47,7 +51,8 @@ describe("api-contract report corpus", () => { for (const row of rows) expect(Object.keys(row)).toEqual(fields); } expect(Object.keys(seedReports())).toEqual( - ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "ProductRevenue"]); + ["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", + "SalesByCategory", "SalesBySku", "ProductRevenue"]); }); // A lane inserts every top-level key but `reports` in file order, so the order is the @@ -55,9 +60,9 @@ describe("api-contract report corpus", () => { test("the base tables of seed.json are the model's tables, listed parents first", async () => { const root = await loadMetadataFile(join(API_CONTRACT_REPORT_DIR, "meta.json")); const tables = Object.keys(seed).filter((k) => k !== SEED_REPORTS_KEY); - expect(tables).toEqual(["invoices", "products", "sales"]); + expect(tables).toEqual(["invoices", "categories", "products", "sales"]); // Every key is an entity's table and every row key one of its fields (seedInserts throws otherwise). - expect(seedInserts(root, seed).length).toBe(5 + 3 + 3); + expect(seedInserts(root, seed).length).toBe(5 + 2 + 3 + 3); for (const [i, table] of tables.entries()) { const entity = root.objects().find((o) => o.isEntity() && resolveTableName(o) === table); if (entity === undefined) throw new Error(`no entity has table ${table}`); @@ -71,9 +76,9 @@ describe("api-contract report corpus", () => { } }); - test("every scenario parses, and there are sixteen", () => { + test("every scenario parses, and there are twenty-two", () => { const scenarios = loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR); - expect(scenarios.length).toBe(16); + expect(scenarios.length).toBe(22); for (const s of scenarios) expect(s.requests.length).toBeGreaterThan(0); }); 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 f05ddbe04..abd09ee7f 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 @@ -26,7 +26,11 @@ const SERVED_PATHS: Array<[string, string]> = [ ["InvoiceStatusTotals", "/api/invoice_status_totals"], ["InvoicesByMonth", "/api/invoices_by_months"], ["InvoiceTotals", "/api/invoice_totals"], + ["InvoiceCalendar", "/api/invoice_calendars"], + ["InvoiceDisputes", "/api/invoice_disputes"], ["ProductRevenue", "/api/product_revenues"], + ["SalesByCategory", "/api/sales_by_categories"], + ["SalesBySku", "/api/sales_by_skus"], ]; 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 a655dc872..313d97aca 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 @@ -32,7 +32,7 @@ describe("canonical report-shapes artifact (report-shapes.json)", () => { expect(generated).toBe(committed); }); - test("the nine canonical reports appear in declaration order, with the contract's byte format", () => { + test("the canonical reports appear in declaration order, with the contract's byte format", () => { const committed = readReportShapesJson(); expect(committed.endsWith("}\n")).toBe(true); expect(committed.startsWith('{\n "reports": [\n {\n "report": "fitness::ProgramMinutes"')).toBe(true); @@ -47,6 +47,18 @@ describe("canonical report-shapes artifact (report-shapes.json)", () => { ["fitness::ProgramRoster", "v_program_roster"], ["fitness::ProgramLongWeeks", "v_program_long_weeks"], ["fitness::FitnessTotalsFilled", "v_fitness_totals_filled"], + ["fitness::MeasurementStats", "v_measurement_stats"], + ["fitness::MeasurementStatsFilled", "v_measurement_stats_filled"], + ["fitness::AssetDays", "v_asset_days"], + ["fitness::AllTypesBreakdown", "v_all_types_breakdown"], + ["fitness::ProgramsOverTime", "v_programs_over_time"], + ["fitness::LiveIntroPrograms", "v_live_intro_programs"], + ["fitness::ProgramsEmptyScope", "v_programs_empty_scope"], + ["fitness::WeekShapes", "v_week_shapes"], + ["fitness::SessionsByProgram", "v_sessions_by_program"], + ["fitness::ProgramSessions", "v_program_sessions"], + ["fitness::NodesByParent", "v_nodes_by_parent"], + ["fitness::AuthsByKind", "v_auths_by_kind"], ]); }); 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 a37fa83f5..d615248f0 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 @@ -86,9 +86,14 @@ function reportViews(root: MetaRoot) { return buildReportViews(root, { dialect: "mysql", columnNamingStrategy: "literal" }); } -/** `CREATE VIEW` for every report view of `root`, exactly as the recipe shows. */ -async function createViews(root: MetaRoot): Promise { - const views = reportViews(root); +/** + * `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 + * 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 { + const views = reportViews(root).filter((v) => only === undefined || only.includes(v.name)); for (const v of views) { await conn.query(`DROP VIEW IF EXISTS \`${v.name}\``); await conn.query(`CREATE VIEW \`${v.name}\` AS\n${v.sql}`); @@ -209,7 +214,7 @@ beforeAll(async () => { for (const t of OWN_TABLES) await conn.query(`DROP TABLE IF EXISTS \`${t}\``); for (const ddl of DDL) await conn.query(ddl); canonical = await loadMetadataDir(CANONICAL_DIR); - await createViews(canonical); + await createViews(canonical, CANONICAL_VIEWS); }, 240_000); afterAll(async () => { @@ -231,7 +236,7 @@ describe("report views — canonical model on real MySQL 8.4", () => { expect(String(mode?.s)).toContain("ONLY_FULL_GROUP_BY"); // Re-create them here, under the mode just asserted, so this test does not lean on beforeAll. - const names = await createViews(canonical); + const names = await createViews(canonical, CANONICAL_VIEWS); expect([...names].sort()).toEqual([...CANONICAL_VIEWS].sort()); for (const name of names) { // Selecting proves the stored body still resolves, not merely that it parsed. diff --git a/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts index 41fb66355..d65bee100 100644 --- a/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts @@ -29,10 +29,11 @@ import { join } from "node:path"; import { Kysely, sql } from "kysely"; import { LibsqlDialect } from "@libsql/kysely-libsql"; import { - buildExpectedSchema, diff, emit, introspectSqlite, type Change, type SchemaSnapshot, + buildExpectedSchema, diff, emit, introspectSqlite, type SchemaSnapshot, } from "@metaobjectsdev/migrate-ts"; import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { isInetResidue } from "../src/drift-residue.ts"; import { loadMetadataDir } from "../src/load-metadata.ts"; import { CANONICAL_DIR } from "../src/paths.ts"; @@ -71,16 +72,8 @@ function expectedFor(root: MetaRoot): SchemaSnapshot { }); } -/** - * `field.inet` has no SQLite storage class of its own: the canonical model's `all_types` - * table declares two, SQLite introspects them back as TEXT, and the diff reports a blocked - * `text -> inet` change on every run. That is a property of the table, not of any report - * (nothing here reads `all_types`), and it is the only residue the canonical model leaves. - * It is named, not swallowed: a residual change on any OTHER table or on any view fails. - */ -const INET_COLUMNS: ReadonlySet = new Set(["inetVal", "inet6Val"]); -const isInetResidue = (c: Change): boolean => - c.kind === "change-column-type" && c.table === "all_types" && INET_COLUMNS.has(c.column) && c.to.kind === "inet"; +// The known `field.inet`-on-SQLite residue is named once in `../src/drift-residue.ts` — +// with the view-recreation it silences — so the lanes that tolerate it cannot drift apart. /** build -> introspect -> diff -> emit -> apply. */ async function migrate(root: MetaRoot) { @@ -153,7 +146,7 @@ describe("report views — canonical model on real SQLite", () => { ({ expected } = await migrate(canonical)); }); - test("CONVERGENCE: the nine canonical views apply, then a second and third migrate propose nothing (the emitter is deterministic)", async () => { + test("CONVERGENCE: the canonical views apply, then a second and third migrate propose nothing (the emitter is deterministic)", async () => { const views = (await select(`SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name`)).map((r) => r.name); for (const v of [ "v_program_minutes", "v_fitness_totals", "v_programs_by_month", diff --git a/server/typescript/packages/integration-tests/test/view-recreate-drift-pg.test.ts b/server/typescript/packages/integration-tests/test/view-recreate-drift-pg.test.ts index 9c5d56e5c..c9a2678bb 100644 --- a/server/typescript/packages/integration-tests/test/view-recreate-drift-pg.test.ts +++ b/server/typescript/packages/integration-tests/test/view-recreate-drift-pg.test.ts @@ -9,10 +9,11 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test"; import { buildExpectedSchema, collectUnmanagedNames, computeDriftFromActual, diff, emit, introspectPostgres, - isViewRecreateOnly, type Change, type SchemaSnapshot, + isViewRecreateOnly, type SchemaSnapshot, } from "@metaobjectsdev/migrate-ts"; import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { isViewChange } from "../src/drift-residue.ts"; import { Kysely, PostgresDialect, sql } from "kysely"; import { Pool } from "pg"; import { startPostgres, type RunningPg } from "../src/postgres-container.ts"; @@ -52,10 +53,6 @@ async function applyRaw(text: string): Promise { } } -function isViewChange(c: Change): boolean { - return c.kind === "create-view" || c.kind === "drop-view" || c.kind === "replace-view"; -} - async function migrate(allow = {}) { const expected = expectedSchema(); const unmanagedNames = collectUnmanagedNames(canonical); diff --git a/server/typescript/packages/integration-tests/test/view-recreate-drift-sqlite.test.ts b/server/typescript/packages/integration-tests/test/view-recreate-drift-sqlite.test.ts index 69e7fd938..04cecf81f 100644 --- a/server/typescript/packages/integration-tests/test/view-recreate-drift-sqlite.test.ts +++ b/server/typescript/packages/integration-tests/test/view-recreate-drift-sqlite.test.ts @@ -19,10 +19,11 @@ import { Kysely, sql } from "kysely"; import { LibsqlDialect } from "@libsql/kysely-libsql"; import { buildExpectedSchema, computeDriftFromActual, diff, emit, introspectSqlite, isViewRecreateOnly, - type Change, type SchemaSnapshot, + type SchemaSnapshot, } from "@metaobjectsdev/migrate-ts"; import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { isInetResidue, isViewChange } from "../src/drift-residue.ts"; import { loadMetadataDir } from "../src/load-metadata.ts"; import { CANONICAL_DIR } from "../src/paths.ts"; @@ -54,18 +55,8 @@ async function applyRaw(text: string): Promise { for (const stmt of text.trim().split(";").map((s) => s.trim()).filter(Boolean)) await sql.raw(stmt).execute(k); } -function isViewChange(c: Change): boolean { - return c.kind === "create-view" || c.kind === "drop-view" || c.kind === "replace-view"; -} - -/** - * `field.inet` has no SQLite storage class, so the canonical `all_types` table reports a - * blocked `text -> inet` change on every run (see report-views-sqlite.test.ts). No view - * reads `all_types`. It is named here, not swallowed: any other residue still fails. - */ -const INET_COLUMNS: ReadonlySet = new Set(["inetVal", "inet6Val"]); -const isInetResidue = (c: Change): boolean => - c.kind === "change-column-type" && c.table === "all_types" && INET_COLUMNS.has(c.column) && c.to.kind === "inet"; +// The known `field.inet`-on-SQLite residue is named once in `../src/drift-residue.ts` — +// with the view-recreation it silences — so the lanes that tolerate it cannot drift apart. /** * The canonical model migrated from empty, except that `weeks` is created without its @@ -97,7 +88,7 @@ describe("D3 — a view recreated only around a table rebuild is not drift", () test(`${dialect}: the diff still plans each pair, marked unchanged`, async () => { await migrateWithWeeksFkMissing(); const r = await diff({ expected: expectedFor(dialect), actual: await introspectSqlite(k), dialect }); - const views = r.changes.filter(isViewChange); + const views = r.changes.filter((c) => isViewChange(c) && !isInetResidue(c)); expect(views.length).toBeGreaterThan(0); for (const c of views) { expect(isViewRecreateOnly(c)).toBe(true);