Skip to content

Commit e7ace3f

Browse files
committed
no-mistakes(review): Narrow export withCount refusal, wire-aware JVM export cells
1 parent a36a7f4 commit e7ace3f

41 files changed

Lines changed: 565 additions & 305 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ here.**
1919
generator emit the `format` branch. Without `format`, or with no export generator wired, a
2020
response is byte-identical to before. CSV is RFC 4180 with CRLF, a header row of the derived
2121
field names, wire-form values and a formula-injection guard on every string cell (the render
22-
escapers' rule); JSON is the list route's array plus `Content-Disposition`; XML is
23-
`<{Report}><row><{field}>...` with a pinnable null form (`xmlNulls`). A format the route does not
24-
wire answers `400 {"error":"invalid_format","expected":[...]}`, `withCount` with a format answers
25-
`400 export_with_count`, and more rows than the generator option `maxRows` (default 100,000)
22+
escapers' rule); JSON is the list route's body (the array, or the `withCount` envelope) plus
23+
`Content-Disposition`; XML is `<{Report}><row><{field}>...` with a pinnable null form
24+
(`xmlNulls`). A format the route does not wire answers
25+
`400 {"error":"invalid_format","expected":[...]}`, `withCount` with `format=csv` or `format=xml`
26+
answers `400 export_with_count`, and more rows than the generator option `maxRows` (default 100,000)
2627
answers `400 {"error":"export_too_large","maxRows":N}` before a byte is written; nothing is ever
2728
truncated. With `format` set `maxRows` governs and the list route's `report_too_large` cap does
2829
not. The export adds no dependency an adopter must install in any port, and `xlsx` is reserved

‎docs/features/api-contract.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,11 @@ never consulted**: the selector is the query parameter, in every port.
295295
| Request | Answer |
296296
|---|---|
297297
| no `format` | Exactly the list route above: same body, same headers, same caps. |
298-
| `format=json` | The list route's JSON array, unchanged, plus `Content-Disposition`. `Content-Type: application/json`. |
298+
| `format=json` | The list route's JSON body, unchanged, plus `Content-Disposition`: the array, or with `withCount=1` the `{ rows, total }` envelope. `Content-Type: application/json`. |
299299
| `format=csv` | RFC 4180 CSV. `Content-Type: text/csv; charset=utf-8`. |
300300
| `format=xml` | `application/xml`, shape below. |
301301
| 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. |
302-
| `format` set and `withCount` truthy | `400 { "error": "export_with_count" }` (`message` is free prose). An export has no `{ rows, total }` envelope, JSON included. |
302+
| `format=csv` or `format=xml` and `withCount` truthy | `400 { "error": "export_with_count" }` (`message` is free prose). Only JSON carries the `{ rows, total }` envelope. |
303303
| more rows than `maxRows` | `400 { "error": "export_too_large", "maxRows": N }` before a byte of the body is written. |
304304

305305
- **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.

‎docs/features/own-your-codegen.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,8 +1150,8 @@ function, the digest and the renderer and filter hook types stay in the package.
11501150
with its own command (`meta eject report-export`, `metaobjects eject`, `mvn metaobjects:eject
11511151
-Dnames=report-export`, `dotnet meta eject report-export`). The copy holds the CSV and XML writers,
11521152
so the guard, quoting and null form are yours to change. The routes generator emits the `format`
1153-
branch only when the generator is wired (the TypeScript, C# and Python runners detect it from the
1154-
generator list; Java and Kotlin take `reportExport=true` on `routes`). Options and defaults:
1153+
branch only when the generator is wired: every port's runner detects it from the generator list (on
1154+
Java and Kotlin the Maven plugin hands that list to `routes`). Options and defaults:
11551155
[reporting.md](reporting.md#exporting-a-report).
11561156

11571157
### Java `entity` and `extractor`

‎docs/features/reporting.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,11 @@ guard, XML escaping).
784784
**The cap, once.** A report has two caps and they never both apply to one request. Without
785785
`format`, the list route's cap (`reportMaxRows` or its port's name, default 10,000, answers
786786
`report_too_large`) is untouched. With `format`, `maxRows` governs and `export_too_large` answers,
787-
including for `format=json`, so an export can be larger than the list. The route fetches
787+
including for `format=json`, so an export can be larger than the list.
788+
789+
**`withCount`.** `format=json&withCount=1` downloads the list route's `{ rows, total }` envelope.
790+
CSV and XML have no place for a total, so `withCount` with `format=csv` or `format=xml` is
791+
refused with `400 export_with_count`. The route fetches
788792
`maxRows + 1` rows to tell exactly the cap from more than the cap before writing a byte. A report
789793
is materialised up to the cap; there is no streaming in this version.
790794

