From 0e0e373e0760550943779ffc2434e32849489a39 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 27 Oct 2026 20:25:41 -0400 Subject: [PATCH 1/7] feat(reporting): export a report as CSV, JSON or XML via ?format= in all five ports Adds the standalone report-export catalog generator (importable and ejectable in every port), the ?format= branch on report list routes, the invalid_format, export_too_large and export_with_count envelopes, a maxRows cap that never truncates, a CSV formula-injection guard, and six shared api-contract scenarios. ADR-0031 Amendment 1 records that JSON and XML come from each port's standard serializer for now. Excel (xlsx) is reserved by name only. --- CHANGELOG.md | 24 + docs/CONFORMANCE.md | 8 +- docs/README.md | 1 + docs/features/api-contract.md | 60 +++ docs/features/reporting.md | 58 +++ docs/ports/csharp.md | 31 ++ docs/ports/java.md | 29 ++ docs/ports/kotlin.md | 30 ++ docs/ports/python.md | 18 + docs/ports/typescript.md | 22 + .../codegen/generators/routes.ts | 12 +- .../codegen/runtime/drizzle-fastify/index.ts | 1 + .../drizzle-fastify/mount-read-only.ts | 37 +- .../runtime/drizzle-fastify/report-export.ts | 135 +++++ .../showcase/codegen/generators/routes.ts | 12 +- .../codegen/runtime/drizzle-fastify/index.ts | 1 + .../drizzle-fastify/mount-read-only.ts | 37 +- .../runtime/drizzle-fastify/report-export.ts | 135 +++++ .../api-contract-conformance/report/README.md | 38 +- .../api-contract-conformance/report/meta.json | 20 + .../report/scenarios/export-csv.yaml | 58 +++ .../scenarios/export-invalid-format.yaml | 48 ++ .../report/scenarios/export-json.yaml | 45 ++ .../report/scenarios/export-too-large.yaml | 59 +++ .../report/scenarios/export-with-count.yaml | 35 ++ .../report/scenarios/export-xml.yaml | 34 ++ .../report/schema.mysql.sql | 14 + .../report/schema.postgres.sql | 16 + .../report/schema.sqlite.sql | 14 + .../api-contract-conformance/report/seed.json | 24 + .../registry.json | 7 + .../GenListAndSelectionTests.cs | 3 +- .../GeneratorRegistryTests.cs | 2 + .../ReportExportGeneratorTests.cs | 242 +++++++++ .../ReportingInertTests.cs | 10 +- .../MetaObjects.Codegen/CodegenRunner.cs | 1 + .../csharp/MetaObjects.Codegen/Generator.cs | 28 ++ .../MetaObjects.Codegen/GeneratorRegistry.cs | 16 + .../Generators/ReportExportGenerator.cs | 306 ++++++++++++ .../Generators/RoutesGenerator.cs | 56 ++- .../MetaObjects.Codegen/HelperRuntime.cs | 3 + .../MetaObjects.Codegen.csproj | 2 + .../Runtime/ReportExportHttp.cs | 106 ++++ .../Api/ApiContractExportAssertions.cs | 100 ++++ .../Api/ApiContractReportConformanceTest.cs | 15 +- .../Api/ApiContractScenarios.cs | 17 +- .../Api/ReportGeneratedServerFactory.cs | 21 +- server/java/codegen-kotlin/pom.xml | 1 + .../generator/kotlin/GeneratorRegistry.kt | 15 +- .../kotlin/KotlinReportExportGenerator.kt | 392 +++++++++++++++ .../kotlin/KotlinSpringControllerGenerator.kt | 128 ++++- .../kotlin/KotlinReportExportGeneratorTest.kt | 189 +++++++ .../generator/kotlin/ReportingInertTest.kt | 12 +- server/java/codegen-spring/pom.xml | 2 + .../generator/GeneratorRegistry.java | 16 +- .../spring/SpringControllerGenerator.java | 148 +++++- .../spring/SpringReportExportGenerator.java | 460 ++++++++++++++++++ .../spring/runtime/ReportExport.java | 87 ++++ .../GeneratedReportExportCompileRunTest.java | 231 +++++++++ .../generator/spring/ReportingInertTest.java | 8 +- .../spring/runtime/ReportExportTest.java | 58 +++ .../kotlin/api/ApiContractAssertions.kt | 58 +++ .../kotlin/api/ApiContractScenarioLoader.kt | 2 + .../kotlin/api/ApiContractScenarios.kt | 4 + .../integration/kotlin/api/TomcatHost.kt | 25 +- .../integration/kotlin/api/XmlInfoset.kt | 89 ++++ ...portGeneratedApiContractConformanceTest.kt | 14 +- .../GeneratedReportControllerHarness.kt | 25 +- .../api/ApiContractAssertions.java | 44 ++ .../api/ApiContractScenarioLoader.java | 10 +- .../integration/api/ApiContractScenarios.java | 11 +- .../integration/api/ReportCorpus.java | 2 +- .../api/ReportOmdbApiContractLane.java | 17 +- .../integration/api/TomcatHost.java | 32 +- .../integration/api/XmlInfoset.java | 98 ++++ .../GeneratedReportControllerHarness.java | 34 +- .../mojo/EjectRuntimeRoundTripTest.java | 7 + .../metaobjects/mojo/EjectSupportTest.java | 5 +- .../src/metaobjects/codegen/generator.py | 3 + .../metaobjects/codegen/generator_registry.py | 16 +- .../generators/report_export_generator.py | 240 +++++++++ .../codegen/generators/router_generator.py | 160 +++++- .../python/src/metaobjects/codegen/runner.py | 1 + .../codegen/runtime/report_export.py | 74 +++ .../python/tests/codegen/test_cli_registry.py | 2 +- .../tests/codegen/test_report_export.py | 169 +++++++ .../tests/integration/generated_report_app.py | 13 +- .../integration/test_api_contract_report.py | 36 +- server/python/tests/test_reporting_inert.py | 10 +- .../catalog-declarations-resolved.test.ts | 5 + .../cli/test/eject-templates-contract.test.ts | 5 +- .../cli/test/unit/reporting-inert.test.ts | 2 + .../codegen-ts/src/generator-registry.ts | 20 + .../packages/codegen-ts/src/generator.ts | 5 + .../codegen-ts/src/generators/index.ts | 1 + .../src/generators/report-export-file.ts | 76 +++ .../codegen-ts/src/metaobjects-config.ts | 4 + .../codegen-ts/src/reference-templates.ts | 3 + .../codegen-ts/src/reference/report-export.ts | 260 ++++++++++ .../codegen-ts/src/reference/routes-hono.ts | 12 +- .../codegen-ts/src/reference/routes.ts | 12 +- .../packages/codegen-ts/src/render-context.ts | 8 +- .../packages/codegen-ts/src/runner.ts | 7 + .../src/templates/report-export-file.ts | 166 +++++++ .../src/templates/routes-file-hono.ts | 13 +- .../codegen-ts/src/templates/routes-file.ts | 13 +- .../test/generator-registry.test.ts | 1 + .../test/metaobjects-config.test.ts | 2 +- .../test/reference-byte-identical.test.ts | 42 ++ .../test/reference-templates.test.ts | 2 +- .../codegen-ts/test/report-export.test.ts | 123 +++++ .../api-contract-report-generated-server.ts | 19 +- .../src/api-contract-report-mysql-schema.ts | 5 + .../src/api-contract-report-mysql-server.ts | 16 +- .../src/api-contract-report-sqlite-server.ts | 17 +- .../src/api-contract-scenario.ts | 55 ++- .../integration-tests/src/xml-infoset.ts | 109 +++++ .../test/api-contract-report-corpus.test.ts | 20 +- .../test/api-contract-report.test.ts | 13 +- .../runtime-ts/src/drizzle-fastify/index.ts | 1 + .../src/drizzle-fastify/mount-read-only.ts | 37 +- .../src/drizzle-fastify/report-export.ts | 135 +++++ .../packages/runtime-ts/src/hono/index.ts | 1 + .../runtime-ts/src/hono/mount-read-only.ts | 28 +- .../drizzle-fastify/report-export.test.ts | 159 ++++++ .../src/generators/report-export.ts | 260 ++++++++++ .../test-generators/src/generators/routes.ts | 12 +- .../packages/test-generators/src/index.ts | 2 + ...serialization-vs-data-download-boundary.md | 41 ++ spec/roadmap.md | 2 +- 130 files changed, 6495 insertions(+), 158 deletions(-) create mode 100644 examples/advanced-modeling/codegen/runtime/drizzle-fastify/report-export.ts create mode 100644 examples/showcase/codegen/runtime/drizzle-fastify/report-export.ts create mode 100644 fixtures/api-contract-conformance/report/scenarios/export-csv.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/export-invalid-format.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/export-json.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/export-too-large.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/export-with-count.yaml create mode 100644 fixtures/api-contract-conformance/report/scenarios/export-xml.yaml create mode 100644 server/csharp/MetaObjects.Codegen.Tests/ReportExportGeneratorTests.cs create mode 100644 server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs create mode 100644 server/csharp/MetaObjects.Codegen/Runtime/ReportExportHttp.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/ApiContractExportAssertions.cs create mode 100644 server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinReportExportGenerator.kt create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportExportGeneratorTest.kt create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringReportExportGenerator.java create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/runtime/ReportExport.java create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedReportExportCompileRunTest.java create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/runtime/ReportExportTest.java create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/XmlInfoset.kt create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/XmlInfoset.java create mode 100644 server/python/src/metaobjects/codegen/generators/report_export_generator.py create mode 100644 server/python/src/metaobjects/codegen/runtime/report_export.py create mode 100644 server/python/tests/codegen/test_report_export.py create mode 100644 server/typescript/packages/codegen-ts/src/generators/report-export-file.ts create mode 100644 server/typescript/packages/codegen-ts/src/reference/report-export.ts create mode 100644 server/typescript/packages/codegen-ts/src/templates/report-export-file.ts create mode 100644 server/typescript/packages/codegen-ts/test/report-export.test.ts create mode 100644 server/typescript/packages/integration-tests/src/xml-infoset.ts create mode 100644 server/typescript/packages/runtime-ts/src/drizzle-fastify/report-export.ts create mode 100644 server/typescript/packages/runtime-ts/test/drizzle-fastify/report-export.test.ts create mode 100644 server/typescript/packages/test-generators/src/generators/report-export.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 110abade77..78ee65dd19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,30 @@ here.** ## [Unreleased] +## [Unreleased] + +### Added + +- **Report export: `GET /?format=csv|json|xml` on a served report, in all five ports.** + A new standalone `report-export` catalog generator (importable as shipped, ejectable in every + port, drift-checked by `verify --codegen`, never implied by a library) generates one export + module per served report plus one shared writer module per project, and makes the routes + generator emit the `format` branch. Without `format`, or with no export generator wired, a + response is byte-identical to before. CSV is RFC 4180 with CRLF, a header row of the derived + field names, wire-form values and a formula-injection guard on every string cell (the render + escapers' rule); JSON is the list route's array plus `Content-Disposition`; XML is + `<{Report}><{field}>...` with a pinnable null form (`xmlNulls`). A format the route does not + wire answers `400 {"error":"invalid_format","expected":[...]}`, `withCount` with a format answers + `400 export_with_count`, and more rows than the generator option `maxRows` (default 100,000) + answers `400 {"error":"export_too_large","maxRows":N}` before a byte is written; nothing is ever + truncated. With `format` set `maxRows` governs and the list route's `report_too_large` cap does + not. The export adds no dependency an adopter must install in any port, and `xlsx` is reserved + (refused today like any unwired format). Pinned by six `report/scenarios/export-*.yaml` + scenarios run by every port's generated report lane. See + [docs/features/reporting.md](docs/features/reporting.md#exporting-a-report), + [docs/features/api-contract.md](docs/features/api-contract.md#report-export) and + [ADR-0031 Amendment 1](spec/decisions/ADR-0031-serialization-vs-data-download-boundary.md). + ## [1.1.4] — 2026-10-26 _npm `1.1.4` (full lockstep across all 14 `@metaobjectsdev/*` publish candidates), PyPI `1.1.4`, NuGet `1.1.4` and Maven Central `8.1.4`._ diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 4fbac3ddc0..d42c4fde77 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -34,7 +34,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`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/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 106 (31 core + 10 tph + 9 m2m + 4 jsonb + 2 write-through + 12 projection + 27 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | +| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 112 (31 core + 10 tph + 9 m2m + 4 jsonb + 2 write-through + 12 projection + 33 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | | [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) | | [`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) | ✓ | ✓ | ✓ | ✓ | @@ -348,7 +348,7 @@ 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/` (106) +### `fixtures/api-contract-conformance/` (112) All 106 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD @@ -369,7 +369,7 @@ each corpus alone could not reach, and 1 pinning the collection-URL spelling), 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), `decimal/` (11, ADR-0019 Amendment 1: a decimal, array element and undeclared-precision column included, is one -canonical string, read exactly, filtered exactly and refused past its declared digits) and `report/` (27, +canonical string, read exactly, filtered exactly and refused past its declared digits) and `report/` (33, 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, @@ -555,7 +555,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) The fixtures in the nine corpora mapped above (metamodel 413 + yaml 16 + verify 37 -+ render 16 + persistence 59 + api-contract 106 + source-resolution 25 + scope 10 + ++ render 16 + persistence 59 + api-contract 112 + 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/README.md b/docs/README.md index d922143dfe..b958e2a88b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -72,6 +72,7 @@ this tree is documentation, not the source of truth. | Adopt a design MetaObjects already ships — users/groups/roles, an LLM trace envelope — instead of authoring it (`libraries`, `meta eject `) | [`features/libraries.md`](features/libraries.md) | | Record what the system is supposed to do, and stop agents reviving retired features | [`features/requirements.md`](features/requirements.md) | | Declare what a dashboard groups by and counts (`dimension`, `measure`, `segment`, `object.report`; load-time checked; a view-backed report becomes a SQL view served by a generated read-only list route and a TypeScript list hook) | [`features/reporting.md`](features/reporting.md) | +| Download a report as CSV, JSON or XML (`?format=`, the `report-export` generator, its cap and null forms) | [`features/reporting.md`](features/reporting.md#exporting-a-report) | | Export the reporting vocabulary to Cube (`cube-model`: cubes, joins, rollups; the `cube` lane that checks it against a real Cube) | [`features/cube-export.md`](features/cube-export.md) | | Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) | | Share a metadata shape across multiple instances (abstracts, `extends:`) | [`features/abstracts-and-inheritance.md`](features/abstracts-and-inheritance.md) | diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 3c478dd70a..bdc3723dfb 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -266,6 +266,9 @@ each handler they register instead; an `HTTPException` is rethrown to your A `limit` below 1, a negative `offset` or a value that is not an integer is `400 { "error": "pagination.invalid_value", "param": "limit" | "offset", "value", "expected" }` on a report in every port. See [reporting.md](reporting.md#how-a-report-is-served). +- **A report list can be exported: `?format=csv|json|xml`.** See + [Report export](#report-export) below. Without `format` the response is byte-identical to the list + route described here. - **No page-size maximum on an entity, and the default is per port.** The contract pins what an explicit `limit`/`offset` returns (`list-with-pagination`); it does not pin a default or a ceiling, and the ports differ. With `limit` **omitted**, the @@ -281,6 +284,63 @@ each handler they register instead; an `HTTPException` is rethrown to your `[...]` to `{ rows: [...], total: }` (needed for grid pagination). The grid hook always sends `withCount=1`. +### Report export + +One selector on the existing report list route: `GET /?format=csv|json|xml`. +The route accepts `format` only when a `report-export` generator is wired for the project (see +[reporting.md](reporting.md#exporting-a-report)); a project that wires none sees no change at all, +and `?format=` is then ignored exactly as any unknown query parameter is today. **Accept headers are +never consulted**: the selector is the query parameter, in every port. + +| Request | Answer | +|---|---| +| no `format` | Exactly the list route above: same body, same headers, same caps. | +| `format=json` | The list route's JSON array, unchanged, plus `Content-Disposition`. `Content-Type: application/json`. | +| `format=csv` | RFC 4180 CSV. `Content-Type: text/csv; charset=utf-8`. | +| `format=xml` | `application/xml`, shape below. | +| any other value (`xlsx`, `pdf`, `CSV`, empty) | `400 { "error": "invalid_format", "expected": ["csv", "json", "xml"] }`. `expected` lists the formats this route wires, in that order. `xlsx` is reserved for a later generator (`report-export-xlsx`) and is refused today like any unknown value. Matching is case-sensitive. | +| `format` set and `withCount` truthy | `400 { "error": "export_with_count" }` (`message` is free prose). An export has no `{ rows, total }` envelope, JSON included. | +| more rows than `maxRows` | `400 { "error": "export_too_large", "maxRows": N }` before a byte of the body is written. | + +- **Header comparison.** Header names are case-insensitive. A runner compares `Content-Type` with the whitespace around `;` normalized (`text/csv;charset=utf-8` equals `text/csv; charset=utf-8`), because some servers re-spell it; every other header is compared exactly. +- **Headers.** Every successful export carries + `Content-Disposition: attachment; filename="."` with `` one of `csv`, `json`, + `xml`. A response without `format` carries none. +- **Filters, sort, `limit` and `offset`** use the list route's parser, allowlists and 400 envelopes + unchanged. In export mode an absent `limit` means every row up to `maxRows`; an explicit `limit` or + `offset` is honoured. +- **One cap, two names.** `maxRows` is an option of the `report-export` generator, default 100,000. + The route fetches `maxRows + 1` rows and refuses with `export_too_large` rather than truncate, and + an explicit `limit` above `maxRows` is refused the same way. With `?format` set the export cap + governs and `report_too_large` never answers; without it the list route's own cap + (`report_too_large`, default 10,000) is untouched. A report is materialised up to the cap; there is + no streaming in this version. +- **CSV.** UTF-8, no BOM unless the generated constant `EXPORT_CSV_BOM` is flipped to true, CRLF after + every record including the last. One header row with the report's derived field names, in derived-field + order (dimensions, then measures, as `reportShape` lists them). A value is written in its wire + form: currency as integer minor units, a date as `YYYY-MM-DD`, a decimal as the `wire` setting spells + it, a boolean as `true`/`false`, null as an empty cell. A cell containing `"`, `,`, CR or LF is + quoted with embedded quotes doubled. **The formula-injection guard applies to every string-valued + cell** (`field.string`, an enum symbol, a uri): a value starting with `=`, `+`, `-`, `@`, TAB or CR is + prefixed with `'` before quoting. This is the render escapers' rule + (`render-csv-injection` fixture). Numbers, decimals, dates and booleans are never guarded, so a + negative number stays a number. +- **XML.** `<{ReportName}><{field}>value......`, no XML declaration, + no indentation, UTF-8, values in the same wire form as CSV, text and attribute content escaped + (`& < >`). There is no injection guard in XML. A null follows the generator option + `xmlNulls`: `"nil"` writes the field as an empty element with `xsi:nil="true"` and declares + `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"` on the root; `"empty"` writes an empty element + and declares nothing. **The default is each port's own standard** (ADR-0019 Amendment 2): see the + per-port table in [reporting.md](reporting.md#exporting-a-report). The corpus pins `"nil"` in every lane + and compares the body as an XML infoset (names, attributes, text, order), not byte for byte. +- **JSON** is whatever the list route's standard serializer writes in that port, so a `wire` setting + applies to it exactly as it does to the list route. + +The report-export scenarios (`report/scenarios/export-*.yaml`) use three assertion keys the rest of the +corpus does not: `headers` on `expect` (a map of lower-cased header name to its exact value, or `null` for +"the header is absent"), `text` on the body (the exact body, compared as UTF-8 text), and `xml` on the body +(compared as an XML infoset). A request may also carry `headers` (a map sent with the request). + ### Base-URL policy The two sides are configured **separately, and deliberately so**. diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 2936621ab3..a3fca8a881 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -555,6 +555,7 @@ rule every object uses ([api-contract.md](api-contract.md)). | `POST //` | `405 {"error": "method_not_allowed"}`. `message` is free prose. | | any verb on `///{id}` | Not mounted. The framework answers its own `404`, whose body is outside the contract. A report has no identity, so a row of it has no address, even when a derived field happens to be named `id`. | | a filter or sort error | The four field-naming envelopes of [api-contract.md](api-contract.md), unchanged. | +| `GET //?format=csv\|json\|xml` | A download of the same rows when the `report-export` generator is wired; see [Exporting a report](#exporting-a-report). | The route lists the compiled view. No request parameter picks dimensions, measures or a grain. @@ -754,6 +755,63 @@ real views on Postgres, SQLite and MySQL (`report/schema.sqlite.sql`, `report/sc and TypeScript also through local D1. A report's decimal columns are asserted as canonical strings by the `decimal/` sub-corpus, which pins `"decimals": "string"`, on the same engines. +## Exporting a report + +`GET //?format=csv|json|xml` downloads a served report's rows as a file. +It is the same list route with one more query parameter, so filters, sort, `limit` and `offset` +mean what they mean there, and without `format` the response is byte-identical to today. The +cross-port contract (the envelopes, headers, CSV rules and the XML shape) is +[api-contract.md](api-contract.md#report-export); this section is the how-to. + +**Turn it on.** Wire the standalone `report-export` generator. It is in `meta gen --list` in every +port, ships as a catalog generator you import as shipped or copy with the port's eject command +(`meta eject report-export`, `metaobjects eject`, `mvn metaobjects:eject`, `dotnet meta eject`), +and is drift-checked by `verify --codegen`. It is **not** implied by a library: a project that does +not wire it gets no export module and no `format` branch in any route. The routes generator emits +that branch only when the export generator is wired. + +| Option | Default | Meaning | +|---|---|---| +| `maxRows` | `100000` | The most rows one export answers. One more is refused with `400 export_too_large`; nothing is truncated. | +| `xmlNulls` | the port's standard, below | `"nil"` writes a null field as `xsi:nil="true"`; `"empty"` writes an empty element. | +| `csvBom` | `false` | Write a UTF-8 byte order mark first. Emitted as the generated constant `EXPORT_CSV_BOM`; Excel on Windows wants it, most tools do not. | + +**What is generated.** One `.export.*` module per served report (the column list in +derived-field order, the labels, the wire-form conversion, and the CSV and XML writers) and one +shared writer module per project (CSV quoting, CRLF, the BOM constant, the formula-injection +guard, XML escaping). + +**The cap, once.** A report has two caps and they never both apply to one request. Without +`format`, the list route's cap (`reportMaxRows` or its port's name, default 10,000, answers +`report_too_large`) is untouched. With `format`, `maxRows` governs and `export_too_large` answers, +including for `format=json`, so an export can be larger than the list. The route fetches +`maxRows + 1` rows to tell exactly the cap from more than the cap before writing a byte. A report +is materialised up to the cap; there is no streaming in this version. + +**Formula injection.** A CSV cell that is a string, an enum symbol or a uri and starts with `=`, +`+`, `-`, `@`, TAB or CR gets a leading `'`, so a spreadsheet reads it as text. This is the +render escapers' rule, one rule in every port. A number or a date is never changed, so `-5` +stays a number. + +**Per-port XML null default.** The default follows the port's standard XML serializer (ADR-0019 +Amendment 2); the corpus pins `"nil"` in every lane. + +| Port | Default `xmlNulls` | Writer | +|---|---|---| +| TypeScript | `"empty"` (an empty element: TypeScript has no XML convention of its own, and it matches the empty CSV cell) | generated writer | +| C# | `"nil"` (`XmlSerializer` and `DataContractSerializer` write `xsi:nil="true"` for a null, and it keeps null apart from an empty string) | `XmlWriter` | +| Java | `"empty"` (an empty element, which is what Jackson's XML mapper writes) | JDK StAX | +| Kotlin | `"empty"` (the JVM standard: Jackson's XML mapper writes a null as an empty element) | JDK StAX | +| Python | `"empty"` (an empty element, which is what Python's XML serializers write for `None`) | `xml.sax.saxutils.XMLGenerator` | + +**Not in this version.** Excel: the name `xlsx` is reserved (it is refused with `invalid_format` +today, like any unwired format) for a later `report-export-xlsx` generator. PDF is out. Export of +an entity list route, with streaming, is FR-027 and follows; it reuses the selector, the envelopes +and the generator shape. Per-request date ranges, totals and derived measures are not part of +export. The XML body comes from each port's standard serializer, recorded as +[ADR-0031 Amendment 1](../../spec/decisions/ADR-0031-serialization-vs-data-download-boundary.md#amendment-1-2026-10-27--report-export-ships-first-on-each-ports-standard-serializer); +FR-030's serializer replaces the XML writer later without changing the route. + ## Exporting to Cube The `cube-model` generator writes the reporting vocabulary as [Cube](https://cube.dev) diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index edb0844146..7671715a0f 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -344,6 +344,37 @@ when the committed output was generated with another rule, rather than telling y regenerate. See the "Column naming" section of [`features/field-types.md`](../features/field-types.md). +### Report export + +A served report (a view-backed `object.report`) can be downloaded as a file: +`GET /api/?format=csv|json|xml`, the contract in +[api-contract.md](../features/api-contract.md#report-export). It is on when you list the +`report-export` generator beside `routes` in `codegen/Generators.cs`, and not otherwise: with it +unlisted, the generated routes are byte-identical to before. + +```csharp +new ReportExportGenerator { MaxRows = 50_000, XmlNulls = ReportExportXmlNulls.Nil, CsvBom = false } +``` + +- **Wiring** is the generator list. `routes` asks the run (`GenContext.ReportExportMaxRows`) whether + a `report-export` entry is listed, so an ejected copy (`dotnet meta eject report-export`) wires the + routes exactly as the packaged one does. `dotnet meta gen --generators routes` alone leaves it off. +- **Writes** one shared `ReportExportWriter.g.cs` per project (CSV quoting, CRLF, the injection guard, + the `ExportCsvBom` constant, `XmlWriter` output, wire-form conversion of a cell) and one + `Export.g.cs` per served report (its columns in derived-field order). The generated code uses + `TextWriter`, `XmlWriter` and `System.Text.Json` only and adds no package. The HTTP side (`?format=`, + the three 400 envelopes, `Content-Disposition`) is `ReportExportHttp`, one of the helper-runtime files + `dotnet meta eject routes` copies. +- **`MaxRows`** (default 100,000) is the export cap. With `?format` set it governs and + `report_too_large` never answers; without it the list route's `ReportMaxRows` (10,000) is untouched. +- **`XmlNulls`** defaults to `Nil`: a null field is an empty element with `xsi:nil="true"` and the root + declares `xmlns:xsi`. That is what `XmlSerializer` writes for a nullable value type and what + `DataContractSerializer` writes for any null, and it keeps a null apart from an empty string. `Empty` + writes a bare empty element and declares nothing. +- **JSON** is the list route's array through the host's configured JSON options (so a `wire` setting and + a `JsonStringEnumConverter` apply as on the list route), served as `application/json`. +- A character XML 1.0 cannot carry is written as U+FFFD rather than failing the export. + ## Use ```csharp diff --git a/docs/ports/java.md b/docs/ports/java.md index 69ee19efb4..784f751570 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -344,6 +344,7 @@ name in every port. | `output-prompt` | `SpringOutputPromptGenerator` | `metaobjects-codegen-spring` | One `