‎docs/ports/csharp.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ new ReportExportGenerator { MaxRows = 50_000, XmlNulls = ReportExportXmlNulls.Ni
371371
declares `xmlns:xsi`. That is what `XmlSerializer` writes for a nullable value type and what
372372
`DataContractSerializer` writes for any null, and it keeps a null apart from an empty string. `Empty`
373373
writes a bare empty element and declares nothing.
374-
- **JSON** is the list route's array through the host's configured JSON options (so a `wire` setting and
374+
- **JSON** is the list route's body (the array, or `{ rows, total }` with `withCount`) through the host's configured JSON options (so a `wire` setting and
375375
a `JsonStringEnumConverter` apply as on the list route), served as `application/json`.
376376
- A character XML 1.0 cannot carry is written as U+FFFD rather than failing the export.
377377

‎docs/ports/java.md‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -447,27 +447,31 @@ generates nothing. The contract is in
447447

448448
`GET <apiPrefix>/<segment>?format=csv|json|xml` downloads a served report's rows (the contract is
449449
[api-contract](../features/api-contract.md#report-export), the how-to
450-
[reporting](../features/reporting.md#exporting-a-report)). Two entries turn it on, and both are
451-
needed: wire the `report-export` generator, and give the `routes` generator the arg
452-
`<reportExport>true</reportExport>`. The routes arg is what mounts the `format` branch in each report
453-
`<R>Controller` (the generator list is not visible to a sibling generator, so the wiring is a
454-
visible arg, not a guess); the branch reads its cap from `<R>Export.MAX_ROWS`, so the cap is set
455-
once, as `maxRows` on `report-export`. A project with the arg unset gets controllers byte-identical to
456-
today, and `?format=` is ignored like any unknown parameter.
450+
[reporting](../features/reporting.md#exporting-a-report)). Wire the `report-export` generator to turn
451+
it on. The Maven plugin hands the whole configured generator list to the `routes` generator
452+
(`com.metaobjects.generator.RunAware`), which mounts the `format` branch in each report
453+
`<R>Controller` when that list holds a `report-export` generator (`EmitsReportExport`, so an
454+
ejected copy counts too). The branch reads its cap from `<R>Export.MAX_ROWS`, so the cap is set
455+
once, as `maxRows` on `report-export`. A project that does not wire it gets controllers
456+
byte-identical to today, and `?format=` is ignored like any unknown parameter.
457457

458458
With `?format` set the export cap (`maxRows`, default 100,000) governs and `report_too_large` never
459459
answers; without it `reportMaxRows` (default 10,000) is untouched. The handler reads `maxRows + 1`
460460
rows and answers `export_too_large` before writing a byte. The request side (format parsing, the
461461
three 400 envelopes, the `Content-Disposition` and `Content-Type` values) is
462462
`com.metaobjects.generator.spring.runtime.ReportExport`, a JDK-only class. (The Kotlin port generates its own copy of these helpers into its writer module.) With the branch mounted, the list route answers an explicit `application/json`, so a
463463
request's `Accept` header is never consulted (a client sending `Accept: text/csv` without `format`
464-
still gets JSON, not a 406). `withCount` is read as text on such a route (`1` or `true`).
464+
still gets JSON, not a 406). `withCount` is read as text on such a route: `1` or `true` with `csv` or
465+
`xml` is `export_with_count`, and `format=json&withCount=1` is the list route's `{ rows, total }`
466+
envelope.
465467

466468
`xmlNulls` defaults to `empty`: a null field is an empty element, which is what Jackson's XML mapper
467469
writes for a null, the Java standard (ADR-0019 Amendment 2). `nil` writes `xsi:nil="true"` and declares
468-
`xmlns:xsi` on the root. A decimal cell is its canonical plain string (no exponent, no trailing zeros)
469-
under either `wire` setting, and an array or map dimension is its compact JSON text. The JSON export is
470-
the list route's array through the application's `ObjectMapper`, so a `wire` setting applies to it as
470+
`xmlns:xsi` on the root. A decimal cell is written as the `wire` setting writes the list route: under
471+
`decimals: string` its canonical plain string (`12.5`), under the default `native` what Jackson writes
472+
for a `BigDecimal` (`12.50`). A time or a zone-less timestamp keeps its seconds (`10:00:00`), as
473+
Jackson's JSR-310 serializers write it, and an array or map dimension is its compact JSON text. The JSON export is
474+
the list route's body through the application's `ObjectMapper`, so a `wire` setting applies to it as
471475
it does to the list. CSV and XML are written as `byte[]`, which every Spring MVC setup has a converter
472476
for.
473477

‎docs/ports/kotlin.md‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -549,13 +549,13 @@ follows the output mode: `options` and `storageParameters` are `Table` members o
549549

550550
`GET <apiPrefix>/<segment>?format=csv|json|xml` downloads a served report's rows (the contract is
551551
[api-contract](../features/api-contract.md#report-export), the how-to
552-
[reporting](../features/reporting.md#exporting-a-report)). Two entries turn it on, and both are
553-
needed: wire `KotlinReportExportGenerator` (`report-export`), and give `KotlinSpringControllerGenerator`
554-
the arg `<reportExport>true</reportExport>`. The routes arg is what mounts the `format` branch in each
555-
report `<Report>Controller` (a generator cannot see its siblings, so the wiring is a visible arg); the
556-
branch reads its cap from `<Report>Export.MAX_ROWS`, so the cap is set once, as `maxRows` on
557-
`report-export`. With the arg unset the controllers are byte-identical to today and `?format=` is
558-
ignored like any unknown parameter.
552+
[reporting](../features/reporting.md#exporting-a-report)). Wire `KotlinReportExportGenerator`
553+
(`report-export`) to turn it on. The Maven plugin hands the whole configured generator list to
554+
`KotlinSpringControllerGenerator` (`com.metaobjects.generator.RunAware`), which mounts the `format`
555+
branch in each report `<Report>Controller` when that list holds a `report-export` generator
556+
(`EmitsReportExport`, so an ejected copy counts too). The branch reads its cap from
557+
`<Report>Export.MAX_ROWS`, so the cap is set once, as `maxRows` on `report-export`. Without it the
558+
controllers are byte-identical to today and `?format=` is ignored like any unknown parameter.
559559

560560
The Kotlin controllers import nothing from MetaObjects, so the request side (format parsing, the three
561561
400 envelopes, the `Content-Disposition` and `Content-Type` values) is generated into the shared
@@ -564,14 +564,17 @@ use Jackson (already present) and the JDK's StAX, so nothing new is on the class
564564
set the export cap (`maxRows`, default 100,000) governs and `report_too_large` never answers; without
565565
it `reportMaxRows` (default 10,000) is untouched. The handler reads `maxRows + 1` rows and answers
566566
`export_too_large` before writing a byte. With the branch mounted, every answer of the list route
567-
carries an explicit `application/json`, so `Accept` is never consulted, and `withCount` is read as text
568-
(`1` or `true`). CSV and XML are written as `ByteArray`, for which every Spring MVC setup has a converter.
567+
carries an explicit `application/json`, so `Accept` is never consulted, and `withCount` is read as text:
568+
`1` or `true` with `csv` or `xml` is `export_with_count`, and `format=json&withCount=1` is the list
569+
route's `{ rows, total }` envelope. CSV and XML are written as `ByteArray`, for which every Spring MVC setup has a converter.
569570

570571
`xmlNulls` defaults to `empty`: a null field is an empty element, which is what Jackson's XML mapper
571572
writes for a null, the JVM standard (ADR-0019 Amendment 2, the same default as the Java port). `nil`
572-
writes `xsi:nil="true"` and declares `xmlns:xsi` on the root. A decimal cell is its canonical plain
573-
string (no exponent, no trailing zeros) under either `wire` setting, and an array or map dimension is
574-
its compact JSON text. The JSON export is the list route's array through the application's
573+
writes `xsi:nil="true"` and declares `xmlns:xsi` on the root. A decimal cell is written as the `wire`
574+
setting writes the list route: under `decimals: string` its canonical plain string (`12.5`), under the
575+
default `native` what Jackson writes for a `BigDecimal` (`12.50`). A time or a zone-less timestamp keeps
576+
its seconds (`10:00:00`), as Jackson's JSR-310 serializers write it, and an array or map dimension is
577+
its compact JSON text. The JSON export is the list route's body through the application's
575578
`ObjectMapper`. `csvBom` flips the generated constant `EXPORT_CSV_BOM`.
576579

577580
### Decimals and null members on the wire

‎docs/ports/python.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ xml_nulls="empty", csv_bom=False)`. `xml_nulls` defaults to `"empty"` (an empty
175175
is what Python's own XML serializers write for `None`; `"nil"` writes `xsi:nil="true"` and
176176
declares `xmlns:xsi` on the root. With `?format` set the export cap (`max_rows`,
177177
`export_too_large`) governs; without it the list route's `report_max_rows` cap
178-
(`report_too_large`) is unchanged. JSON export is the list route's array through the same
178+
(`report_too_large`) is unchanged. JSON export is the list route's body (the array, or
179+
`{rows, total}` with `withCount=1`) through the same
179180
`render_wire` and FastAPI encoder, so the `wire` setting applies. A column holding an array,
180181
map or object is written as compact JSON text in CSV and XML. `routes` imports the request
181182
helper `metaobjects.codegen.runtime.report_export` (an owned `routes` emits it under

‎examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
211211
const withCount = isTruthyFlag(parsed["withCount"]);
212212
// Export mode: the selector is validated before any query runs, and the export cap governs.
213213
const format = exportSpec ? readExportFormat(parsed, exportSpec) : undefined;
214-
if (exportSpec && format !== undefined && isExportCountFlag(parsed["withCount"])) throw exportWithCountError();
214+
if (format !== undefined && format !== "json" && isExportCountFlag(parsed["withCount"])) throw exportWithCountError();
215215
const reportPage = exportSpec && format !== undefined
216216
? parseExportPage(parsed, exportSpec.maxRows)
217217
: isReport ? parseReportPage(parsed, reportMaxRows) : undefined;
@@ -246,24 +246,25 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
246246
}
247247
const rows = fetched.map(toWire);
248248

249+
let total: number | undefined;
250+
if (withCount) {
251+
// Count query: same WHERE, no limit/offset/orderBy.
252+
let cq = db.select({ c: count() }).from(view);
253+
if (combinedWhere) cq = cq.where(combinedWhere);
254+
const countRow = (await cq)[0] as { c: number } | undefined;
255+
total = countRow?.c ?? 0;
256+
}
249257
if (exportSpec && format !== undefined) {
250258
// Render first: a writer that throws must not leave download headers on the 500.
251259
// A Buffer, not a string: Fastify appends "; charset=utf-8" to a string body's
252260
// application/json, and the contract pins the content type exactly.
253-
const body = Buffer.from(renderExport(exportSpec, format, rows as Record<string, unknown>[]), "utf8");
261+
const body = Buffer.from(renderExport(exportSpec, format, rows as Record<string, unknown>[], total), "utf8");
254262
reply
255263
.header("content-disposition", exportContentDisposition(path, format))
256264
.header("content-type", EXPORT_CONTENT_TYPES[format]);
257265
return body;
258266
}
259-
if (!withCount) return rows;
260-
261-
// Count query: same WHERE, no limit/offset/orderBy.
262-
let cq = db.select({ c: count() }).from(view);
263-
if (combinedWhere) cq = cq.where(combinedWhere);
264-
const countRow = (await cq)[0] as { c: number } | undefined;
265-
const total = countRow?.c ?? 0;
266-
return { rows, total };
267+
return total === undefined ? rows : { rows, total };
267268
} catch (err) {
268269
if (err instanceof ReportTooLargeError) {
269270
reply.code(400).send({ error: "report_too_large", maxRows: err.maxRows });

‎examples/advanced-modeling/codegen/runtime/drizzle-fastify/report-export.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ export function readExportFormat(query: Record<string, unknown>, spec: ReportExp
7070
}
7171

7272
/**
73-
* Whether `withCount` is set, for the `export_with_count` refusal. Wider than the list route's
74-
* own flag (`1`): the contract says "truthy", and a client that sends `withCount=true` to an export
75-
* must be told it cannot have a count rather than silently get the file. The list route's
76-
* flag is unchanged.
73+
* Whether `withCount` is set, for the `export_with_count` refusal of a CSV or XML export. Wider
74+
* than the list route's own flag (`1`): the contract says "truthy", and a client that sends
75+
* `withCount=true` to a CSV export must be told it cannot have a count rather than silently get
76+
* the file. The list route's flag is unchanged, and a JSON export reads that one.
7777
*/
7878
export function isExportCountFlag(v: unknown): boolean {
7979
if (v === undefined || v === null) return false;
@@ -82,11 +82,11 @@ export function isExportCountFlag(v: unknown): boolean {
8282
return s === "1" || s === "true";
8383
}
8484

85-
/** `export_with_count`: an export has no `{ rows, total }` envelope, JSON included. */
85+
/** `export_with_count`: a CSV or XML export has no `{ rows, total }` envelope. */
8686
export function exportWithCountError(): ReportExportError {
8787
return new ReportExportError(
88-
{ error: "export_with_count", message: "withCount cannot be combined with format; an export has no { rows, total } envelope." },
89-
"withCount cannot be combined with format.",
88+
{ error: "export_with_count", message: "withCount cannot be combined with format=csv or format=xml; only JSON carries the { rows, total } envelope." },
89+
"withCount cannot be combined with format=csv or format=xml.",
9090
);
9191
}
9292

@@ -124,9 +124,12 @@ export function exportContentDisposition(path: string, format: ExportFormat): st
124124
return `attachment; filename="${segment}.${format}"`;
125125
}
126126

127-
/** The response body of an export. JSON is the list route's array, serialized as `JSON.stringify` does. */
128-
export function renderExport(spec: ReportExportSpec, format: ExportFormat, rows: ExportRows): string {
129-
if (format === "json") return JSON.stringify(rows);
127+
/**
128+
* The response body of an export. JSON is the list route's own body, serialized as
129+
* `JSON.stringify` does: the array, or `{ rows, total }` when the request asked for a count.
130+
*/
131+
export function renderExport(spec: ReportExportSpec, format: ExportFormat, rows: ExportRows, total?: number): string {
132+
if (format === "json") return JSON.stringify(total === undefined ? rows : { rows, total });
130133
const write = spec[format];
131134
if (write === undefined) throw new Error(`report export: no ${format} writer is wired.`);
132135
return write(rows);

0 commit comments

Comments
 (0)