From 2f9f367d5b5e8b4a5a0b876d7cdc5e7170f2bd09 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 13:15:09 -0400 Subject: [PATCH 1/4] fix: make the JSON wire form a project setting, defaulting to each language's standard (#454) 1.1.0 normalized every port's generated API onto one wire form with no opt-out: decimals as canonical strings and value-object null members omitted. That broke live C#, Java and Kotlin clients on a 1.x upgrade. The output form is now `wire` in .metaobjects/config.json, read by every port's codegen the way `naming` is (ADR-0019 Amendment 2). Both keys default to native, the language's standard serializer output: C#/Java/Kotlin decimals are JSON numbers again and null members are written; Python and TypeScript keep the decimal's scale. `{"decimals": "string", "nullMembers": "omit"}` restores the 1.1.0 form, and the conformance corpora pin it in their own config. Input rules are unchanged in every mode. Every port's codegen drift gate names the wire block that reproduces committed output generated under another setting. Also fixes the C# DecimalWire.g.cs / MetaNetValidation.g.cs collision when a model binds more than one namespace. Fixes #454 --- .claude/rules/cross-language-porting.md | 1 + AGENTS.md | 4 +- CHANGELOG.md | 41 +- .../references/reporting.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 17 +- .../metaobjects-codegen/references/csharp.md | 13 + .../metaobjects-codegen/references/java.md | 13 + .../metaobjects-codegen/references/kotlin.md | 13 + .../metaobjects-codegen/references/python.md | 12 + .../references/typescript.md | 13 + .../references/wire-format.md | 88 ++++ agent-context/templates/always-on.md.mustache | 2 +- docs/CONFORMANCE.md | 3 +- docs/features/api-contract.md | 54 +- docs/features/field-types.md | 18 +- .../migrations/upgrading-1.0-to-1.1.md | 62 ++- docs/features/reporting.md | 4 +- docs/ports/csharp.md | 41 +- docs/ports/java.md | 39 +- docs/ports/kotlin.md | 32 +- docs/ports/python.md | 37 +- docs/ports/typescript.md | 34 +- .../codegen/generators/routes.ts | 15 +- .../codegen/runtime/decimal-wire.ts | 32 +- .../codegen/runtime/drizzle-fastify/index.ts | 19 +- .../runtime/drizzle-fastify/mount-m2m.ts | 8 +- .../drizzle-fastify/mount-read-only.ts | 14 +- .../codegen/runtime/wire-format.ts | 103 ++++ .../.metaobjects/.gen-state/.hashes.json | 4 +- .../showcase/codegen/generators/routes.ts | 15 +- .../showcase/codegen/runtime/decimal-wire.ts | 32 +- .../codegen/runtime/drizzle-fastify/index.ts | 19 +- .../runtime/drizzle-fastify/mount-m2m.ts | 8 +- .../drizzle-fastify/mount-read-only.ts | 14 +- .../showcase/codegen/runtime/wire-format.ts | 103 ++++ .../csharp/SubscriberBlurbPayload.g.cs | 1 - .../java/acme/SubscriberBlurbPayload.java | 2 - .../kotlin/acme/SubscriberBlurbPayload.kt | 2 - .../generated/python/subscriber_router.py | 16 +- examples/showcase/site-payload.json | 2 +- .../references/reporting.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 17 +- .../metaobjects-codegen/references/java.md | 13 + .../metaobjects-codegen/references/kotlin.md | 13 + .../references/wire-format.md | 88 ++++ .../expected/.metaobjects/AGENTS.md | 2 +- .../expected/.metaobjects/CLAUDE.md | 2 +- .../references/reporting.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 17 +- .../metaobjects-codegen/references/java.md | 13 + .../references/wire-format.md | 88 ++++ .../expected/.metaobjects/AGENTS.md | 2 +- .../expected/.metaobjects/CLAUDE.md | 2 +- .../references/reporting.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 17 +- .../metaobjects-codegen/references/python.md | 12 + .../references/wire-format.md | 88 ++++ .../python/expected/.metaobjects/AGENTS.md | 2 +- .../python/expected/.metaobjects/CLAUDE.md | 2 +- .../references/reporting.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 17 +- .../references/typescript.md | 13 + .../references/wire-format.md | 88 ++++ .../expected/.metaobjects/AGENTS.md | 2 +- .../expected/.metaobjects/CLAUDE.md | 2 +- .../references/reporting.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 17 +- .../references/typescript.md | 13 + .../references/wire-format.md | 88 ++++ .../expected/.metaobjects/AGENTS.md | 2 +- .../expected/.metaobjects/CLAUDE.md | 2 +- .../.metaobjects/config.json | 3 +- .../.metaobjects/config.json | 3 +- fixtures/wire-conformance/README.md | 22 + fixtures/wire-conformance/config-blocks.json | 21 + .../WireSettingCliTests.cs | 120 +++++ server/csharp/MetaObjects.Cli/Program.cs | 2 + .../csharp/MetaObjects.Cli/VerifyCommand.cs | 6 + .../CodegenCompileConformanceTests.cs | 1 + .../MetaObjects.Codegen.Tests/CorpusPaths.cs | 8 + .../DecimalWireTests.cs | 213 ++++++-- .../IntegrationFixtureDriftTests.cs | 2 + .../ValueObjectWireIgnoreTests.cs | 29 +- .../WireConformanceTests.cs | 90 ++++ .../csharp/MetaObjects.Codegen/CodegenCli.cs | 40 ++ .../Generators/EntityGenerator.cs | 164 +++++-- .../Runtime/DecimalWire.cs | 80 ++- .../Api/ApiContractCorpusPaths.cs | 7 + .../Api/GeneratedAuthorServerFactory.cs | 1 + .../Api/GeneratedRoutesServer.cs | 1 + .../Api/JsonbGeneratedServerFactory.cs | 1 + .../Api/M2mGeneratedServerFactory.cs | 1 + .../Api/ProjectionGeneratedServerFactory.cs | 1 + .../Api/ReportGeneratedServerFactory.cs | 1 + .../Api/TphGeneratedServerFactory.cs | 1 + .../Api/WriteThroughGeneratedServerFactory.cs | 1 + .../Generated/DecimalWire.g.cs | 80 ++- .../Runner/CorpusPaths.cs | 7 + .../Runner/QueryScenarioRunner.cs | 1 + .../MetaObjects/Config/MetadataLocation.cs | 18 +- .../MetaObjects/Config/NeutralConfig.cs | 57 ++- server/csharp/MetaObjects/Meta/MetaRoot.cs | 8 + server/csharp/MetaObjects/Meta/WireFormat.cs | 57 +++ .../kotlin/KotlinDecimalWireEmitter.kt | 29 +- .../generator/kotlin/KotlinEntityGenerator.kt | 28 +- .../kotlin/KotlinExposedTableGenerator.kt | 7 +- .../kotlin/KotlinSpringControllerGenerator.kt | 4 +- .../kotlin/KotlinCodegenSnapshotTest.kt | 19 +- .../generator/kotlin/KotlinDecimalWireTest.kt | 103 +++- .../config.json | 1 + .../meta.json | 12 + .../entity-with-storage-wire-omit/config.json | 1 + .../entity-with-storage-wire-omit/meta.json | 28 ++ .../acme/blog/MetaDecimalWire.kt | 12 +- .../acme/catalog/MetaDecimalWire.kt | 148 ++++++ .../acme/catalog/MetaPortableColumnTypes.kt | 106 ++++ .../acme/catalog/Product.kt | 70 +++ .../acme/catalog/ProductController.kt | 460 ++++++++++++++++++ .../acme/catalog/ProductFilterAllowlist.kt | 18 + .../acme/catalog/ProductTable.kt | 13 + .../acme/catalog/MetaDecimalWire.kt | 12 +- .../acme/catalog/Product.kt | 2 - .../acme/commerce/Address.kt | 56 +++ .../acme/commerce/MetaJsonbMapper.kt | 20 + .../acme/commerce/User.kt | 65 +++ .../acme/commerce/UserMetadata.kt | 43 ++ .../acme/commerce/UserTable.kt | 16 + .../acme/commerce/Address.kt | 2 - .../acme/commerce/UserMetadata.kt | 2 - .../acme/ops/OrderReportArgs.kt | 2 - .../entity-with-tph/acme/auth/Auth.kt | 2 - .../acme/auth/MetaDecimalWire.kt | 12 +- .../acme/report/MetaDecimalWire.kt | 12 +- .../acme/ai/OpinionOutputPayload.kt | 2 - .../acme/ai/Greeting.kt | 2 - .../template-prompt-simple/acme/ai/Author.kt | 2 - .../generator/spring/SpringDtoGenerator.java | 11 +- .../spring/SpringGeneratorSupport.java | 26 +- .../spring/SpringValueObjectGenerator.java | 18 +- .../SpringDecimalWireCompileRunTest.java | 3 + .../spring/SpringReportRestSurfaceTest.java | 8 +- .../SpringWireFormatCompileRunTest.java | 153 ++++++ .../expected/acme/shipping/ShipmentNote.java | 2 - .../integration/kotlin/QueryScenarioRunner.kt | 1 + .../GeneratedAuthorControllerHarness.kt | 6 + .../GeneratedDocumentControllerHarness.kt | 6 + .../GeneratedM2mControllerHarness.kt | 6 + .../GeneratedProjectionControllerHarness.kt | 6 + .../GeneratedReportControllerHarness.kt | 6 + .../GeneratedTphControllerHarness.kt | 6 + .../GeneratedWriteThroughControllerHarness.kt | 6 + .../GeneratedAuthorControllerHarness.java | 4 + .../GeneratedDecimalControllerHarness.java | 4 + .../GeneratedJsonbControllerHarness.java | 4 + .../GeneratedProjectionControllerHarness.java | 6 + .../GeneratedReportControllerHarness.java | 4 + .../GeneratedM2mControllerHarness.java | 4 + .../GeneratedTphControllerHarness.java | 4 + .../mojo/AbstractMetaDataMojo.java | 18 +- .../metaobjects/mojo/MetaDataVerifyMojo.java | 63 +++ .../mojo/EjectRuntimeRoundTripTest.java | 11 + .../mojo/MetaDataVerifyWireTest.java | 123 +++++ .../main/java/com/metaobjects/MetaRoot.java | 20 + .../com/metaobjects/config/NeutralConfig.java | 63 ++- .../metaobjects/config/SourceResolver.java | 27 +- .../com/metaobjects/config/WireFormat.java | 102 ++++ .../metaobjects/loader/MetaDataLoader.java | 7 + .../config/WireConformanceTest.java | 82 ++++ server/python/src/metaobjects/cli.py | 31 ++ .../codegen/generators/router_generator.py | 97 +++- .../codegen/runtime/decimal_wire.py | 84 +++- .../src/metaobjects/config/dependencies.py | 5 + .../src/metaobjects/config/neutral_config.py | 42 ++ .../src/metaobjects/config/source_resolver.py | 3 + .../metaobjects/loader/meta_data_loader.py | 2 + .../python/src/metaobjects/meta/meta_root.py | 5 + server/python/src/metaobjects/wire.py | 70 +++ .../tests/codegen/test_config_run_settings.py | 90 ++++ .../tests/codegen/test_router_generator.py | 4 +- .../test_router_generator_m2m_target_tph.py | 2 +- .../codegen/test_router_generator_tph_m2m.py | 6 +- .../test_router_generator_tph_m2m_shadowed.py | 6 +- .../python/tests/codegen/test_tph_codegen.py | 2 +- .../conformance/test_wire_conformance.py | 54 ++ .../python/tests/integration/corpus_config.py | 18 + .../tests/integration/generated_jsonb_app.py | 2 + .../tests/integration/generated_m2m_app.py | 2 + .../integration/generated_projection_app.py | 2 + .../tests/integration/generated_report_app.py | 2 + .../tests/integration/generated_router_app.py | 2 + .../tests/integration/generated_tph_app.py | 2 + .../test_generated_decimal_wire.py | 147 ++++++ .../packages/cli/src/commands/verify.ts | 62 +++ .../cli/src/lib/collection-load-options.ts | 3 +- .../integration/verify-codegen-wire.test.ts | 125 +++++ .../packages/codegen-ts/src/api-surface.ts | 94 +++- .../codegen-ts/src/reference/routes-hono.ts | 7 +- .../codegen-ts/src/reference/routes.ts | 15 +- .../src/templates/routes-file-hono.ts | 8 +- .../codegen-ts/src/templates/routes-file.ts | 16 +- .../codegen-ts/test/decimal-wire-e2e.test.ts | 6 +- .../codegen-ts/test/wire-format-e2e.test.ts | 189 +++++++ .../src/api-contract-tph-server.ts | 9 +- .../integration-tests/src/load-metadata.ts | 30 +- .../test/mysql-generated-app.test.ts | 2 +- .../typescript/packages/metadata/src/index.ts | 9 + .../packages/metadata/src/shared/meta-root.ts | 7 + .../typescript/packages/metadata/src/wire.ts | 43 ++ .../packages/runtime-ts/src/decimal-wire.ts | 32 +- .../runtime-ts/src/drizzle-fastify/index.ts | 19 +- .../src/drizzle-fastify/mount-m2m.ts | 8 +- .../src/drizzle-fastify/mount-read-only.ts | 14 +- .../packages/runtime-ts/src/hono/index.ts | 19 +- .../runtime-ts/src/hono/mount-read-only.ts | 14 +- .../packages/runtime-ts/src/wire-format.ts | 103 ++++ .../runtime-ts/test/decimal-wire.test.ts | 15 +- .../runtime-ts/test/wire-format.test.ts | 65 +++ .../typescript/packages/sdk/src/collection.ts | 9 +- server/typescript/packages/sdk/src/config.ts | 37 ++ server/typescript/packages/sdk/src/index.ts | 2 +- server/typescript/packages/sdk/src/memory.ts | 11 +- .../sdk/test/wire-conformance.test.ts | 87 ++++ .../test-generators/src/generators/routes.ts | 15 +- .../ADR-0019-runtime-return-type-contract.md | 82 ++++ 224 files changed, 5953 insertions(+), 636 deletions(-) create mode 100644 agent-context/skills/metaobjects-codegen/references/wire-format.md create mode 100644 examples/advanced-modeling/codegen/runtime/wire-format.ts create mode 100644 examples/showcase/codegen/runtime/wire-format.ts create mode 100644 fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md create mode 100644 fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md create mode 100644 fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md create mode 100644 fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md create mode 100644 fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md create mode 100644 fixtures/wire-conformance/README.md create mode 100644 fixtures/wire-conformance/config-blocks.json create mode 100644 server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs create mode 100644 server/csharp/MetaObjects/Meta/WireFormat.cs create mode 100644 server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/config.json create mode 100644 server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/meta.json create mode 100644 server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/config.json create mode 100644 server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/meta.json create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaDecimalWire.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaPortableColumnTypes.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/Product.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductController.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductFilterAllowlist.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductTable.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/Address.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/MetaJsonbMapper.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/User.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserMetadata.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserTable.kt create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWireFormatCompileRunTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyWireTest.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java create mode 100644 server/python/src/metaobjects/wire.py create mode 100644 server/python/tests/conformance/test_wire_conformance.py create mode 100644 server/python/tests/integration/corpus_config.py create mode 100644 server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/wire-format-e2e.test.ts create mode 100644 server/typescript/packages/metadata/src/wire.ts create mode 100644 server/typescript/packages/runtime-ts/src/wire-format.ts create mode 100644 server/typescript/packages/runtime-ts/test/wire-format.test.ts create mode 100644 server/typescript/packages/sdk/test/wire-conformance.test.ts diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md index ae20d2f7d..fb479ac9e 100644 --- a/.claude/rules/cross-language-porting.md +++ b/.claude/rules/cross-language-porting.md @@ -33,6 +33,7 @@ Preserve the following contracts exactly across all language ports: **Wire format:** - Currency: integer minor units on the wire always. Float arithmetic for money is forbidden. - Pagination: `?limit=N&offset=N` — identical across all generated endpoints. +- **Output form is a project setting, and the default is each language's own** (ADR-0019 Amendment 2): `wire` in `.metaobjects/config.json` (`decimals`: `native`|`string`; `nullMembers`: `native`|`emit`|`omit`), read by the same config reader as `naming` and carried on the loaded root. `native` means what that port's standard serializer writes. Never make a port's default match another port or a corpus. The corpora pin `{"decimals": "string", "nullMembers": "omit"}` in their own config, and a harness reads it from there. Input rules (W2, W3) hold in every mode. Shared cases: `fixtures/wire-conformance/`. - **Runtime return types**: a port's runtime `ObjectManager` returns **native in-process language types** (`field.decimal`→`BigDecimal`/`decimal`/`Decimal`, TS `string`; temporal→native; jsonb→native map). Wire canonicalization (the bullets above + `normalization.md`) is applied at the **serialization boundary**, never inside the runtime query path. See [ADR-0019](spec/decisions/ADR-0019-runtime-return-type-contract.md). **Grammar:** diff --git a/AGENTS.md b/AGENTS.md index c6773b526..f19aa41f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (410 fixtures; 30 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (410 fixtures; 31 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. @@ -389,6 +389,8 @@ These are the load-bearing principles that have emerged through implementation. - **Expanding the metamodel vocabulary follows ONE decision procedure (ADR-0037) — driven by semantic behavior, not surface storage.** Don't ask "is X a string/number/date?"; ask "what does X *do*?" Ordered test: (0) **derivable** from existing subtype + attrs (`isArray`/`@maxLength`) + structure? → derive in codegen, add nothing; (1) **physical-only** (native type/meaning unchanged)? → the `@dbColumnType` escape hatch, not first-class vocab; (2) logical — **does X have its own native type, behavior, or attributes** (a *thing* that owns custom logic)? → **subtype** (the extension point: `field.uuid`, `field.uri`, `field.inet`); a **structural variant *within* a subtype** (changes generated shape, shares native type)? → **`@kind`** (the one chartered structural-variant axis: source table/view, uri url/urn — never a catch-all, never on a plain string); otherwise X just **modifies/validates/configures** an existing type → **attribute** (boolean flag `@localTime`; validation `@stringFormat` email/hostname; config `@maxLength`). The "string formats" set splits by behavior: url/uri→`field.uri`, ip→`field.inet` (native types + behavior), only email/hostname→`@stringFormat` (plain validated strings); uuid is already `field.uuid`. Self-documentation over economy; no same-name overloads (hence `@stringFormat`, not a third `@format`). Consult it every time. See [ADR-0037](spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md). +- **Each port defaults to its own language's standard behaviour; identical cross-port output is opt-in.** Generated code writes what that language's standard serializer writes: C# decimals as JSON numbers, a value object's null member as `null`. Do not standardize the ports for the convenience of our own conformance tests. A cross-port form, such as `"wire": { "decimals": "string", "nullMembers": "omit" }`, is a project setting that adopters choose. The corpora pin it explicitly in their own config and never decide a default. 1.1.0 broke this rule by making the decimal string and null omission the only output, and 1.1.2 put it back. See [ADR-0019 Amendment 2](spec/decisions/ADR-0019-runtime-return-type-contract.md). + - **Pattern-derivable from metadata = codegen, never hand-code.** This is the metaobjects raison d'être. If you find yourself proposing that users hand-write something the metadata fully describes (FK references, basic CRUD, validator chains, type-safe finders, relations() blocks), stop. Codegen it. The only exception is what metadata genuinely cannot express (custom SQL views, regex patterns from outside metadata, business logic). When in doubt, generate. - **Study reference implementations for subtle pipeline behavior; don't re-derive from spec.** For complex orchestration (loader, parser, super resolution, overlay/override merging, registry lifecycle), the spec describes WHAT but the implementation captures HOW — including edge cases and error handling. When porting, read existing implementations first. First-principles reasoning produces subtly wrong behavior that breaks cross-language interop. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed02e04c0..f9a985174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,49 @@ here.** ## [1.1.2] — unreleased -_Fixes #455. A bug-fix release: no vocabulary or `metamodelVersion` change._ +_Fixes #454 and #455. A bug-fix release: no vocabulary or `metamodelVersion` change._ ### Fixed +- **Each port writes its language's standard JSON again by default, and the cross-port form is + a setting (#454).** 1.1.0 changed what every generated API writes, with no way back. Decimals + became canonical strings (`12.50` was sent as `"12.5"`), and C#, Java and Kotlin stopped + writing a value object's null members. On an upgrade within 1.x, that broke clients of live + APIs. The output form is now the project setting `wire` in `.metaobjects/config.json`, read by + every port's codegen the way `naming` is (ADR-0019 Amendment 2): + + ```json + { "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } + ``` + + Both keys default to `native`, meaning what the language's standard serializer writes. **C#, + Java and Kotlin** write a decimal as a JSON number again (`12.50`), and a value object's null + member as `null` with its key present: the 1.0.x output. **Python and TypeScript** write a + decimal as their standard string, which keeps its scale (`"12.50"`). That is a change from + 1.1.1's `"12.5"`. + **To keep 1.1.0's output** in every port, declare + `"wire": { "decimals": "string", "nullMembers": "omit" }` and regenerate. `nullMembers` also + takes `"emit"`, which writes every declared member of a value object, `null` when unset, + including members the stored value lacks (Python and TypeScript; in C#, Java and Kotlin + `emit` is what `native` already writes). Input is unchanged in every mode: a decimal is + accepted as a string or a number, and garbage or a value past `@scale` or `@precision` is + `400 validation`, never rounded. An unknown key or value is refused when the config is read. + Every port's codegen drift gate says which `wire` block reproduces committed output generated + under a different setting, instead of telling you to regenerate. The conformance + corpora pin the portable form in their own config. The per-port table is in + `docs/features/api-contract.md`, "The wire setting". + + **What to expect when you regenerate with no setting.** C# entity and value-object properties + carry `[DecimalWire.Number]` in place of the string converters, and lose + `[JsonIgnore(WhenWritingNull)]`. A non-required array member of a value object starts as an + empty list again. Java DTOs and records, and Kotlin data classes, lose the decimal serializer + annotation and `@JsonInclude(NON_NULL)`. Python and TypeScript routes stop canonicalizing + decimals on output. +- **C#: two namespaces no longer collide on `DecimalWire.g.cs` and `MetaNetValidation.g.cs`.** + When the model's packages bound to more than one namespace, each namespace's copy of a helper + was named `DecimalWire.g.cs`, and `gen` refused the duplicate output path (`emitted by both + "entity" and "entity"`). With more than one namespace, each copy is now named after its + namespace (`DecimalWire.Acme.Billing.g.cs`). A single-namespace project keeps the plain name. - **C#: `MetaDataLoader.FromDirectory` (every overload, the registry overload included) now applies the project's `naming`**, as the JVM `fromDirectory` does. A host that composed its own registry and loaded with `FromDirectory(dir, registry, strict: true)` got `PhysicalNaming.Default` diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index b8547d3fc..8136ec433 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -166,7 +166,7 @@ A concrete report whose read source is `@kind: view` is served by every port's g - `` is the report name, snake_cased then pluralized, like any object: `InvoicesByMonth` is `/invoices_by_months`, `StoreTotals` is `/store_totals`. There is no report-specific spelling. - **Every derived field with filter operators is filterable and sortable**, dimension and measure alike, with its type's own operators. You cannot narrow that set: a report has no field to put `@filterable` on. A field of the `@from` entity that the report does not expose is refused with a `400`. - A time dimension at `day` or coarser is `YYYY-MM-DD`; a null measure is `null` with the key present (a measure with `@default` is never null); `currency` is integer minor units. -- **A decimal (`avg`, a ratio, a `sum` of a decimal) is one canonical string in every port** (ADR-0019 Amendment 1: `12.5`, never `12.50` or an exponent). Display formatting is a view's job. Do not parse it as a float to compare. +- **A decimal (`avg`, a ratio, a `sum` of a decimal) is written per the project's `wire` setting**: by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript), and under `"wire": { "decimals": "string" }` one canonical string in every port (`12.5`, never `12.50` or an exponent). Do not parse it as a float to compare. - `meta gen` writes a model page for every report (`docsFile()`; a sourceless one is marked "Not served") and an API page for a served one (`apiDocsFile()`) — each an ordinary entry in `generators`. The files each port generates are in the `metaobjects-codegen` skill's reference for that language ("Reports"). diff --git a/agent-context/skills/metaobjects-codegen/SKILL.md b/agent-context/skills/metaobjects-codegen/SKILL.md index 7c6a2ff82..e8e56449e 100644 --- a/agent-context/skills/metaobjects-codegen/SKILL.md +++ b/agent-context/skills/metaobjects-codegen/SKILL.md @@ -40,6 +40,7 @@ exact config, generator names and commands for this project; read it. The other | `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code | | `references/template-or-code.md` | choosing between a code generator and a Mustache template | | `references/physical-names.md` | before you write a table, column or index name as a string | +| `references/wire-format.md` | a generated API's JSON for decimals or null value-object members is not what a client expects, or you are about to edit a JSON converter or annotation | | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | @@ -60,8 +61,11 @@ in order and every change that needs action. args are removed. - Documentation is a generator in the project's list, and the docs commands are removed. C# and Python also need a generator list (`dotnet meta init`; `metaobjects.config.yaml`). -- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min` - or `@max` is refused (write an integer bound). +- Upgrade to 1.1.2 or later. With no `wire` setting, each port writes its language's standard + JSON (C#, Java and Kotlin keep their 1.0.x decimals and null members). A project whose clients + expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", + "nullMembers": "omit" }`; see `references/wire-format.md`. A fractional validator `@min` or + `@max` is refused (write an integer bound). - **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no `eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin) @@ -389,6 +393,15 @@ names artifact (`.names.ts`, `Names.java`, …) — **but only w migration scripts and external mappings; prefer the ORM's typed column handle where one exists. Details: `references/physical-names.md`. +## The JSON your API writes is a project setting + +Each port writes what its language's standard serializer writes: a C# decimal is a JSON +number, a Python one `str(Decimal)`. Identical output across ports is opt-in, through `wire` in +`.metaobjects/config.json` (`"decimals": "string"`, `"nullMembers": "omit"` or `"emit"`). Never +change one port to match another or to match a conformance corpus, and never hand-edit a +generated converter or annotation: change the setting and regenerate. Input rules hold in every +mode. Details and the per-port table: `references/wire-format.md`. + ## Dialects Generated DB schema/DDL targets a SQL **dialect**: diff --git a/agent-context/skills/metaobjects-codegen/references/csharp.md b/agent-context/skills/metaobjects-codegen/references/csharp.md index 81def21b4..32395566e 100644 --- a/agent-context/skills/metaobjects-codegen/references/csharp.md +++ b/agent-context/skills/metaobjects-codegen/references/csharp.md @@ -11,6 +11,7 @@ is **schema-migrations only** on the C# side (ADR-0015): `meta migrate` / - `MetaObjects.Codegen` generators - Docs — one more entry in `codegen/Generators.cs` - Persistence + routes are the deployed artifact +- The JSON the API writes - Write your own generator - Scoping a generator to some objects - Extending the generators (open-for-extension, ADR-0002) @@ -207,6 +208,18 @@ persistence layer (EF Core), and the minimal-API routes mount on your `WebApplic There is no runtime "ObjectManager" layer to wire — the generated EF Core code is what runs. (The other ports leave a repository seam; C# does not.) +## The JSON the API writes + +With no `wire` setting, the generated API writes what System.Text.Json writes: a decimal is a +JSON number with its scale (`12.50`, through `[DecimalWire.Number]`), and a value object's null +member is `null` with its key present. That is the 1.0.x output. `"wire": { "decimals": +"string", "nullMembers": "omit" }` in `.metaobjects/config.json` gives the cross-port form +(`"12.5"`, null members left out). The input checks (a string or a number; `400` past `@scale` +or `@precision`) hold in every mode. Never strip the attributes or delete `DecimalWire.g.cs` to +change the output: set `wire` and regenerate. `dotnet meta verify --codegen` names the `wire` +block that reproduces committed output generated under another setting. See +`references/wire-format.md`. + ## Write your own generator For any output the model describes and no reference emits, write an `IGenerator` in the diff --git a/agent-context/skills/metaobjects-codegen/references/java.md b/agent-context/skills/metaobjects-codegen/references/java.md index 2e3872643..74b7f4518 100644 --- a/agent-context/skills/metaobjects-codegen/references/java.md +++ b/agent-context/skills/metaobjects-codegen/references/java.md @@ -12,6 +12,7 @@ Maven plugin goal — there is no standalone `meta` binary on the Java side (the - When a generator's output is wrong — own the generator - Docs — the `api-docs` generator - `codegen-spring` generators +- The JSON the API writes - Discriminator inheritance (TPH) - Serializing generated objects @@ -307,6 +308,18 @@ Metadata lives wherever the plugin's `` points (the examples ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. +## The JSON the API writes + +With no `wire` setting, the generated records write what the host's Jackson `ObjectMapper` writes: a +decimal is a JSON number (`12.50`; `1E-7` under Spring Boot's defaults), and a value object's +null member is `null` with its key present. `"wire": { "decimals": "string", "nullMembers": +"omit" }` in `.metaobjects/config.json` gives the cross-port form (`"12.5"`, null members left +out): `string` adds the canonical decimal serializer, and `omit` adds `@JsonInclude(NON_NULL)` to +each value-object type. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. Never strip those annotations from generated code to change +the output: set `wire` and regenerate. `mvn metaobjects:verify` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) `codegen-spring` fully supports **table-per-hierarchy (TPH) inheritance**. `TphPlan` diff --git a/agent-context/skills/metaobjects-codegen/references/kotlin.md b/agent-context/skills/metaobjects-codegen/references/kotlin.md index 6a80fafdd..da1178b4d 100644 --- a/agent-context/skills/metaobjects-codegen/references/kotlin.md +++ b/agent-context/skills/metaobjects-codegen/references/kotlin.md @@ -16,6 +16,7 @@ migration reference). - Write your own generator - When a generator's output is wrong — own the generator - `codegen-kotlin` generators +- The JSON the API writes ## Maven coordinates @@ -296,6 +297,18 @@ Metadata lives wherever the plugin's `` points (the examples ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. +## The JSON the API writes + +With no `wire` setting, the generated data classes write what the host's Jackson `ObjectMapper` writes: a +decimal is a JSON number (`12.50`; `1E-7` under Spring Boot's defaults), and a value object's +null member is `null` with its key present. `"wire": { "decimals": "string", "nullMembers": +"omit" }` in `.metaobjects/config.json` gives the cross-port form (`"12.5"`, null members left +out): `string` adds the canonical decimal serializer, and `omit` adds `@JsonInclude(NON_NULL)` to +each value-object type. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. Never strip those annotations from generated code to change +the output: set `wire` and regenerate. `mvn metaobjects:verify` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) `codegen-kotlin` fully supports **table-per-hierarchy (TPH) inheritance**. diff --git a/agent-context/skills/metaobjects-codegen/references/python.md b/agent-context/skills/metaobjects-codegen/references/python.md index 244c9253a..7f5934bb5 100644 --- a/agent-context/skills/metaobjects-codegen/references/python.md +++ b/agent-context/skills/metaobjects-codegen/references/python.md @@ -14,6 +14,7 @@ through the Node `meta` tool — the Python CLI has **no `migrate` subcommand** - Generators - Discriminator inheritance (TPH) - No ORM — you own persistence (unlike the C# port) +- The JSON the routers write - Known gaps (current — may require a hand-edit) - Re-scaffold this context @@ -242,6 +243,17 @@ persistence layer and no runnable server**. Two things you hand-write: app.include_router(author_router) ``` +## The JSON the routers write + +With no `wire` setting, a generated router writes a decimal as Pydantic writes a `Decimal`, +`str(value)` (`"12.50"`), and a value object as the runtime holds it. `"wire": { "decimals": +"string" }` in `.metaobjects/config.json` writes the canonical string (`"12.5"`). +`"nullMembers": "emit"` writes every declared value-object member, `null` when unset, and +`"omit"` leaves null members out. The input checks hold in every mode. Set `wire` and regenerate +rather than editing `render_wire(..., _WIRE)` in a router. `metaobjects verify` names the +`wire` block that reproduces committed output generated under another setting. See +`references/wire-format.md`. + ## Known gaps (current — may require a hand-edit) - **Composite PKs need a hand-edit.** The generated router/repository key on a single diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index daca582a3..6db44b8d4 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -9,6 +9,7 @@ packages. Codegen runs through the Node `meta` CLI (`@metaobjectsdev/cli`, binar - Install - `metaobjects.config.ts` - The generators +- The JSON the routes write - Run - Multiple output targets - Field subtype → column mapping @@ -262,6 +263,18 @@ writes target the table with derived fields excluded from the insert/update code create/update re-reads the row via the view by primary key (read-your-writes). The replica view's DDL is emitted by `meta migrate` from the same origin assembly as a projection view. +## The JSON the routes write + +With no `wire` setting, the generated routes write a decimal as the string the driver returns +(`"12.50"` on Postgres and MySQL; SQLite and D1 at 15 significant digits) and a value object as +stored. `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json` +gives the cross-port form (`"12.5"`, null members left out); `"nullMembers": "emit"` writes every +declared member, `null` when unset. The setting reaches the generated route as mount options +(`decimalWire`, `nullMembers`, `valueObjects`), so set it and run `meta gen`; do not edit the +options in a generated route. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. `meta verify --codegen` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) The TS reference implementation fully supports **table-per-hierarchy (TPH) diff --git a/agent-context/skills/metaobjects-codegen/references/wire-format.md b/agent-context/skills/metaobjects-codegen/references/wire-format.md new file mode 100644 index 000000000..17847c27c --- /dev/null +++ b/agent-context/skills/metaobjects-codegen/references/wire-format.md @@ -0,0 +1,88 @@ +# The JSON a generated API writes: the `wire` setting + +> Part of the `metaobjects-codegen` skill. Read it before you change how a generated API writes a decimal or a null value-object member, before you hand-edit a JSON converter or annotation in generated code, and before you upgrade a project that serves a live API. + +**The rule: each port writes what its language's standard serializer writes.** A C# API writes +JSON the way System.Text.Json does, a Java API the way Jackson does, a Python API the way +Pydantic does. Identical output across ports is something a project opts into. The cross-port +conformance corpora pin that opt-in form in their own config. They never decide a default, so do +not "fix" a port to match another one, or to match the corpus. + +## Contents +- The setting +- What each port writes +- Choosing a form +- What it never changes +- Upgrading, and drift + +## The setting + +`wire` in `.metaobjects/config.json`, beside `naming`. Every port's codegen reads it from the +loaded root; there is no CLI flag and no generator argument. + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +- `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no + exponent, no trailing zeros). +- `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an + `object.value` only. A top-level entity field that is null is always written as `null`. +- An absent block or key means `native`. An unknown key or value is refused when the config is + read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. + +## What each port writes + +| Port | `decimals: native` | `decimals: string` | `nullMembers: native` | `emit` | `omit` | +|---|---|---|---|---|---| +| C# | JSON number, scale kept (`12.50`) | `"12.5"` | `null`, key present | same as native | left out | +| Java | JSON number, as the host's Jackson writes a `BigDecimal` (`12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | +| Python | string, `str(Decimal)` (`"12.50"`, `"1E-7"`) | `"12.5"` | as stored | every declared member, `null` when unset | left out | +| TypeScript | string as the driver returns it (`"12.50"`; 15 significant digits on SQLite and D1) | `"12.5"` | as stored | every declared member, `null` when unset | left out | + +How it shows in generated code: + +- **C#.** `native`: `[DecimalWire.Number]`, or `[DecimalWire.Number(p, s)]` with `@precision`. + `string`: the `DecimalWire` string converters, or `[DecimalWire.Declared(p, s)]`. `omit` + adds `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to each nullable + value-object member. +- **Java and Kotlin.** `string` adds the canonical decimal serializer; `omit` adds + `@JsonInclude(NON_NULL)` to each value-object type. `native` adds neither, so the host's + `ObjectMapper` decides. +- **Python and TypeScript.** The generated routes write each response through the port's wire + helper with the setting baked in. + +## Choosing a form + +- **Keep the default** for an API whose clients already exist, or are written in the same + language as the server. It is what a developer of that language expects to see. +- **Choose `"decimals": "string"`** when clients in several languages share one contract, or a + browser client must read a decimal exactly. `JSON.parse` turns a JSON number into a double + before any code sees it. This is the form 1.1.0 and 1.1.1 wrote. +- **Choose `"nullMembers": "omit"` or `"emit"`** when every port must agree on whether a null + member appears. `emit` is the one that changes Python and TypeScript output; in the JVM ports + and C# it is what `native` already writes. + +Change the setting, then regenerate and commit. Do not hand-edit the converter or annotation in a +generated file to get a different form: a regen puts it back, and `verify --codegen` fails on it. + +## What it never changes + +Input. In every mode a decimal in a request body is a string in the JSON-number grammar or a JSON +number, read exactly; garbage, and a value past `@scale` or `@precision`, is `400 validation`, +never rounded; a filter operand is compared exactly and a bad one is `400 invalid_filter_value`. +The generated input converters stay in every mode. + +## Upgrading, and drift + +- From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x + output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members + declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. +- Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, + `mvn metaobjects:verify`, `metaobjects verify`) names the `wire` block that reproduces + committed output generated under another setting. When it does, declare that block; do not regenerate. + +Full table and detail: `docs/features/api-contract.md`, "The wire setting"; the decision is +ADR-0019 Amendment 2. diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index 99f8489e0..6445663b5 100644 --- a/agent-context/templates/always-on.md.mustache +++ b/agent-context/templates/always-on.md.mustache @@ -82,7 +82,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 825a94a48..5b584f716 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **30 shared conformance corpora** under +The MetaObjects standard ships **31 shared conformance corpora** under [`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to it* and asserts the same expected behaviour against the same fixtures. **This page is the inverse index**: fixture → feature doc + per-port pass status, and it is the @@ -54,6 +54,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/requirement-check-conformance/`](../fixtures/requirement-check-conformance/) (the `verify` requirement gate, ADR-0057) | 43 cases | ✓ (reference) | ✓ | inherits via Java (one Maven `metaobjects:verify` goal) | ✓ | ✓ | | [`fixtures/requirement-test-identity-conformance/`](../fixtures/requirement-test-identity-conformance/) (the identity, skip state and digest of each generated requirement test, and the filter seam, ADR-0057) | 26 cases | ✓ (reference) | ✓ | identity function inherits via Java; emitted names asserted by its own generator test | ✓ | ✓ | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | +| [`fixtures/wire-conformance/`](../fixtures/wire-conformance/) (the `wire` setting, ADR-0019 Amendment 2) | 6 cases + 8 refusals | ✓ | ✓ | inherits via Java (one config reader) | ✓ | ✓ | | [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary: what is lowered, what stays inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/cube-model/`](../fixtures/cube-model/) (FR-044 Plan 4 — the `cube-model` generator's golden Cube files, plus the canonical model's golden) | 46 cases (36 trees + 10 errors) + 1 canonical golden | ✓ (the exporter is TypeScript-only, spec R6; its `cube` lane also loads the files into a real Cube) | — | — | — | — | diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index de11075b7..c949f314a 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -322,12 +322,12 @@ for the one-line change. | `field.int`, `field.long`, `field.double` | number | `long` MAY be string on overflow; defer to per-port docs. | | `field.boolean` | boolean | – | | `field.date` | string | ISO 8601 calendar date (`YYYY-MM-DD`). Asserted literally by the `report/` sub-corpus (a time dimension's bucket), the first corpus to do so. | -| `field.decimal` | string, canonical | ADR-0019 Amendment 1. **Out:** plain notation, no exponent, no trailing fraction zeros, zero as `"0"`, matching `^-?(0\|[1-9][0-9]*)(\.[0-9]*[1-9])?$` (`12.50` is sent `"12.5"`). **In:** a string in JSON-number grammar or a JSON number, read exactly (TypeScript reads a JSON number exactly to 15 significant digits, so send a string for more); anything else is `400 validation`, and so is a value with more fraction digits than `@scale` or more integer digits than `@precision - @scale` (nothing is rounded). A filter operand uses the same grammar, is compared exactly, and a bad one is `400 invalid_filter_value`. Postgres and MySQL are exact to the declared precision; SQLite and D1 to 15 significant digits. Every decimal (entity, projection, report row) in every port; the `decimal/` sub-corpus asserts it. The wire carries the value, not a presentation: showing `12.5` as `12.50`, or with two digits for money, is a view's job, not the API's. | +| `field.decimal` | per [the wire setting](#the-wire-setting) | **Out:** by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript). Under `"wire": { "decimals": "string" }`, the canonical string of ADR-0019 Amendment 1: plain notation, no exponent, no trailing fraction zeros, zero as `"0"`, matching `^-?(0\|[1-9][0-9]*)(\.[0-9]*[1-9])?$` (`12.50` is sent `"12.5"`). **In, in every mode:** a string in JSON-number grammar or a JSON number, read exactly (TypeScript reads a JSON number exactly to 15 significant digits, so send a string for more); anything else is `400 validation`, and so is a value with more fraction digits than `@scale` or more integer digits than `@precision - @scale` (nothing is rounded). A filter operand uses the same grammar, is compared exactly, and a bad one is `400 invalid_filter_value`. Postgres and MySQL are exact to the declared precision; SQLite and D1 to 15 significant digits. The setting covers every decimal (entity, projection, report row) in every port; the `decimal/` sub-corpus asserts the string form, which the corpus pins. | | `field.time` | string | `HH:MM:SS[.fff]`. | | `field.timestamp` | string | Instant, tz-aware by default (ADR-0036 Wave 2) — `YYYY-MM-DDTHH:MM:SS[.fff]Z`, **always UTC**. | | `field.timestamp` (`@localTime: true`) | string | Naive wall clock — `YYYY-MM-DDTHH:MM:SS[.fff]`, **no `Z`**. | | `field.currency` | **integer minor units** | Cents for USD, yen for JPY. Float arithmetic is forbidden. Server never formats. | -| `field.object` (`@storage: jsonb`) | object | Nested per the sub-object schema. | +| `field.object` (`@storage: jsonb`) | object | Nested per the sub-object schema. A null member is written per [the wire setting](#the-wire-setting) (`nullMembers`). | | `field.object` (`@storage: flattened`) | object | Same JSON shape — only the storage differs. | The currency invariant is the load-bearing one: every port emits and @@ -352,6 +352,53 @@ compares equal: entirely when zero**. A whole-second instant is `2026-05-25T14:30:00Z`, never `2026-05-25T14:30:00.000Z`. +### The wire setting + +What a generated API writes for a decimal and for a value object's null member is a project +setting, `wire` in `.metaobjects/config.json`, read by every port's codegen the way `naming` is +([ADR-0019 Amendment 2](../../spec/decisions/ADR-0019-runtime-return-type-contract.md)): + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +Both keys default to `native`: **each port writes what its language's standard serializer +writes**, so a C# API looks like a C# API and a Python API like a Python API. Identical output +across ports is something you opt into: + +```json +{ "schema_version": 1, "wire": { "decimals": "string", "nullMembers": "omit" } } +``` + +That is the form 1.1.0 and 1.1.1 wrote, and the form the cross-port conformance corpora pin in +their own config. Choose it when clients written in several languages share one contract, or when +a browser client must read a decimal exactly: `JSON.parse` turns a JSON number into a double +before your code sees it. + +| Port | `decimals: native` (default) | `decimals: string` | `nullMembers: native` (default) | `emit` | `omit` | Change from 1.1.1 with no setting | +|---|---|---|---|---|---|---| +| C# | JSON number, as System.Text.Json writes a `decimal` (`12.50`, `0.0000001`) | `"12.5"` | `null`, key present | same as native | left out | decimals are numbers again, null members are written again (the 1.0.x output) | +| Java | JSON number, as the host's Jackson `ObjectMapper` writes a `BigDecimal` (Spring Boot: `12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | the same: the 1.0.x output | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | the same: the 1.0.x output | +| Python | string, as Pydantic writes a `Decimal` (`"12.50"`, `"1E-7"`) | `"12.5"` | as stored: a stored `null` is `null`, a missing member is absent | every declared member, `null` when unset | left out | decimals keep their scale (`"12.50"`, not `"12.5"`) | +| TypeScript | string, as the runtime holds it: the driver's text on Postgres and MySQL (`"12.50"`); the stored number at 15 significant digits on SQLite and D1 | `"12.5"` | as stored: a stored `null` is `null`, a missing member is absent | every declared member, `null` when unset | left out | decimals keep their scale (`"12.50"`, not `"12.5"`) | + +- **Input never follows the setting.** A decimal in a request body is a string in the + JSON-number grammar or a JSON number in every mode. Garbage, and a value past the field's + `@scale` or `@precision`, is `400 validation`, never rounded. A filter operand is compared + exactly. +- `nullMembers` covers the members of an `object.value` only, and so also the jsonb that a typed + serializer writes. A top-level entity field that is null is always written as `null`. +- There is no `"number"` value for `decimals`. In C#, Java and Kotlin, `native` already is a + number; in Python and TypeScript, a number would be a lossy spelling those languages do not + use. +- An unknown key or value is refused when the config is read. Every port's codegen drift gate + (`meta verify --codegen`, `dotnet meta verify --codegen`, `mvn metaobjects:verify`, + `metaobjects verify`) says which `wire` block reproduces committed output that was generated + under another setting, instead of telling you to regenerate. +- Changing the setting changes generated code (converters, annotations, serializers), so + regenerate after you change it, and commit the result. + ### Error response Non-2xx responses MUST return: @@ -437,7 +484,8 @@ The surface: mount item routes only for a **single-column** identity, while TypeScript and Python bind a composite one to its first field. - Filters apply to a projection's `field.decimal` and `field.float` fields as they do to - an entity's. A decimal is the canonical string of "Type encodings". + an entity's. A decimal is written as "Type encodings" and [the wire setting](#the-wire-setting) + say, as for an entity. - **The api docs list the same routes.** A projection's api page documents `GET `, and `GET /{id}` only when the item route exists. The `405` refusals are not operations a caller can use, so no write verb is documented. Pinned in every port by diff --git a/docs/features/field-types.md b/docs/features/field-types.md index c5e04a990..f11c8fd23 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -33,14 +33,16 @@ abstract registry anchor, never authored (`ERR_ABSTRACT_SUBTYPE_AUTHORED`). Four rows need a footnote: -- **`field.decimal`** is exact in every port's own type, and crosses JSON as a canonical - string (`"12.5"`, zero as `"0"`; a JSON number is accepted on input). TypeScript's type is - that string. Postgres and MySQL keep the declared `numeric(@precision, @scale)` exactly; - SQLite and D1 have no decimal type and keep 15 significant digits, so `meta migrate` warns - for a decimal there with `@precision` above 15. Trailing fraction zeros are not kept - (`12.50` crosses as `"12.5"`): the wire carries the value, and display formatting, such as - two digits for an amount of money, is a view concern. The rule is ADR-0019 Amendment 1, and - [api-contract.md](api-contract.md) "Type encodings" has the wire detail. +- **`field.decimal`** is exact in every port's own type. TypeScript's type is a string. + Postgres and MySQL keep the declared `numeric(@precision, @scale)` exactly; SQLite and D1 have + no decimal type and keep 15 significant digits, so `meta migrate` warns for a decimal there + with `@precision` above 15. On the JSON wire, each port writes its language's standard form + by default: a number in C#, Java and Kotlin (`12.50`), and a string in Python and TypeScript + (`"12.50"`). The project setting `"wire": { "decimals": "string" }` makes every port write + the canonical string of ADR-0019 Amendment 1 (`"12.5"`, zero as `"0"`). Input takes a string + or a number in every mode, and a value past `@scale` or `@precision` is refused, never + rounded. [api-contract.md, "The wire setting"](api-contract.md#the-wire-setting) has the + per-port table. - **`field.uri` / `field.inet`** bind a native URL/IP type on every port but TypeScript, which has none (the same reason `field.uuid` is a `string` there). `@lenient: true` diff --git a/docs/features/migrations/upgrading-1.0-to-1.1.md b/docs/features/migrations/upgrading-1.0-to-1.1.md index 1a7c342a7..c260dbf89 100644 --- a/docs/features/migrations/upgrading-1.0-to-1.1.md +++ b/docs/features/migrations/upgrading-1.0-to-1.1.md @@ -143,19 +143,41 @@ them all; pick the one the object meant. In 1.0 the TypeScript, Python and C# lo first match in load order, so with one candidate the qualified spelling keeps the meaning it had. `meta upgrade` does not make this edit: choosing the package needs the whole model. -### Decimals on the wire (every port) - -A `field.decimal` crosses the generated API as one canonical string in every port (ADR-0019 -Amendment 1): `12.50` is sent `"12.5"`, zero `"0"`, never a number and never an exponent. In -1.0 C#, Java, Kotlin and Python sent a number, and TypeScript sent `"12.50"`, now `"12.5"`. A client that reads a decimal from those ports -parses a string now; format it for display with the field's `@scale` (display is a view's job). -On input a string or a JSON number is accepted, read exactly (TypeScript reads a JSON number -exactly to 15 significant digits, so send a string for more), and a value with more fraction -digits than `@scale` or more integer digits than `@precision - @scale` is `400 validation` -rather than rounded. A decimal filter operand is compared exactly; a bad one is -`400 invalid_filter_value`. On SQLite and D1 a decimal keeps 15 significant digits, and -`meta migrate` warns for one declared with `@precision` above 15. An owned routes generator -keeps its 1.0 decimal handling until you resync it (step 7). +### Decimals and null members on the wire (every port) + +**Upgrade to 1.1.2 or later, not 1.1.0 or 1.1.1.** 1.1.0 and 1.1.1 changed what every generated +API writes, with no opt-out: a decimal became a canonical string (`12.50` was sent as `"12.5"`), +and C#, Java and Kotlin stopped writing a value object's null members. From 1.1.2 the output is +the project setting `wire` in `.metaobjects/config.json` (ADR-0019 Amendment 2), and with no +setting each port writes its language's standard JSON: + +- **C#, Java and Kotlin:** a decimal is a JSON number and a null member is `null`, as in 1.0.x. + Nothing changes for your clients. +- **TypeScript:** a decimal is the string the driver returns, which keeps its scale (`"12.50"`): + the 1.0.x output on Postgres and MySQL. +- **Python:** a decimal is the string Pydantic writes for a `Decimal`, `str(value)` (`"12.50"`). + 1.0.x routers returned plain dicts, and FastAPI's encoder turned a `Decimal` into a float + (`12.5`, losing digits past a double's precision). A client that read a number now reads a + string. No setting restores the float, because it loses digits. + +To have every port write one form, which suits clients in several languages or a browser that +must read decimals exactly, declare the form 1.1.0 wrote and regenerate: + +```json +{ "schema_version": 1, "wire": { "decimals": "string", "nullMembers": "omit" } } +``` + +If you already upgraded to 1.1.0 or 1.1.1 and your clients now expect that form, declare that +block before you move to 1.1.2, or your output changes back. [api-contract.md, "The wire +setting"](../api-contract.md#the-wire-setting) has the full per-port table. + +Input is the same in every mode. A string or a JSON number is accepted and read exactly +(TypeScript reads a JSON number exactly to 15 significant digits, so send a string for more). A +value with more fraction digits than `@scale`, or more integer digits than +`@precision - @scale`, is `400 validation` rather than rounded. A decimal filter operand is +compared exactly; a bad one is `400 invalid_filter_value`. On SQLite and D1 a decimal keeps 15 +significant digits, and `meta migrate` warns for one declared with `@precision` above 15. An +owned routes generator keeps its 1.0 decimal handling until you resync it (step 7). ### Fractional validator bounds (TypeScript, C#, Python) @@ -189,10 +211,12 @@ whole number written `1.0` still loads, as `1`. (`emitSharedEnums=true`, an `` entry on `SpringValueObjectGenerator` or `SpringOutputParserGenerator`; Kotlin's `providedEnumNamespace` / `providedEnumPackages` in the plugin's ``). See the CHANGELOG entry. -- **Decimal wire files.** C# writes `DecimalWire.g.cs` per namespace and Kotlin - `MetaDecimalWire.kt` per package; Java's DTOs and allowlists gain decimal annotations and - `DECIMAL_FIELDS`; TypeScript and Python routes call their decimal wire. See "Decimals on the - wire". +- **Decimal wire files.** C# writes `DecimalWire.g.cs` per namespace (named + `DecimalWire..g.cs` when the model binds more than one namespace) and Kotlin + `MetaDecimalWire.kt` per package. Java's allowlists gain `DECIMAL_FIELDS`, and TypeScript and + Python routes call their decimal wire. These check input in every mode. The output + annotations appear only under `"wire": { "decimals": "string" }`. See "Decimals and null + members on the wire". - **C# array properties are `IList`** (scalar and enum arrays on entities, TPH subtypes, value objects and report rows; they were `ICollection`, which EF Core 8 cannot read on SQLite). Code that assigns a `HashSet` to one assigns a `List`. @@ -214,8 +238,8 @@ that reach them are in the CHANGELOG. |---|---|---|---| | 1.0.5 and after | `HasForeignKey(nameof(Owner.Prop))` | `HasForeignKey(e => e.Prop)`, or `e => new { e.A, e.B }` for a composite (the `M:N` `UsingEntity` sides too) | `DbContextGenerator.EmitOnModelCreatingBody(sb, modelLines, ctx)` receives the lines as a list before they are written. There is no seam for the key expression itself, so match the lambda form | | 1.1.0 | scalar and enum arrays `ICollection` | `IList`; a non-required value-object array is nullable and has no initializer | `EntityGenerator.ScalarProperty(...)` / `EnumProperty(...)` return the property text; override the one you changed | -| 1.1.0 | no `JsonIgnore` on value-object members | on a nullable member, `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` beside `[JsonPropertyName("…")]` | `EntityGenerator.EmitPropertyAttributes(sb, owner, field, ctx)` adds attributes; to change the POCO itself, override `EmitValueObjectPoco(vo, ctx)` | -| 1.1.0 | `decimal` written and read as a JSON number | a `[DecimalWire…]` converter attribute on each decimal member and one `DecimalWire.g.cs` per namespace | `EntityGenerator.ScalarProperty(...)` for the member; `EmitFileUsings(sb, entity, ctx)` for the usings the attribute needs | +| 1.1.0, narrowed in 1.1.2 | no `JsonIgnore` on value-object members | 1.1.0 and 1.1.1: on every nullable member, `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` beside `[JsonPropertyName("…")]`. From 1.1.2, only under `"wire": { "nullMembers": "omit" }`; with no setting there is none, as in 1.0 | to drop or keep the attribute, set `wire.nullMembers` and regenerate (do not strip it). `EntityGenerator.EmitPropertyAttributes(sb, owner, field, ctx)` adds other attributes; to change the POCO itself, override `EmitValueObjectPoco(vo, ctx)` | +| 1.1.0, changed in 1.1.2 | `decimal` written and read as a JSON number | 1.1.0 and 1.1.1: a `[DecimalWire…]` string-converter attribute on each decimal member. From 1.1.2, with no setting, `[DecimalWire.Number]` (or `[DecimalWire.Number(p, s)]` with `@precision`), which writes a JSON number and keeps the strict input check; the string converters, or `[DecimalWire.Declared(p, s)]`, only under `"wire": { "decimals": "string" }`. One `DecimalWire.g.cs` per namespace in every mode (`DecimalWire..g.cs` when there is more than one); do not delete it, because the input check lives there | to change the output form, set `wire.decimals` and regenerate (do not strip the attribute). `EntityGenerator.ScalarProperty(...)` for the member; `EmitFileUsings(sb, entity, ctx)` for the usings the attribute needs | | 1.0.13 | `DbSet` and navigation names from the 1.0 pluralizer (`CallScriptses`) | an already-plural entity name stays as it is (`CallScripts`) | `DbContextGenerator.EmitDbSetDeclarations(sb, objects, ctx)`; the name itself is `CSharpNaming`'s, so a rewrite keyed to the old plural has to be re-keyed | | 1.1.0 | a reference written bare in canonical output | `pkg::Name` (a root-level target is `::Name`), so `MetaReferenceIdentity.TargetEntity` is a qualified reference | none needed: resolve a reference through the model (`ValueObjectNames.ResolveFieldRef(field, ctx.Root)` for an `@objectRef`) instead of comparing the string | diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 75a8fc72f..dbe294334 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -580,7 +580,7 @@ encodings"), applied to the column types above: | `currency` | integer minor units | | `date` (a time dimension at `day`, `week`, `month`, `quarter` or `year`) | `YYYY-MM-DD`, the first day of the bucket | | `timestamp` (a time dimension at `hour`) | an instant with `Z`, or naive without for a `@localTime` field | -| `decimal` (`avg`, a ratio, a `sum` of a decimal) | a canonical string, as for every decimal ([api-contract.md](api-contract.md), ADR-0019 Amendment 1): `"12.5"`, zero as `"0"`, no exponent. The digits are the engine's: Postgres and MySQL are exact, SQLite and D1 exact to 15 significant digits. | +| `decimal` (`avg`, a ratio, a `sum` of a decimal) | as for every decimal, per the project's `wire` setting ([api-contract.md, "The wire setting"](api-contract.md#the-wire-setting)): by default the port's language-standard form, and the canonical string (`"12.5"`, zero as `"0"`, no exponent) under `"decimals": "string"`. The digits are the engine's: Postgres and MySQL are exact, SQLite and D1 exact to 15 significant digits. | | a null value | `null`, with the key present | **What each port generates for a served report.** `` is the report's name. @@ -731,7 +731,7 @@ array and one over a map, which refuse a filter (`invalid_filter_field`) and a s report it finds fails. Every port runs the scenarios through its generated routes against the real views on Postgres, SQLite and MySQL (`report/schema.sqlite.sql`, `report/schema.mysql.sql`), and TypeScript also through local D1. A report's decimal columns are asserted as canonical -strings by the `decimal/` sub-corpus, on the same engines. +strings by the `decimal/` sub-corpus, which pins `"decimals": "string"`, on the same engines. ## Exporting to Cube diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 8d79fbbfe..61a493069 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -194,20 +194,33 @@ The codegen emits: - `AuthorFilterAllowlist.g.cs` — the server-side filter/sort allowlist feeding the generated list handler. -### Decimals on the wire - -A `field.decimal` crosses the API as one canonical string (ADR-0019 Amendment 1): `12.50` is -`"12.5"`, zero is `"0"`, never a JSON number or an exponent. Input may be a string or a number, -is read exactly, and is `400 validation` when it has more fraction digits than `@scale` or more -integer digits than `@precision - @scale` (no `@scale` beside `@precision` means 0). A filter -operand compares exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at -15 significant digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. - -`dotnet meta gen` writes `DecimalWire.g.cs` once per namespace. A decimal property carries its -JSON converter and, with `@precision`, `[DecimalWire.Declared(precision, scale)]`; a decimal -array is an `IList` with `DecimalListJsonConverter`. A stored value the CLR `decimal` -cannot hold answers `500 {"error": "decimal_out_of_range"}` (`DecimalRangeEndpointFilter`). -An owned `routes` generator copies `DecimalWire.cs` and `DecimalRangeEndpointFilter.cs`. +### Decimals and null members on the wire + +What the generated API writes is the project setting `wire` in `.metaobjects/config.json` +([api-contract.md, "The wire setting"](../features/api-contract.md#the-wire-setting)). With no +setting, it writes what System.Text.Json writes: a decimal is a JSON number that keeps its scale +(`12.50`, `0.0000001`), and a value object's null member is `null` with its key present. That is +the 1.0.x output. `"wire": { "decimals": "string" }` writes the canonical string of ADR-0019 +Amendment 1 (`12.50` is `"12.5"`, zero is `"0"`). `"nullMembers": "omit"` leaves a null member +out (`[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` on each nullable member), +and `"emit"` is what the default already writes. + +Input is the same in every mode. It may be a string or a number, is read exactly, and is +`400 validation` when it has more fraction digits than `@scale` or more integer digits than +`@precision - @scale` (no `@scale` beside `@precision` means 0). A filter operand compares +exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at 15 significant +digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. + +`dotnet meta gen` writes `DecimalWire.g.cs` once per namespace that has a decimal (named +`DecimalWire..g.cs` when the model binds more than one namespace; +`MetaNetValidation` follows the same rule). By default a decimal property carries +`[DecimalWire.Number]`, or `[DecimalWire.Number(precision, scale)]` with `@precision`. Under +`"decimals": "string"` it carries the string converter, or `[DecimalWire.Declared(precision, +scale)]`. A decimal array is an `IList`. A stored value the CLR `decimal` cannot hold +answers `500 {"error": "decimal_out_of_range"}` (`DecimalRangeEndpointFilter`). An owned `routes` +generator copies `DecimalWire.cs` and `DecimalRangeEndpointFilter.cs`. Do not strip the +attributes or delete `DecimalWire.g.cs` to change the output: they also check input. Set `wire` +and regenerate. ### `Names` — the physical names, as constants diff --git a/docs/ports/java.md b/docs/ports/java.md index f64b45188..dffb33439 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -442,21 +442,30 @@ derived field reads a `field.object`. A report with no view source, or an abstra generates nothing. The contract is in [reporting](../features/reporting.md#how-a-report-is-served). -### Decimals on the wire - -A `field.decimal` crosses the API as one canonical string (ADR-0019 Amendment 1): `12.50` is -`"12.5"`, zero is `"0"`, never a JSON number or an exponent. Input may be a string or a number, -is read exactly, and is `400 validation` when it has more fraction digits than `@scale` or more -integer digits than `@precision - @scale` (no `@scale` beside `@precision` means 0). A filter -operand compares exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at -15 significant digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. - -The generated DTO, Patch and value-object records write a `BigDecimal` through -`CanonicalDecimalSerializer` and read it through `CanonicalDecimalDeserializer`, which checks -the field's `@DeclaredDecimal(precision, scale)`. A filter allowlist lists its decimal fields in -`DECIMAL_FIELDS`, and `FilterParser.parse(rawQuery, FIELDS, OPS_BY_FIELD, DECIMAL_FIELDS)` -hands the repository `BigDecimal` operands. These classes live in -`com.metaobjects.generator.spring.runtime`. +### Decimals and null members on the wire + +What the generated records write is the project setting `wire` in `.metaobjects/config.json` +([api-contract.md, "The wire setting"](../features/api-contract.md#the-wire-setting)). With no +setting, the host's Jackson `ObjectMapper` decides: a decimal is a JSON number as Jackson writes +a `BigDecimal` (`12.50`, and `1E-7` under Spring Boot's defaults), and a value object's null +member is `null` with its key present. `"wire": { "decimals": "string" }` writes the canonical +string of ADR-0019 Amendment 1 (`"12.5"`, zero `"0"`), and `"nullMembers": "omit"` leaves a null +member out. `emit` writes what `native` writes. A top-level null field is always `null`. + +Input is the same in every mode. It may be a string or a number, is read exactly, and is +`400 validation` when it has more fraction digits than `@scale` or more integer digits than +`@precision - @scale` (no `@scale` beside `@precision` means 0). A filter operand compares +exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at 15 significant +digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. + +The generated DTO, Patch and value-object records read a `BigDecimal` through +`CanonicalDecimalDeserializer`, which checks the field's `@DeclaredDecimal(precision, scale)`. +Under `"decimals": "string"` they also write it through `CanonicalDecimalSerializer`, and under +`"nullMembers": "omit"` each value-object record carries `@JsonInclude(NON_NULL)`. A filter +allowlist lists its decimal fields in `DECIMAL_FIELDS`, and `FilterParser.parse(rawQuery, FIELDS, +OPS_BY_FIELD, DECIMAL_FIELDS)` hands the repository `BigDecimal` operands. These classes live in +`com.metaobjects.generator.spring.runtime`. `mvn metaobjects:verify` names the `wire` +block that reproduces committed output generated under another setting. ### `Names` — the physical names, as constants diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index fab7bd9a8..3f0ce81e2 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -544,21 +544,31 @@ A column property whose name is a member of Exposed's `Table` gets a `Column` su follows the output mode: `options` and `storageParameters` are `Table` members only in Exposed 1.x, so they are suffixed only with `exposedApi=1`. -### Decimals on the wire - -A `field.decimal` crosses the API as one canonical string (ADR-0019 Amendment 1): `12.50` is -`"12.5"`, zero is `"0"`, never a JSON number or an exponent. Input may be a string or a number, -is read exactly, and is `400 validation` when it has more fraction digits than `@scale` or more -integer digits than `@precision - @scale` (no `@scale` beside `@precision` means 0). A filter -operand compares exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at -15 significant digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. +### Decimals and null members on the wire + +What the generated data classes write is the project setting `wire` in `.metaobjects/config.json` +([api-contract.md, "The wire setting"](../features/api-contract.md#the-wire-setting)). With no +setting, the host's Jackson `ObjectMapper` decides: a decimal is a JSON number as Jackson writes +a `BigDecimal` (`12.50`, and `1E-7` under Spring Boot's defaults), and a value object's null +member is `null` with its key present. `"wire": { "decimals": "string" }` writes the canonical +string of ADR-0019 Amendment 1 (`"12.5"`, zero `"0"`) through `@get:JsonSerialize`, and +`"nullMembers": "omit"` puts `@JsonInclude(NON_NULL)` on each value-object data class. `emit` +writes what `native` writes. A top-level null field is always `null`. + +Input is the same in every mode. It may be a string or a number, is read exactly, and is +`400 validation` when it has more fraction digits than `@scale` or more integer digits than +`@precision - @scale` (no `@scale` beside `@precision` means 0). A filter operand compares +exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at 15 significant +digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. Generated Kotlin imports nothing from MetaObjects, so codegen writes `MetaDecimalWire.kt` into -each package that has a decimal: the canonical writer, the strict reader, the declared-digit -check and the `400 validation` envelope for an unreadable body. The Exposed tables of a package +each package that has a decimal: the strict reader, the declared-digit check, the `400 +validation` envelope for an unreadable body and, under `"decimals": "string"`, the canonical +writer. The Exposed tables of a package that has a decimal or uuid column also get `MetaPortableColumnTypes.kt`, which binds them on SQLite and MySQL as well as Postgres; the exposed-table generator writes it. Ejecting `entity`, -`routes` or `exposed-table` copies `KotlinDecimalWireEmitter.kt` with the generator. +`routes` or `exposed-table` copies `KotlinDecimalWireEmitter.kt` with the generator. `mvn metaobjects:verify` names the `wire` +block that reproduces committed output generated under another setting. ### `Names` — the physical names, as constants diff --git a/docs/ports/python.md b/docs/ports/python.md index 86f16c9a4..05a8f7c9a 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -267,18 +267,31 @@ The generated `get_repository` raises until you override it. Any object with the `AuthorRepository` methods above works, including an in-memory dict while you try it out. Serve the app with `uvicorn my_app.main:app --reload` (`pip install uvicorn`). -### Decimals on the wire - -A `field.decimal` crosses the API as one canonical string (ADR-0019 Amendment 1): `12.50` is -`"12.5"`, zero is `"0"`, never a JSON number or an exponent. Input may be a string or a number, -is read exactly, and is `400 validation` when it has more fraction digits than `@scale` or more -integer digits than `@precision - @scale` (no `@scale` beside `@precision` means 0). A filter -operand compares exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at -15 significant digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. - -Generated routers import `metaobjects.codegen.runtime.decimal_wire` (`format_decimal`, -`parse_decimal`, `parse_decimal_operand`, `check_decimal_range`) and pass each decimal field's -declared limits. A malformed or non-object JSON body is `400 validation`. +### Decimals and null members on the wire + +What the generated routers write is the project setting `wire` in `.metaobjects/config.json` +([api-contract.md, "The wire setting"](../features/api-contract.md#the-wire-setting)). With no +setting, a decimal is written the way Pydantic writes a `Decimal`, `str(value)`, which keeps its +scale (`"12.50"`, `"1E-7"`). A value object is written as the runtime holds it: a member stored +as `null` is `null`, and a member the stored value lacks is absent. `"wire": { "decimals": +"string" }` writes the canonical string of ADR-0019 Amendment 1 (`"12.5"`, zero `"0"`). +`"nullMembers": "emit"` writes every declared member, `null` when unset, and `"omit"` leaves a +null member out. Both reach arrays and maps of value objects, and nested ones. A top-level null +field is always `null`. On SQLite a decimal is read back without its scale, so `native` writes +`"12.5"` there. + +Input is the same in every mode. It may be a string or a number, is read exactly, and is +`400 validation` when it has more fraction digits than `@scale` or more integer digits than +`@precision - @scale` (no `@scale` beside `@precision` means 0). A filter operand compares +exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at 15 significant +digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. + +Generated routers import `metaobjects.codegen.runtime.decimal_wire` (`parse_decimal`, +`parse_decimal_operand`, `check_decimal_range`, `render_wire`) and pass each decimal field's +declared limits. Each router writes its responses through `render_wire(value, _WIRE)`, where +`_WIRE` is the setting it was generated with. A malformed or non-object JSON body is +`400 validation`. `metaobjects verify` names the `wire` block that reproduces committed output +generated under another setting. ### `_names.py` — the physical names, as constants diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index f5db3e0e0..21ec8f853 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -374,19 +374,31 @@ export default defineConfig({ The mapping, the refusals, the Cube query that reproduces a report and the `cube` lane that checks the output against a real Cube are in [cube-export](../features/cube-export.md). -### Decimals on the wire - -A `field.decimal` crosses the API as one canonical string (ADR-0019 Amendment 1): `12.50` is -`"12.5"`, zero is `"0"`, never a JSON number or an exponent. Input may be a string or a number, -is read exactly, and is `400 validation` when it has more fraction digits than `@scale` or more -integer digits than `@precision - @scale` (no `@scale` beside `@precision` means 0). A filter -operand compares exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at -15 significant digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. +### Decimals and null members on the wire + +What the generated routes write is the project setting `wire` in `.metaobjects/config.json` +([api-contract.md, "The wire setting"](../features/api-contract.md#the-wire-setting)). With no +setting, a decimal is the string the database driver returns, which keeps its scale (`"12.50"` +on Postgres and MySQL). SQLite and D1 return a number, which is written at 15 significant digits +(`0.1 + 0.2` is `"0.3"`). A value object is written as stored: a member stored as `null` is +`null`, and a member the stored value lacks is absent. `"wire": { "decimals": "string" }` writes +the canonical string of ADR-0019 Amendment 1 (`"12.5"`, zero `"0"`). `"nullMembers": "emit"` +writes every declared member, `null` when unset, and `"omit"` leaves a null member out. Both +reach arrays and maps of value objects, and nested ones. A top-level null field is always `null`. + +Input is the same in every mode. It may be a string or a number, is read exactly, and is +`400 validation` when it has more fraction digits than `@scale` or more integer digits than +`@precision - @scale` (no `@scale` beside `@precision` means 0). A filter operand compares +exactly; a bad one is `400 invalid_filter_value`. On SQLite a decimal is read at 15 significant +digits. Every port runs the shared vectors in `fixtures/decimal-wire/`. A generated model module's write schema carries a local `decimalWrite` function, so generated -code imports nothing from MetaObjects; the route mounts in `@metaobjectsdev/runtime-ts` write -decimals and read filter operands by the same rule. `meta migrate` warns for a decimal declared -with `@precision` above 15 on SQLite or D1. +code imports nothing from MetaObjects. A generated route passes its mount `decimalColumns`, plus +`decimalWire: "string"` and `nullMembers` with the value-object shapes when the setting asks for +them; the route mounts in `@metaobjectsdev/runtime-ts` write the response and read filter +operands by those options. `meta migrate` warns for a decimal declared with `@precision` above 15 +on SQLite or D1. `meta verify --codegen` names the `wire` block that reproduces committed output +generated under another setting. ### `Names` — the physical names, as constants diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index d9cc73bbf..69e2521b8 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -69,7 +69,6 @@ import { isReport, itemRouteField, decimalColumnsLine, - decimalFieldNames, servesReadApi, formatTs, entityOutputPath, @@ -155,15 +154,13 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. - // A decimal is a canonical string on the wire on every dialect (ADR-0019 Amendment 1). - const decimalColumns = decimalFieldNames(entity); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + - (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — @@ -298,7 +295,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -323,7 +320,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -479,7 +476,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ")}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } @@ -580,7 +577,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ")}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -623,7 +620,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/examples/advanced-modeling/codegen/runtime/decimal-wire.ts b/examples/advanced-modeling/codegen/runtime/decimal-wire.ts index 8b4f4f349..3f6e0a41c 100644 --- a/examples/advanced-modeling/codegen/runtime/decimal-wire.ts +++ b/examples/advanced-modeling/codegen/runtime/decimal-wire.ts @@ -125,20 +125,39 @@ export function decimalToWire(value: unknown): unknown { } /** - * Rewrite the values under `columns` to their canonical string. Returns `undefined` when there is - * nothing to do. Which keys are decimals comes from the caller (the generated route passes the - * names of the entity's `field.decimal` fields), never from the shape of a value: an integer - * column is not a decimal because it is a number. + * The `native` output form (`wire.decimals: native`, ADR-0019 Amendment 2): the string the + * in-process type holds, NOT canonicalized. A string a driver handed back (Postgres `numeric`, + * MySQL `DECIMAL`, SQLite text) passes through as it is (`"12.50"` stays `"12.50"`), a number + * (a SQLite or D1 REAL or INTEGER) is rendered at 15 significant digits (W4, because the TS type + * is `string`), a bigint is its digits, and an array is rewritten element by element. + */ +export function decimalToNative(value: unknown): unknown { + if (Array.isArray(value)) return value.map(decimalToNative); + if (typeof value === "number") return decimalFromDouble15(value) ?? value; + if (typeof value === "bigint") return value.toString(); + return value; +} + +/** How a decimal is written: `native` (default) or the canonical `string` (`wire.decimals`). */ +export type DecimalWireMode = "native" | "string"; + +/** + * Rewrite the values under `columns` to their wire form (`mode`, default `native`). Returns + * `undefined` when there is nothing to do. Which keys are decimals comes from the caller (the + * generated route passes the names of the entity's `field.decimal` fields), never from the shape + * of a value: an integer column is not a decimal because it is a number. */ export function decimalWire( columns: readonly string[] | undefined, + mode: DecimalWireMode = "native", ): ((row: unknown) => unknown) | undefined { if (columns === undefined || columns.length === 0) return undefined; + const convert = mode === "string" ? decimalToWire : decimalToNative; return (row) => { if (row === null || typeof row !== "object") return row; const out = { ...(row as Record) }; for (const c of columns) { - if (c in out) out[c] = decimalToWire(out[c]); + if (c in out) out[c] = convert(out[c]); } return out; }; @@ -148,7 +167,8 @@ export function decimalWire( export function withDecimals( base: (row: unknown) => unknown, columns: readonly string[] | undefined, + mode: DecimalWireMode = "native", ): (row: unknown) => unknown { - const decimals = decimalWire(columns); + const decimals = decimalWire(columns, mode); return decimals === undefined ? base : (row) => decimals(base(row)); } diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts index d159a176a..6c6636ec8 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts @@ -29,13 +29,15 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; -export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { decimalWire, withDecimals, decimalToNative, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { withWire, nullMembersWire } from "../wire-format.js"; +export type { WireOptions, ValueObjectShape, NullMembersMode } from "../wire-format.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper @@ -49,7 +51,7 @@ type AnyTable = any; export type CrudVerb = "list" | "get" | "create" | "update" | "delete"; -export interface CrudRoutesOptions { +export interface CrudRoutesOptions extends WireOptions { fastify: FastifyInstance; /** REST resource path, e.g. "/subscribers". */ path: string; @@ -86,13 +88,6 @@ export interface CrudRoutesOptions { updateMethod?: "patch" | "put"; filterAllowlist?: FilterAllowlist; sortAllowlist?: SortAllowlist; - /** - * The names of the entity's `field.decimal` fields. A decimal is a canonical string on the - * wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten - * under these keys. The generated route passes them; absent, rows are sent as read. - */ - decimalColumns?: readonly string[]; /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */ dialect?: SqlDialect; /** @@ -131,7 +126,7 @@ function readSource(opts: VerbOptions): AnyTable { * non-timestamp of the same name. */ function readWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns); + return withWire(timestampWire(readSource(opts)), opts); } /** @@ -140,7 +135,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown { * against the union of both sources. */ function echoWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns); + return withWire(timestampWire(opts.table, opts.readView), opts); } /** #214 — after a write to the base table, re-read the row THROUGH the replica diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-m2m.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-m2m.ts index 31e1e4e8c..b975e61b6 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-m2m.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-m2m.ts @@ -34,7 +34,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify"; import { and, eq, or, inArray } from "drizzle-orm"; import { coerceIdForColumn } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg. @@ -43,7 +43,7 @@ type AnyDrizzle = any; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table type AnyTable = any; -export interface M2mRouteOptions { +export interface M2mRouteOptions extends WireOptions { fastify: FastifyInstance; /** Source resource path, e.g. "/posts". The route mounts at `{path}/:id/{relationName}`. */ path: string; @@ -61,8 +61,6 @@ export interface M2mRouteOptions { targetColumn: string; /** Target entity PK column (physical column name, e.g. "id"). Defaults to "id". */ targetPkColumn?: string; - /** The names of the TARGET entity's `field.decimal` fields, sent as canonical strings (ADR-0019 Amendment 1). */ - decimalColumns?: readonly string[]; /** Undirected self-join: union both junction FK columns on read. */ symmetric: boolean; /** @@ -90,7 +88,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void { const route = `${opts.path}/:id/${opts.relationName}`; // Route-scoped contract error handler — see route-error-handler.ts. const ro = withContractErrorHandler(opts.routeOptions); - const toWire = withDecimals(timestampWire(opts.targetTable), opts.decimalColumns); + const toWire = withWire(timestampWire(opts.targetTable), opts); opts.fastify.get(route, ro, async (req, reply) => { const { id } = req.params as { id: string }; diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts index 471334410..2451dfebb 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/mount-read-only.ts @@ -6,13 +6,13 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views type AnyView = any; -export interface MountReadOnlyOptions { +export interface MountReadOnlyOptions extends WireOptions { readonly fastify: FastifyInstance; readonly path: string; // biome-ignore lint/suspicious/noExplicitAny: dynamic Drizzle client @@ -50,14 +50,6 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; - /** - * The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire - * on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio, - * average or sum). Each row is rewritten under these keys. The generated route passes them; - * absent, rows are sent as read. Default none. - */ - readonly decimalColumns?: readonly string[]; } const rejectMutation = (resource: string) => async ( @@ -160,7 +152,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. const timestamps = timestampWire(view); - const toWire = withDecimals(timestamps, opts.decimalColumns); + const toWire = withWire(timestamps, opts); // ── List ────────────────────────────────────────────────────────────────── fastify.get(path, ro, async (req, reply) => { diff --git a/examples/advanced-modeling/codegen/runtime/wire-format.ts b/examples/advanced-modeling/codegen/runtime/wire-format.ts new file mode 100644 index 000000000..086147c55 --- /dev/null +++ b/examples/advanced-modeling/codegen/runtime/wire-format.ts @@ -0,0 +1,103 @@ +// The OUTPUT form of a generated REST surface: the `wire` project setting (ADR-0019 Amendment 2). +// A generated route passes the options below to a mount helper; the helper rewrites each row it +// sends. Input rules do not follow the setting (decimal bodies are parsed by the generated write +// schemas, filter operands by the filter parser). +// +// decimals `native` (default): the string the TS type holds, not canonicalized +// (`decimalToNative`); `string`: the canonical W1 string (`decimalToWire`). +// nullMembers a value object's members. `native` (default): as stored. `omit`: a null member +// is dropped. `emit`: every DECLARED member is present, `null` when unset. +// +// Which columns are decimals and which hold value objects comes from the model, never from the +// shape of a value, so the generated route carries both as static data. + +import { withDecimals, type DecimalWireMode } from "./decimal-wire.js"; + +/** `wire.nullMembers`. */ +export type NullMembersMode = "native" | "emit" | "omit"; + +/** + * The declared shape of a value object held by a jsonb column: its member names, and the shape + * of any member that is itself a value object. `map` marks a `field.map` of value objects (the + * column's value is an object keyed by arbitrary text, each value a value object). An array + * (`isArray`) needs no flag: the walk reads the value's own shape. + */ +export interface ValueObjectShape { + readonly members: readonly string[]; + readonly map?: boolean; + readonly nested?: Readonly>; +} + +/** The wire options every read-side mount takes beside its other options. */ +export interface WireOptions { + /** + * The names of the entity's `field.decimal` fields. Each row sent is rewritten under these + * keys: `native` (default) leaves the string a driver returned and renders a SQLite number at + * 15 significant digits; `decimalWire: "string"` sends the canonical string (ADR-0019 + * Amendment 1). The generated route passes them; absent, rows are sent as read. + */ + readonly decimalColumns?: readonly string[]; + /** `wire.decimals`. Default `native`. */ + readonly decimalWire?: DecimalWireMode; + /** `wire.nullMembers` for the members of the value objects under `valueObjects`. Default `native`. */ + readonly nullMembers?: NullMembersMode; + /** The jsonb columns that hold a value object (or an array / map of them), by column name. */ + readonly valueObjects?: Readonly>; +} + +function isPlainObject(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function shapeMember(value: Record, shape: ValueObjectShape, mode: NullMembersMode): Record { + const out: Record = {}; + if (mode === "emit") { + // Every declared member, in declaration order, null when unset. + for (const m of shape.members) out[m] = m in value && value[m] !== undefined ? value[m] : null; + for (const [k, v] of Object.entries(value)) if (!(k in out) && v !== undefined) out[k] = v; + } else { + for (const [k, v] of Object.entries(value)) if (v !== null && v !== undefined) out[k] = v; + } + for (const [name, inner] of Object.entries(shape.nested ?? {})) { + if (name in out && out[name] !== null) out[name] = shapeValue(out[name], inner, mode); + } + return out; +} + +/** Apply `mode` to a stored value object, an array of them, or (for `shape.map`) a map of them. */ +function shapeValue(value: unknown, shape: ValueObjectShape, mode: NullMembersMode): unknown { + if (Array.isArray(value)) return value.map((el) => shapeValue(el, shape, mode)); + if (!isPlainObject(value)) return value; + if (shape.map === true) { + // Keys are arbitrary text, never compared with the value object's member names. + const out: Record = {}; + for (const [k, el] of Object.entries(value)) out[k] = isPlainObject(el) ? shapeMember(el, shape, mode) : el; + return out; + } + return shapeMember(value, shape, mode); +} + +/** Rewrite the value-object columns of a row per `mode`. `undefined` when there is nothing to do. */ +export function nullMembersWire( + shapes: Readonly> | undefined, + mode: NullMembersMode | undefined, +): ((row: unknown) => unknown) | undefined { + if (shapes === undefined || mode === undefined || mode === "native") return undefined; + const entries = Object.entries(shapes); + if (entries.length === 0) return undefined; + return (row) => { + if (!isPlainObject(row)) return row; + const out = { ...row }; + for (const [column, shape] of entries) { + if (column in out && out[column] !== null && out[column] !== undefined) out[column] = shapeValue(out[column], shape, mode); + } + return out; + }; +} + +/** A wire mapper: `base` (timestamps), then the decimal rewrite, then the null-member rewrite. */ +export function withWire(base: (row: unknown) => unknown, opts: WireOptions): (row: unknown) => unknown { + const decimals = withDecimals(base, opts.decimalColumns, opts.decimalWire); + const members = nullMembersWire(opts.valueObjects, opts.nullMembers); + return members === undefined ? decimals : (row) => members(decimals(row)); +} diff --git a/examples/showcase/.metaobjects/.gen-state/.hashes.json b/examples/showcase/.metaobjects/.gen-state/.hashes.json index 22528756c..03e78ca66 100644 --- a/examples/showcase/.metaobjects/.gen-state/.hashes.json +++ b/examples/showcase/.metaobjects/.gen-state/.hashes.json @@ -1,7 +1,7 @@ { "generated/csharp/AppDbContext.g.cs": "314c6078030ba151687430b37e6b6ea936238bb5df02df238e4acce2f95a430d", "generated/csharp/Subscriber.g.cs": "3ca160941378fbb8b6ad6acf080ff85573f463931e21b8756873a4476e5dd163", - "generated/csharp/SubscriberBlurbPayload.g.cs": "30ae31959c0cd98ba4ab12b2e827fa48f3bf59a05aa34974ebc4a1fe930378be", + "generated/csharp/SubscriberBlurbPayload.g.cs": "6b1ce5b5a4018c647586d86125718c4764ae5d6eeb94e9545e8006a7ec2b2b6f", "generated/csharp/SubscriberFilterAllowlist.g.cs": "7428d47f6ccb64bb315266ee0564fa3803736623a34ab491435c042848c5d472", "generated/csharp/SubscriberNames.g.cs": "91f6eba78657b31fceef7021c6ca6bd799524dcb5120acbc8fcbe18786787174", "generated/csharp/SubscriberRoutes.g.cs": "e520084727642d242609cbf82b3598d7c996224c3d0aef250bd26c7540ab4423", @@ -10,7 +10,7 @@ "generated/python/__init__.py": "daa04cec586768866d0996c779f62073e6773fe63750ff668327991fac2fd0f5", "generated/python/subscriber_filter_allowlist.py": "b0414cc95ff3982c80a0e1a06839f090c0b423e41f5a94275df93426cea09df5", "generated/python/subscriber_names.py": "269637a02bf9b46f55658b58cb42f83196c03a877e1e2101337847fd406c1561", - "generated/python/subscriber_router.py": "9c2393da6c055bd7a76b81cbc1492fccc44bae257cab5755830ab58f59f522c7", + "generated/python/subscriber_router.py": "0d04c1d7286fba33459454e1cd8a8acb2de38b9309cdb1015cb4438c3881daeb", "generated/ts/Subscriber.queries.ts": "9f1a459c57fafc98bf074b4443b506d4a1e7023ed188f293021ae89c298d4562", "generated/ts/Subscriber.routes.ts": "147d32ae658f985e024c3de6e8894ca7359f767eeaa765cec70756802e3bd300", "generated/ts/Subscriber.ts": "a78c17f915222ee108290c28468313d77f77b5cff438f485324409302e389a96", diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index d9cc73bbf..69e2521b8 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -69,7 +69,6 @@ import { isReport, itemRouteField, decimalColumnsLine, - decimalFieldNames, servesReadApi, formatTs, entityOutputPath, @@ -155,15 +154,13 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. - // A decimal is a canonical string on the wire on every dialect (ADR-0019 Amendment 1). - const decimalColumns = decimalFieldNames(entity); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + - (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — @@ -298,7 +295,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -323,7 +320,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -479,7 +476,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ")}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } @@ -580,7 +577,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ")}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -623,7 +620,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/examples/showcase/codegen/runtime/decimal-wire.ts b/examples/showcase/codegen/runtime/decimal-wire.ts index 8b4f4f349..3f6e0a41c 100644 --- a/examples/showcase/codegen/runtime/decimal-wire.ts +++ b/examples/showcase/codegen/runtime/decimal-wire.ts @@ -125,20 +125,39 @@ export function decimalToWire(value: unknown): unknown { } /** - * Rewrite the values under `columns` to their canonical string. Returns `undefined` when there is - * nothing to do. Which keys are decimals comes from the caller (the generated route passes the - * names of the entity's `field.decimal` fields), never from the shape of a value: an integer - * column is not a decimal because it is a number. + * The `native` output form (`wire.decimals: native`, ADR-0019 Amendment 2): the string the + * in-process type holds, NOT canonicalized. A string a driver handed back (Postgres `numeric`, + * MySQL `DECIMAL`, SQLite text) passes through as it is (`"12.50"` stays `"12.50"`), a number + * (a SQLite or D1 REAL or INTEGER) is rendered at 15 significant digits (W4, because the TS type + * is `string`), a bigint is its digits, and an array is rewritten element by element. + */ +export function decimalToNative(value: unknown): unknown { + if (Array.isArray(value)) return value.map(decimalToNative); + if (typeof value === "number") return decimalFromDouble15(value) ?? value; + if (typeof value === "bigint") return value.toString(); + return value; +} + +/** How a decimal is written: `native` (default) or the canonical `string` (`wire.decimals`). */ +export type DecimalWireMode = "native" | "string"; + +/** + * Rewrite the values under `columns` to their wire form (`mode`, default `native`). Returns + * `undefined` when there is nothing to do. Which keys are decimals comes from the caller (the + * generated route passes the names of the entity's `field.decimal` fields), never from the shape + * of a value: an integer column is not a decimal because it is a number. */ export function decimalWire( columns: readonly string[] | undefined, + mode: DecimalWireMode = "native", ): ((row: unknown) => unknown) | undefined { if (columns === undefined || columns.length === 0) return undefined; + const convert = mode === "string" ? decimalToWire : decimalToNative; return (row) => { if (row === null || typeof row !== "object") return row; const out = { ...(row as Record) }; for (const c of columns) { - if (c in out) out[c] = decimalToWire(out[c]); + if (c in out) out[c] = convert(out[c]); } return out; }; @@ -148,7 +167,8 @@ export function decimalWire( export function withDecimals( base: (row: unknown) => unknown, columns: readonly string[] | undefined, + mode: DecimalWireMode = "native", ): (row: unknown) => unknown { - const decimals = decimalWire(columns); + const decimals = decimalWire(columns, mode); return decimals === undefined ? base : (row) => decimals(base(row)); } diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/index.ts b/examples/showcase/codegen/runtime/drizzle-fastify/index.ts index d159a176a..6c6636ec8 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/index.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/index.ts @@ -29,13 +29,15 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; -export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { decimalWire, withDecimals, decimalToNative, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { withWire, nullMembersWire } from "../wire-format.js"; +export type { WireOptions, ValueObjectShape, NullMembersMode } from "../wire-format.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper @@ -49,7 +51,7 @@ type AnyTable = any; export type CrudVerb = "list" | "get" | "create" | "update" | "delete"; -export interface CrudRoutesOptions { +export interface CrudRoutesOptions extends WireOptions { fastify: FastifyInstance; /** REST resource path, e.g. "/subscribers". */ path: string; @@ -86,13 +88,6 @@ export interface CrudRoutesOptions { updateMethod?: "patch" | "put"; filterAllowlist?: FilterAllowlist; sortAllowlist?: SortAllowlist; - /** - * The names of the entity's `field.decimal` fields. A decimal is a canonical string on the - * wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten - * under these keys. The generated route passes them; absent, rows are sent as read. - */ - decimalColumns?: readonly string[]; /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */ dialect?: SqlDialect; /** @@ -131,7 +126,7 @@ function readSource(opts: VerbOptions): AnyTable { * non-timestamp of the same name. */ function readWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns); + return withWire(timestampWire(readSource(opts)), opts); } /** @@ -140,7 +135,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown { * against the union of both sources. */ function echoWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns); + return withWire(timestampWire(opts.table, opts.readView), opts); } /** #214 — after a write to the base table, re-read the row THROUGH the replica diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/mount-m2m.ts b/examples/showcase/codegen/runtime/drizzle-fastify/mount-m2m.ts index 31e1e4e8c..b975e61b6 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/mount-m2m.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/mount-m2m.ts @@ -34,7 +34,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify"; import { and, eq, or, inArray } from "drizzle-orm"; import { coerceIdForColumn } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg. @@ -43,7 +43,7 @@ type AnyDrizzle = any; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table type AnyTable = any; -export interface M2mRouteOptions { +export interface M2mRouteOptions extends WireOptions { fastify: FastifyInstance; /** Source resource path, e.g. "/posts". The route mounts at `{path}/:id/{relationName}`. */ path: string; @@ -61,8 +61,6 @@ export interface M2mRouteOptions { targetColumn: string; /** Target entity PK column (physical column name, e.g. "id"). Defaults to "id". */ targetPkColumn?: string; - /** The names of the TARGET entity's `field.decimal` fields, sent as canonical strings (ADR-0019 Amendment 1). */ - decimalColumns?: readonly string[]; /** Undirected self-join: union both junction FK columns on read. */ symmetric: boolean; /** @@ -90,7 +88,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void { const route = `${opts.path}/:id/${opts.relationName}`; // Route-scoped contract error handler — see route-error-handler.ts. const ro = withContractErrorHandler(opts.routeOptions); - const toWire = withDecimals(timestampWire(opts.targetTable), opts.decimalColumns); + const toWire = withWire(timestampWire(opts.targetTable), opts); opts.fastify.get(route, ro, async (req, reply) => { const { id } = req.params as { id: string }; diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts b/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts index 471334410..2451dfebb 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/mount-read-only.ts @@ -6,13 +6,13 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views type AnyView = any; -export interface MountReadOnlyOptions { +export interface MountReadOnlyOptions extends WireOptions { readonly fastify: FastifyInstance; readonly path: string; // biome-ignore lint/suspicious/noExplicitAny: dynamic Drizzle client @@ -50,14 +50,6 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; - /** - * The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire - * on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio, - * average or sum). Each row is rewritten under these keys. The generated route passes them; - * absent, rows are sent as read. Default none. - */ - readonly decimalColumns?: readonly string[]; } const rejectMutation = (resource: string) => async ( @@ -160,7 +152,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. const timestamps = timestampWire(view); - const toWire = withDecimals(timestamps, opts.decimalColumns); + const toWire = withWire(timestamps, opts); // ── List ────────────────────────────────────────────────────────────────── fastify.get(path, ro, async (req, reply) => { diff --git a/examples/showcase/codegen/runtime/wire-format.ts b/examples/showcase/codegen/runtime/wire-format.ts new file mode 100644 index 000000000..086147c55 --- /dev/null +++ b/examples/showcase/codegen/runtime/wire-format.ts @@ -0,0 +1,103 @@ +// The OUTPUT form of a generated REST surface: the `wire` project setting (ADR-0019 Amendment 2). +// A generated route passes the options below to a mount helper; the helper rewrites each row it +// sends. Input rules do not follow the setting (decimal bodies are parsed by the generated write +// schemas, filter operands by the filter parser). +// +// decimals `native` (default): the string the TS type holds, not canonicalized +// (`decimalToNative`); `string`: the canonical W1 string (`decimalToWire`). +// nullMembers a value object's members. `native` (default): as stored. `omit`: a null member +// is dropped. `emit`: every DECLARED member is present, `null` when unset. +// +// Which columns are decimals and which hold value objects comes from the model, never from the +// shape of a value, so the generated route carries both as static data. + +import { withDecimals, type DecimalWireMode } from "./decimal-wire.js"; + +/** `wire.nullMembers`. */ +export type NullMembersMode = "native" | "emit" | "omit"; + +/** + * The declared shape of a value object held by a jsonb column: its member names, and the shape + * of any member that is itself a value object. `map` marks a `field.map` of value objects (the + * column's value is an object keyed by arbitrary text, each value a value object). An array + * (`isArray`) needs no flag: the walk reads the value's own shape. + */ +export interface ValueObjectShape { + readonly members: readonly string[]; + readonly map?: boolean; + readonly nested?: Readonly>; +} + +/** The wire options every read-side mount takes beside its other options. */ +export interface WireOptions { + /** + * The names of the entity's `field.decimal` fields. Each row sent is rewritten under these + * keys: `native` (default) leaves the string a driver returned and renders a SQLite number at + * 15 significant digits; `decimalWire: "string"` sends the canonical string (ADR-0019 + * Amendment 1). The generated route passes them; absent, rows are sent as read. + */ + readonly decimalColumns?: readonly string[]; + /** `wire.decimals`. Default `native`. */ + readonly decimalWire?: DecimalWireMode; + /** `wire.nullMembers` for the members of the value objects under `valueObjects`. Default `native`. */ + readonly nullMembers?: NullMembersMode; + /** The jsonb columns that hold a value object (or an array / map of them), by column name. */ + readonly valueObjects?: Readonly>; +} + +function isPlainObject(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function shapeMember(value: Record, shape: ValueObjectShape, mode: NullMembersMode): Record { + const out: Record = {}; + if (mode === "emit") { + // Every declared member, in declaration order, null when unset. + for (const m of shape.members) out[m] = m in value && value[m] !== undefined ? value[m] : null; + for (const [k, v] of Object.entries(value)) if (!(k in out) && v !== undefined) out[k] = v; + } else { + for (const [k, v] of Object.entries(value)) if (v !== null && v !== undefined) out[k] = v; + } + for (const [name, inner] of Object.entries(shape.nested ?? {})) { + if (name in out && out[name] !== null) out[name] = shapeValue(out[name], inner, mode); + } + return out; +} + +/** Apply `mode` to a stored value object, an array of them, or (for `shape.map`) a map of them. */ +function shapeValue(value: unknown, shape: ValueObjectShape, mode: NullMembersMode): unknown { + if (Array.isArray(value)) return value.map((el) => shapeValue(el, shape, mode)); + if (!isPlainObject(value)) return value; + if (shape.map === true) { + // Keys are arbitrary text, never compared with the value object's member names. + const out: Record = {}; + for (const [k, el] of Object.entries(value)) out[k] = isPlainObject(el) ? shapeMember(el, shape, mode) : el; + return out; + } + return shapeMember(value, shape, mode); +} + +/** Rewrite the value-object columns of a row per `mode`. `undefined` when there is nothing to do. */ +export function nullMembersWire( + shapes: Readonly> | undefined, + mode: NullMembersMode | undefined, +): ((row: unknown) => unknown) | undefined { + if (shapes === undefined || mode === undefined || mode === "native") return undefined; + const entries = Object.entries(shapes); + if (entries.length === 0) return undefined; + return (row) => { + if (!isPlainObject(row)) return row; + const out = { ...row }; + for (const [column, shape] of entries) { + if (column in out && out[column] !== null && out[column] !== undefined) out[column] = shapeValue(out[column], shape, mode); + } + return out; + }; +} + +/** A wire mapper: `base` (timestamps), then the decimal rewrite, then the null-member rewrite. */ +export function withWire(base: (row: unknown) => unknown, opts: WireOptions): (row: unknown) => unknown { + const decimals = withDecimals(base, opts.decimalColumns, opts.decimalWire); + const members = nullMembersWire(opts.valueObjects, opts.nullMembers); + return members === undefined ? decimals : (row) => members(decimals(row)); +} diff --git a/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs b/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs index a8ce3bcde..33d8e9964 100644 --- a/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs +++ b/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs @@ -13,7 +13,6 @@ public class SubscriberBlurbPayload { public enum SubscriberBlurbPayloadStatus { active, paused, cancelled } [JsonPropertyName("name")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Name { get; set; } [JsonPropertyName("status")] public SubscriberBlurbPayloadStatus Status { get; set; } diff --git a/examples/showcase/generated/java/acme/SubscriberBlurbPayload.java b/examples/showcase/generated/java/acme/SubscriberBlurbPayload.java index d436bc252..b0e6be9ee 100644 --- a/examples/showcase/generated/java/acme/SubscriberBlurbPayload.java +++ b/examples/showcase/generated/java/acme/SubscriberBlurbPayload.java @@ -1,10 +1,8 @@ package acme; import jakarta.validation.constraints.*; -import com.fasterxml.jackson.annotation.JsonInclude; /** GENERATED — value object SubscriberBlurbPayload. Do not hand-edit; regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record SubscriberBlurbPayload( String name, @NotNull Status status diff --git a/examples/showcase/generated/kotlin/acme/SubscriberBlurbPayload.kt b/examples/showcase/generated/kotlin/acme/SubscriberBlurbPayload.kt index 503d969fd..acb1c79b0 100644 --- a/examples/showcase/generated/kotlin/acme/SubscriberBlurbPayload.kt +++ b/examples/showcase/generated/kotlin/acme/SubscriberBlurbPayload.kt @@ -1,6 +1,5 @@ package acme -import com.fasterxml.jackson.`annotation`.JsonInclude import jakarta.validation.constraints.NotNull import kotlin.String import kotlin.jvm.JvmStatic @@ -8,7 +7,6 @@ import kotlin.jvm.JvmStatic /** * GENERATED — do not hand-edit. Regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public data class SubscriberBlurbPayload( public val name: String? = null, @field:NotNull diff --git a/examples/showcase/generated/python/subscriber_router.py b/examples/showcase/generated/python/subscriber_router.py index e25715a76..294f9506a 100644 --- a/examples/showcase/generated/python/subscriber_router.py +++ b/examples/showcase/generated/python/subscriber_router.py @@ -22,10 +22,11 @@ classify_constraint_error, ) from metaobjects.codegen.runtime.decimal_wire import ( + WireOutput, coerce_decimal_fields, coerce_decimal_predicates, parse_json_object, - to_wire, + render_wire, ) from metaobjects.codegen.runtime.filter_parser import ( FilterPredicate, @@ -78,6 +79,9 @@ class _SortClause(BaseModel): _NESTED_DECIMALS: dict[str, Any] = {} +_WIRE = WireOutput(decimals="native", null_members="native") + + async def _json_body(request: Request) -> dict[str, Any] | None: """GENERATED — the request body as a JSON object (non-integer numbers are exact `Decimal`s), or None.""" try: @@ -160,8 +164,8 @@ def list_subscribers( rows = repo.list(actual_limit, actual_offset, sort_clause, predicates) if with_count == 1: total = repo.count(predicates) - return {"rows": to_wire(rows), "total": total} - return to_wire(rows) + return {"rows": render_wire(rows, _WIRE), "total": total} + return render_wire(rows, _WIRE) @router.get("/{subscriber_id}") @@ -172,7 +176,7 @@ def get_subscriber( row = repo.find_by_id(subscriber_id) if row is None: return JSONResponse(status_code=404, content={"error": "not_found"}) - return to_wire(row) + return render_wire(row, _WIRE) @router.post("", status_code=status.HTTP_201_CREATED) @@ -200,7 +204,7 @@ def create_subscriber( _asnow = _dt.datetime.now(_dt.timezone.utc) dto["createdAt"] = _asnow try: - return to_wire(repo.create(dto)) + return render_wire(repo.create(dto), _WIRE) except Exception as exc: failure = classify_constraint_error(exc) if failure is None: @@ -247,7 +251,7 @@ def update_subscriber( return JSONResponse(status_code=failure.status, content=failure.body()) if saved is None: return JSONResponse(status_code=404, content={"error": "not_found"}) - return to_wire(saved) + return render_wire(saved, _WIRE) @router.delete("/{subscriber_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index fd9e7b03d..afd8ad0d4 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -8,7 +8,7 @@ }, "counts": { "fixtures": 410, - "corpora": 30, + "corpora": 31, "baseTypes": 17 }, "snippets": { diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index b8547d3fc..8136ec433 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -166,7 +166,7 @@ A concrete report whose read source is `@kind: view` is served by every port's g - `` is the report name, snake_cased then pluralized, like any object: `InvoicesByMonth` is `/invoices_by_months`, `StoreTotals` is `/store_totals`. There is no report-specific spelling. - **Every derived field with filter operators is filterable and sortable**, dimension and measure alike, with its type's own operators. You cannot narrow that set: a report has no field to put `@filterable` on. A field of the `@from` entity that the report does not expose is refused with a `400`. - A time dimension at `day` or coarser is `YYYY-MM-DD`; a null measure is `null` with the key present (a measure with `@default` is never null); `currency` is integer minor units. -- **A decimal (`avg`, a ratio, a `sum` of a decimal) is one canonical string in every port** (ADR-0019 Amendment 1: `12.5`, never `12.50` or an exponent). Display formatting is a view's job. Do not parse it as a float to compare. +- **A decimal (`avg`, a ratio, a `sum` of a decimal) is written per the project's `wire` setting**: by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript), and under `"wire": { "decimals": "string" }` one canonical string in every port (`12.5`, never `12.50` or an exponent). Do not parse it as a float to compare. - `meta gen` writes a model page for every report (`docsFile()`; a sourceless one is marked "Not served") and an API page for a served one (`apiDocsFile()`) — each an ordinary entry in `generators`. The files each port generates are in the `metaobjects-codegen` skill's reference for that language ("Reports"). diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index 7c6a2ff82..e8e56449e 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -40,6 +40,7 @@ exact config, generator names and commands for this project; read it. The other | `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code | | `references/template-or-code.md` | choosing between a code generator and a Mustache template | | `references/physical-names.md` | before you write a table, column or index name as a string | +| `references/wire-format.md` | a generated API's JSON for decimals or null value-object members is not what a client expects, or you are about to edit a JSON converter or annotation | | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | @@ -60,8 +61,11 @@ in order and every change that needs action. args are removed. - Documentation is a generator in the project's list, and the docs commands are removed. C# and Python also need a generator list (`dotnet meta init`; `metaobjects.config.yaml`). -- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min` - or `@max` is refused (write an integer bound). +- Upgrade to 1.1.2 or later. With no `wire` setting, each port writes its language's standard + JSON (C#, Java and Kotlin keep their 1.0.x decimals and null members). A project whose clients + expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", + "nullMembers": "omit" }`; see `references/wire-format.md`. A fractional validator `@min` or + `@max` is refused (write an integer bound). - **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no `eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin) @@ -389,6 +393,15 @@ names artifact (`.names.ts`, `Names.java`, …) — **but only w migration scripts and external mappings; prefer the ORM's typed column handle where one exists. Details: `references/physical-names.md`. +## The JSON your API writes is a project setting + +Each port writes what its language's standard serializer writes: a C# decimal is a JSON +number, a Python one `str(Decimal)`. Identical output across ports is opt-in, through `wire` in +`.metaobjects/config.json` (`"decimals": "string"`, `"nullMembers": "omit"` or `"emit"`). Never +change one port to match another or to match a conformance corpus, and never hand-edit a +generated converter or annotation: change the setting and regenerate. Input rules hold in every +mode. Details and the per-port table: `references/wire-format.md`. + ## Dialects Generated DB schema/DDL targets a SQL **dialect**: diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md index 2e3872643..74b7f4518 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md @@ -12,6 +12,7 @@ Maven plugin goal — there is no standalone `meta` binary on the Java side (the - When a generator's output is wrong — own the generator - Docs — the `api-docs` generator - `codegen-spring` generators +- The JSON the API writes - Discriminator inheritance (TPH) - Serializing generated objects @@ -307,6 +308,18 @@ Metadata lives wherever the plugin's `` points (the examples ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. +## The JSON the API writes + +With no `wire` setting, the generated records write what the host's Jackson `ObjectMapper` writes: a +decimal is a JSON number (`12.50`; `1E-7` under Spring Boot's defaults), and a value object's +null member is `null` with its key present. `"wire": { "decimals": "string", "nullMembers": +"omit" }` in `.metaobjects/config.json` gives the cross-port form (`"12.5"`, null members left +out): `string` adds the canonical decimal serializer, and `omit` adds `@JsonInclude(NON_NULL)` to +each value-object type. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. Never strip those annotations from generated code to change +the output: set `wire` and regenerate. `mvn metaobjects:verify` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) `codegen-spring` fully supports **table-per-hierarchy (TPH) inheritance**. `TphPlan` diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md index 6a80fafdd..da1178b4d 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md @@ -16,6 +16,7 @@ migration reference). - Write your own generator - When a generator's output is wrong — own the generator - `codegen-kotlin` generators +- The JSON the API writes ## Maven coordinates @@ -296,6 +297,18 @@ Metadata lives wherever the plugin's `` points (the examples ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. +## The JSON the API writes + +With no `wire` setting, the generated data classes write what the host's Jackson `ObjectMapper` writes: a +decimal is a JSON number (`12.50`; `1E-7` under Spring Boot's defaults), and a value object's +null member is `null` with its key present. `"wire": { "decimals": "string", "nullMembers": +"omit" }` in `.metaobjects/config.json` gives the cross-port form (`"12.5"`, null members left +out): `string` adds the canonical decimal serializer, and `omit` adds `@JsonInclude(NON_NULL)` to +each value-object type. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. Never strip those annotations from generated code to change +the output: set `wire` and regenerate. `mvn metaobjects:verify` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) `codegen-kotlin` fully supports **table-per-hierarchy (TPH) inheritance**. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md new file mode 100644 index 000000000..17847c27c --- /dev/null +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -0,0 +1,88 @@ +# The JSON a generated API writes: the `wire` setting + +> Part of the `metaobjects-codegen` skill. Read it before you change how a generated API writes a decimal or a null value-object member, before you hand-edit a JSON converter or annotation in generated code, and before you upgrade a project that serves a live API. + +**The rule: each port writes what its language's standard serializer writes.** A C# API writes +JSON the way System.Text.Json does, a Java API the way Jackson does, a Python API the way +Pydantic does. Identical output across ports is something a project opts into. The cross-port +conformance corpora pin that opt-in form in their own config. They never decide a default, so do +not "fix" a port to match another one, or to match the corpus. + +## Contents +- The setting +- What each port writes +- Choosing a form +- What it never changes +- Upgrading, and drift + +## The setting + +`wire` in `.metaobjects/config.json`, beside `naming`. Every port's codegen reads it from the +loaded root; there is no CLI flag and no generator argument. + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +- `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no + exponent, no trailing zeros). +- `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an + `object.value` only. A top-level entity field that is null is always written as `null`. +- An absent block or key means `native`. An unknown key or value is refused when the config is + read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. + +## What each port writes + +| Port | `decimals: native` | `decimals: string` | `nullMembers: native` | `emit` | `omit` | +|---|---|---|---|---|---| +| C# | JSON number, scale kept (`12.50`) | `"12.5"` | `null`, key present | same as native | left out | +| Java | JSON number, as the host's Jackson writes a `BigDecimal` (`12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | +| Python | string, `str(Decimal)` (`"12.50"`, `"1E-7"`) | `"12.5"` | as stored | every declared member, `null` when unset | left out | +| TypeScript | string as the driver returns it (`"12.50"`; 15 significant digits on SQLite and D1) | `"12.5"` | as stored | every declared member, `null` when unset | left out | + +How it shows in generated code: + +- **C#.** `native`: `[DecimalWire.Number]`, or `[DecimalWire.Number(p, s)]` with `@precision`. + `string`: the `DecimalWire` string converters, or `[DecimalWire.Declared(p, s)]`. `omit` + adds `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to each nullable + value-object member. +- **Java and Kotlin.** `string` adds the canonical decimal serializer; `omit` adds + `@JsonInclude(NON_NULL)` to each value-object type. `native` adds neither, so the host's + `ObjectMapper` decides. +- **Python and TypeScript.** The generated routes write each response through the port's wire + helper with the setting baked in. + +## Choosing a form + +- **Keep the default** for an API whose clients already exist, or are written in the same + language as the server. It is what a developer of that language expects to see. +- **Choose `"decimals": "string"`** when clients in several languages share one contract, or a + browser client must read a decimal exactly. `JSON.parse` turns a JSON number into a double + before any code sees it. This is the form 1.1.0 and 1.1.1 wrote. +- **Choose `"nullMembers": "omit"` or `"emit"`** when every port must agree on whether a null + member appears. `emit` is the one that changes Python and TypeScript output; in the JVM ports + and C# it is what `native` already writes. + +Change the setting, then regenerate and commit. Do not hand-edit the converter or annotation in a +generated file to get a different form: a regen puts it back, and `verify --codegen` fails on it. + +## What it never changes + +Input. In every mode a decimal in a request body is a string in the JSON-number grammar or a JSON +number, read exactly; garbage, and a value past `@scale` or `@precision`, is `400 validation`, +never rounded; a filter operand is compared exactly and a bad one is `400 invalid_filter_value`. +The generated input converters stay in every mode. + +## Upgrading, and drift + +- From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x + output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members + declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. +- Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, + `mvn metaobjects:verify`, `metaobjects verify`) names the `wire` block that reproduces + committed output generated under another setting. When it does, declare that block; do not regenerate. + +Full table and detail: `docs/features/api-contract.md`, "The wire setting"; the decision is +ADR-0019 Amendment 2. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md index 676ab0431..dd0e2f325 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md index 676ab0431..dd0e2f325 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index b8547d3fc..8136ec433 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -166,7 +166,7 @@ A concrete report whose read source is `@kind: view` is served by every port's g - `` is the report name, snake_cased then pluralized, like any object: `InvoicesByMonth` is `/invoices_by_months`, `StoreTotals` is `/store_totals`. There is no report-specific spelling. - **Every derived field with filter operators is filterable and sortable**, dimension and measure alike, with its type's own operators. You cannot narrow that set: a report has no field to put `@filterable` on. A field of the `@from` entity that the report does not expose is refused with a `400`. - A time dimension at `day` or coarser is `YYYY-MM-DD`; a null measure is `null` with the key present (a measure with `@default` is never null); `currency` is integer minor units. -- **A decimal (`avg`, a ratio, a `sum` of a decimal) is one canonical string in every port** (ADR-0019 Amendment 1: `12.5`, never `12.50` or an exponent). Display formatting is a view's job. Do not parse it as a float to compare. +- **A decimal (`avg`, a ratio, a `sum` of a decimal) is written per the project's `wire` setting**: by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript), and under `"wire": { "decimals": "string" }` one canonical string in every port (`12.5`, never `12.50` or an exponent). Do not parse it as a float to compare. - `meta gen` writes a model page for every report (`docsFile()`; a sourceless one is marked "Not served") and an API page for a served one (`apiDocsFile()`) — each an ordinary entry in `generators`. The files each port generates are in the `metaobjects-codegen` skill's reference for that language ("Reports"). diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md index 7c6a2ff82..e8e56449e 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -40,6 +40,7 @@ exact config, generator names and commands for this project; read it. The other | `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code | | `references/template-or-code.md` | choosing between a code generator and a Mustache template | | `references/physical-names.md` | before you write a table, column or index name as a string | +| `references/wire-format.md` | a generated API's JSON for decimals or null value-object members is not what a client expects, or you are about to edit a JSON converter or annotation | | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | @@ -60,8 +61,11 @@ in order and every change that needs action. args are removed. - Documentation is a generator in the project's list, and the docs commands are removed. C# and Python also need a generator list (`dotnet meta init`; `metaobjects.config.yaml`). -- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min` - or `@max` is refused (write an integer bound). +- Upgrade to 1.1.2 or later. With no `wire` setting, each port writes its language's standard + JSON (C#, Java and Kotlin keep their 1.0.x decimals and null members). A project whose clients + expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", + "nullMembers": "omit" }`; see `references/wire-format.md`. A fractional validator `@min` or + `@max` is refused (write an integer bound). - **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no `eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin) @@ -389,6 +393,15 @@ names artifact (`.names.ts`, `Names.java`, …) — **but only w migration scripts and external mappings; prefer the ORM's typed column handle where one exists. Details: `references/physical-names.md`. +## The JSON your API writes is a project setting + +Each port writes what its language's standard serializer writes: a C# decimal is a JSON +number, a Python one `str(Decimal)`. Identical output across ports is opt-in, through `wire` in +`.metaobjects/config.json` (`"decimals": "string"`, `"nullMembers": "omit"` or `"emit"`). Never +change one port to match another or to match a conformance corpus, and never hand-edit a +generated converter or annotation: change the setting and regenerate. Input rules hold in every +mode. Details and the per-port table: `references/wire-format.md`. + ## Dialects Generated DB schema/DDL targets a SQL **dialect**: diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md index 2e3872643..74b7f4518 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md @@ -12,6 +12,7 @@ Maven plugin goal — there is no standalone `meta` binary on the Java side (the - When a generator's output is wrong — own the generator - Docs — the `api-docs` generator - `codegen-spring` generators +- The JSON the API writes - Discriminator inheritance (TPH) - Serializing generated objects @@ -307,6 +308,18 @@ Metadata lives wherever the plugin's `` points (the examples ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. +## The JSON the API writes + +With no `wire` setting, the generated records write what the host's Jackson `ObjectMapper` writes: a +decimal is a JSON number (`12.50`; `1E-7` under Spring Boot's defaults), and a value object's +null member is `null` with its key present. `"wire": { "decimals": "string", "nullMembers": +"omit" }` in `.metaobjects/config.json` gives the cross-port form (`"12.5"`, null members left +out): `string` adds the canonical decimal serializer, and `omit` adds `@JsonInclude(NON_NULL)` to +each value-object type. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. Never strip those annotations from generated code to change +the output: set `wire` and regenerate. `mvn metaobjects:verify` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) `codegen-spring` fully supports **table-per-hierarchy (TPH) inheritance**. `TphPlan` diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md new file mode 100644 index 000000000..17847c27c --- /dev/null +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -0,0 +1,88 @@ +# The JSON a generated API writes: the `wire` setting + +> Part of the `metaobjects-codegen` skill. Read it before you change how a generated API writes a decimal or a null value-object member, before you hand-edit a JSON converter or annotation in generated code, and before you upgrade a project that serves a live API. + +**The rule: each port writes what its language's standard serializer writes.** A C# API writes +JSON the way System.Text.Json does, a Java API the way Jackson does, a Python API the way +Pydantic does. Identical output across ports is something a project opts into. The cross-port +conformance corpora pin that opt-in form in their own config. They never decide a default, so do +not "fix" a port to match another one, or to match the corpus. + +## Contents +- The setting +- What each port writes +- Choosing a form +- What it never changes +- Upgrading, and drift + +## The setting + +`wire` in `.metaobjects/config.json`, beside `naming`. Every port's codegen reads it from the +loaded root; there is no CLI flag and no generator argument. + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +- `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no + exponent, no trailing zeros). +- `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an + `object.value` only. A top-level entity field that is null is always written as `null`. +- An absent block or key means `native`. An unknown key or value is refused when the config is + read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. + +## What each port writes + +| Port | `decimals: native` | `decimals: string` | `nullMembers: native` | `emit` | `omit` | +|---|---|---|---|---|---| +| C# | JSON number, scale kept (`12.50`) | `"12.5"` | `null`, key present | same as native | left out | +| Java | JSON number, as the host's Jackson writes a `BigDecimal` (`12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | +| Python | string, `str(Decimal)` (`"12.50"`, `"1E-7"`) | `"12.5"` | as stored | every declared member, `null` when unset | left out | +| TypeScript | string as the driver returns it (`"12.50"`; 15 significant digits on SQLite and D1) | `"12.5"` | as stored | every declared member, `null` when unset | left out | + +How it shows in generated code: + +- **C#.** `native`: `[DecimalWire.Number]`, or `[DecimalWire.Number(p, s)]` with `@precision`. + `string`: the `DecimalWire` string converters, or `[DecimalWire.Declared(p, s)]`. `omit` + adds `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to each nullable + value-object member. +- **Java and Kotlin.** `string` adds the canonical decimal serializer; `omit` adds + `@JsonInclude(NON_NULL)` to each value-object type. `native` adds neither, so the host's + `ObjectMapper` decides. +- **Python and TypeScript.** The generated routes write each response through the port's wire + helper with the setting baked in. + +## Choosing a form + +- **Keep the default** for an API whose clients already exist, or are written in the same + language as the server. It is what a developer of that language expects to see. +- **Choose `"decimals": "string"`** when clients in several languages share one contract, or a + browser client must read a decimal exactly. `JSON.parse` turns a JSON number into a double + before any code sees it. This is the form 1.1.0 and 1.1.1 wrote. +- **Choose `"nullMembers": "omit"` or `"emit"`** when every port must agree on whether a null + member appears. `emit` is the one that changes Python and TypeScript output; in the JVM ports + and C# it is what `native` already writes. + +Change the setting, then regenerate and commit. Do not hand-edit the converter or annotation in a +generated file to get a different form: a regen puts it back, and `verify --codegen` fails on it. + +## What it never changes + +Input. In every mode a decimal in a request body is a string in the JSON-number grammar or a JSON +number, read exactly; garbage, and a value past `@scale` or `@precision`, is `400 validation`, +never rounded; a filter operand is compared exactly and a bad one is `400 invalid_filter_value`. +The generated input converters stay in every mode. + +## Upgrading, and drift + +- From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x + output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members + declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. +- Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, + `mvn metaobjects:verify`, `metaobjects verify`) names the `wire` block that reproduces + committed output generated under another setting. When it does, declare that block; do not regenerate. + +Full table and detail: `docs/features/api-contract.md`, "The wire setting"; the decision is +ADR-0019 Amendment 2. diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md index 6a582934c..2051a2b50 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md index 6a582934c..2051a2b50 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index b8547d3fc..8136ec433 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -166,7 +166,7 @@ A concrete report whose read source is `@kind: view` is served by every port's g - `` is the report name, snake_cased then pluralized, like any object: `InvoicesByMonth` is `/invoices_by_months`, `StoreTotals` is `/store_totals`. There is no report-specific spelling. - **Every derived field with filter operators is filterable and sortable**, dimension and measure alike, with its type's own operators. You cannot narrow that set: a report has no field to put `@filterable` on. A field of the `@from` entity that the report does not expose is refused with a `400`. - A time dimension at `day` or coarser is `YYYY-MM-DD`; a null measure is `null` with the key present (a measure with `@default` is never null); `currency` is integer minor units. -- **A decimal (`avg`, a ratio, a `sum` of a decimal) is one canonical string in every port** (ADR-0019 Amendment 1: `12.5`, never `12.50` or an exponent). Display formatting is a view's job. Do not parse it as a float to compare. +- **A decimal (`avg`, a ratio, a `sum` of a decimal) is written per the project's `wire` setting**: by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript), and under `"wire": { "decimals": "string" }` one canonical string in every port (`12.5`, never `12.50` or an exponent). Do not parse it as a float to compare. - `meta gen` writes a model page for every report (`docsFile()`; a sourceless one is marked "Not served") and an API page for a served one (`apiDocsFile()`) — each an ordinary entry in `generators`. The files each port generates are in the `metaobjects-codegen` skill's reference for that language ("Reports"). diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md index 7c6a2ff82..e8e56449e 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -40,6 +40,7 @@ exact config, generator names and commands for this project; read it. The other | `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code | | `references/template-or-code.md` | choosing between a code generator and a Mustache template | | `references/physical-names.md` | before you write a table, column or index name as a string | +| `references/wire-format.md` | a generated API's JSON for decimals or null value-object members is not what a client expects, or you are about to edit a JSON converter or annotation | | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | @@ -60,8 +61,11 @@ in order and every change that needs action. args are removed. - Documentation is a generator in the project's list, and the docs commands are removed. C# and Python also need a generator list (`dotnet meta init`; `metaobjects.config.yaml`). -- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min` - or `@max` is refused (write an integer bound). +- Upgrade to 1.1.2 or later. With no `wire` setting, each port writes its language's standard + JSON (C#, Java and Kotlin keep their 1.0.x decimals and null members). A project whose clients + expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", + "nullMembers": "omit" }`; see `references/wire-format.md`. A fractional validator `@min` or + `@max` is refused (write an integer bound). - **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no `eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin) @@ -389,6 +393,15 @@ names artifact (`.names.ts`, `Names.java`, …) — **but only w migration scripts and external mappings; prefer the ORM's typed column handle where one exists. Details: `references/physical-names.md`. +## The JSON your API writes is a project setting + +Each port writes what its language's standard serializer writes: a C# decimal is a JSON +number, a Python one `str(Decimal)`. Identical output across ports is opt-in, through `wire` in +`.metaobjects/config.json` (`"decimals": "string"`, `"nullMembers": "omit"` or `"emit"`). Never +change one port to match another or to match a conformance corpus, and never hand-edit a +generated converter or annotation: change the setting and regenerate. Input rules hold in every +mode. Details and the per-port table: `references/wire-format.md`. + ## Dialects Generated DB schema/DDL targets a SQL **dialect**: diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md index 244c9253a..7f5934bb5 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md @@ -14,6 +14,7 @@ through the Node `meta` tool — the Python CLI has **no `migrate` subcommand** - Generators - Discriminator inheritance (TPH) - No ORM — you own persistence (unlike the C# port) +- The JSON the routers write - Known gaps (current — may require a hand-edit) - Re-scaffold this context @@ -242,6 +243,17 @@ persistence layer and no runnable server**. Two things you hand-write: app.include_router(author_router) ``` +## The JSON the routers write + +With no `wire` setting, a generated router writes a decimal as Pydantic writes a `Decimal`, +`str(value)` (`"12.50"`), and a value object as the runtime holds it. `"wire": { "decimals": +"string" }` in `.metaobjects/config.json` writes the canonical string (`"12.5"`). +`"nullMembers": "emit"` writes every declared value-object member, `null` when unset, and +`"omit"` leaves null members out. The input checks hold in every mode. Set `wire` and regenerate +rather than editing `render_wire(..., _WIRE)` in a router. `metaobjects verify` names the +`wire` block that reproduces committed output generated under another setting. See +`references/wire-format.md`. + ## Known gaps (current — may require a hand-edit) - **Composite PKs need a hand-edit.** The generated router/repository key on a single diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md new file mode 100644 index 000000000..17847c27c --- /dev/null +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -0,0 +1,88 @@ +# The JSON a generated API writes: the `wire` setting + +> Part of the `metaobjects-codegen` skill. Read it before you change how a generated API writes a decimal or a null value-object member, before you hand-edit a JSON converter or annotation in generated code, and before you upgrade a project that serves a live API. + +**The rule: each port writes what its language's standard serializer writes.** A C# API writes +JSON the way System.Text.Json does, a Java API the way Jackson does, a Python API the way +Pydantic does. Identical output across ports is something a project opts into. The cross-port +conformance corpora pin that opt-in form in their own config. They never decide a default, so do +not "fix" a port to match another one, or to match the corpus. + +## Contents +- The setting +- What each port writes +- Choosing a form +- What it never changes +- Upgrading, and drift + +## The setting + +`wire` in `.metaobjects/config.json`, beside `naming`. Every port's codegen reads it from the +loaded root; there is no CLI flag and no generator argument. + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +- `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no + exponent, no trailing zeros). +- `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an + `object.value` only. A top-level entity field that is null is always written as `null`. +- An absent block or key means `native`. An unknown key or value is refused when the config is + read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. + +## What each port writes + +| Port | `decimals: native` | `decimals: string` | `nullMembers: native` | `emit` | `omit` | +|---|---|---|---|---|---| +| C# | JSON number, scale kept (`12.50`) | `"12.5"` | `null`, key present | same as native | left out | +| Java | JSON number, as the host's Jackson writes a `BigDecimal` (`12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | +| Python | string, `str(Decimal)` (`"12.50"`, `"1E-7"`) | `"12.5"` | as stored | every declared member, `null` when unset | left out | +| TypeScript | string as the driver returns it (`"12.50"`; 15 significant digits on SQLite and D1) | `"12.5"` | as stored | every declared member, `null` when unset | left out | + +How it shows in generated code: + +- **C#.** `native`: `[DecimalWire.Number]`, or `[DecimalWire.Number(p, s)]` with `@precision`. + `string`: the `DecimalWire` string converters, or `[DecimalWire.Declared(p, s)]`. `omit` + adds `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to each nullable + value-object member. +- **Java and Kotlin.** `string` adds the canonical decimal serializer; `omit` adds + `@JsonInclude(NON_NULL)` to each value-object type. `native` adds neither, so the host's + `ObjectMapper` decides. +- **Python and TypeScript.** The generated routes write each response through the port's wire + helper with the setting baked in. + +## Choosing a form + +- **Keep the default** for an API whose clients already exist, or are written in the same + language as the server. It is what a developer of that language expects to see. +- **Choose `"decimals": "string"`** when clients in several languages share one contract, or a + browser client must read a decimal exactly. `JSON.parse` turns a JSON number into a double + before any code sees it. This is the form 1.1.0 and 1.1.1 wrote. +- **Choose `"nullMembers": "omit"` or `"emit"`** when every port must agree on whether a null + member appears. `emit` is the one that changes Python and TypeScript output; in the JVM ports + and C# it is what `native` already writes. + +Change the setting, then regenerate and commit. Do not hand-edit the converter or annotation in a +generated file to get a different form: a regen puts it back, and `verify --codegen` fails on it. + +## What it never changes + +Input. In every mode a decimal in a request body is a string in the JSON-number grammar or a JSON +number, read exactly; garbage, and a value past `@scale` or `@precision`, is `400 validation`, +never rounded; a filter operand is compared exactly and a bad one is `400 invalid_filter_value`. +The generated input converters stay in every mode. + +## Upgrading, and drift + +- From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x + output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members + declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. +- Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, + `mvn metaobjects:verify`, `metaobjects verify`) names the `wire` block that reproduces + committed output generated under another setting. When it does, declare that block; do not regenerate. + +Full table and detail: `docs/features/api-contract.md`, "The wire setting"; the decision is +ADR-0019 Amendment 2. diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index 128af00ae..1e16742ed 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md @@ -57,7 +57,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md index 128af00ae..1e16742ed 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md @@ -57,7 +57,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index b8547d3fc..8136ec433 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -166,7 +166,7 @@ A concrete report whose read source is `@kind: view` is served by every port's g - `` is the report name, snake_cased then pluralized, like any object: `InvoicesByMonth` is `/invoices_by_months`, `StoreTotals` is `/store_totals`. There is no report-specific spelling. - **Every derived field with filter operators is filterable and sortable**, dimension and measure alike, with its type's own operators. You cannot narrow that set: a report has no field to put `@filterable` on. A field of the `@from` entity that the report does not expose is refused with a `400`. - A time dimension at `day` or coarser is `YYYY-MM-DD`; a null measure is `null` with the key present (a measure with `@default` is never null); `currency` is integer minor units. -- **A decimal (`avg`, a ratio, a `sum` of a decimal) is one canonical string in every port** (ADR-0019 Amendment 1: `12.5`, never `12.50` or an exponent). Display formatting is a view's job. Do not parse it as a float to compare. +- **A decimal (`avg`, a ratio, a `sum` of a decimal) is written per the project's `wire` setting**: by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript), and under `"wire": { "decimals": "string" }` one canonical string in every port (`12.5`, never `12.50` or an exponent). Do not parse it as a float to compare. - `meta gen` writes a model page for every report (`docsFile()`; a sourceless one is marked "Not served") and an API page for a served one (`apiDocsFile()`) — each an ordinary entry in `generators`. The files each port generates are in the `metaobjects-codegen` skill's reference for that language ("Reports"). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index 7c6a2ff82..e8e56449e 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -40,6 +40,7 @@ exact config, generator names and commands for this project; read it. The other | `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code | | `references/template-or-code.md` | choosing between a code generator and a Mustache template | | `references/physical-names.md` | before you write a table, column or index name as a string | +| `references/wire-format.md` | a generated API's JSON for decimals or null value-object members is not what a client expects, or you are about to edit a JSON converter or annotation | | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | @@ -60,8 +61,11 @@ in order and every change that needs action. args are removed. - Documentation is a generator in the project's list, and the docs commands are removed. C# and Python also need a generator list (`dotnet meta init`; `metaobjects.config.yaml`). -- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min` - or `@max` is refused (write an integer bound). +- Upgrade to 1.1.2 or later. With no `wire` setting, each port writes its language's standard + JSON (C#, Java and Kotlin keep their 1.0.x decimals and null members). A project whose clients + expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", + "nullMembers": "omit" }`; see `references/wire-format.md`. A fractional validator `@min` or + `@max` is refused (write an integer bound). - **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no `eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin) @@ -389,6 +393,15 @@ names artifact (`.names.ts`, `Names.java`, …) — **but only w migration scripts and external mappings; prefer the ORM's typed column handle where one exists. Details: `references/physical-names.md`. +## The JSON your API writes is a project setting + +Each port writes what its language's standard serializer writes: a C# decimal is a JSON +number, a Python one `str(Decimal)`. Identical output across ports is opt-in, through `wire` in +`.metaobjects/config.json` (`"decimals": "string"`, `"nullMembers": "omit"` or `"emit"`). Never +change one port to match another or to match a conformance corpus, and never hand-edit a +generated converter or annotation: change the setting and regenerate. Input rules hold in every +mode. Details and the per-port table: `references/wire-format.md`. + ## Dialects Generated DB schema/DDL targets a SQL **dialect**: diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md index daca582a3..6db44b8d4 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -9,6 +9,7 @@ packages. Codegen runs through the Node `meta` CLI (`@metaobjectsdev/cli`, binar - Install - `metaobjects.config.ts` - The generators +- The JSON the routes write - Run - Multiple output targets - Field subtype → column mapping @@ -262,6 +263,18 @@ writes target the table with derived fields excluded from the insert/update code create/update re-reads the row via the view by primary key (read-your-writes). The replica view's DDL is emitted by `meta migrate` from the same origin assembly as a projection view. +## The JSON the routes write + +With no `wire` setting, the generated routes write a decimal as the string the driver returns +(`"12.50"` on Postgres and MySQL; SQLite and D1 at 15 significant digits) and a value object as +stored. `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json` +gives the cross-port form (`"12.5"`, null members left out); `"nullMembers": "emit"` writes every +declared member, `null` when unset. The setting reaches the generated route as mount options +(`decimalWire`, `nullMembers`, `valueObjects`), so set it and run `meta gen`; do not edit the +options in a generated route. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. `meta verify --codegen` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) The TS reference implementation fully supports **table-per-hierarchy (TPH) diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md new file mode 100644 index 000000000..17847c27c --- /dev/null +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -0,0 +1,88 @@ +# The JSON a generated API writes: the `wire` setting + +> Part of the `metaobjects-codegen` skill. Read it before you change how a generated API writes a decimal or a null value-object member, before you hand-edit a JSON converter or annotation in generated code, and before you upgrade a project that serves a live API. + +**The rule: each port writes what its language's standard serializer writes.** A C# API writes +JSON the way System.Text.Json does, a Java API the way Jackson does, a Python API the way +Pydantic does. Identical output across ports is something a project opts into. The cross-port +conformance corpora pin that opt-in form in their own config. They never decide a default, so do +not "fix" a port to match another one, or to match the corpus. + +## Contents +- The setting +- What each port writes +- Choosing a form +- What it never changes +- Upgrading, and drift + +## The setting + +`wire` in `.metaobjects/config.json`, beside `naming`. Every port's codegen reads it from the +loaded root; there is no CLI flag and no generator argument. + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +- `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no + exponent, no trailing zeros). +- `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an + `object.value` only. A top-level entity field that is null is always written as `null`. +- An absent block or key means `native`. An unknown key or value is refused when the config is + read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. + +## What each port writes + +| Port | `decimals: native` | `decimals: string` | `nullMembers: native` | `emit` | `omit` | +|---|---|---|---|---|---| +| C# | JSON number, scale kept (`12.50`) | `"12.5"` | `null`, key present | same as native | left out | +| Java | JSON number, as the host's Jackson writes a `BigDecimal` (`12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | +| Python | string, `str(Decimal)` (`"12.50"`, `"1E-7"`) | `"12.5"` | as stored | every declared member, `null` when unset | left out | +| TypeScript | string as the driver returns it (`"12.50"`; 15 significant digits on SQLite and D1) | `"12.5"` | as stored | every declared member, `null` when unset | left out | + +How it shows in generated code: + +- **C#.** `native`: `[DecimalWire.Number]`, or `[DecimalWire.Number(p, s)]` with `@precision`. + `string`: the `DecimalWire` string converters, or `[DecimalWire.Declared(p, s)]`. `omit` + adds `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to each nullable + value-object member. +- **Java and Kotlin.** `string` adds the canonical decimal serializer; `omit` adds + `@JsonInclude(NON_NULL)` to each value-object type. `native` adds neither, so the host's + `ObjectMapper` decides. +- **Python and TypeScript.** The generated routes write each response through the port's wire + helper with the setting baked in. + +## Choosing a form + +- **Keep the default** for an API whose clients already exist, or are written in the same + language as the server. It is what a developer of that language expects to see. +- **Choose `"decimals": "string"`** when clients in several languages share one contract, or a + browser client must read a decimal exactly. `JSON.parse` turns a JSON number into a double + before any code sees it. This is the form 1.1.0 and 1.1.1 wrote. +- **Choose `"nullMembers": "omit"` or `"emit"`** when every port must agree on whether a null + member appears. `emit` is the one that changes Python and TypeScript output; in the JVM ports + and C# it is what `native` already writes. + +Change the setting, then regenerate and commit. Do not hand-edit the converter or annotation in a +generated file to get a different form: a regen puts it back, and `verify --codegen` fails on it. + +## What it never changes + +Input. In every mode a decimal in a request body is a string in the JSON-number grammar or a JSON +number, read exactly; garbage, and a value past `@scale` or `@precision`, is `400 validation`, +never rounded; a filter operand is compared exactly and a bad one is `400 invalid_filter_value`. +The generated input converters stay in every mode. + +## Upgrading, and drift + +- From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x + output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members + declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. +- Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, + `mvn metaobjects:verify`, `metaobjects verify`) names the `wire` block that reproduces + committed output generated under another setting. When it does, declare that block; do not regenerate. + +Full table and detail: `docs/features/api-contract.md`, "The wire setting"; the decision is +ADR-0019 Amendment 2. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md index 6d591a47e..5667b3da6 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md index 6d591a47e..5667b3da6 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index b8547d3fc..8136ec433 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -166,7 +166,7 @@ A concrete report whose read source is `@kind: view` is served by every port's g - `` is the report name, snake_cased then pluralized, like any object: `InvoicesByMonth` is `/invoices_by_months`, `StoreTotals` is `/store_totals`. There is no report-specific spelling. - **Every derived field with filter operators is filterable and sortable**, dimension and measure alike, with its type's own operators. You cannot narrow that set: a report has no field to put `@filterable` on. A field of the `@from` entity that the report does not expose is refused with a `400`. - A time dimension at `day` or coarser is `YYYY-MM-DD`; a null measure is `null` with the key present (a measure with `@default` is never null); `currency` is integer minor units. -- **A decimal (`avg`, a ratio, a `sum` of a decimal) is one canonical string in every port** (ADR-0019 Amendment 1: `12.5`, never `12.50` or an exponent). Display formatting is a view's job. Do not parse it as a float to compare. +- **A decimal (`avg`, a ratio, a `sum` of a decimal) is written per the project's `wire` setting**: by default, the port's language-standard form (a JSON number in C#, Java and Kotlin; a string in Python and TypeScript), and under `"wire": { "decimals": "string" }` one canonical string in every port (`12.5`, never `12.50` or an exponent). Do not parse it as a float to compare. - `meta gen` writes a model page for every report (`docsFile()`; a sourceless one is marked "Not served") and an API page for a served one (`apiDocsFile()`) — each an ordinary entry in `generators`. The files each port generates are in the `metaobjects-codegen` skill's reference for that language ("Reports"). diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md index 7c6a2ff82..e8e56449e 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -40,6 +40,7 @@ exact config, generator names and commands for this project; read it. The other | `references/choosing-layers.md` | the generated API does not fit, you need joined or derived data, or you are adopting onto existing code | | `references/template-or-code.md` | choosing between a code generator and a Mustache template | | `references/physical-names.md` | before you write a table, column or index name as a string | +| `references/wire-format.md` | a generated API's JSON for decimals or null value-object members is not what a client expects, or you are about to edit a JSON converter or annotation | | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | @@ -60,8 +61,11 @@ in order and every change that needs action. args are removed. - Documentation is a generator in the project's list, and the docs commands are removed. C# and Python also need a generator list (`dotnet meta init`; `metaobjects.config.yaml`). -- A `field.decimal` crosses the API as one canonical string, and a fractional validator `@min` - or `@max` is refused (write an integer bound). +- Upgrade to 1.1.2 or later. With no `wire` setting, each port writes its language's standard + JSON (C#, Java and Kotlin keep their 1.0.x decimals and null members). A project whose clients + expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", + "nullMembers": "omit" }`; see `references/wire-format.md`. A fractional validator `@min` or + `@max` is refused (write an integer bound). - **Resync the generators you own.** An ejected copy keeps the logic it was copied with, and a plain `gen` does not refresh it. `meta eject --list` (TypeScript; `metaobjects gen --list` in Python, which has no `eject --list`; `dotnet meta gen --list`; `mvn metaobjects:eject -Dlist=true` in Java and Kotlin) @@ -389,6 +393,15 @@ names artifact (`.names.ts`, `Names.java`, …) — **but only w migration scripts and external mappings; prefer the ORM's typed column handle where one exists. Details: `references/physical-names.md`. +## The JSON your API writes is a project setting + +Each port writes what its language's standard serializer writes: a C# decimal is a JSON +number, a Python one `str(Decimal)`. Identical output across ports is opt-in, through `wire` in +`.metaobjects/config.json` (`"decimals": "string"`, `"nullMembers": "omit"` or `"emit"`). Never +change one port to match another or to match a conformance corpus, and never hand-edit a +generated converter or annotation: change the setting and regenerate. Input rules hold in every +mode. Details and the per-port table: `references/wire-format.md`. + ## Dialects Generated DB schema/DDL targets a SQL **dialect**: diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md index daca582a3..6db44b8d4 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -9,6 +9,7 @@ packages. Codegen runs through the Node `meta` CLI (`@metaobjectsdev/cli`, binar - Install - `metaobjects.config.ts` - The generators +- The JSON the routes write - Run - Multiple output targets - Field subtype → column mapping @@ -262,6 +263,18 @@ writes target the table with derived fields excluded from the insert/update code create/update re-reads the row via the view by primary key (read-your-writes). The replica view's DDL is emitted by `meta migrate` from the same origin assembly as a projection view. +## The JSON the routes write + +With no `wire` setting, the generated routes write a decimal as the string the driver returns +(`"12.50"` on Postgres and MySQL; SQLite and D1 at 15 significant digits) and a value object as +stored. `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json` +gives the cross-port form (`"12.5"`, null members left out); `"nullMembers": "emit"` writes every +declared member, `null` when unset. The setting reaches the generated route as mount options +(`decimalWire`, `nullMembers`, `valueObjects`), so set it and run `meta gen`; do not edit the +options in a generated route. The input checks (a string or a number; `400` past `@scale` or +`@precision`) hold in every mode. `meta verify --codegen` names the `wire` block that +reproduces committed output generated under another setting. See `references/wire-format.md`. + ## Discriminator inheritance (TPH) The TS reference implementation fully supports **table-per-hierarchy (TPH) diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md new file mode 100644 index 000000000..17847c27c --- /dev/null +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -0,0 +1,88 @@ +# The JSON a generated API writes: the `wire` setting + +> Part of the `metaobjects-codegen` skill. Read it before you change how a generated API writes a decimal or a null value-object member, before you hand-edit a JSON converter or annotation in generated code, and before you upgrade a project that serves a live API. + +**The rule: each port writes what its language's standard serializer writes.** A C# API writes +JSON the way System.Text.Json does, a Java API the way Jackson does, a Python API the way +Pydantic does. Identical output across ports is something a project opts into. The cross-port +conformance corpora pin that opt-in form in their own config. They never decide a default, so do +not "fix" a port to match another one, or to match the corpus. + +## Contents +- The setting +- What each port writes +- Choosing a form +- What it never changes +- Upgrading, and drift + +## The setting + +`wire` in `.metaobjects/config.json`, beside `naming`. Every port's codegen reads it from the +loaded root; there is no CLI flag and no generator argument. + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +- `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no + exponent, no trailing zeros). +- `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an + `object.value` only. A top-level entity field that is null is always written as `null`. +- An absent block or key means `native`. An unknown key or value is refused when the config is + read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. + +## What each port writes + +| Port | `decimals: native` | `decimals: string` | `nullMembers: native` | `emit` | `omit` | +|---|---|---|---|---|---| +| C# | JSON number, scale kept (`12.50`) | `"12.5"` | `null`, key present | same as native | left out | +| Java | JSON number, as the host's Jackson writes a `BigDecimal` (`12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | +| Python | string, `str(Decimal)` (`"12.50"`, `"1E-7"`) | `"12.5"` | as stored | every declared member, `null` when unset | left out | +| TypeScript | string as the driver returns it (`"12.50"`; 15 significant digits on SQLite and D1) | `"12.5"` | as stored | every declared member, `null` when unset | left out | + +How it shows in generated code: + +- **C#.** `native`: `[DecimalWire.Number]`, or `[DecimalWire.Number(p, s)]` with `@precision`. + `string`: the `DecimalWire` string converters, or `[DecimalWire.Declared(p, s)]`. `omit` + adds `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to each nullable + value-object member. +- **Java and Kotlin.** `string` adds the canonical decimal serializer; `omit` adds + `@JsonInclude(NON_NULL)` to each value-object type. `native` adds neither, so the host's + `ObjectMapper` decides. +- **Python and TypeScript.** The generated routes write each response through the port's wire + helper with the setting baked in. + +## Choosing a form + +- **Keep the default** for an API whose clients already exist, or are written in the same + language as the server. It is what a developer of that language expects to see. +- **Choose `"decimals": "string"`** when clients in several languages share one contract, or a + browser client must read a decimal exactly. `JSON.parse` turns a JSON number into a double + before any code sees it. This is the form 1.1.0 and 1.1.1 wrote. +- **Choose `"nullMembers": "omit"` or `"emit"`** when every port must agree on whether a null + member appears. `emit` is the one that changes Python and TypeScript output; in the JVM ports + and C# it is what `native` already writes. + +Change the setting, then regenerate and commit. Do not hand-edit the converter or annotation in a +generated file to get a different form: a regen puts it back, and `verify --codegen` fails on it. + +## What it never changes + +Input. In every mode a decimal in a request body is a string in the JSON-number grammar or a JSON +number, read exactly; garbage, and a value past `@scale` or `@precision`, is `400 validation`, +never rounded; a filter operand is compared exactly and a bad one is `400 invalid_filter_value`. +The generated input converters stay in every mode. + +## Upgrading, and drift + +- From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x + output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members + declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. +- Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, + `mvn metaobjects:verify`, `metaobjects verify`) names the `wire` block that reproduces + committed output generated under another setting. When it does, declare that block; do not regenerate. + +Full table and detail: `docs/features/api-contract.md`, "The wire setting"; the decision is +ADR-0019 Amendment 2. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md index 72993110a..4ca195ea5 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md index 72993110a..4ca195ea5 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md @@ -60,7 +60,7 @@ MetaObjects ships as separate packages per language on DIFFERENT version lines ( `metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. For a 1.0.x → 1.0.x upgrade, follow https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-within-1.0.md: it covers resyncing the generators you own (`meta eject --list`), which a plain `meta gen` does not do. -**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; a `field.decimal` crosses the API as one canonical string; and a fractional validator `@min`/`@max` is refused. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. +**Upgrading from 1.0 to 1.1:** read https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md before you run `gen`, because the build can stay green while the result is wrong. It is the one page for every port and lists the steps in order. The changes that fail nothing when skipped: the default column and table names are now `snake_case` in every port, so a C#, Java, Kotlin or Python database whose columns are named like its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or the regenerated code queries columns that do not exist; and a fractional validator `@min`/`@max` is refused. Upgrade to 1.1.2 or later: with no `wire` setting each port writes its language's standard JSON, and a project whose clients expect 1.1.0's canonical decimal strings declares `"wire": { "decimals": "string", "nullMembers": "omit" }` in `.metaobjects/config.json`. Documentation is a generator in the project's list, and the docs commands are removed. Then resync every generator you own (`meta eject --list` reports each copy that differs from the reference; `meta eject --force` takes the new one, and a plain `meta gen` does not): an owned copy keeps the logic it was copied with. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/api-contract-conformance/.metaobjects/config.json b/fixtures/api-contract-conformance/.metaobjects/config.json index 1e4b8166e..d6d2b1359 100644 --- a/fixtures/api-contract-conformance/.metaobjects/config.json +++ b/fixtures/api-contract-conformance/.metaobjects/config.json @@ -1,4 +1,5 @@ { "schema_version": 1, - "naming": { "columns": "literal" } + "naming": { "columns": "literal" }, + "wire": { "decimals": "string", "nullMembers": "omit" } } diff --git a/fixtures/persistence-conformance/.metaobjects/config.json b/fixtures/persistence-conformance/.metaobjects/config.json index 1e4b8166e..d6d2b1359 100644 --- a/fixtures/persistence-conformance/.metaobjects/config.json +++ b/fixtures/persistence-conformance/.metaobjects/config.json @@ -1,4 +1,5 @@ { "schema_version": 1, - "naming": { "columns": "literal" } + "naming": { "columns": "literal" }, + "wire": { "decimals": "string", "nullMembers": "omit" } } diff --git a/fixtures/wire-conformance/README.md b/fixtures/wire-conformance/README.md new file mode 100644 index 000000000..17ad57a54 --- /dev/null +++ b/fixtures/wire-conformance/README.md @@ -0,0 +1,22 @@ +# Wire conformance + +The `wire` block of `.metaobjects/config.json` (ADR-0019 Amendment 2) chooses the OUTPUT form of +a generated REST surface: how a `field.decimal` is written, and whether a value object's null +member is written. The default is `native` for both keys, which means each port writes what its +language's standard serializer writes. `"decimals": "string"` and `"nullMembers": "omit"` give +identical output across the five ports; the api-contract and persistence corpora pin exactly +that form in their own `.metaobjects/config.json`. + +[`config-blocks.json`](./config-blocks.json) holds the blocks every port must accept, with the +values they resolve to, and the blocks every port must refuse when it reads the file. Each port +runs it through the same config reader it uses for `naming`: + +| Port | Test | +|---|---| +| TypeScript | `server/typescript/packages/sdk/test/wire-conformance.test.ts` | +| C# | `server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs` | +| Java / Kotlin | `server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java` | +| Python | `server/python/tests/conformance/test_wire_conformance.py` | + +The per-port output of each setting is tabled in +[`docs/features/api-contract.md`](../../docs/features/api-contract.md#the-wire-setting). diff --git a/fixtures/wire-conformance/config-blocks.json b/fixtures/wire-conformance/config-blocks.json new file mode 100644 index 000000000..eaa35234b --- /dev/null +++ b/fixtures/wire-conformance/config-blocks.json @@ -0,0 +1,21 @@ +{ + "_comment": "Cross-port wire-format setting (ADR-0019 Amendment 2). Each case is the `wire` block of a project's .metaobjects/config.json (null = the key is absent). A port parses it with the reader it uses for that file and must resolve `decimals` and `nullMembers` to the values below; the default is `native` for both, which means each port's own language-standard output. `invalid` blocks must be refused when the config is read.", + "cases": [ + { "name": "default", "wire": null, "decimals": "native", "nullMembers": "native" }, + { "name": "empty block is the default", "wire": {}, "decimals": "native", "nullMembers": "native" }, + { "name": "explicit native", "wire": { "decimals": "native", "nullMembers": "native" }, "decimals": "native", "nullMembers": "native" }, + { "name": "portable form (what the corpora pin)", "wire": { "decimals": "string", "nullMembers": "omit" }, "decimals": "string", "nullMembers": "omit" }, + { "name": "decimals alone", "wire": { "decimals": "string" }, "decimals": "string", "nullMembers": "native" }, + { "name": "emit null members", "wire": { "nullMembers": "emit" }, "decimals": "native", "nullMembers": "emit" } + ], + "invalid": [ + { "name": "wire is not an object", "wire": "string" }, + { "name": "wire is an array", "wire": ["string"] }, + { "name": "unknown key", "wire": { "dates": "iso" } }, + { "name": "number is not a decimals value", "wire": { "decimals": "number" } }, + { "name": "unknown decimals value", "wire": { "decimals": "canonical" } }, + { "name": "decimals is not a string", "wire": { "decimals": true } }, + { "name": "unknown nullMembers value", "wire": { "nullMembers": "skip" } }, + { "name": "nullMembers is not a string", "wire": { "nullMembers": null } } + ] +} diff --git a/server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs b/server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs new file mode 100644 index 000000000..d3cde2ecb --- /dev/null +++ b/server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs @@ -0,0 +1,120 @@ +using Xunit; + +namespace MetaObjects.Cli.Tests; + +/// +/// ADR-0019 Amendment 2: the JSON a generated API writes is the project setting wire in +/// .metaobjects/config.json, read the way naming is. With no setting, a decimal is +/// what System.Text.Json writes (a JSON number) and a value object's null member is null; +/// "decimals": "string" and "nullMembers": "omit" are the cross-port form. Driven +/// through , the entry point a project's codegen/Program.cs calls. +/// +[Collection(OwnedRunnerCollection.Name)] +public sealed class WireSettingCliTests : IDisposable +{ + private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-cli-wire-" + Guid.NewGuid().ToString("N")); + + private const string Metadata = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Price", "children": [ + { "field.decimal": { "name": "net", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "note" } } + ]}}, + { "object.entity": { "name": "Item", "children": [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "amount", "@precision": 10, "@scale": 2 } }, + { "field.object": { "name": "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + private string MetaDir => Path.Combine(_tmp, "metaobjects"); + + public WireSettingCliTests() + { + Directory.CreateDirectory(MetaDir); + File.WriteAllText(Path.Combine(MetaDir, "meta.acme.json"), Metadata); + } + + public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } } + + /// Writes .metaobjects/config.json with the given wire body, or no + /// wire key at all when is null. + private void WriteConfig(string? wire) + { + var dir = Path.Combine(_tmp, ".metaobjects"); + Directory.CreateDirectory(dir); + var json = wire is null + ? """{ "schema_version": 1 }""" + : $$"""{ "schema_version": 1, "wire": { {{wire}} } }"""; + File.WriteAllText(Path.Combine(dir, "config.json"), json); + } + + private string Generate(string outName) + { + var outDir = Path.Combine(_tmp, outName); + var (exit, stdout, stderr) = OwnedRunner.Run( + _tmp, ["entity"], "gen", MetaDir, "--out", outDir, "--namespace", "Acme.Generated"); + Assert.True(exit == 0, $"exit={exit}\n{stdout}{stderr}"); + return outDir; + } + + [Fact] + public void With_no_setting_gen_writes_the_language_standard_form() + { + var outDir = Generate("generated-native"); + var item = File.ReadAllText(Path.Combine(outDir, "Item.g.cs")); + var price = File.ReadAllText(Path.Combine(outDir, "Price.g.cs")); + Assert.Contains("[DecimalWire.Number(10, 2)]", item); + Assert.Contains("[DecimalWire.Number(12, 2)]", price); + Assert.DoesNotContain("WhenWritingNull", price); + } + + [Fact] + public void The_portable_setting_writes_the_canonical_string_and_omits_null_members() + { + WriteConfig("\"decimals\": \"string\", \"nullMembers\": \"omit\""); + var outDir = Generate("generated-portable"); + var item = File.ReadAllText(Path.Combine(outDir, "Item.g.cs")); + var price = File.ReadAllText(Path.Combine(outDir, "Price.g.cs")); + Assert.Contains("[DecimalWire.Declared(10, 2)]", item); + Assert.Contains("[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]", price); + } + + [Fact] + public void An_unknown_wire_value_is_refused() + { + WriteConfig("\"decimals\": \"number\""); + var (exit, stdout, stderr) = OwnedRunner.Run( + _tmp, ["entity"], "gen", MetaDir, "--out", Path.Combine(_tmp, "generated-bad")); + Assert.NotEqual(0, exit); + Assert.Contains("\"wire.decimals\" must be one of \"native\", \"string\"", stdout + stderr); + } + + [Fact] + public void Verify_with_the_wrong_wire_setting_says_the_setting_differs_instead_of_regenerate() + { + // Committed under the portable form; then the setting is removed. The default + // disagrees, and the portable form reproduces the committed output exactly, so verify + // names the block to declare rather than "regenerate" (that would change the API's JSON). + WriteConfig("\"decimals\": \"string\", \"nullMembers\": \"omit\""); + var outDir = Generate("generated-committed"); + + WriteConfig(null); + var (exit, stdout, stderr) = OwnedRunner.Run( + _tmp, ["entity"], "verify", "--codegen", MetaDir, "--out", outDir, "--namespace", "Acme.Generated"); + var output = stdout + stderr; + Assert.Equal(1, exit); + Assert.Contains( + "The committed output was generated with \"wire\": { \"decimals\": \"string\", \"nullMembers\": \"omit\" }; " + + "this run used \"wire\": { \"decimals\": \"native\", \"nullMembers\": \"native\" } " + + "(.metaobjects/config.json; each key defaults to native). Do not regenerate", + output); + Assert.Contains( + "Declare \"wire\": { \"decimals\": \"string\", \"nullMembers\": \"omit\" } in .metaobjects/config.json", + output); + Assert.DoesNotContain("Run 'dotnet meta gen' to regenerate", output); + } +} diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index 4aae48719..8f72a93cb 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -497,6 +497,8 @@ static int RunVerify(string[] rest) // ADR-0058 — same source as Libraries; the --codegen gate's regen reads it from // the loaded root rather than a flag (see VerifyCommand.RunCodegenDrift). Naming = resolvedMeta.Naming, + // ADR-0019 Amendment 2 — the wire setting, from the same file, for the --codegen regen. + Wire = resolvedMeta.Wire ?? MetaObjects.Meta.WireFormat.Default, TemplatesRoot = templatesRoot, OutDir = outDir, Namespace = ns, diff --git a/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index 6756ef9f1..2640f39d0 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -111,6 +111,11 @@ public sealed record Options /// directly and names no project. /// public MetaObjects.Meta.PhysicalNaming Naming { get; init; } = MetaObjects.Meta.PhysicalNaming.Default; + /// + /// ADR-0019 Amendment 2 — the project's wire-format setting, from the same file as + /// and set on the loaded root beside it. Defaults to native. + /// + public MetaObjects.Meta.WireFormat Wire { get; init; } = MetaObjects.Meta.WireFormat.Default; /// Templates root for the --templates gate (--templates <root>). public string? TemplatesRoot { get; init; } /// The committed output dir for the --codegen gate (--out <dir>). @@ -254,6 +259,7 @@ private static LoadResult LoadMetadata(Options opts) : MetaDataLoader.FromDirectory(opts.MetadataDir, opts.Libraries, strict: opts.Strict); // ADR-0058 — every resolver reads naming.columns/naming.tables from the root. result.Root.PhysicalNaming = opts.Naming; + result.Root.WireFormat = opts.Wire; return result; } diff --git a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs index ecd5b1f0b..addc7d18d 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs @@ -76,6 +76,7 @@ private static MetaRoot LoadCorpus() // ADR-0058 — the corpus's committed naming rule (read from its .metaobjects/config.json // via CorpusPaths.Naming), not a hardcoded default. result.Root.PhysicalNaming = CorpusPaths.Naming; + result.Root.WireFormat = CorpusPaths.Wire; return result.Root; } diff --git a/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs b/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs index e0d707d52..3eb9f9184 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CorpusPaths.cs @@ -51,6 +51,14 @@ internal static string RepoRoot() internal static PhysicalNaming Naming => NeutralConfig.Read(CorpusDir)?.Naming ?? PhysicalNaming.Default; + /// + /// ADR-0019 Amendment 2 — the corpus's pinned wire setting, read from the same file as + /// : the corpus pins the cross-port form + /// ({"wire":{"decimals":"string","nullMembers":"omit"}}); a project's default is native. + /// + internal static WireFormat Wire => + NeutralConfig.Read(CorpusDir)?.Wire ?? WireFormat.Default; + /// The shared fitness corpus every codegen gate generates from. internal static string FitnessMetadata => Path.Combine(RepoRoot(), "fixtures", "persistence-conformance", "canonical", "meta.fitness.json"); diff --git a/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs index 1c5ae9a9e..beae89d55 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs @@ -197,6 +197,7 @@ private static IReadOnlyList EmitFitness(out MetaRoot root) Assert.Empty(result.Errors); root = result.Root; root.PhysicalNaming = CorpusPaths.Naming; + root.WireFormat = CorpusPaths.Wire; var ctx = new GenContext { Entities = root.Objects(), @@ -291,24 +292,37 @@ public void Every_generated_decimal_property_carries_its_converter_and_the_outpu ]}} """; - [Fact] - public void A_decimal_inside_a_jsonb_value_object_reads_exactly_checks_its_declared_range_and_writes_canonical() + // ADR-0019 Amendment 2: the cross-port form a project opts into, and the form the corpora pin. + private static readonly WireFormat Portable = new(DecimalWireForm.String, NullMemberForm.Omit); + + private static IReadOnlyList EmitModel(string model, WireFormat? wire, GenConfig? config = null) { - var r = new MetaDataLoader().Load([new InMemoryStringSource(ValueObjectModel, id: "vo-decimal.json")]); + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "wire-model.json")]); Assert.Empty(r.Errors); + if (wire is not null) r.Root.WireFormat = wire; var ctx = new GenContext { Entities = r.Root.Objects(), Root = r.Root, - Config = new GenConfig { OutDir = Path.Combine(Path.GetTempPath(), "unused-vo-decimal"), Namespace = "Acme.Shop" }, + Config = config ?? new GenConfig { OutDir = Path.Combine(Path.GetTempPath(), "unused-wire-model"), Namespace = "Acme.Shop" }, }; - var files = new EntityGenerator().Generate(ctx); + return new EntityGenerator().Generate(ctx).ToList(); + } + + private static Assembly Compile(IEnumerable files) + { var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)); - var comp = CSharpCompilation.Create("vo_decimal_" + Guid.NewGuid().ToString("N"), trees, + var comp = CSharpCompilation.Create("wire_" + Guid.NewGuid().ToString("N"), trees, DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using var ms = new MemoryStream(); var emit = comp.Emit(ms); Assert.True(emit.Success, string.Join("\n", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); - var asm = Assembly.Load(ms.ToArray()); + return Assembly.Load(ms.ToArray()); + } + + [Fact] + public void A_decimal_inside_a_jsonb_value_object_reads_exactly_checks_its_declared_range_and_writes_canonical() + { + var asm = Compile(EmitModel(ValueObjectModel, Portable)); var price = asm.GetTypes().Single(t => t.Name == "Price"); var item = asm.GetTypes().Single(t => t.Name == "Item"); @@ -341,24 +355,15 @@ public void FitsDeclared_finds_the_declared_range_on_an_entity_carrying_another_ { // A route mounted in a namespace other than the entity's resolves THIS DecimalWire while the // entity's property carries the DeclaredAttribute of its own namespace's copy (review C4). - var r = new MetaDataLoader().Load([new InMemoryStringSource(ValueObjectModel, id: "c4.json")]); - Assert.Empty(r.Errors); - var ctx = new GenContext + // Both wire forms: the native Number attribute carries the declared range as Declared does. + foreach (var wire in new[] { Portable, WireFormat.Default }) { - Entities = r.Root.Objects(), Root = r.Root, - Config = new GenConfig { OutDir = Path.Combine(Path.GetTempPath(), "unused-c4"), Namespace = "Acme.Shop" }, - }; - var trees = new EntityGenerator().Generate(ctx) - .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)); - var comp = CSharpCompilation.Create("c4_" + Guid.NewGuid().ToString("N"), trees, - DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - using var ms = new MemoryStream(); - Assert.True(comp.Emit(ms).Success); - var price = Assembly.Load(ms.ToArray()).GetTypes().Single(t => t.Name == "Price"); - Assert.True(DecimalWire.FitsDeclared(price, "Net", 99999999.99m)); - Assert.False(DecimalWire.FitsDeclared(price, "Net", 1.001m)); - Assert.False(DecimalWire.FitsDeclared(price, "Net", 123456789012m)); - Assert.True(DecimalWire.FitsDeclared(price, "Free", 1.2345678901234567890123m)); + var price = Compile(EmitModel(ValueObjectModel, wire)).GetTypes().Single(t => t.Name == "Price"); + Assert.True(DecimalWire.FitsDeclared(price, "Net", 99999999.99m)); + Assert.False(DecimalWire.FitsDeclared(price, "Net", 1.001m)); + Assert.False(DecimalWire.FitsDeclared(price, "Net", 123456789012m)); + Assert.True(DecimalWire.FitsDeclared(price, "Free", 1.2345678901234567890123m)); + } } private const string ArrayShapeModel = """ @@ -381,21 +386,7 @@ public void FitsDeclared_finds_the_declared_range_on_an_entity_carrying_another_ [Fact] public void A_non_decimal_array_inherited_from_an_abstract_shape_starts_empty_while_a_value_object_one_is_omitted_when_null() { - var r = new MetaDataLoader().Load([new InMemoryStringSource(ArrayShapeModel, id: "array-shape.json")]); - Assert.Empty(r.Errors); - var ctx = new GenContext - { - Entities = r.Root.Objects(), Root = r.Root, - Config = new GenConfig { OutDir = Path.Combine(Path.GetTempPath(), "unused-array-shape"), Namespace = "Acme.Shop" }, - }; - var files = new EntityGenerator().Generate(ctx); - var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)); - var comp = CSharpCompilation.Create("array_shape_" + Guid.NewGuid().ToString("N"), trees, - DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - using var ms = new MemoryStream(); - var emit = comp.Emit(ms); - Assert.True(emit.Success, string.Join("\n", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); - var asm = Assembly.Load(ms.ToArray()); + var asm = Compile(EmitModel(ArrayShapeModel, Portable)); var post = asm.GetTypes().Single(t => t.Name == "Post"); var tags = Assert.IsAssignableFrom>(post.GetProperty("Tags")!.GetValue(Activator.CreateInstance(post))); @@ -404,4 +395,148 @@ public void A_non_decimal_array_inherited_from_an_abstract_shape_starts_empty_wh var labels = asm.GetTypes().Single(t => t.Name == "Labels"); Assert.Equal("{}", JsonSerializer.Serialize(Activator.CreateInstance(labels), labels)); } + + // ---- ADR-0019 Amendment 2: the wire setting ----------------------------------------------- + + private const string NativeShapeModel = """ + { "metadata.root": { "package": "acme::shop", "children": [ + { "object.value": { "name": "Price", "children": [ + { "field.decimal": { "name": "net", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "note" } }, + { "field.string": { "name": "tags", "isArray": true } } + ]}}, + { "object.entity": { "name": "Item", "children": [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "amount", "@precision": 10, "@scale": 2 } }, + { "field.decimal": { "name": "ratio" } }, + { "field.decimal": { "name": "steps", "isArray": true } }, + { "field.object": { "name": "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + [Fact] + public void The_native_default_writes_what_System_Text_Json_writes_and_keeps_the_input_rules() + { + // No setting at all: the 1.0.x shape. A decimal is a JSON number carrying its scale, a + // value object's null member is null with its key present, an unset array member is []. + var asm = Compile(EmitModel(NativeShapeModel, wire: null)); + var item = asm.GetTypes().Single(t => t.Name == "Item"); + var price = asm.GetTypes().Single(t => t.Name == "Price"); + var vo = Activator.CreateInstance(price)!; + price.GetProperty("Net")!.SetValue(vo, 12.50m); + var row = Activator.CreateInstance(item)!; + item.GetProperty("Amount")!.SetValue(row, 12.50m); + item.GetProperty("Ratio")!.SetValue(row, 0.0000001m); + item.GetProperty("Steps")!.SetValue(row, new List { 1.50m, 2m }); + item.GetProperty("Price")!.SetValue(row, vo); + + var json = JsonSerializer.Serialize(row, item); + Assert.Contains("\"Amount\":12.50", json); + Assert.Contains("\"Ratio\":0.0000001", json); + Assert.Contains("\"Steps\":[1.50,2]", json); + Assert.Contains("\"Price\":{\"net\":12.50,\"note\":null,\"tags\":[]}", json); + + // W2/W3 hold in native mode: a string or a number is read, garbage and over-scale refused. + var back = JsonSerializer.Deserialize("{\"Amount\":\"12.5\",\"Ratio\":3}", item)!; + Assert.Equal(12.5m, item.GetProperty("Amount")!.GetValue(back)); + foreach (var bad in new[] { "{\"Amount\":\"abc\"}", "{\"Amount\":1.234}", "{\"Amount\":\"123456789\"}", + "{\"Price\":{\"net\":\"1.001\"}}", "{\"Steps\":[\"x\"]}" }) + Assert.Throws(() => JsonSerializer.Deserialize(bad, item)); + Assert.True(DecimalWire.FitsDeclared(item, "Amount", 12345678.12m)); + Assert.False(DecimalWire.FitsDeclared(item, "Amount", 1.001m)); + } + + [Fact] + public void The_portable_setting_writes_canonical_strings_and_omits_null_members() + { + var asm = Compile(EmitModel(NativeShapeModel, Portable)); + var item = asm.GetTypes().Single(t => t.Name == "Item"); + var price = asm.GetTypes().Single(t => t.Name == "Price"); + var vo = Activator.CreateInstance(price)!; + price.GetProperty("Net")!.SetValue(vo, 12.50m); + var row = Activator.CreateInstance(item)!; + item.GetProperty("Amount")!.SetValue(row, 12.50m); + item.GetProperty("Steps")!.SetValue(row, new List { 1.50m, 2m }); + item.GetProperty("Price")!.SetValue(row, vo); + + var json = JsonSerializer.Serialize(row, item); + Assert.Contains("\"Amount\":\"12.5\"", json); + Assert.Contains("\"Steps\":[\"1.5\",\"2\"]", json); + Assert.Contains("\"Price\":{\"net\":\"12.5\"}", json); + } + + [Fact] + public void Each_wire_key_switches_only_its_own_output() + { + string Gen(WireFormat w) => string.Join("\n", EmitModel(NativeShapeModel, w).Select(f => f.Path + "\n" + f.Content)); + var native = Gen(WireFormat.Default); + // C#'s standard serializer writes a null member with its key: `emit` generates what native does. + Assert.Equal(native, Gen(new WireFormat(DecimalWireForm.Native, NullMemberForm.Emit))); + Assert.DoesNotContain("WhenWritingNull", native, StringComparison.Ordinal); + Assert.DoesNotContain("DecimalWire.Declared", native, StringComparison.Ordinal); + + var stringOnly = Gen(new WireFormat(DecimalWireForm.String, NullMemberForm.Native)); + Assert.Contains("[DecimalWire.Declared(10, 2)]", stringOnly, StringComparison.Ordinal); + Assert.DoesNotContain("WhenWritingNull", stringOnly, StringComparison.Ordinal); + + var omitOnly = Gen(new WireFormat(DecimalWireForm.Native, NullMemberForm.Omit)); + Assert.Contains("[DecimalWire.Number(10, 2)]", omitOnly, StringComparison.Ordinal); + Assert.Contains("WhenWritingNull", omitOnly, StringComparison.Ordinal); + } + + private const string TwoNamespaceModel = """ + { "metadata.root": { "package": "acme::billing", "children": [ + { "object.entity": { "name": "Invoice", "children": [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "total" } }, + { "field.uri": { "name": "link" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + private const string SecondNamespaceModel = """ + { "metadata.root": { "package": "acme::shipping", "children": [ + { "object.entity": { "name": "Parcel", "children": [ + { "source.rdb": { "@table": "parcels" } }, + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "weight" } }, + { "field.uri": { "name": "tracking" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + [Fact] + public void Helpers_for_packages_bound_to_different_namespaces_get_distinct_file_names() + { + // #454: two namespaces in one output directory each need DecimalWire and MetaNetValidation; + // both copies used to be named DecimalWire.g.cs, and gen refused the duplicate path. + var r = new MetaDataLoader().Load([ + new InMemoryStringSource(TwoNamespaceModel, id: "billing.json"), + new InMemoryStringSource(SecondNamespaceModel, id: "shipping.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig + { + OutDir = Path.Combine(Path.GetTempPath(), "unused-two-ns"), + Namespace = "Acme", + PackageNamespaces = new() { ["acme::billing"] = "Acme.Billing", ["acme::shipping"] = "Acme.Shipping" }, + }, + }; + var files = new EntityGenerator().Generate(ctx); + var paths = files.Select(f => f.Path).ToList(); + Assert.Equal(paths.Count, paths.Distinct(StringComparer.Ordinal).Count()); + Assert.Contains("DecimalWire.Acme.Billing.g.cs", paths); + Assert.Contains("DecimalWire.Acme.Shipping.g.cs", paths); + Assert.Contains("MetaNetValidation.Acme.Billing.g.cs", paths); + Assert.Contains("MetaNetValidation.Acme.Shipping.g.cs", paths); + Compile(files); + } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/IntegrationFixtureDriftTests.cs b/server/csharp/MetaObjects.Codegen.Tests/IntegrationFixtureDriftTests.cs index 483691fa6..e47bb517b 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/IntegrationFixtureDriftTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/IntegrationFixtureDriftTests.cs @@ -66,6 +66,8 @@ private static IReadOnlyList Regenerate() var result = new MetaDataLoader().Load([new FileSource(CorpusMetadata)]); Assert.True(result.Errors.Count == 0, "corpus metadata failed to load: " + string.Join("; ", result.Errors.Select(e => e.ToString()))); + // The corpus pins the cross-port wire form (ADR-0019 Amendment 2) in the same config file. + result.Root.WireFormat = CorpusPaths.Wire; var ctx = new GenContext { diff --git a/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs index f72690240..0ceb89159 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs @@ -5,16 +5,18 @@ using MetaObjects.Codegen; using MetaObjects.Codegen.Generators; using MetaObjects.Loader; +using MetaObjects.Meta; using Xunit; namespace MetaObjects.Codegen.Tests; /// -/// A value-object member that is null is omitted on the wire via -/// [JsonIgnore(WhenWritingNull)], but System.Text.Json throws InvalidOperationException +/// Under wire.nullMembers: omit (ADR-0019 Amendment 2) a value-object member that is null +/// is omitted on the wire via [JsonIgnore(WhenWritingNull)], but System.Text.Json throws InvalidOperationException /// the first time it builds type info for a type carrying that condition on a NON-nullable value /// type (double, DateTimeOffset), so every request that touched the owning entity -/// answered 500. The condition belongs on the nullable members only. +/// answered 500. The condition belongs on the nullable members only. The default (native) writes +/// the member as System.Text.Json does, null with its key present. /// public class ValueObjectWireIgnoreTests { @@ -39,11 +41,14 @@ public class ValueObjectWireIgnoreTests ]}} """; - [Fact] - public void A_value_object_with_required_value_type_members_round_trips_and_omits_a_null_optional_member() + [Theory] + [InlineData(NullMemberForm.Omit)] + [InlineData(NullMemberForm.Native)] + public void A_value_object_with_required_value_type_members_round_trips_and_writes_a_null_optional_member_per_the_setting(NullMemberForm nulls) { var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "model.json")]); Assert.Empty(r.Errors); + r.Root.WireFormat = new WireFormat(DecimalWireForm.String, nulls); var files = new EntityGenerator().Generate(new GenContext { Entities = r.Root.Objects(), @@ -70,7 +75,17 @@ public void A_value_object_with_required_value_type_members_round_trips_and_omit Assert.Equal(51.5, wire.GetProperty("latitude").GetDouble()); Assert.Equal(DateTimeOffset.Parse("2026-03-04T05:06:07Z"), wire.GetProperty("capturedAt").GetDateTimeOffset()); Assert.True(wire.GetProperty("live").GetBoolean()); - Assert.False(wire.TryGetProperty("tempC", out _)); - Assert.False(wire.TryGetProperty("note", out _)); + if (nulls == NullMemberForm.Omit) + { + Assert.False(wire.TryGetProperty("tempC", out _)); + Assert.False(wire.TryGetProperty("note", out _)); + Assert.False(wire.TryGetProperty("tags", out _)); + } + else + { + Assert.Equal(JsonValueKind.Null, wire.GetProperty("tempC").ValueKind); + Assert.Equal(JsonValueKind.Null, wire.GetProperty("note").ValueKind); + Assert.Equal(0, wire.GetProperty("tags").GetArrayLength()); + } } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs new file mode 100644 index 000000000..a2f320e42 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs @@ -0,0 +1,90 @@ +// WireConformanceTests — runs the shared cross-port wire-setting corpus +// (fixtures/wire-conformance/config-blocks.json, ADR-0019 Amendment 2) against the C# port. +// +// Each `cases[]` entry is the `wire` block of a project's `.metaobjects/config.json`. This test +// writes exactly that file to a throwaway project directory and parses it with the C# config +// reader (MetaObjects.Config.NeutralConfig, the same reader every project-aware load goes +// through). Each `invalid` block must be refused when the config is read. +using System.Text.Json; +using MetaObjects.Config; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class WireConformanceTests +{ + private static string CasesPath => + Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "wire-conformance", "config-blocks.json"); + + private static JsonElement Section(string name) => + JsonDocument.Parse(File.ReadAllText(CasesPath)).RootElement.GetProperty(name); + + public static TheoryData CaseNames() + { + var data = new TheoryData(); + foreach (var el in Section("cases").EnumerateArray()) data.Add(el.GetProperty("name").GetString()!); + return data; + } + + public static TheoryData InvalidNames() + { + var data = new TheoryData(); + foreach (var el in Section("invalid").EnumerateArray()) data.Add(el.GetProperty("name").GetString()!); + return data; + } + + private static JsonElement Named(string section, string name) => + Section(section).EnumerateArray().Single(e => e.GetProperty("name").GetString() == name); + + /// + /// Writes a throwaway project's .metaobjects/config.json carrying + /// (no wire key at all when it is JSON null) and parses it with . + /// + private static WireFormat ReadWire(JsonElement wire) + { + var dir = Path.Combine(Path.GetTempPath(), "mo-wire-conf-" + Guid.NewGuid().ToString("N")); + var cfgDir = Path.Combine(dir, ".metaobjects"); + Directory.CreateDirectory(cfgDir); + var body = wire.ValueKind == JsonValueKind.Null + ? """{ "schema_version": 1 }""" + : $$"""{ "schema_version": 1, "wire": {{wire.GetRawText()}} }"""; + File.WriteAllText(Path.Combine(cfgDir, "config.json"), body); + try + { + return NeutralConfig.Read(dir)?.Wire ?? WireFormat.Default; + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Theory] + [MemberData(nameof(CaseNames))] + public void Resolves_each_wire_block(string name) + { + var c = Named("cases", name); + var wire = ReadWire(c.GetProperty("wire")); + Assert.Equal(c.GetProperty("decimals").GetString(), WireFormat.Spelling(wire.Decimals)); + Assert.Equal(c.GetProperty("nullMembers").GetString(), WireFormat.Spelling(wire.NullMembers)); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Refuses_invalid_wire_blocks(string name) + { + var ex = Assert.Throws(() => ReadWire(Named("invalid", name).GetProperty("wire"))); + Assert.Equal(ErrorCode.ERR_BAD_ATTR_VALUE, ex.Code); + Assert.Contains("\"wire", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_project_without_the_setting_loads_with_the_native_default() + { + Assert.Equal(new WireFormat(DecimalWireForm.Native, NullMemberForm.Native), WireFormat.Default); + var r = new MetaObjects.Loader.MetaDataLoader().Load([new MetaObjects.Loader.InMemoryStringSource( + """{ "metadata.root": { "package": "acme", "children": [] } }""", id: "empty.json")]); + Assert.Equal(WireFormat.Default, r.Root.WireFormat); + } +} diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 22ecef8d2..904ca5beb 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -785,6 +785,38 @@ private static string NamingDiffers(PhysicalNaming committed, PhysicalNaming use "regenerate: that rewrites column or table names to ones the database does not have. Declare " + $"{NamingBlock(committed)} in .metaobjects/config.json, and gen and verify --codegen both read it."; + /// + /// The other wire setting (ADR-0019 Amendment 2) that reproduces the committed output + /// exactly, or null. Candidates are every combination of the two keys other than + /// , native before string and native before emit before omit; + /// emit is never offered, because in C# it generates what native does. + /// + private static WireFormat? MatchingWire( + GenConfig config, Func load, IReadOnlyList suite, bool narrowed, WireFormat used) + { + var generated = used.NullMembers == NullMemberForm.Emit ? used with { NullMembers = NullMemberForm.Native } : used; + foreach (var decimals in new[] { DecimalWireForm.Native, DecimalWireForm.String }) + foreach (var nulls in new[] { NullMemberForm.Native, NullMemberForm.Omit }) + { + var candidate = new WireFormat(decimals, nulls); + if (candidate == generated) continue; + var root = load(); + root.WireFormat = candidate; + var probe = CodegenDrift.Compute(config, root, suite, narrowed); + if (probe.Error is null && probe.Clean) return candidate; + } + return null; + } + + /// What verify --codegen says, in place of "regenerate", when the committed output + /// was generated with the wire setting and this run used + /// . The Python port prints the same text. + private static string WireDiffers(WireFormat committed, WireFormat used) => + $"The committed output was generated with {committed.Block()}; this run used {used.Block()} " + + "(.metaobjects/config.json; each key defaults to native). Do not regenerate: that changes the " + + "JSON your API writes for decimals or for a value object's null members. Declare " + + $"{committed.Block()} in .metaobjects/config.json, and gen and verify --codegen both read it."; + private static int RunVerifyArgs(string[] rest, IReadOnlyList declared) { var a = OwnedArgs.Parse(rest, VerifyCommandName, VerifyValueFlags, VerifyBoolFlags); @@ -891,6 +923,14 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar Console.Error.WriteLine(NamingDiffers(committed, naming)); return 1; } + // The same for the wire setting: it reaches every decimal property's converter and every + // value object's null handling, so a regen under the wrong one changes the API's JSON. + var wire = load.Root.WireFormat; + if (MatchingWire(config, () => meta.Load(strict: !a.Lax).Root, suite, a.Selection is { Count: > 0 }, wire) is { } committedWire) + { + Console.Error.WriteLine(WireDiffers(committedWire, wire)); + return 1; + } foreach (var line in CodegenDrift.Remedy(result)) Console.Error.WriteLine(line); return 1; } diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index b7a37aa5f..d823ea6cc 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -49,9 +49,15 @@ public class EntityGenerator : IGenerator /// object. See . public Func? Include { get; init; } + // The run's wire setting (ADR-0019 Amendment 2), read from the loaded root when Generate + // starts. Read from the run, not from each field's own root: a report's row model is a + // synthesized tree that belongs to no root. + private WireFormat _wire = WireFormat.Default; + public virtual IEnumerable Generate(GenContext ctx) { ctx = ctx.Narrow(Include); + _wire = ctx.Root.WireFormat; // FR-044 — a view-backed report joins the set as its ROW MODEL (a keyless, // projection-shaped object carrying its derived fields); every other report node // is dropped. See ReportRows. @@ -181,23 +187,31 @@ public virtual IEnumerable Generate(GenContext ctx) // A sourceless entity with a field.uri hit this the moment #248 moved it out of `mapped`; // the value-object arm was the same hole, reachable already whenever no MAPPED entity in // the namespace happened to carry a strict uri/inet. - foreach (var ns in mapped.Concat(unmappedShapes).Concat(valueObjects).Where(HasStrictNetField) - .Select(e => PackageBindingResolver.Resolve( - ctx.Config, PackageBindingResolver.EffectivePackage(e), e.Name, fallbackContext: e.Name)) - .Distinct(StringComparer.Ordinal)) - files.Add(EmitNetValidationFile(ns)); + var netNamespaces = HelperNamespaces(mapped.Concat(unmappedShapes).Concat(valueObjects).Where(HasStrictNetField), ctx); + foreach (var ns in netNamespaces) + files.Add(EmitNetValidationFile(ns, HelperFileName("MetaNetValidation", ns, netNamespaces))); // ADR-0019 Amendment 1 — the decimal wire helper, ONCE per namespace that has a decimal // property (every emitted shape that can carry one: mapped entities incl. projections, // report rows, TPH classes and abstract shapes, sourceless shapes and value objects). - foreach (var ns in mapped.Concat(unmappedShapes).Concat(valueObjects).Where(HasDecimalWireField) - .Select(e => PackageBindingResolver.Resolve( - ctx.Config, PackageBindingResolver.EffectivePackage(e), e.Name, fallbackContext: e.Name)) - .Distinct(StringComparer.Ordinal)) - files.Add(EmitDecimalWireFile(ns)); + var decimalNamespaces = HelperNamespaces(mapped.Concat(unmappedShapes).Concat(valueObjects).Where(HasDecimalWireField), ctx); + foreach (var ns in decimalNamespaces) + files.Add(EmitDecimalWireFile(ns, HelperFileName("DecimalWire", ns, decimalNamespaces))); return files; } + // The distinct namespaces (package bindings) the given shapes land in, in first-seen order. + private static List HelperNamespaces(IEnumerable shapes, GenContext ctx) => + shapes.Select(e => PackageBindingResolver.Resolve( + ctx.Config, PackageBindingResolver.EffectivePackage(e), e.Name, fallbackContext: e.Name)) + .Distinct(StringComparer.Ordinal).ToList(); + + // A per-namespace helper's file name. Every namespace's copy lands in the one output + // directory, so a model whose packages bind to more than one namespace names each copy + // after its namespace (`DecimalWire.Acme.Billing.g.cs`); one namespace keeps the plain name. + private static string HelperFileName(string stem, string ns, IReadOnlyCollection namespaces) => + namespaces.Count > 1 ? $"{stem}.{ns}.g.cs" : $"{stem}.g.cs"; + // EF Core entity (table) or projection (view) class. For a write-through entity // (#214) this emits the WRITE half: the table-mapped, derived-FREE entity (the // derived origin.* fields live only on the view read model — EmitWriteThroughReadModel). @@ -608,7 +622,7 @@ protected virtual string TphSubtypeScalarProperty( var arr = new StringBuilder(); arr.AppendLine($" [Column({CSharpNaming.ColumnRef(entity, field, strategy, includeNames)})]"); AppendArrayValidatorAttributes(arr, field); - AppendDecimalConverterAttribute(arr, field, nullable: false); + AppendDecimalConverterAttribute(arr, field, nullable: false, _wire); // A NON-required decimal array is nullable (no initializer) so PATCH null / an omitted // POST value stays a NULL column instead of failing as a required collection. if (IsDecimalWireField(field) && !CSharpNaming.IsRequired(entity, field)) @@ -625,7 +639,7 @@ protected virtual string TphSubtypeScalarProperty( else AppendNumericValidatorAttributes(sb, field); var required = CSharpNaming.IsRequired(entity, field); - AppendDecimalConverterAttribute(sb, field, nullable: !required); + AppendDecimalConverterAttribute(sb, field, nullable: !required, _wire); var nullable = required ? "" : "?"; // Mirrors ScalarProperty's own initializer handling (its non-TPH sibling): a // literal-safe @default (bool/int/long/double/float/decimal/string) wins over the @@ -859,11 +873,13 @@ protected virtual EmittedFile EmitValueObjectPoco(MetaObject vo, GenContext ctx) if (member is null) continue; // Pin the jsonb element name (+ wire name) to the metadata field name so the // stored owned-JSON keys match the shared seed + the cross-port wire contract. - // A null member is omitted on the wire (the cross-port value-object contract). Only a - // NULLABLE member can be null, and System.Text.Json throws InvalidOperationException - // when WhenWritingNull sits on a non-nullable value type (double, DateTimeOffset, ...), - // so the condition is emitted for the nullable members alone. - var nullableMember = System.Text.RegularExpressions.Regex.IsMatch(member, @"^\s*public\s+[^=;{(]*\?\s+\w+", System.Text.RegularExpressions.RegexOptions.Multiline); + // Under `wire.nullMembers: omit` (ADR-0019 Amendment 2) a null member is left out of + // the wire; the default writes it as System.Text.Json does, `null` with the key + // present. Only a NULLABLE member can be null, and System.Text.Json throws + // InvalidOperationException when WhenWritingNull sits on a non-nullable value type + // (double, DateTimeOffset, ...), so the condition is emitted for the nullable members alone. + var nullableMember = _wire.NullMembers == NullMemberForm.Omit + && System.Text.RegularExpressions.Regex.IsMatch(member, @"^\s*public\s+[^=;{(]*\?\s+\w+", System.Text.RegularExpressions.RegexOptions.Multiline); member = $" [JsonPropertyName(\"{field.Name}\")]\n" + (nullableMember ? " [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" : "") + member; @@ -1033,14 +1049,16 @@ private static void AppendNetConverterAttribute(StringBuilder sb, MetaField fiel // the namespace swap. It lives HERE (not read from the HelperRuntime resource) so an // ejected EntityGenerator stays self-contained, the way the MetaNetValidation text is. private const string DecimalWireSourceTemplate = """ -// DecimalWire — the wire form of a field.decimal (ADR-0019 Amendment 1, W1-W4). +// DecimalWire — the wire form of a field.decimal (ADR-0019 Amendments 1 and 2, W1-W4). // -// A decimal crosses JSON as a canonical STRING: `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`, -// plain notation, no exponent, no trailing fractional zeros, zero as "0". A JSON number is -// a double to every JavaScript client, so the string is the only spelling a browser reads -// exactly. Input takes a string in the JSON-number grammar or a JSON number; anything else -// is refused. A value `decimal` cannot hold (more than 28-29 significant digits) is an -// error, never a rounded value (W3). +// Output follows the project's `wire.decimals` setting (Amendment 2). Under `native`, the +// default, a decimal is what System.Text.Json writes for it: a JSON number carrying its scale +// (`12.50`), through the `Number` attribute. Under `string` it is the canonical STRING, +// `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`: plain notation, no exponent, no trailing fractional +// zeros, zero as "0" (the plain converters and `Declared`), the form every port writes alike and +// the only spelling a browser reads exactly. Input is the same under both: a string in the +// JSON-number grammar or a JSON number; anything else is refused. A value `decimal` cannot hold +// (more than 28-29 significant digits) is an error, never a rounded value (W3). // // ONE source, two homes. This file is the package's production helper (the filter // dispatcher parses its operands through it). The entity generator emits the SAME text, @@ -1203,8 +1221,9 @@ public static bool Fits(decimal value, int precision, int scale) /// /// W3 for a declared property: false when of - /// carries and does not fit it. - /// True for a property with no declared precision (the PATCH merge asks this per key). + /// carries (or a with a precision) + /// and does not fit it. True for a property with no declared + /// precision (the PATCH merge asks this per key). /// public static bool FitsDeclared(Type owner, string property, decimal value) { @@ -1216,7 +1235,8 @@ public static bool FitsDeclared(Type owner, string property, decimal value) // than the entity resolves THIS DecimalWire, while the entity carries the copy emitted // into its own namespace, and the two DeclaredAttribute types are different types. var type = attribute.GetType(); - if (type.Name != nameof(DeclaredAttribute) || type.DeclaringType?.Name != nameof(DecimalWire)) continue; + if (type.Name is not (nameof(DeclaredAttribute) or nameof(NumberAttribute)) + || type.DeclaringType?.Name != nameof(DecimalWire)) continue; var precision = type.GetProperty(nameof(DeclaredAttribute.Precision))?.GetValue(attribute); var scale = type.GetProperty(nameof(DeclaredAttribute.Scale))?.GetValue(attribute); return precision is int p && scale is int sc ? Fits(value, p, sc) : true; @@ -1246,8 +1266,9 @@ private static decimal ReadOne(ref Utf8JsonReader reader, int? precision = null, } /// - /// A decimal property with a declared @precision and @scale: the same wire form - /// as the plain converters, and a value the column cannot hold is refused, never rounded (W3). + /// A decimal property with a declared @precision and @scale, under + /// wire.decimals: string: the same wire form as the plain converters, and a value the + /// column cannot hold is refused, never rounded (W3). /// [AttributeUsage(AttributeTargets.Property)] public sealed class DeclaredAttribute(int precision, int scale) : JsonConverterAttribute @@ -1266,8 +1287,43 @@ public sealed class DeclaredAttribute(int precision, int scale) : JsonConverterA } } - /// A decimal property: written as the canonical string, read from a string or a number. - public sealed class DecimalJsonConverter(int? precision, int? scale) : JsonConverter + /// + /// A decimal property under wire.decimals: native: written as System.Text.Json writes a + /// decimal (a JSON number carrying its scale), read by the same rules as the string form + /// (a string or a number; a declared @precision and @scale refuse a value the + /// column cannot hold, never rounded). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class NumberAttribute : JsonConverterAttribute + { + public NumberAttribute() { } + + public NumberAttribute(int precision, int scale) + { + Precision = precision; + Scale = scale; + } + + public int? Precision { get; } + public int? Scale { get; } + + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (typeToConvert == typeof(decimal)) return new DecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(decimal?)) return new NullableDecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(IList)) return new DecimalListJsonConverter(Precision, Scale, asString: false); + return null; + } + } + + private static void WriteOne(Utf8JsonWriter writer, decimal value, bool asString) + { + if (asString) writer.WriteStringValue(Format(value)); + else writer.WriteNumberValue(value); + } + + /// A decimal property: written as the canonical string (or, from , a JSON number), read from a string or a number. + public sealed class DecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter { public DecimalJsonConverter() : this(null, null) { } @@ -1275,11 +1331,11 @@ public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, Json => ReadOne(ref reader, precision, scale); public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) - => writer.WriteStringValue(Format(value)); + => WriteOne(writer, value, asString); } /// A nullable decimal property: null is JSON null, with the key present. - public sealed class NullableDecimalJsonConverter(int? precision, int? scale) : JsonConverter + public sealed class NullableDecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter { public NullableDecimalJsonConverter() : this(null, null) { } @@ -1291,12 +1347,12 @@ public NullableDecimalJsonConverter() : this(null, null) { } public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options) { if (value is null) writer.WriteNullValue(); - else writer.WriteStringValue(Format(value.Value)); + else WriteOne(writer, value.Value, asString); } } - /// An @isArray decimal property, typed IList<decimal> (the one collection shape EF Core 8 on SQLite can materialize): an array of canonical strings. - public sealed class DecimalListJsonConverter(int? precision, int? scale) : JsonConverter> + /// An @isArray decimal property, typed IList<decimal> (the one collection shape EF Core 8 on SQLite can materialize): an array of canonical strings (or JSON numbers). + public sealed class DecimalListJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter> { public DecimalListJsonConverter() : this(null, null) { } @@ -1312,15 +1368,15 @@ public DecimalListJsonConverter() : this(null, null) { } public override void Write(Utf8JsonWriter writer, IList value, JsonSerializerOptions options) { writer.WriteStartArray(); - foreach (var d in value) writer.WriteStringValue(Format(d)); + foreach (var d in value) WriteOne(writer, d, asString); writer.WriteEndArray(); } } } """; - private static EmittedFile EmitDecimalWireFile(string ns) => - new("DecimalWire.g.cs", DecimalWireSourceTemplate.Replace("__NS__", ns, StringComparison.Ordinal) + "\n"); + private static EmittedFile EmitDecimalWireFile(string ns, string fileName) => + new(fileName, DecimalWireSourceTemplate.Replace("__NS__", ns, StringComparison.Ordinal) + "\n"); // true iff FIELD surfaces as a CLR decimal (single or @isArray) and so carries the // per-property wire converter. A jsonb override keeps the JsonDocument type, not decimal. @@ -1330,10 +1386,19 @@ private static bool IsDecimalWireField(MetaField field) => private static bool HasDecimalWireField(MetaObject entity) => entity.Fields().Any(IsDecimalWireField); // The per-property converter: host JSON options cannot drop it. `required` decides which - // closed type the property has (decimal, decimal?, IList). - private static void AppendDecimalConverterAttribute(StringBuilder sb, MetaField field, bool nullable) + // closed type the property has (decimal, decimal?, IList). Under the default + // `wire.decimals: native` (ADR-0019 Amendment 2) it writes what System.Text.Json writes, a + // JSON number, and keeps only the input rules; `string` writes the canonical string. + private static void AppendDecimalConverterAttribute(StringBuilder sb, MetaField field, bool nullable, WireFormat wire) { if (!IsDecimalWireField(field)) return; + if (wire.Decimals == DecimalWireForm.Native) + { + sb.AppendLine(field.Precision is long p + ? $" [DecimalWire.Number({p}, {field.Scale ?? 0})]" + : " [DecimalWire.Number]"); + return; + } // W3: a declared @precision and @scale bound the input; the attribute carries both. if (field.Precision is long precision) { @@ -1349,7 +1414,7 @@ private static void AppendDecimalConverterAttribute(StringBuilder sb, MetaField // converters implementing the Contract-A rules (absolute-scheme URI; IPv4/IPv6 literal, no DNS) // verified against the shared validation-conformance corpus. Dependency-free, so the generated // code runs without any MetaObjects runtime. The body is fixed; only the namespace varies. - private static EmittedFile EmitNetValidationFile(string ns) + private static EmittedFile EmitNetValidationFile(string ns, string fileName) { var sb = new StringBuilder(); sb.AppendLine("// Generated by MetaObjects entity-generator. Do not edit by hand."); @@ -1440,7 +1505,7 @@ private static EmittedFile EmitNetValidationFile(string ns) sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine("}"); - return new EmittedFile("MetaNetValidation.g.cs", sb.ToString()); + return new EmittedFile(fileName, sb.ToString()); } // A property for an enum-subtype field. The property type is the nested enum name. @@ -1474,9 +1539,9 @@ protected virtual string EnumProperty( // to before). // // When the field is an array (isArray: true), emit IList with an empty-list - // initializer instead of a scalar T property. A NON-required decimal array, and a - // NON-required array member of a value object (valueObject: true, so a null member is - // omitted on the wire), are nullable with no initializer instead. + // initializer instead of a scalar T property. A NON-required decimal array, and, under + // `wire.nullMembers: omit`, a NON-required array member of a value object (valueObject: + // true, so a null member is omitted on the wire), are nullable with no initializer instead. // // includeNames (C1) is only consulted when withAttributes is true — it is the // ctx.Config.IncludeNames run-level presence gate CSharpNaming.ColumnRef needs; @@ -1512,11 +1577,12 @@ protected virtual string ScalarProperty( AppendArrayValidatorAttributes(arr, field); // EF Core 8 on SQLite materializes a primitive collection only through IList (an // ICollection property throws at query compile time), so a scalar array is an IList. - AppendDecimalConverterAttribute(arr, field, nullable: false); + AppendDecimalConverterAttribute(arr, field, nullable: false, _wire); // A NON-required decimal array is nullable (no initializer) so PATCH null / an omitted // POST value stays a NULL column instead of failing as a required collection; a // NON-required value-object array is nullable so a null member is omitted on the wire. - if ((IsDecimalWireField(field) || valueObject) && !CSharpNaming.IsRequired(owner, field)) + var omittable = valueObject && _wire.NullMembers == NullMemberForm.Omit; + if ((IsDecimalWireField(field) || omittable) && !CSharpNaming.IsRequired(owner, field)) arr.Append($" public IList<{baseType}>? {propName} {{ get; set; }}"); else arr.Append($" public IList<{baseType}> {propName} {{ get; set; }} = new List<{baseType}>();"); @@ -1573,7 +1639,7 @@ protected virtual string ScalarProperty( AppendNetConverterAttribute(sb, field); // ADR-0019 Amendment 1: a decimal crosses JSON as a canonical string. Attached per // property (not through host JsonSerializerOptions) so no host configuration drops it. - AppendDecimalConverterAttribute(sb, field, nullable: !nonNull); + AppendDecimalConverterAttribute(sb, field, nullable: !nonNull, _wire); var type = nonNull ? baseType : baseType + "?"; // A literal-safe @default (bool / integer / floating / string) emits a property diff --git a/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs b/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs index 0387611a6..41083c658 100644 --- a/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs +++ b/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs @@ -1,11 +1,13 @@ -// DecimalWire — the wire form of a field.decimal (ADR-0019 Amendment 1, W1-W4). +// DecimalWire — the wire form of a field.decimal (ADR-0019 Amendments 1 and 2, W1-W4). // -// A decimal crosses JSON as a canonical STRING: `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`, -// plain notation, no exponent, no trailing fractional zeros, zero as "0". A JSON number is -// a double to every JavaScript client, so the string is the only spelling a browser reads -// exactly. Input takes a string in the JSON-number grammar or a JSON number; anything else -// is refused. A value `decimal` cannot hold (more than 28-29 significant digits) is an -// error, never a rounded value (W3). +// Output follows the project's `wire.decimals` setting (Amendment 2). Under `native`, the +// default, a decimal is what System.Text.Json writes for it: a JSON number carrying its scale +// (`12.50`), through the `Number` attribute. Under `string` it is the canonical STRING, +// `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`: plain notation, no exponent, no trailing fractional +// zeros, zero as "0" (the plain converters and `Declared`), the form every port writes alike and +// the only spelling a browser reads exactly. Input is the same under both: a string in the +// JSON-number grammar or a JSON number; anything else is refused. A value `decimal` cannot hold +// (more than 28-29 significant digits) is an error, never a rounded value (W3). // // ONE source, two homes. This file is the package's production helper (the filter // dispatcher parses its operands through it). The entity generator emits the SAME text, @@ -168,8 +170,9 @@ public static bool Fits(decimal value, int precision, int scale) /// /// W3 for a declared property: false when of - /// carries and does not fit it. - /// True for a property with no declared precision (the PATCH merge asks this per key). + /// carries (or a with a precision) + /// and does not fit it. True for a property with no declared + /// precision (the PATCH merge asks this per key). /// public static bool FitsDeclared(Type owner, string property, decimal value) { @@ -181,7 +184,8 @@ public static bool FitsDeclared(Type owner, string property, decimal value) // than the entity resolves THIS DecimalWire, while the entity carries the copy emitted // into its own namespace, and the two DeclaredAttribute types are different types. var type = attribute.GetType(); - if (type.Name != nameof(DeclaredAttribute) || type.DeclaringType?.Name != nameof(DecimalWire)) continue; + if (type.Name is not (nameof(DeclaredAttribute) or nameof(NumberAttribute)) + || type.DeclaringType?.Name != nameof(DecimalWire)) continue; var precision = type.GetProperty(nameof(DeclaredAttribute.Precision))?.GetValue(attribute); var scale = type.GetProperty(nameof(DeclaredAttribute.Scale))?.GetValue(attribute); return precision is int p && scale is int sc ? Fits(value, p, sc) : true; @@ -211,8 +215,9 @@ private static decimal ReadOne(ref Utf8JsonReader reader, int? precision = null, } /// - /// A decimal property with a declared @precision and @scale: the same wire form - /// as the plain converters, and a value the column cannot hold is refused, never rounded (W3). + /// A decimal property with a declared @precision and @scale, under + /// wire.decimals: string: the same wire form as the plain converters, and a value the + /// column cannot hold is refused, never rounded (W3). /// [AttributeUsage(AttributeTargets.Property)] public sealed class DeclaredAttribute(int precision, int scale) : JsonConverterAttribute @@ -231,8 +236,43 @@ public sealed class DeclaredAttribute(int precision, int scale) : JsonConverterA } } - /// A decimal property: written as the canonical string, read from a string or a number. - public sealed class DecimalJsonConverter(int? precision, int? scale) : JsonConverter + /// + /// A decimal property under wire.decimals: native: written as System.Text.Json writes a + /// decimal (a JSON number carrying its scale), read by the same rules as the string form + /// (a string or a number; a declared @precision and @scale refuse a value the + /// column cannot hold, never rounded). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class NumberAttribute : JsonConverterAttribute + { + public NumberAttribute() { } + + public NumberAttribute(int precision, int scale) + { + Precision = precision; + Scale = scale; + } + + public int? Precision { get; } + public int? Scale { get; } + + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (typeToConvert == typeof(decimal)) return new DecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(decimal?)) return new NullableDecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(IList)) return new DecimalListJsonConverter(Precision, Scale, asString: false); + return null; + } + } + + private static void WriteOne(Utf8JsonWriter writer, decimal value, bool asString) + { + if (asString) writer.WriteStringValue(Format(value)); + else writer.WriteNumberValue(value); + } + + /// A decimal property: written as the canonical string (or, from , a JSON number), read from a string or a number. + public sealed class DecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter { public DecimalJsonConverter() : this(null, null) { } @@ -240,11 +280,11 @@ public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, Json => ReadOne(ref reader, precision, scale); public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) - => writer.WriteStringValue(Format(value)); + => WriteOne(writer, value, asString); } /// A nullable decimal property: null is JSON null, with the key present. - public sealed class NullableDecimalJsonConverter(int? precision, int? scale) : JsonConverter + public sealed class NullableDecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter { public NullableDecimalJsonConverter() : this(null, null) { } @@ -256,12 +296,12 @@ public NullableDecimalJsonConverter() : this(null, null) { } public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options) { if (value is null) writer.WriteNullValue(); - else writer.WriteStringValue(Format(value.Value)); + else WriteOne(writer, value.Value, asString); } } - /// An @isArray decimal property, typed IList<decimal> (the one collection shape EF Core 8 on SQLite can materialize): an array of canonical strings. - public sealed class DecimalListJsonConverter(int? precision, int? scale) : JsonConverter> + /// An @isArray decimal property, typed IList<decimal> (the one collection shape EF Core 8 on SQLite can materialize): an array of canonical strings (or JSON numbers). + public sealed class DecimalListJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter> { public DecimalListJsonConverter() : this(null, null) { } @@ -277,7 +317,7 @@ public DecimalListJsonConverter() : this(null, null) { } public override void Write(Utf8JsonWriter writer, IList value, JsonSerializerOptions options) { writer.WriteStartArray(); - foreach (var d in value) writer.WriteStringValue(Format(d)); + foreach (var d in value) WriteOne(writer, d, asString); writer.WriteEndArray(); } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs index 843be3386..3108d9e6f 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs @@ -27,6 +27,13 @@ internal static class ApiContractCorpusPaths /// public static readonly PhysicalNaming Naming = NeutralConfig.Read(Corpus)?.Naming ?? PhysicalNaming.Default; + /// + /// ADR-0019 Amendment 2 — the corpus's pinned wire setting, read from the same file as + /// : the corpus pins the cross-port form + /// ({"wire":{"decimals":"string","nullMembers":"omit"}}); a project's default is native. + /// + public static readonly WireFormat Wire = NeutralConfig.Read(Corpus)?.Wire ?? WireFormat.Default; + // The corpus Author model — fed to the C# generators in the SP-F generated-server // lane (GeneratedAuthorServerFactory) to produce + host the real routes/AppDbContext. public static readonly string MetaJson = Path.Combine(Corpus, "meta.json"); diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs index aef1aa04a..8c3c351f1 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs @@ -156,6 +156,7 @@ private static Assembly CompileGeneratedServer() [new FileSource(ApiContractCorpusPaths.MetaJson)]); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "corpus metadata failed to load: " + diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedRoutesServer.cs b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedRoutesServer.cs index cff984496..ccf7db45b 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedRoutesServer.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedRoutesServer.cs @@ -44,6 +44,7 @@ public static async Task StartAsync( throw new InvalidOperationException("metadata failed to load: " + string.Join("; ", load.Errors.Select(e => e.ToString()))); var root = load.Root; root.PhysicalNaming = ApiContractCorpusPaths.Naming; + root.WireFormat = ApiContractCorpusPaths.Wire; var ctx = new GenContext { Entities = root.Objects(), diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs index ff299db1b..47bcb9a52 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs @@ -93,6 +93,7 @@ private static (Assembly Assembly, IReadOnlyList RouteEntities) CompileG var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.JsonbMetaJson)]); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "jsonb corpus metadata failed to load: " + diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs index eb381de99..c026b5310 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/M2mGeneratedServerFactory.cs @@ -88,6 +88,7 @@ private static (Assembly Assembly, IReadOnlyList EntityNames) CompileGen var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.M2mMetaJson)]); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "m2m corpus metadata failed to load: " + diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs index f426551bc..d80f1fe45 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs @@ -92,6 +92,7 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.ProjectionMetaJson)]); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "projection corpus metadata failed to load: " + diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs index 099cc965d..097fcbcc1 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs @@ -123,6 +123,7 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.ReportMetaJson)]); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "report corpus metadata failed to load: " + diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs index 07b7c31fb..c7a765d6b 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/TphGeneratedServerFactory.cs @@ -95,6 +95,7 @@ private static (Assembly Assembly, IReadOnlyList RouteEntities) CompileG var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.TphMetaJson)]); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "tph corpus metadata failed to load: " + diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs index 92252de50..de4a9f03d 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/WriteThroughGeneratedServerFactory.cs @@ -87,6 +87,7 @@ private static (Assembly Assembly, IReadOnlyList EntityNames) CompileGen var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.WriteThroughMetaJson)]); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; if (loadResult.Errors.Count != 0) throw new InvalidOperationException( "write-through corpus metadata failed to load: " + diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs index d8b5f0edc..efd47a183 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs @@ -1,11 +1,13 @@ -// DecimalWire — the wire form of a field.decimal (ADR-0019 Amendment 1, W1-W4). +// DecimalWire — the wire form of a field.decimal (ADR-0019 Amendments 1 and 2, W1-W4). // -// A decimal crosses JSON as a canonical STRING: `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`, -// plain notation, no exponent, no trailing fractional zeros, zero as "0". A JSON number is -// a double to every JavaScript client, so the string is the only spelling a browser reads -// exactly. Input takes a string in the JSON-number grammar or a JSON number; anything else -// is refused. A value `decimal` cannot hold (more than 28-29 significant digits) is an -// error, never a rounded value (W3). +// Output follows the project's `wire.decimals` setting (Amendment 2). Under `native`, the +// default, a decimal is what System.Text.Json writes for it: a JSON number carrying its scale +// (`12.50`), through the `Number` attribute. Under `string` it is the canonical STRING, +// `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`: plain notation, no exponent, no trailing fractional +// zeros, zero as "0" (the plain converters and `Declared`), the form every port writes alike and +// the only spelling a browser reads exactly. Input is the same under both: a string in the +// JSON-number grammar or a JSON number; anything else is refused. A value `decimal` cannot hold +// (more than 28-29 significant digits) is an error, never a rounded value (W3). // // ONE source, two homes. This file is the package's production helper (the filter // dispatcher parses its operands through it). The entity generator emits the SAME text, @@ -168,8 +170,9 @@ public static bool Fits(decimal value, int precision, int scale) /// /// W3 for a declared property: false when of - /// carries and does not fit it. - /// True for a property with no declared precision (the PATCH merge asks this per key). + /// carries (or a with a precision) + /// and does not fit it. True for a property with no declared + /// precision (the PATCH merge asks this per key). /// public static bool FitsDeclared(Type owner, string property, decimal value) { @@ -181,7 +184,8 @@ public static bool FitsDeclared(Type owner, string property, decimal value) // than the entity resolves THIS DecimalWire, while the entity carries the copy emitted // into its own namespace, and the two DeclaredAttribute types are different types. var type = attribute.GetType(); - if (type.Name != nameof(DeclaredAttribute) || type.DeclaringType?.Name != nameof(DecimalWire)) continue; + if (type.Name is not (nameof(DeclaredAttribute) or nameof(NumberAttribute)) + || type.DeclaringType?.Name != nameof(DecimalWire)) continue; var precision = type.GetProperty(nameof(DeclaredAttribute.Precision))?.GetValue(attribute); var scale = type.GetProperty(nameof(DeclaredAttribute.Scale))?.GetValue(attribute); return precision is int p && scale is int sc ? Fits(value, p, sc) : true; @@ -211,8 +215,9 @@ private static decimal ReadOne(ref Utf8JsonReader reader, int? precision = null, } /// - /// A decimal property with a declared @precision and @scale: the same wire form - /// as the plain converters, and a value the column cannot hold is refused, never rounded (W3). + /// A decimal property with a declared @precision and @scale, under + /// wire.decimals: string: the same wire form as the plain converters, and a value the + /// column cannot hold is refused, never rounded (W3). /// [AttributeUsage(AttributeTargets.Property)] public sealed class DeclaredAttribute(int precision, int scale) : JsonConverterAttribute @@ -231,8 +236,43 @@ public sealed class DeclaredAttribute(int precision, int scale) : JsonConverterA } } - /// A decimal property: written as the canonical string, read from a string or a number. - public sealed class DecimalJsonConverter(int? precision, int? scale) : JsonConverter + /// + /// A decimal property under wire.decimals: native: written as System.Text.Json writes a + /// decimal (a JSON number carrying its scale), read by the same rules as the string form + /// (a string or a number; a declared @precision and @scale refuse a value the + /// column cannot hold, never rounded). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class NumberAttribute : JsonConverterAttribute + { + public NumberAttribute() { } + + public NumberAttribute(int precision, int scale) + { + Precision = precision; + Scale = scale; + } + + public int? Precision { get; } + public int? Scale { get; } + + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (typeToConvert == typeof(decimal)) return new DecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(decimal?)) return new NullableDecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(IList)) return new DecimalListJsonConverter(Precision, Scale, asString: false); + return null; + } + } + + private static void WriteOne(Utf8JsonWriter writer, decimal value, bool asString) + { + if (asString) writer.WriteStringValue(Format(value)); + else writer.WriteNumberValue(value); + } + + /// A decimal property: written as the canonical string (or, from , a JSON number), read from a string or a number. + public sealed class DecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter { public DecimalJsonConverter() : this(null, null) { } @@ -240,11 +280,11 @@ public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, Json => ReadOne(ref reader, precision, scale); public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) - => writer.WriteStringValue(Format(value)); + => WriteOne(writer, value, asString); } /// A nullable decimal property: null is JSON null, with the key present. - public sealed class NullableDecimalJsonConverter(int? precision, int? scale) : JsonConverter + public sealed class NullableDecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter { public NullableDecimalJsonConverter() : this(null, null) { } @@ -256,12 +296,12 @@ public NullableDecimalJsonConverter() : this(null, null) { } public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options) { if (value is null) writer.WriteNullValue(); - else writer.WriteStringValue(Format(value.Value)); + else WriteOne(writer, value.Value, asString); } } - /// An @isArray decimal property, typed IList<decimal> (the one collection shape EF Core 8 on SQLite can materialize): an array of canonical strings. - public sealed class DecimalListJsonConverter(int? precision, int? scale) : JsonConverter> + /// An @isArray decimal property, typed IList<decimal> (the one collection shape EF Core 8 on SQLite can materialize): an array of canonical strings (or JSON numbers). + public sealed class DecimalListJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter> { public DecimalListJsonConverter() : this(null, null) { } @@ -277,7 +317,7 @@ public DecimalListJsonConverter() : this(null, null) { } public override void Write(Utf8JsonWriter writer, IList value, JsonSerializerOptions options) { writer.WriteStartArray(); - foreach (var d in value) writer.WriteStringValue(Format(d)); + foreach (var d in value) WriteOne(writer, d, asString); writer.WriteEndArray(); } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs index e4b67954b..002cd9412 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs @@ -72,4 +72,11 @@ public static IEnumerable ScenariosFor(ScenarioEngine engine) => /// pins {"naming":{"columns":"literal"}}, matching the committed schema artifacts. /// public static readonly PhysicalNaming Naming = NeutralConfig.Read(Corpus)?.Naming ?? PhysicalNaming.Default; + + /// + /// ADR-0019 Amendment 2 — the corpus's pinned wire setting, read from the same file as + /// : the corpus pins the cross-port form + /// ({"wire":{"decimals":"string","nullMembers":"omit"}}); a project's default is native. + /// + public static readonly WireFormat Wire = NeutralConfig.Read(Corpus)?.Wire ?? WireFormat.Default; } diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs index e52569352..143284adb 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs @@ -148,6 +148,7 @@ private static MetaRoot LoadCorpusMetadata() "failed to load corpus metadata: " + string.Join("; ", result.Errors.Select(e => e.Message))); // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. result.Root.PhysicalNaming = CorpusPaths.Naming; + result.Root.WireFormat = CorpusPaths.Wire; return result.Root; } diff --git a/server/csharp/MetaObjects/Config/MetadataLocation.cs b/server/csharp/MetaObjects/Config/MetadataLocation.cs index 4c7fa89a4..856dccd5f 100644 --- a/server/csharp/MetaObjects/Config/MetadataLocation.cs +++ b/server/csharp/MetaObjects/Config/MetadataLocation.cs @@ -26,17 +26,24 @@ namespace MetaObjects.Config; /// none). sets it on the loaded root, so every resolver (codegen, the /// runtime, verify) agrees with the project's config without being passed a strategy value. /// +/// +/// ADR-0019 Amendment 2 — the project's wire-format setting from the same file (null: the +/// native default). sets it on the loaded root for codegen. +/// public sealed record ResolvedMetadata( - string Directory, IReadOnlyList? Files, IReadOnlyList Libraries, PhysicalNaming Naming) + string Directory, IReadOnlyList? Files, IReadOnlyList Libraries, PhysicalNaming Naming, + WireFormat? Wire = null) { /// Load this metadata, with the project's libraries, and set the loaded - /// root's from (ADR-0058). + /// root's from (ADR-0058) and its + /// from . public LoadResult Load(bool strict = false) { var result = Files is { } files ? MetaDataLoader.FromUris(files.Select(f => new Uri(f)).ToList(), Libraries, strict) : MetaDataLoader.FromDirectory(Directory, Libraries, strict: strict); result.Root.PhysicalNaming = Naming; + result.Root.WireFormat = Wire ?? WireFormat.Default; return result; } } @@ -87,20 +94,21 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) var explicitCfg = ConfigFor(metadataDir); var explicitLibs = explicitCfg?.Libraries ?? Array.Empty(); var explicitNaming = explicitCfg?.Naming ?? PhysicalNaming.Default; - return new ResolvedMetadata(metadataDir, null, explicitLibs, explicitNaming); + return new ResolvedMetadata(metadataDir, null, explicitLibs, explicitNaming, explicitCfg?.Wire); } var cfg = NeutralConfig.Read(cwd); var specs = cfg?.Sources ?? Array.Empty>(); var libraries = cfg?.Libraries ?? Array.Empty(); var naming = cfg?.Naming ?? PhysicalNaming.Default; + var wire = cfg?.Wire; if (specs.Count == 0) { // No declared sources: resolve the DEFAULT directory through the same ladder // the shared conformance corpus gates (ERR_COLLECTION_NOT_FOUND when absent). var defaultFiles = SourceResolver.ResolveCollection(cwd); - return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming); + return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming, wire); } if (specs.Count > 1) @@ -130,6 +138,6 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) "\"sources\" at the file's containing directory."); } - return new ResolvedMetadata(resolved, files, libraries, naming); + return new ResolvedMetadata(resolved, files, libraries, naming, wire); } } diff --git a/server/csharp/MetaObjects/Config/NeutralConfig.cs b/server/csharp/MetaObjects/Config/NeutralConfig.cs index d9d84adc0..c5504ba62 100644 --- a/server/csharp/MetaObjects/Config/NeutralConfig.cs +++ b/server/csharp/MetaObjects/Config/NeutralConfig.cs @@ -18,6 +18,9 @@ // becomes a physical database name, read by every port. An unknown key, an unknown value, // or a non-object `naming` is refused (ERR_BAD_ATTR_VALUE), the same "present but // wrong-shaped raises" rule `sources`/`libraries` already follow. +// +// `wire` (ADR-0019 Amendment 2) is read here too — the output form of a generated REST surface +// (`decimals`, `nullMembers`), refused exactly as `naming` is. using System.Text.Json; using MetaObjects.Meta; @@ -53,13 +56,18 @@ public sealed class NeutralConfig /// same as an empty `naming: {}` block. public PhysicalNaming Naming { get; } + /// ADR-0019 Amendment 2 — the project's wire-format setting (`wire` in config.json), each + /// absent key at its native default. Never null. + public WireFormat Wire { get; } + private NeutralConfig( IReadOnlyList> sources, IReadOnlyList libraries, - PhysicalNaming naming) + PhysicalNaming naming, WireFormat wire) { Sources = sources; Libraries = libraries; Naming = naming; + Wire = wire; } /// Returns null when `/.metaobjects/config.json` does not exist. @@ -212,8 +220,53 @@ private NeutralConfig( // Unknown top-level keys (including `scope`/`migrate`) are IGNORED by // design — see the file header. - return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables)); + return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables), ReadWire(root, path)); + } + } + + /// The `wire` block (ADR-0019 Amendment 2): an unknown key, an unknown value, or a + /// non-object block is refused, the same rule as `naming`. + private static WireFormat ReadWire(JsonElement root, string path) + { + if (!root.TryGetProperty("wire", out var wire)) return WireFormat.Default; + if (wire.ValueKind != JsonValueKind.Object) + throw new MetaModelException($"{path}: \"wire\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); + + var decimals = DecimalWireForm.Native; + var nullMembers = NullMemberForm.Native; + foreach (var p in wire.EnumerateObject()) + { + var raw = p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : null; + switch (p.Name) + { + case "decimals": + decimals = raw switch + { + "native" => DecimalWireForm.Native, + "string" => DecimalWireForm.String, + _ => throw new MetaModelException( + $"{path}: \"wire.decimals\" must be one of \"native\", \"string\"", + ErrorCode.ERR_BAD_ATTR_VALUE), + }; + break; + case "nullMembers": + nullMembers = raw switch + { + "native" => NullMemberForm.Native, + "emit" => NullMemberForm.Emit, + "omit" => NullMemberForm.Omit, + _ => throw new MetaModelException( + $"{path}: \"wire.nullMembers\" must be one of \"native\", \"emit\", \"omit\"", + ErrorCode.ERR_BAD_ATTR_VALUE), + }; + break; + default: + throw new MetaModelException( + $"{path}: \"wire\" has an unknown key \"{p.Name}\" (expected \"decimals\" and/or \"nullMembers\")", + ErrorCode.ERR_BAD_ATTR_VALUE); + } } + return new WireFormat(decimals, nullMembers); } /// The cross-port spellings (ADR-0058) — not .NET enum names, so the JSON value reads diff --git a/server/csharp/MetaObjects/Meta/MetaRoot.cs b/server/csharp/MetaObjects/Meta/MetaRoot.cs index 169620b9d..35fd33728 100644 --- a/server/csharp/MetaObjects/Meta/MetaRoot.cs +++ b/server/csharp/MetaObjects/Meta/MetaRoot.cs @@ -19,6 +19,14 @@ public class MetaRoot(TypeId typeId, string name) : MetaData(typeId, name) /// public PhysicalNaming PhysicalNaming { get; set; } = PhysicalNaming.Default; + /// + /// The project's wire-format setting (wire in .metaobjects/config.json, + /// ADR-0019 Amendment 2), set by the same project-aware load as + /// and read by codegen through . A tree loaded with + /// no project keeps the native default. + /// + public WireFormat WireFormat { get; set; } = WireFormat.Default; + /// Object entities defined at this root level. public IReadOnlyList Objects() { diff --git a/server/csharp/MetaObjects/Meta/WireFormat.cs b/server/csharp/MetaObjects/Meta/WireFormat.cs new file mode 100644 index 000000000..20a78f769 --- /dev/null +++ b/server/csharp/MetaObjects/Meta/WireFormat.cs @@ -0,0 +1,57 @@ +// Wire format — ADR-0019 Amendment 2: one project setting for the OUTPUT form of a generated +// REST surface (`wire` in .metaobjects/config.json), read by every port's codegen. Input rules +// (W2, W3) never change with it. Carried on the loaded root, like PhysicalNaming (ADR-0058). + +namespace MetaObjects.Meta; + +/// How a field.decimal is written (wire.decimals). +public enum DecimalWireForm +{ + /// The default: what System.Text.Json writes for a decimal, a JSON number + /// carrying the value's scale (12.50). + Native, + /// The canonical string of ADR-0019 Amendment 1, W1 ("12.5"), the form every + /// port writes alike. + String, +} + +/// How a value object's null member is written (wire.nullMembers). +public enum NullMemberForm +{ + /// The default: what System.Text.Json writes, null with the key present. + Native, + /// null with the key present (in C#, the same as ). + Emit, + /// Left out ([JsonIgnore(Condition = WhenWritingNull)] on each nullable member). + Omit, +} + +/// +/// The project's wire-format setting: wire in .metaobjects/config.json +/// (ADR-0019 Amendment 2). Both keys default to native, each port's own language-standard output; +/// { "decimals": "string", "nullMembers": "omit" } is the cross-port form. +/// +public sealed record WireFormat(DecimalWireForm Decimals, NullMemberForm NullMembers) +{ + /// Both keys native: what a tree loaded with no project carries. + public static readonly WireFormat Default = new(DecimalWireForm.Native, NullMemberForm.Native); + + /// The setting the tree belongs to carries, else . + public static WireFormat Of(MetaData node) => + node.Root() is MetaRoot root ? root.WireFormat : Default; + + /// The wire.decimals spelling of . + public static string Spelling(DecimalWireForm form) => form == DecimalWireForm.String ? "string" : "native"; + + /// The wire.nullMembers spelling of . + public static string Spelling(NullMemberForm form) => form switch + { + NullMemberForm.Emit => "emit", + NullMemberForm.Omit => "omit", + _ => "native", + }; + + /// This setting as the wire block that declares it. + public string Block() => + $"\"wire\": {{ \"decimals\": \"{Spelling(Decimals)}\", \"nullMembers\": \"{Spelling(NullMembers)}\" }}"; +} diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireEmitter.kt index 53fa4fc7b..56c2cdcef 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireEmitter.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireEmitter.kt @@ -1,5 +1,6 @@ package com.metaobjects.generator.kotlin +import com.metaobjects.config.WireFormat import com.metaobjects.generator.util.GeneratedFileWriter import java.nio.file.Path @@ -19,12 +20,36 @@ object KotlinDecimalWireEmitter { const val FILE_NAME = "MetaDecimalWire" /** Write `MetaDecimalWire.kt` into [pkg] under [outRoot]. */ - fun write(pkg: String, outRoot: Path) { + fun write(pkg: String, outRoot: Path, wire: WireFormat) { val header = if (pkg.isEmpty()) "" else "package $pkg\n\n" val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("$FILE_NAME.kt") - GeneratedFileWriter.write(outFile, header + BODY + "\n") + GeneratedFileWriter.write(outFile, header + body(wire) + "\n") } + /** + * The body for [wire] (ADR-0019 Amendment 2): the output [MetaDecimalWire.Serializer] exists only + * under `decimals: string`; under `native` Jackson writes a BigDecimal as the host configures it. + * The strict input half (parse, range checks, [MetaDecimalWire.Deserializer]) is in every mode. + */ + fun body(wire: WireFormat): String { + if (wire.decimalsAsString()) return BODY + val start = BODY.indexOf(SERIALIZER_START) + val end = BODY.indexOf(SERIALIZER_END, start) + check(start >= 0 && end > start) { "serializer block markers missing from the decimal wire body" } + var out = BODY.substring(0, start) + BODY.substring(end + SERIALIZER_END.length) + for (imp in SERIALIZER_IMPORTS) out = out.replace(imp, "") + return out.replace("written as a canonical string (plain notation, no trailing fraction zeros, zero as \"0\"),\n * and read", + "written as the host serializer writes it,\n * and read") + } + + private const val SERIALIZER_START = " /** Writes a decimal as its canonical string. */\n" + private const val SERIALIZER_END = "\n }\n\n" + private val SERIALIZER_IMPORTS = listOf( + "import com.fasterxml.jackson.core.JsonGenerator\n", + "import com.fasterxml.jackson.databind.SerializerProvider\n", + "import com.fasterxml.jackson.databind.ser.std.StdSerializer\n", + ) + /** * The fixed body. `+`-concatenated messages and no Kotlin string templates, so this raw string * needs no `$` escaping. Depends on Jackson only, which a generated entity already imports. diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt index dac7c31bc..692e16ce4 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt @@ -4,6 +4,7 @@ package com.metaobjects.generator.kotlin // com.metaobjects.generator.kotlin when this generator is copied out via // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.config.WireFormat import com.metaobjects.generator.kotlin.Fr019SharedEnum import com.metaobjects.generator.kotlin.KotlinEnumEmitter import com.metaobjects.generator.kotlin.KotlinGenUtil @@ -89,6 +90,8 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { parseArgs() val outRoot = Paths.get(outDir.absolutePath) decimalWirePackages.clear() + // ADR-0019 Amendment 2: the project's wire setting, read from the loaded root. + wire = WireFormat.of(loader.root) fr019Config = Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) // emitAbstractShapes (default OFF): when ON, an abstract entity is emitted as a Kotlin // `interface` shape (read-only properties) instead of being suppressed. It is NEVER @@ -126,12 +129,15 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { // has such a field (a same-package MetaNetJson the data-class properties bind through). emitNetJsonSupport(loader, outRoot) // ADR-0019 Amendment 1: the decimal wire rule, once per package that wrote a decimal property. - for (pkg in decimalWirePackages) KotlinDecimalWireEmitter.write(pkg, outRoot) + for (pkg in decimalWirePackages) KotlinDecimalWireEmitter.write(pkg, outRoot, wire) } /** Packages whose emitted data classes carry a decimal property: each gets one `MetaDecimalWire.kt`. */ private val decimalWirePackages = linkedSetOf() + /** ADR-0019 Amendment 2: the project's wire setting (native unless `.metaobjects/config.json` says otherwise). */ + private var wire: WireFormat = WireFormat.DEFAULT + /** * #234: emit the per-package `MetaNetJson.kt` support object (the strict field.uri / field.inet * Jackson deserializers a data-class property binds through via `@field:JsonDeserialize`), ONCE @@ -223,9 +229,9 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { .addModifiers(KModifier.DATA) .addKdoc("GENERATED — do not hand-edit. Regenerated from metadata.\n") .apply { - // A value object is stored as jsonb and the api-contract wire omits an absent member - // rather than writing it as null, so its null members are not serialized. - if (obj.subType == MetaObject.SUBTYPE_VALUE) { + // `wire.nullMembers: omit` (ADR-0019 Amendment 2): a value object's null members are left + // out. Under native/emit Jackson writes them as null with the key present. + if (obj.subType == MetaObject.SUBTYPE_VALUE && wire.omitNullMembers()) { addAnnotation( AnnotationSpec.builder(ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) .addMember("%T.Include.NON_NULL", ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) @@ -641,12 +647,14 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { val serializer = ClassName(pkg, KotlinDecimalWireEmitter.FILE_NAME, "Serializer") val deserializer = ClassName(pkg, KotlinDecimalWireEmitter.FILE_NAME, "Deserializer") val member = if (field.isArrayType) "contentUsing" else "using" - val annotations = mutableListOf( - AnnotationSpec.builder(JSON_SERIALIZE).useSiteTarget(AnnotationSpec.UseSiteTarget.GET) - .addMember("$member = %T::class", serializer).build(), - AnnotationSpec.builder(JSON_DESERIALIZE).useSiteTarget(AnnotationSpec.UseSiteTarget.PARAM) - .addMember("$member = %T::class", deserializer).build(), - ) + val annotations = mutableListOf() + // The output half only under `wire.decimals: string`; native lets Jackson write the BigDecimal. + if (wire.decimalsAsString()) { + annotations += AnnotationSpec.builder(JSON_SERIALIZE).useSiteTarget(AnnotationSpec.UseSiteTarget.GET) + .addMember("$member = %T::class", serializer).build() + } + annotations += AnnotationSpec.builder(JSON_DESERIALIZE).useSiteTarget(AnnotationSpec.UseSiteTarget.PARAM) + .addMember("$member = %T::class", deserializer).build() // C1: a decimal inside a (jsonb) value object has no controller range check of its own, so the // deserializer enforces the field's declared @precision/@scale: nothing rounded, excess is a 400. // Only a declared precision bounds it, as with a top-level decimal. diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index 9007f57da..b407ce10d 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -4,6 +4,7 @@ package com.metaobjects.generator.kotlin // com.metaobjects.generator.kotlin when this generator is copied out via // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.config.WireFormat import com.metaobjects.generator.kotlin.ExposedApi import com.metaobjects.generator.kotlin.ExposedImports import com.metaobjects.generator.kotlin.Fr019SharedEnum @@ -108,8 +109,12 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase = MetaObject::class.java + /** ADR-0019 Amendment 2: the project's wire setting; only the shared decimal-wire file's content depends on it. */ + private var wire: WireFormat = WireFormat.DEFAULT + override fun execute(loader: MetaDataLoader) { parseArgs() + wire = WireFormat.of(loader.root) // ADR-0058 — the pom generator arg is REMOVED; refuse by name rather than silently // ignoring a value that used to work. Column naming is read from the loaded root // (com.metaobjects.database.PhysicalNaming) wherever a field or table name is @@ -650,7 +655,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase KotlinEntityGenerator() @@ -79,6 +82,17 @@ class KotlinCodegenSnapshotTest { val snapshotDir = snapshotsRoot.resolve(name) val emittedFiles = Files.walk(outDir).filter { it.isRegularFile() }.sorted().toList() + if (System.getProperty("snapshots.update") == "true") { + // Deliberate refresh: -Dsnapshots.update=true rewrites this fixture's snapshot tree. + snapshotDir.toFile().deleteRecursively() + for (f in emittedFiles) { + val target = snapshotDir.resolve(outDir.relativize(f).toString()) + Files.createDirectories(target.parent) + Files.copy(f, target) + } + return + } + if (!snapshotDir.isDirectory() || Files.walk(snapshotDir).filter { it.isRegularFile() }.count() == 0L) { // First run: create snapshots Files.createDirectories(snapshotDir) @@ -122,6 +136,7 @@ class KotlinCodegenSnapshotTest { val generators: List, val validatorPackage: String?, val useNames: Boolean, + val wire: WireFormat, ) private fun parseConfig(json: String): FixtureConfig { @@ -130,6 +145,8 @@ class KotlinCodegenSnapshotTest { ?.split(",")?.map { it.trim().trim('"') } ?: emptyList() val pkg = Regex("\"validatorPackage\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1) val useNames = Regex("\"useNames\"\\s*:\\s*true").containsMatchIn(json) - return FixtureConfig(gens, pkg, useNames) + val decimals = if (Regex("\"wireDecimals\"\\s*:\\s*\"string\"").containsMatchIn(json)) WireFormat.Decimals.STRING else WireFormat.Decimals.NATIVE + val nulls = if (Regex("\"wireNullMembers\"\\s*:\\s*\"omit\"").containsMatchIn(json)) WireFormat.NullMembers.OMIT else WireFormat.NullMembers.NATIVE + return FixtureConfig(gens, pkg, useNames, WireFormat(decimals, nulls)) } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireTest.kt index fa900a07d..813db20c9 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireTest.kt @@ -1,5 +1,6 @@ package com.metaobjects.generator.kotlin +import com.metaobjects.config.WireFormat import com.metaobjects.generator.Generator import com.metaobjects.metadata.ktx.loadString import java.nio.file.Files @@ -49,10 +50,14 @@ class KotlinDecimalWireTest { ] } }""".trimIndent() - private fun emit(json: String, generators: List): Map { + /** The cross-port form the conformance corpora pin: canonical decimal strings, null members omitted. */ + private val pinned = WireFormat(WireFormat.Decimals.STRING, WireFormat.NullMembers.OMIT) + + private fun emit(json: String, generators: List, wire: WireFormat = pinned): Map { val outDir = Files.createTempDirectory("kt-decimal-wire-") try { val loader = loadString("decimal-wire", json) + loader.root.wireFormat = wire for (gen in generators) { gen.setArgs(mapOf("outputDir" to outDir.toString())) gen.execute(loader) @@ -461,4 +466,100 @@ class KotlinDecimalWireTest { println("decimal-wire vectors: format=${vs.get("format").size()} parse=${vs.get("parse").size()} " + "filterOperand=${vs.get("filterOperand").size()} double15=${vs.get("double15").size()}") } + + private val nativeModel = """{ + "metadata.root": { "package": "acme::shop", "children": [ + { "object.value": { "name": "Price", "children": [ + { "field.decimal": { "name": "net", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "note", "@maxLength": 20 } } + ] } }, + { "object.entity": { "name": "Item", "children": [ + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "price", "@precision": 12, "@scale": 2 } }, + { "field.decimal": { "name": "tiny", "@precision": 12, "@scale": 8 } }, + { "field.decimal": { "name": "readings", "isArray": true, "@precision": 9, "@scale": 4 } }, + { "field.object": { "name": "cost", "@objectRef": "Price", "@storage": "jsonb" } }, + { "source.rdb": { "@table": "items" } }, + { "identity.primary": { "@fields": ["id"], "@generation": "increment" } } + ] } } + ] } + }""".trimIndent() + + private fun compileAndLoadItem(wire: WireFormat): Pair { + val files = emit(nativeModel, listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()), wire) + val result = com.tschuchort.compiletesting.KotlinCompilation().apply { + sources = files.filterKeys { it.endsWith(".kt") }.map { (path, src) -> + com.tschuchort.compiletesting.SourceFile.kotlin(path.substringAfterLast('/'), src) + } + inheritClassPath = true + messageOutputStream = System.out + }.compile() + assertEquals(com.tschuchort.compiletesting.KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val mapper = com.fasterxml.jackson.databind.ObjectMapper() + .registerModule(com.fasterxml.jackson.module.kotlin.KotlinModule.Builder().build()) + return result.classLoader to mapper + } + + private fun writeItem(wire: WireFormat): String { + val (cl, mapper) = compileAndLoadItem(wire) + val item = mapper.readValue( + """{"id":1,"price":"12.50","tiny":"0.0000001","readings":["1.5000",2],"cost":{"net":"3.10"}}""", + cl.loadClass("acme.shop.Item")) + return mapper.writeValueAsString(item) + } + + @Test + fun `the native default writes a decimal as a JSON number, a decimal list as numbers and a null member as null`() { + val json = writeItem(WireFormat.DEFAULT) + // Jackson's own BigDecimal output as the plain ObjectMapper configures it: a number, scale kept. + assertTrue("\"price\":12.50" in json, json) + // Recorded: BigDecimal("0.0000001") under native is written by Jackson as 1E-7 (BigDecimal.toString()). + assertTrue("\"tiny\":1E-7" in json, json) + assertTrue("\"readings\":[1.5000,2]" in json, json) + // A value object's null member: key present, null. + assertTrue("\"cost\":{\"net\":3.10,\"note\":null}" in json, json) + } + + @Test + fun `emit writes a null member as null like native, and omit leaves it out`() { + val emit = writeItem(WireFormat(WireFormat.Decimals.NATIVE, WireFormat.NullMembers.EMIT)) + assertTrue("\"note\":null" in emit, emit) + val omit = writeItem(WireFormat(WireFormat.Decimals.NATIVE, WireFormat.NullMembers.OMIT)) + assertFalse("note" in omit, omit) + assertTrue("\"price\":12.50" in omit, omit) + } + + @Test + fun `string writes the canonical decimal strings, and omit with it drops the null member`() { + val json = writeItem(pinned) + assertTrue("\"price\":\"12.5\"" in json, json) + assertTrue("\"tiny\":\"0.0000001\"" in json, json) + assertTrue("\"readings\":[\"1.5\",\"2\"]" in json, json) + assertTrue("\"cost\":{\"net\":\"3.1\"}" in json, json) + } + + @Test + fun `native keeps the strict input rules - garbage and over-scale are refused`() { + val (cl, mapper) = compileAndLoadItem(WireFormat.DEFAULT) + val item = cl.loadClass("acme.shop.Item") + for (bad in listOf( + """{"id":1,"price":"12abc"}""", + """{"id":1,"price":"NaN"}""", + """{"id":1,"cost":{"net":"1.234"}}""", + )) { + val e = runCatching { mapper.readValue(bad, item) }.exceptionOrNull() + assertTrue(e is com.fasterxml.jackson.core.JsonProcessingException, "must be refused: $bad but got $e") + } + } + + @Test + fun `native emits no serializer and no JsonInclude, but keeps the deserializer`() { + val files = emit(nativeModel, all(), WireFormat.DEFAULT) + val src = files.getValue("acme/shop/Item.kt") + assertFalse("JsonSerialize" in src, src) + assertTrue("MetaDecimalWire.Deserializer" in src, src) + assertFalse("JsonInclude" in files.getValue("acme/shop/Price.kt")) + assertFalse("class Serializer" in files.getValue("acme/shop/MetaDecimalWire.kt")) + assertTrue("class Deserializer" in files.getValue("acme/shop/MetaDecimalWire.kt")) + } } diff --git a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/config.json b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/config.json new file mode 100644 index 000000000..ba0a6e16b --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/config.json @@ -0,0 +1 @@ +{ "generators": ["entity", "table", "filter-allowlist", "controller"], "wireDecimals": "string", "wireNullMembers": "omit" } diff --git a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/meta.json b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/meta.json new file mode 100644 index 000000000..6eff8c388 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-decimal-float-filter-wire-string/meta.json @@ -0,0 +1,12 @@ +{ + "metadata.root": { "package": "acme::catalog", "children": [ + { "object.entity": { "name": "Product", "children": [ + { "source.rdb": { "@table": "products" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@maxLength": 40, "@required": true } }, + { "field.decimal": { "name": "price", "@precision": 10, "@scale": 2, "@filterable": true } }, + { "field.float": { "name": "rating", "@filterable": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } } + ] } } + ] } +} diff --git a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/config.json b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/config.json new file mode 100644 index 000000000..9b11b6939 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/config.json @@ -0,0 +1 @@ +{"generators": ["entity", "table"], "wireDecimals": "string", "wireNullMembers": "omit"} \ No newline at end of file diff --git a/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/meta.json b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/meta.json new file mode 100644 index 000000000..6efdf2bbf --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/fixtures/entity-with-storage-wire-omit/meta.json @@ -0,0 +1,28 @@ +{ + "metadata.root": { + "package": "acme::commerce", + "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "street", "@maxLength": 200 } }, + { "field.string": { "name": "city", "@maxLength": 100 } }, + { "field.string": { "name": "zip", "@maxLength": 20 } } + ] } }, + { "object.value": { "name": "UserMetadata", "children": [ + { "field.string": { "name": "locale" } }, + { "field.string": { "name": "timezone" } } + ] } }, + { "object.entity": { "name": "User", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "email", "@maxLength": 255 } }, + { "field.object": { "name": "address", + "@objectRef": "Address", + "@storage": "flattened" } }, + { "field.object": { "name": "preferences", + "@objectRef": "UserMetadata", + "@storage": "jsonb" } }, + { "source.rdb": { "@table": "users" } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ] } } + ] + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-controller/acme/blog/MetaDecimalWire.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-controller/acme/blog/MetaDecimalWire.kt index 793670d22..9e860ee65 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-controller/acme/blog/MetaDecimalWire.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-controller/acme/blog/MetaDecimalWire.kt @@ -1,6 +1,5 @@ package acme.blog -import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.DeserializationContext @@ -8,17 +7,15 @@ import com.fasterxml.jackson.databind.BeanProperty import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.ContextualDeserializer import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.ser.std.StdSerializer import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode /** * GENERATED (ADR-0019 Amendment 1) — do not hand-edit. The decimal wire rule: a decimal is - * written as a canonical string (plain notation, no trailing fraction zeros, zero as "0"), + * written as the host serializer writes it, * and read from a string or number in the JSON-number grammar, exactly, never through a * binary float. Dependency-free (Jackson only). */ @@ -101,13 +98,6 @@ internal object MetaDecimalWire { return out } - /** Writes a decimal as its canonical string. */ - class Serializer : StdSerializer(BigDecimal::class.java) { - override fun serialize(value: BigDecimal, gen: JsonGenerator, provider: SerializerProvider) { - gen.writeString(format(value)) - } - } - /** * The `@precision` and `@scale` a decimal inside a value object declares. [Deserializer] reads it * from the property it is bound to and refuses, with a 400, a value the field cannot hold diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaDecimalWire.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaDecimalWire.kt new file mode 100644 index 000000000..4b1d1eacd --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaDecimalWire.kt @@ -0,0 +1,148 @@ +package acme.catalog + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.BeanProperty +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.ContextualDeserializer +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode + +/** + * GENERATED (ADR-0019 Amendment 1) — do not hand-edit. The decimal wire rule: a decimal is + * written as a canonical string (plain notation, no trailing fraction zeros, zero as "0"), + * and read from a string or number in the JSON-number grammar, exactly, never through a + * binary float. Dependency-free (Jackson only). + */ +internal object MetaDecimalWire { + private val JSON_NUMBER = Regex("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?") + private const val MAX_INTEGER_DIGITS = 131072 + private const val MAX_FRACTION_DIGITS = 16383 + private const val SQLITE_SIGNIFICANT_DIGITS = 15 + + /** The canonical string of a decimal: plain notation, no trailing fraction zeros, zero as "0". */ + fun format(value: BigDecimal?): String? { + if (value == null) return null + if (value.signum() == 0) return "0" + return value.stripTrailingZeros().toPlainString() + } + + /** Strict read of a decimal string (the JSON-number grammar; an exponent is allowed). */ + fun parse(text: String?): BigDecimal { + require(text != null && JSON_NUMBER.matches(text)) { "not a decimal: " + text } + val value = try { + BigDecimal(text) + } catch (e: NumberFormatException) { + throw IllegalArgumentException("decimal out of range: " + text) + } + return checkRange(value, text) + } + + /** A filter operand: parsed exactly, the same grammar as a body value. */ + fun parseFilterOperand(text: String): BigDecimal = parse(text) + + /** A JSON number token already parsed as a decimal (never a double). */ + fun fromJsonNumber(value: BigDecimal): BigDecimal = checkRange(value, value.toString()) + + /** A decimal from a JSON node: a string or a number, anything else is rejected. */ + fun fromNode(node: JsonNode): BigDecimal { + if (node.isTextual) return parse(node.asText()) + if (node.isNumber) return fromJsonNumber(node.decimalValue()) + throw IllegalArgumentException("not a decimal: " + node) + } + + /** SQLite keeps a REAL: a stored double reads at 15 significant digits, so 0.1 + 0.2 reads 0.3. */ + fun fromDouble15(value: Double): BigDecimal { + require(!value.isNaN() && !value.isInfinite()) { "not a finite decimal: " + value } + if (value == 0.0) return BigDecimal.ZERO + val rounded = BigDecimal(value).round(MathContext(SQLITE_SIGNIFICANT_DIGITS, RoundingMode.HALF_EVEN)) + return if (rounded.signum() == 0) BigDecimal.ZERO else rounded.stripTrailingZeros() + } + + /** True when [value] fits decimal(precision, scale): at most scale fraction digits and precision - scale integer digits. */ + fun fits(value: BigDecimal?, precision: Int, scale: Int): Boolean { + if (value == null || value.signum() == 0) return true + val stripped = value.stripTrailingZeros() + val integerDigits = maxOf(0L, stripped.precision().toLong() - stripped.scale()) + val fractionDigits = maxOf(0, stripped.scale()) + return fractionDigits <= scale && integerDigits <= precision.toLong() - scale + } + + private fun checkRange(value: BigDecimal, shown: String): BigDecimal { + if (value.signum() == 0) return BigDecimal.ZERO + val stripped = value.stripTrailingZeros() + val integerDigits = stripped.precision().toLong() - stripped.scale() + require(integerDigits <= MAX_INTEGER_DIGITS && stripped.scale() <= MAX_FRACTION_DIGITS) { + "decimal out of range: " + shown + } + return value + } + + /** The validation envelope for a request body Jackson could not read: names the field when it knows it. */ + fun unreadableBody(failure: Throwable): Map { + val out = LinkedHashMap() + out["error"] = "validation" + var t: Throwable? = failure + while (t != null) { + if (t is JsonMappingException && t.path.isNotEmpty()) { + val name = t.path[t.path.size - 1].fieldName + if (name != null) { out["field"] = name; break } + } + t = if (t.cause === t) null else t.cause + } + return out + } + + /** Writes a decimal as its canonical string. */ + class Serializer : StdSerializer(BigDecimal::class.java) { + override fun serialize(value: BigDecimal, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeString(format(value)) + } + } + + /** + * The `@precision` and `@scale` a decimal inside a value object declares. [Deserializer] reads it + * from the property it is bound to and refuses, with a 400, a value the field cannot hold + * (more fraction digits than [scale], or more integer digits than precision - scale). Trailing + * zeros do not count and nothing is rounded. + */ + @Retention(AnnotationRetention.RUNTIME) + @Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.PROPERTY, AnnotationTarget.FIELD) + annotation class Declared(val precision: Int, val scale: Int) + + /** + * Reads a decimal from a string or a number, exactly; anything else is a 400-shaped mapping + * error. A property carrying [Declared] is also range-checked against it. + */ + class Deserializer(private val precision: Int? = null, private val scale: Int = 0) : + StdDeserializer(BigDecimal::class.java), ContextualDeserializer { + override fun createContextual(ctxt: DeserializationContext, property: BeanProperty?): JsonDeserializer<*> { + val declared = property?.getAnnotation(Declared::class.java) ?: return this + return Deserializer(declared.precision, declared.scale) + } + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): BigDecimal? { + val token = p.currentToken() + try { + val value = when (token) { + JsonToken.VALUE_STRING -> parse(p.text) + JsonToken.VALUE_NUMBER_INT, JsonToken.VALUE_NUMBER_FLOAT -> fromJsonNumber(p.decimalValue) + else -> return ctxt.handleUnexpectedToken(BigDecimal::class.java, p) as BigDecimal? + } + require(precision == null || fits(value, precision, scale)) { "decimal out of range: " + value } + return value + } catch (e: IllegalArgumentException) { + return ctxt.handleWeirdStringValue(BigDecimal::class.java, p.text, "%s", e.message) as BigDecimal? + } + } + } +} + diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaPortableColumnTypes.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaPortableColumnTypes.kt new file mode 100644 index 000000000..8eb013dce --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/MetaPortableColumnTypes.kt @@ -0,0 +1,106 @@ +package acme.catalog + +import java.math.BigDecimal +import java.util.UUID +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ColumnType +import org.jetbrains.exposed.sql.DecimalColumnType +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi +import org.jetbrains.exposed.sql.UUIDColumnType +import org.jetbrains.exposed.sql.vendors.currentDialect + +private fun isPostgres(): Boolean = currentDialect.name.equals("postgresql", ignoreCase = true) + +private fun isSqlite(): Boolean = currentDialect.name.equals("sqlite", ignoreCase = true) + +/** GENERATED — do not hand-edit. A `java.util.UUID` column: native `uuid` on Postgres, text elsewhere. */ +internal class MetaPortableUuidColumnType : ColumnType() { + private val delegate = UUIDColumnType() + override fun sqlType(): String = delegate.sqlType() + override fun valueFromDB(value: Any): UUID = requireNotNull(delegate.valueFromDB(value)) { "null uuid column value" } + override fun notNullValueToDB(value: UUID): Any = + if (isPostgres()) delegate.notNullValueToDB(value) else value.toString() + override fun nonNullValueToString(value: UUID): String = + if (isPostgres()) delegate.nonNullValueToString(value) else "'$value'" +} + +/** Column builder for `field.uuid`: a `Column`. */ +internal fun Table.portableUuid(name: String): Column = + registerColumn(name, MetaPortableUuidColumnType()) + +/** GENERATED — do not hand-edit. A decimal column: the exact decimal everywhere but SQLite, which gets a REAL. */ +internal class MetaPortableDecimalColumnType(precision: Int, scale: Int) : ColumnType() { + private val delegate = DecimalColumnType(precision, scale) + override fun sqlType(): String = delegate.sqlType() + // ADR-0019 Amendment 1 (W4): SQLite stores a REAL, so a value is read at 15 significant + // digits, which turns `sum(0.1, 0.2)` (0.30000000000000004) back into 0.3. + override fun valueFromDB(value: Any): BigDecimal = + if (isSqlite() && value is Number && value !is BigDecimal) { + MetaDecimalWire.fromDouble15(value.toDouble()) + } else { + delegate.valueFromDB(value) + } + override fun notNullValueToDB(value: BigDecimal): Any = + if (isSqlite()) value.toDouble() else delegate.notNullValueToDB(value) + override fun nonNullValueToString(value: BigDecimal): String = delegate.nonNullValueToString(value) +} + +/** Column builder for `field.decimal`: a `Column`. */ +internal fun Table.portableDecimal(name: String, precision: Int, scale: Int): Column = + registerColumn(name, MetaPortableDecimalColumnType(precision, scale)) + +/** + * GENERATED — do not hand-edit. A decimal column with no declared `@precision`: plain unbounded + * `NUMERIC`, read exactly. Exposed's own `DecimalColumnType` would round every value to its scale + * (HALF_EVEN) on read, so this type does not delegate the read to it. + */ +internal class MetaUnboundedDecimalColumnType : ColumnType() { + override fun sqlType(): String = "NUMERIC" + override fun valueFromDB(value: Any): BigDecimal = when { + value is BigDecimal -> value + value is Number && isSqlite() -> MetaDecimalWire.fromDouble15(value.toDouble()) + else -> BigDecimal(value.toString()) + } + override fun notNullValueToDB(value: BigDecimal): Any = + if (isSqlite()) value.toDouble() else value + override fun nonNullValueToString(value: BigDecimal): String = value.toPlainString() +} + +/** Column builder for a `field.decimal` with no `@precision`: a `Column`, unbounded. */ +internal fun Table.portableDecimal(name: String): Column = + registerColumn(name, MetaUnboundedDecimalColumnType()) + +/** + * GENERATED — do not hand-edit. An `isArray` decimal column: a native `NUMERIC[]` on Postgres, and on + * MySQL and SQLite (no array type) a JSON text array of canonical strings. Elements are read exactly. + */ +internal class MetaDecimalArrayColumnType : ColumnType>() { + private fun isMySql(): Boolean = currentDialect.name.let { it.equals("mysql", ignoreCase = true) || it.equals("mariadb", ignoreCase = true) } + private fun decimalOf(element: Any): BigDecimal = if (element is BigDecimal) element else BigDecimal(element.toString()) + private fun json(value: List): String = value.joinToString(",", "[", "]") { "\"" + MetaDecimalWire.format(it) + "\"" } + override fun sqlType(): String = if (isPostgres()) "NUMERIC[]" else if (isMySql()) "JSON" else "TEXT" + override fun valueFromDB(value: Any): List = when (value) { + is java.sql.Array -> (value.array as Array<*>).map { decimalOf(it!!) } + is Array<*> -> value.map { decimalOf(it!!) } + is List<*> -> value.map { decimalOf(it!!) } + else -> com.fasterxml.jackson.databind.ObjectMapper().readTree(value.toString()).map { MetaDecimalWire.fromNode(it) } + } + override fun notNullValueToDB(value: List): Any = + if (isPostgres()) value.toTypedArray() else json(value) + override fun nonNullValueToString(value: List): String = + if (isPostgres()) value.joinToString(",", "ARRAY[", "]::numeric[]") { it.toPlainString() } else "'" + json(value) + "'" + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + if (value is Array<*>) { + @Suppress("UNCHECKED_CAST") + stmt.setArray(index, "numeric", value as Array) + } else { + super.setParameter(stmt, index, value) + } + } +} + +/** Column builder for an `isArray` `field.decimal`: a `Column>`. */ +internal fun Table.portableDecimalArray(name: String): Column> = + registerColumn(name, MetaDecimalArrayColumnType()) + diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/Product.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/Product.kt new file mode 100644 index 000000000..39b36699b --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/Product.kt @@ -0,0 +1,70 @@ +package acme.catalog + +import com.fasterxml.jackson.databind.`annotation`.JsonDeserialize +import com.fasterxml.jackson.databind.`annotation`.JsonSerialize +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import java.math.BigDecimal +import kotlin.Float +import kotlin.Long +import kotlin.String +import kotlin.jvm.JvmStatic + +/** + * GENERATED — do not hand-edit. Regenerated from metadata. + */ +public data class Product( + public val id: Long? = null, + @field:NotNull + @field:Size(min = 1, max = 40) + public val name: String, + @get:JsonSerialize(using = MetaDecimalWire.Serializer::class) + @param:JsonDeserialize(using = MetaDecimalWire.Deserializer::class) + public val price: BigDecimal? = null, + public val rating: Float? = null, +) { + /** + * Fluent builder — lets a JAVA caller set a subset of properties. + */ + public class Builder { + private var id: Long? = null + + private var name: String? = null + + private var price: BigDecimal? = null + + private var rating: Float? = null + + public fun id(v: Long?): Builder { + this.id = v + return this + } + + public fun name(v: String?): Builder { + this.name = v + return this + } + + public fun price(v: BigDecimal?): Builder { + this.price = v + return this + } + + public fun rating(v: Float?): Builder { + this.rating = v + return this + } + + public fun build(): Product = Product( + id = id, + name = requireNotNull(name) { "name is required" }, + price = price, + rating = rating + ) + } + + public companion object { + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductController.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductController.kt new file mode 100644 index 000000000..f7e34f726 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductController.kt @@ -0,0 +1,460 @@ +package acme.catalog + +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SqlExpressionBuilder +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.sql.transactions.transaction +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.validation.Validator +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import jakarta.servlet.http.HttpServletRequest +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.sql.Timestamp +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** GENERATED — sort allowlist for Product (cross-port API contract). */ +private val ProductSortAllowlist = setOf( + "id", + "name", + "price", + "rating", +) + +/** GENERATED — declared @sortableDefaultOrder per field for Product. */ +private val ProductSortDefaultOrder: Map = mapOf( +) + +private fun parseProductSort(raw: String): Pair? { + val parts = raw.split(":", limit = 2) + val field = parts.getOrNull(0) ?: return null + if (field !in ProductSortAllowlist) return null + val dirRaw = parts.getOrNull(1)?.lowercase() + ?: ProductSortDefaultOrder[field] ?: "asc" + val dir = when (dirRaw) { + "asc" -> SortOrder.ASC + "desc" -> SortOrder.DESC + else -> return null + } + return field to dir +} + +/** GENERATED — cross-port cap on `in`-list size (matches TS DEFAULT_MAX_IN_LIST). */ +private const val MAX_IN_LIST = 100 + +/** GENERATED — single parsed + validated FR-009 filter predicate. */ +private data class ProductFilterPredicate(val field: String, val op: String, val value: Any?) + +/** GENERATED — parse outcome: predicates, or a cross-port error envelope key + the field it rejected. */ +private data class ProductFilterResult(val predicates: List, val error: String?, val field: String? = null) + +/** + * GENERATED — decode one query component, keeping a malformed escape as written: + * {@code %XX} is a byte (runs decode as UTF-8), {@code +} is a space, and a {@code %} + * not followed by two hex digits stays a literal {@code %} — what the TypeScript, C# + * and Python servers do, where {@code URLDecoder} would throw. + */ +private fun decodeQueryComponent(raw: String): String { + if ('%' !in raw && '+' !in raw) return raw + val out = StringBuilder(raw.length) + val bytes = ByteArrayOutputStream() + var i = 0 + while (i < raw.length) { + val c = raw[i] + if (c == '%' && i + 2 < raw.length && raw[i + 1].isHexDigitAscii() && raw[i + 2].isHexDigitAscii()) { + bytes.write(raw.substring(i + 1, i + 3).toInt(16)) + i += 3 + continue + } + if (bytes.size() > 0) { out.append(bytes.toString(StandardCharsets.UTF_8)); bytes.reset() } + out.append(if (c == '+') ' ' else c) + i++ + } + if (bytes.size() > 0) out.append(bytes.toString(StandardCharsets.UTF_8)) + return out.toString() +} + +private fun Char.isHexDigitAscii(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' + +/** + * GENERATED — parse the bracketed-qs FR-009 filter grammar from the raw query + * string. Returns either a list of validated predicates or one of + * the cross-port error envelope keys ({@code invalid_filter_field / + * invalid_filter_op / invalid_filter_value / filter.in_too_large}) plus the + * field each one is about. + */ +private fun parseProductFilter(rawQuery: String?): ProductFilterResult { + val out = mutableListOf() + for (pair in rawQuery.orEmpty().split('&')) { + if (pair.isEmpty()) continue + val eq = pair.indexOf('=') + val rawKey = decodeQueryComponent(if (eq < 0) pair else pair.substring(0, eq)) + val value = decodeQueryComponent(if (eq < 0) "" else pair.substring(eq + 1)) + if (!rawKey.startsWith("filter[")) continue + val firstClose = rawKey.indexOf(']', 7) + if (firstClose < 0) continue + val field = rawKey.substring(7, firstClose) + val rest = firstClose + 1 + val op: String = when { + rest >= rawKey.length -> "eq" + rawKey[rest] == '[' -> { + val secondClose = rawKey.indexOf(']', rest + 1) + if (secondClose < 0) continue + rawKey.substring(rest + 1, secondClose) + } + else -> continue + } + if (field !in ProductFilterAllowlist.FIELDS) return ProductFilterResult(emptyList(), "invalid_filter_field", field) + val ops = ProductFilterAllowlist.OPS_BY_FIELD[field] + if (ops == null || op !in ops) return ProductFilterResult(emptyList(), "invalid_filter_op", field) + val coerced = coerceProductValue(field, op, value) + ?: return ProductFilterResult(emptyList(), "invalid_filter_value", field) + val coercedValue = coerced.value + if (op == "in" && coercedValue is List<*> && coercedValue.size > MAX_IN_LIST) { + return ProductFilterResult(emptyList(), "filter.in_too_large", field) + } + out.add(ProductFilterPredicate(field, op, coercedValue)) + } + return ProductFilterResult(out, null) +} + +/** Box result: null = invalid; Box(value) = coerced. Distinguishes failure from a legitimate null. */ +private data class ProductCoercedValue(val value: Any?) + +private fun coerceProductValue(field: String, op: String, raw: String): ProductCoercedValue? { + if (op == "isNull") return when (raw) { + "true" -> ProductCoercedValue(true) + "false" -> ProductCoercedValue(false) + else -> null + } + return when (field) { + "id" -> coerceProductLong(op, raw) + "name" -> if (op == "in") ProductCoercedValue(raw.split(",").map { it.trim() }) else ProductCoercedValue(raw) + "price" -> coerceProductDecimal(op, raw) + "rating" -> coerceProductFloat(op, raw) + else -> null + } +} + +private fun coerceProductLong(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { java.lang.Long.parseLong(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private fun coerceProductInt(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { java.lang.Integer.parseInt(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private fun coerceProductDouble(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { java.lang.Double.parseDouble(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private fun coerceProductFloat(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { java.lang.Float.parseFloat(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private fun coerceProductDecimal(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { MetaDecimalWire.parseFilterOperand(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private fun coerceProductDate(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { LocalDate.parse(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private fun coerceProductTime(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Any? = { s -> runCatching { LocalTime.parse(s) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private val ProductTimestampFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + +private fun coerceProductTimestamp(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> LocalDateTime? = { s -> runCatching { LocalDateTime.parse(s, ProductTimestampFmt) }.getOrNull() } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +private fun coerceProductBoolean(op: String, raw: String): ProductCoercedValue? { + val parse: (String) -> Boolean? = { s -> when (s) { "true" -> true; "false" -> false; else -> null } } + if (op == "in") { + val parts = raw.split(",").map { it.trim() } + val list = parts.map { parse(it) ?: return null } + return ProductCoercedValue(list) + } + return ProductCoercedValue(parse(raw) ?: return null) +} + +/** + * GENERATED — fold a list of validated predicates into an Exposed + * {@code Op}, AND-combining each predicate's column-op-value triple + * against ProductTable. Returns null when [predicates] is empty so the + * caller can elide the WHERE clause entirely. + */ +@Suppress("UNCHECKED_CAST") +private fun ProductWhereOp(predicates: List): Op? { + if (predicates.isEmpty()) return null + return with(SqlExpressionBuilder) { + var combined: Op? = null + for (p in predicates) { + val op: Op = when (p.field) { + "id" -> when (p.op) { + "eq" -> ProductTable.id eq (p.value as Long) + "ne" -> ProductTable.id neq (p.value as Long) + "gt" -> ProductTable.id greater (p.value as Long) + "gte" -> ProductTable.id greaterEq (p.value as Long) + "lt" -> ProductTable.id less (p.value as Long) + "lte" -> ProductTable.id lessEq (p.value as Long) + "in" -> ProductTable.id inList (p.value as List) + "isNull" -> if (p.value as Boolean) ProductTable.id.isNull() else ProductTable.id.isNotNull() + else -> throw IllegalStateException("unsupported op for id: " + p.op) + } + "name" -> when (p.op) { + "eq" -> ProductTable.name eq (p.value as String) + "ne" -> ProductTable.name neq (p.value as String) + "in" -> ProductTable.name inList (p.value as List) + "like" -> ProductTable.name like (p.value as String) + "isNull" -> if (p.value as Boolean) ProductTable.name.isNull() else ProductTable.name.isNotNull() + else -> throw IllegalStateException("unsupported op for name: " + p.op) + } + "price" -> when (p.op) { + "eq" -> ProductTable.price eq (p.value as BigDecimal) + "ne" -> ProductTable.price neq (p.value as BigDecimal) + "gt" -> ProductTable.price greater (p.value as BigDecimal) + "gte" -> ProductTable.price greaterEq (p.value as BigDecimal) + "lt" -> ProductTable.price less (p.value as BigDecimal) + "lte" -> ProductTable.price lessEq (p.value as BigDecimal) + "in" -> ProductTable.price inList (p.value as List) + "isNull" -> if (p.value as Boolean) ProductTable.price.isNull() else ProductTable.price.isNotNull() + else -> throw IllegalStateException("unsupported op for price: " + p.op) + } + "rating" -> when (p.op) { + "eq" -> ProductTable.rating eq (p.value as Float) + "ne" -> ProductTable.rating neq (p.value as Float) + "gt" -> ProductTable.rating greater (p.value as Float) + "gte" -> ProductTable.rating greaterEq (p.value as Float) + "lt" -> ProductTable.rating less (p.value as Float) + "lte" -> ProductTable.rating lessEq (p.value as Float) + "in" -> ProductTable.rating inList (p.value as List) + "isNull" -> if (p.value as Boolean) ProductTable.rating.isNull() else ProductTable.rating.isNotNull() + else -> throw IllegalStateException("unsupported op for rating: " + p.op) + } + else -> continue + } + combined = combined?.and(op) ?: op + } + combined + } +} + +/** GENERATED — map an Exposed ResultRow to the Product data class. */ +private fun rowToProduct(row: ResultRow): Product = Product( + id = row[ProductTable.id], + name = row[ProductTable.name], + price = row[ProductTable.price], + rating = row[ProductTable.rating], +) + +/** + * GENERATED — REST controller for Product entity. Implements the cross-port API contract. + * + * Auth: these endpoints are unauthenticated. In your Spring Security config, require + * authentication for `/api/products` and every path under it (`requestMatchers` + `authenticated()`). + * A row-ownership rule ("only the owner may read this row") can't be expressed in path + * config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints. + */ +@RestController +@RequestMapping("/api/products") +class ProductController(private val objectMapper: ObjectMapper, private val validator: Validator) { + + @GetMapping + fun list( + @RequestParam(required = false) limit: Int?, + @RequestParam(required = false) offset: Int?, + @RequestParam(required = false) sort: String?, + @RequestParam(required = false, name = "withCount") withCount: Int?, + request: HttpServletRequest, + ): ResponseEntity = transaction { + // FR-009 filter operators — short-circuit 400 on invalid field/op/value. + val filterResult = parseProductFilter(request.queryString) + if (filterResult.error != null) { + return@transaction ResponseEntity.badRequest().body(mapOf("error" to filterResult.error, "field" to filterResult.field) as Any) + } + val whereOp = ProductWhereOp(filterResult.predicates) + var q = if (whereOp != null) ProductTable.selectAll().where { whereOp } else ProductTable.selectAll() + if (sort != null) { + val parsed = parseProductSort(sort) + ?: return@transaction ResponseEntity.badRequest().body(mapOf("error" to "invalid_sort", "field" to sort.substringBefore(':')) as Any) + val (field, dir) = parsed + q = q.orderBy(when (field) { + "id" -> ProductTable.id + "name" -> ProductTable.name + "price" -> ProductTable.price + "rating" -> ProductTable.rating + else -> error("Product: sort field has no column (generator drift): " + field) + } to dir) + } + val total: Long = if (withCount == 1) q.count() else -1L + val effectiveLimit = limit ?: 50 + val effectiveOffset = (offset ?: 0).toLong() + val rows = q.limit(effectiveLimit, effectiveOffset).map { rowToProduct(it) } + if (withCount == 1) ResponseEntity.ok(mapOf("rows" to rows, "total" to total) as Any) + else ResponseEntity.ok(rows as Any) + } + + @GetMapping("/{id}") + fun get(@PathVariable id: Long): ResponseEntity = transaction { + val row = ProductTable.selectAll().where { ProductTable.id eq id }.singleOrNull() + ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to "not_found") as Any) + ResponseEntity.ok(rowToProduct(row) as Any) + } + + @PostMapping + fun create(@RequestBody dto: Product): ResponseEntity = transaction { + if (validator.validate(dto).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") as Any) + if (!MetaDecimalWire.fits(dto.price, 10, 2)) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation", "field" to "price") as Any) + val newId = ProductTable.insert { + it[name] = dto.name + it[price] = dto.price + it[rating] = dto.rating + }[ProductTable.id] + val saved = ProductTable.selectAll().where { ProductTable.id eq newId }.single() + ResponseEntity.status(HttpStatus.CREATED).body(rowToProduct(saved) as Any) + } + + @RequestMapping(value = ["/{id}"], method = [RequestMethod.PATCH, RequestMethod.PUT]) + fun update(@PathVariable id: Long, @RequestBody body: JsonNode): ResponseEntity = transaction { + if (!body.isObject) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") as Any) + if (body.has("name") && body.get("name").isNull) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") as Any) + if (listOf("name", "price", "rating").any { body.has(it) }) { + try { + val hasName = body.has("name") + val vName: kotlin.String? = if (hasName) objectMapper.treeToValue(body.get("name"), object : TypeReference() {}) else null + if (hasName && validator.validateValue(Product::class.java, "name", vName).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") as Any) + val hasPrice = body.has("price") + val nullPrice = hasPrice && body.get("price").isNull + val vPrice: java.math.BigDecimal? = if (hasPrice && !nullPrice) runCatching { MetaDecimalWire.fromNode(body.get("price")) }.getOrElse { return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation", "field" to "price") as Any) } else null + if (hasPrice && !nullPrice && validator.validateValue(Product::class.java, "price", vPrice).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") as Any) + if (!MetaDecimalWire.fits(vPrice, 10, 2)) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation", "field" to "price") as Any) + val hasRating = body.has("rating") + val nullRating = hasRating && body.get("rating").isNull + val vRating: kotlin.Float? = if (hasRating && !nullRating) objectMapper.treeToValue(body.get("rating"), object : TypeReference() {}) else null + if (hasRating && !nullRating && validator.validateValue(Product::class.java, "rating", vRating).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") as Any) + ProductTable.update({ ProductTable.id eq id }) { + if (hasName) it[ProductTable.name] = vName!! + if (hasPrice) { if (nullPrice) it[ProductTable.price] = null else it[ProductTable.price] = vPrice } + if (hasRating) { if (nullRating) it[ProductTable.rating] = null else it[ProductTable.rating] = vRating } + } + } catch (e: com.fasterxml.jackson.databind.JsonMappingException) { + return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") as Any) + } + } + val row = ProductTable.selectAll().where { ProductTable.id eq id }.singleOrNull() + if (row == null) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to "not_found") as Any) + else ResponseEntity.ok(rowToProduct(row) as Any) + } + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): ResponseEntity = transaction { + val deleted = ProductTable.deleteWhere { with(SqlExpressionBuilder) { ProductTable.id eq id } } + if (deleted == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to "not_found") as Any) + else ResponseEntity.noContent().build() + } + + /** A request body that cannot be read is a client error naming the field, not a 500. */ + @ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException::class) + fun handleUnreadableBody(e: org.springframework.http.converter.HttpMessageNotReadableException): ResponseEntity = + ResponseEntity.badRequest().body(MetaDecimalWire.unreadableBody(e) as Any) + + /** + * A database constraint violation is a CLIENT error, not a 500: the + * identity.reference / identity.secondary that declare these constraints are the + * same metadata this controller already validates against. Anything else is + * rethrown, so the operator keeps the diagnostic and the caller gets none of it. + */ + @ExceptionHandler(RuntimeException::class, java.sql.SQLException::class) + fun handleConstraintViolation(e: Exception): ResponseEntity { + val text = generateSequence(e) { if (it.cause === it) null else it.cause } + .take(8) + .flatMap { t -> sequenceOf((t as? java.sql.SQLException)?.sqlState, t.message) } + .filterNotNull() + .joinToString(" ") + .uppercase() + val kind = when { + "23503" in text || "FOREIGN KEY" in text || "SQLITE_CONSTRAINT_FOREIGNKEY" in text -> "foreign_key" + "23505" in text || "UNIQUE CONSTRAINT" in text || "SQLITE_CONSTRAINT_UNIQUE" in text -> "unique" + "23514" in text || "CHECK CONSTRAINT" in text || "SQLITE_CONSTRAINT_CHECK" in text -> "check" + "23502" in text || "NOT NULL" in text || "SQLITE_CONSTRAINT_NOTNULL" in text -> "not_null" + else -> throw e + } + val status = if (kind == "foreign_key" || kind == "unique") HttpStatus.CONFLICT else HttpStatus.BAD_REQUEST + return ResponseEntity.status(status).body(mapOf("error" to "constraint_violation", "constraint" to kind) as Any) + } + +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductFilterAllowlist.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductFilterAllowlist.kt new file mode 100644 index 000000000..b5bb53a0e --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductFilterAllowlist.kt @@ -0,0 +1,18 @@ +package acme.catalog + +/** + * GENERATED — per-entity FR-009 filter allowlist for Product. + * FIELDS lists the filterable field names; OPS_BY_FIELD constrains the + * operator vocabulary for each field by its subtype. + */ +object ProductFilterAllowlist { + val FIELDS: Set = setOf( + "price", + "rating", + ) + + val OPS_BY_FIELD: Map> = mapOf( + "price" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"), + "rating" to setOf("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull") + ) +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductTable.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductTable.kt new file mode 100644 index 000000000..df3a86200 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter-wire-string/acme/catalog/ProductTable.kt @@ -0,0 +1,13 @@ +package acme.catalog + +import org.jetbrains.exposed.sql.Table + +/** GENERATED — do not hand-edit. Regenerated from metadata. */ +object ProductTable : Table("products") { + val id = long("id").autoIncrement() + val name = varchar("name", 40) + val price = portableDecimal("price", 10, 2).nullable() + val rating = float("rating").nullable() + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaDecimalWire.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaDecimalWire.kt index 4b1d1eacd..4e2a5f02f 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaDecimalWire.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaDecimalWire.kt @@ -1,6 +1,5 @@ package acme.catalog -import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.DeserializationContext @@ -8,17 +7,15 @@ import com.fasterxml.jackson.databind.BeanProperty import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.ContextualDeserializer import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.ser.std.StdSerializer import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode /** * GENERATED (ADR-0019 Amendment 1) — do not hand-edit. The decimal wire rule: a decimal is - * written as a canonical string (plain notation, no trailing fraction zeros, zero as "0"), + * written as the host serializer writes it, * and read from a string or number in the JSON-number grammar, exactly, never through a * binary float. Dependency-free (Jackson only). */ @@ -101,13 +98,6 @@ internal object MetaDecimalWire { return out } - /** Writes a decimal as its canonical string. */ - class Serializer : StdSerializer(BigDecimal::class.java) { - override fun serialize(value: BigDecimal, gen: JsonGenerator, provider: SerializerProvider) { - gen.writeString(format(value)) - } - } - /** * The `@precision` and `@scale` a decimal inside a value object declares. [Deserializer] reads it * from the property it is bound to and refuses, with a 400, a value the field cannot hold diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/Product.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/Product.kt index 39b36699b..377fd2b00 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/Product.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/Product.kt @@ -1,7 +1,6 @@ package acme.catalog import com.fasterxml.jackson.databind.`annotation`.JsonDeserialize -import com.fasterxml.jackson.databind.`annotation`.JsonSerialize import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size import java.math.BigDecimal @@ -18,7 +17,6 @@ public data class Product( @field:NotNull @field:Size(min = 1, max = 40) public val name: String, - @get:JsonSerialize(using = MetaDecimalWire.Serializer::class) @param:JsonDeserialize(using = MetaDecimalWire.Deserializer::class) public val price: BigDecimal? = null, public val rating: Float? = null, diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/Address.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/Address.kt new file mode 100644 index 000000000..218258ede --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/Address.kt @@ -0,0 +1,56 @@ +package acme.commerce + +import com.fasterxml.jackson.`annotation`.JsonInclude +import jakarta.validation.constraints.Size +import kotlin.String +import kotlin.jvm.JvmStatic + +/** + * GENERATED — do not hand-edit. Regenerated from metadata. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public data class Address( + @field:Size(max = 200) + public val street: String? = null, + @field:Size(max = 100) + public val city: String? = null, + @field:Size(max = 20) + public val zip: String? = null, +) { + /** + * Fluent builder — lets a JAVA caller set a subset of properties. + */ + public class Builder { + private var street: String? = null + + private var city: String? = null + + private var zip: String? = null + + public fun street(v: String?): Builder { + this.street = v + return this + } + + public fun city(v: String?): Builder { + this.city = v + return this + } + + public fun zip(v: String?): Builder { + this.zip = v + return this + } + + public fun build(): Address = Address( + street = street, + city = city, + zip = zip + ) + } + + public companion object { + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/MetaJsonbMapper.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/MetaJsonbMapper.kt new file mode 100644 index 000000000..dd5233382 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/MetaJsonbMapper.kt @@ -0,0 +1,20 @@ +package acme.commerce + +/** + * GENERATED — do not hand-edit. + * Shared Jackson ObjectMapper backing the typed `field.object @storage:jsonb` and + * `field.map` Exposed `jsonb()` columns in this package. Jackson (not kotlinx) is the + * codec, so the generated entity/value data classes carry NO `@Serializable` and need + * NO per-type serializer plumbing — every java.time / java.util / java.math / java.net + * field a jsonb value object may carry round-trips through the kotlin + jsr310 modules + * with no compiler plugin. + * + * Consumer classpath: jackson-databind, jackson-module-kotlin, jackson-datatype-jsr310. + */ +internal val metaJsonbMapper: com.fasterxml.jackson.databind.ObjectMapper = + com.fasterxml.jackson.databind.json.JsonMapper.builder() + .addModule(com.fasterxml.jackson.module.kotlin.kotlinModule()) + .addModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build() + diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/User.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/User.kt new file mode 100644 index 000000000..eda67ad87 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/User.kt @@ -0,0 +1,65 @@ +package acme.commerce + +import jakarta.validation.Valid +import jakarta.validation.constraints.Size +import kotlin.Long +import kotlin.String +import kotlin.jvm.JvmStatic + +/** + * GENERATED — do not hand-edit. Regenerated from metadata. + */ +public data class User( + public val id: Long? = null, + @field:Size(max = 255) + public val email: String? = null, + @field:Valid + public val address: Address? = null, + @field:Valid + public val preferences: UserMetadata? = null, +) { + /** + * Fluent builder — lets a JAVA caller set a subset of properties. + */ + public class Builder { + private var id: Long? = null + + private var email: String? = null + + private var address: Address? = null + + private var preferences: UserMetadata? = null + + public fun id(v: Long?): Builder { + this.id = v + return this + } + + public fun email(v: String?): Builder { + this.email = v + return this + } + + public fun address(v: Address?): Builder { + this.address = v + return this + } + + public fun preferences(v: UserMetadata?): Builder { + this.preferences = v + return this + } + + public fun build(): User = User( + id = id, + email = email, + address = address, + preferences = preferences + ) + } + + public companion object { + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserMetadata.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserMetadata.kt new file mode 100644 index 000000000..1d1b8024d --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserMetadata.kt @@ -0,0 +1,43 @@ +package acme.commerce + +import com.fasterxml.jackson.`annotation`.JsonInclude +import kotlin.String +import kotlin.jvm.JvmStatic + +/** + * GENERATED — do not hand-edit. Regenerated from metadata. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public data class UserMetadata( + public val locale: String? = null, + public val timezone: String? = null, +) { + /** + * Fluent builder — lets a JAVA caller set a subset of properties. + */ + public class Builder { + private var locale: String? = null + + private var timezone: String? = null + + public fun locale(v: String?): Builder { + this.locale = v + return this + } + + public fun timezone(v: String?): Builder { + this.timezone = v + return this + } + + public fun build(): UserMetadata = UserMetadata( + locale = locale, + timezone = timezone + ) + } + + public companion object { + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserTable.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserTable.kt new file mode 100644 index 000000000..37ca07578 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage-wire-omit/acme/commerce/UserTable.kt @@ -0,0 +1,16 @@ +package acme.commerce + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.json.jsonb + +/** GENERATED — do not hand-edit. Regenerated from metadata. */ +object UserTable : Table("users") { + val id = long("id").autoIncrement() + val email = varchar("email", 255).nullable() + val addressStreet = varchar("address_street", 200).nullable() + val addressCity = varchar("address_city", 100).nullable() + val addressZip = varchar("address_zip", 20).nullable() + val preferences = jsonb("preferences", { metaJsonbMapper.writeValueAsString(it) }, { metaJsonbMapper.readValue(it, acme.commerce.UserMetadata::class.java) }).nullable() + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/Address.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/Address.kt index 218258ede..c7701487a 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/Address.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/Address.kt @@ -1,6 +1,5 @@ package acme.commerce -import com.fasterxml.jackson.`annotation`.JsonInclude import jakarta.validation.constraints.Size import kotlin.String import kotlin.jvm.JvmStatic @@ -8,7 +7,6 @@ import kotlin.jvm.JvmStatic /** * GENERATED — do not hand-edit. Regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public data class Address( @field:Size(max = 200) public val street: String? = null, diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/UserMetadata.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/UserMetadata.kt index 1d1b8024d..8eee5c5c1 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/UserMetadata.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/UserMetadata.kt @@ -1,13 +1,11 @@ package acme.commerce -import com.fasterxml.jackson.`annotation`.JsonInclude import kotlin.String import kotlin.jvm.JvmStatic /** * GENERATED — do not hand-edit. Regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public data class UserMetadata( public val locale: String? = null, public val timezone: String? = null, diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-stored-proc/acme/ops/OrderReportArgs.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-stored-proc/acme/ops/OrderReportArgs.kt index 9d17a177f..9de23dd48 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-stored-proc/acme/ops/OrderReportArgs.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-stored-proc/acme/ops/OrderReportArgs.kt @@ -1,13 +1,11 @@ package acme.ops -import com.fasterxml.jackson.`annotation`.JsonInclude import kotlin.Long import kotlin.jvm.JvmStatic /** * GENERATED — do not hand-edit. Regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public data class OrderReportArgs( public val orderId: Long? = null, ) { diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/Auth.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/Auth.kt index 4b07f675b..e983b0403 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/Auth.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/Auth.kt @@ -1,7 +1,6 @@ package acme.auth import com.fasterxml.jackson.databind.`annotation`.JsonDeserialize -import com.fasterxml.jackson.databind.`annotation`.JsonSerialize import java.math.BigDecimal import kotlin.Int import kotlin.Long @@ -16,7 +15,6 @@ public data class Auth( public val type: AuthType? = null, public val reference: String? = null, public val quantity: Int? = null, - @get:JsonSerialize(using = MetaDecimalWire.Serializer::class) @param:JsonDeserialize(using = MetaDecimalWire.Deserializer::class) public val copayAmount: BigDecimal? = null, public val priorAuthNumber: String? = null, diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaDecimalWire.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaDecimalWire.kt index 3fab045d4..2b74e50c1 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaDecimalWire.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaDecimalWire.kt @@ -1,6 +1,5 @@ package acme.auth -import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.DeserializationContext @@ -8,17 +7,15 @@ import com.fasterxml.jackson.databind.BeanProperty import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.ContextualDeserializer import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.ser.std.StdSerializer import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode /** * GENERATED (ADR-0019 Amendment 1) — do not hand-edit. The decimal wire rule: a decimal is - * written as a canonical string (plain notation, no trailing fraction zeros, zero as "0"), + * written as the host serializer writes it, * and read from a string or number in the JSON-number grammar, exactly, never through a * binary float. Dependency-free (Jackson only). */ @@ -101,13 +98,6 @@ internal object MetaDecimalWire { return out } - /** Writes a decimal as its canonical string. */ - class Serializer : StdSerializer(BigDecimal::class.java) { - override fun serialize(value: BigDecimal, gen: JsonGenerator, provider: SerializerProvider) { - gen.writeString(format(value)) - } - } - /** * The `@precision` and `@scale` a decimal inside a value object declares. [Deserializer] reads it * from the property it is bound to and refuses, with a 400, a value the field cannot hold diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-view/acme/report/MetaDecimalWire.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-view/acme/report/MetaDecimalWire.kt index 4a63baeae..ca96d7490 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-view/acme/report/MetaDecimalWire.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-view/acme/report/MetaDecimalWire.kt @@ -1,6 +1,5 @@ package acme.report -import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.DeserializationContext @@ -8,17 +7,15 @@ import com.fasterxml.jackson.databind.BeanProperty import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.ContextualDeserializer import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.ser.std.StdSerializer import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode /** * GENERATED (ADR-0019 Amendment 1) — do not hand-edit. The decimal wire rule: a decimal is - * written as a canonical string (plain notation, no trailing fraction zeros, zero as "0"), + * written as the host serializer writes it, * and read from a string or number in the JSON-number grammar, exactly, never through a * binary float. Dependency-free (Jackson only). */ @@ -101,13 +98,6 @@ internal object MetaDecimalWire { return out } - /** Writes a decimal as its canonical string. */ - class Serializer : StdSerializer(BigDecimal::class.java) { - override fun serialize(value: BigDecimal, gen: JsonGenerator, provider: SerializerProvider) { - gen.writeString(format(value)) - } - } - /** * The `@precision` and `@scale` a decimal inside a value object declares. [Deserializer] reads it * from the property it is bound to and refuses, with a 400, a value the field cannot hold diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-fr010/acme/ai/OpinionOutputPayload.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-fr010/acme/ai/OpinionOutputPayload.kt index 51b47de84..2d36df6d3 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-fr010/acme/ai/OpinionOutputPayload.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-fr010/acme/ai/OpinionOutputPayload.kt @@ -1,6 +1,5 @@ package acme.ai -import com.fasterxml.jackson.`annotation`.JsonInclude import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size import kotlin.String @@ -9,7 +8,6 @@ import kotlin.jvm.JvmStatic /** * GENERATED — do not hand-edit. Regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public data class OpinionOutputPayload( @field:NotNull @field:Size(min = 1) diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-parser/acme/ai/Greeting.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-parser/acme/ai/Greeting.kt index ccee6f615..3e337a313 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-parser/acme/ai/Greeting.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/template-output-parser/acme/ai/Greeting.kt @@ -1,13 +1,11 @@ package acme.ai -import com.fasterxml.jackson.`annotation`.JsonInclude import kotlin.String import kotlin.jvm.JvmStatic /** * GENERATED — do not hand-edit. Regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public data class Greeting( public val text: String? = null, ) { diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/template-prompt-simple/acme/ai/Author.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/template-prompt-simple/acme/ai/Author.kt index 190b77dca..7bb4ef857 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/template-prompt-simple/acme/ai/Author.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/template-prompt-simple/acme/ai/Author.kt @@ -1,6 +1,5 @@ package acme.ai -import com.fasterxml.jackson.`annotation`.JsonInclude import kotlin.Long import kotlin.String import kotlin.jvm.JvmStatic @@ -8,7 +7,6 @@ import kotlin.jvm.JvmStatic /** * GENERATED — do not hand-edit. Regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public data class Author( public val id: Long? = null, public val name: String? = null, diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java index 29f616d64..3b08e4c32 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java @@ -4,6 +4,7 @@ // com.metaobjects.generator.spring when this generator is copied out via // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.config.WireFormat; import com.metaobjects.generator.spring.AutoSetSupport; import com.metaobjects.generator.spring.Fr019SharedEnum; import com.metaobjects.generator.spring.SpringGeneratorSupport; @@ -100,6 +101,9 @@ public class SpringDtoGenerator extends MultiFileDirectGeneratorBase private Fr019SharedEnum.ProvidedEnumConfig fr019Config = new Fr019SharedEnum.ProvidedEnumConfig(null, java.util.Map.of()); + /** The project's wire setting for the current run (ADR-0019 Amendment 2). */ + private WireFormat wire = WireFormat.DEFAULT; + @Override protected Class getFilterClass() { return MetaObject.class; @@ -110,6 +114,9 @@ protected Class getFilterClass() { public void execute(MetaDataLoader loader) { parseArgs(); runtimePackage = getArg(ARG_RUNTIME_PACKAGE, RUNTIME_PACKAGE); + // ADR-0019 Amendment 2: read from the loaded root, not from a shape being emitted - a + // served report's shape is synthesized from its read model and carries no parent. + wire = loader.getRoot().getWireFormat(); Path outRoot = Paths.get(outDir.getAbsolutePath()); fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( getArg("providedEnumNamespace"), getArg("providedEnumPackages")); @@ -555,7 +562,7 @@ private void emitRecord(MetaObject entity, Path outRoot, List fields, boolean usesDecimal = fields.stream() .anyMatch(f -> SpringGeneratorSupport.isDecimalComponent(componentTypeFr019(f, entity))); if (usesDecimal) { - src.append(SpringGeneratorSupport.decimalAnnotationImports(runtimePackage)); + src.append(SpringGeneratorSupport.decimalAnnotationImports(runtimePackage, wire)); } else if (usesNetDeser) { src.append("import com.fasterxml.jackson.databind.annotation.JsonDeserialize;\n"); } @@ -587,7 +594,7 @@ private void emitRecord(MetaObject entity, Path outRoot, List fields, String jsonName = SpringNaming.jsonPropertyAnnotation(field.getName()); src.append(" "); if (!annotations.isEmpty()) src.append(annotations).append(' '); - String decimalJson = SpringGeneratorSupport.decimalJsonAnnotations(type, runtimePackage, field); + String decimalJson = SpringGeneratorSupport.decimalJsonAnnotations(type, runtimePackage, field, wire); if (!decimalJson.isEmpty()) src.append(decimalJson).append(' '); if (!jsonName.isEmpty()) src.append(jsonName).append(' '); src.append(type).append(' ').append(component); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index f30a53ddb..b15b8ec31 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -10,6 +10,7 @@ import com.metaobjects.generator.spring.SpringTypeMapper; import com.metaobjects.MetaData; +import com.metaobjects.config.WireFormat; import com.metaobjects.field.EnumField; import com.metaobjects.field.MetaField; import com.metaobjects.field.ObjectField; @@ -633,20 +634,25 @@ public static boolean isDecimalComponent(String type) { * type. The annotations ride on the component itself, so a host's {@code ObjectMapper} * options cannot change the bytes. */ - public static String decimalJsonAnnotations(String type, String runtimePackage) { + public static String decimalJsonAnnotations(String type, String runtimePackage, WireFormat wire) { if (!isDecimalComponent(type)) return ""; String key = DECIMAL_LIST_TYPE.equals(type) ? "contentUsing" : "using"; - return "@JsonSerialize(" + key + " = CanonicalDecimalSerializer.class) " - + "@JsonDeserialize(" + key + " = CanonicalDecimalDeserializer.class)"; + // ADR-0019 Amendment 2: the OUTPUT half is emitted only when the project asks for the + // canonical string; under native the host ObjectMapper writes a BigDecimal as a number. + // The INPUT half (strict deserializer, W2/W3) rides in every mode. + String out = wire.decimalsAsString() + ? "@JsonSerialize(" + key + " = CanonicalDecimalSerializer.class) " : ""; + return out + "@JsonDeserialize(" + key + " = CanonicalDecimalDeserializer.class)"; } /** - * {@link #decimalJsonAnnotations(String, String)} plus, for a field that declares + * {@link #decimalJsonAnnotations(String, String, WireFormat)} plus, for a field that declares * {@code @precision}, the {@code @DeclaredDecimal} the strict deserializer reads to refuse a * value the column cannot hold (W3). */ - public static String decimalJsonAnnotations(String type, String runtimePackage, MetaField field) { - String json = decimalJsonAnnotations(type, runtimePackage); + public static String decimalJsonAnnotations(String type, String runtimePackage, MetaField field, + WireFormat wire) { + String json = decimalJsonAnnotations(type, runtimePackage, wire); if (json.isEmpty()) return json; int[] bound = declaredDecimalBound(field); return bound == null ? json @@ -671,11 +677,13 @@ public static int[] declaredDecimalBound(MetaField field) { * runtime classes they name (imported, so the ejected runtime package is what the output * names). */ - public static String decimalAnnotationImports(String runtimePackage) { + public static String decimalAnnotationImports(String runtimePackage, WireFormat wire) { + String out = wire.decimalsAsString() + ? "import com.fasterxml.jackson.databind.annotation.JsonSerialize;\n" + + "import " + runtimePackage + ".CanonicalDecimalSerializer;\n" : ""; return "import com.fasterxml.jackson.databind.annotation.JsonDeserialize;\n" - + "import com.fasterxml.jackson.databind.annotation.JsonSerialize;\n" + + out + "import " + runtimePackage + ".CanonicalDecimalDeserializer;\n" - + "import " + runtimePackage + ".CanonicalDecimalSerializer;\n" + "import " + runtimePackage + ".DeclaredDecimal;\n"; } } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java index 039850f46..39dcde250 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java @@ -9,6 +9,7 @@ import com.metaobjects.generator.spring.SpringRecordBuilder; import com.metaobjects.generator.spring.SpringTypeMapper; +import com.metaobjects.config.WireFormat; import com.metaobjects.field.EnumField; import com.metaobjects.field.MetaField; import com.metaobjects.generator.GeneratorException; @@ -87,10 +88,14 @@ protected Class getFilterClass() { return MetaObject.class; } + /** The project's wire setting for the current run (ADR-0019 Amendment 2). */ + private WireFormat wire = WireFormat.DEFAULT; + @Override public void execute(MetaDataLoader loader) { parseArgs(); runtimePackage = getArg(ARG_RUNTIME_PACKAGE, RUNTIME_PACKAGE); + wire = loader.getRoot().getWireFormat(); Path outRoot = Paths.get(outDir.getAbsolutePath()); fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( getArg("providedEnumNamespace"), getArg("providedEnumPackages")); @@ -153,7 +158,7 @@ private void emit(MetaObject vo, Path outRoot) { boolean usesDecimal = fields.stream() .anyMatch(f -> SpringGeneratorSupport.isDecimalComponent(componentTypeFr019(f, vo))); if (usesDecimal) { - src.append(SpringGeneratorSupport.decimalAnnotationImports(runtimePackage)); + src.append(SpringGeneratorSupport.decimalAnnotationImports(runtimePackage, wire)); } if (usesValid) { src.append("import jakarta.validation.Valid;\n"); @@ -161,11 +166,14 @@ private void emit(MetaObject vo, Path outRoot) { if (usesValidation) { src.append("import jakarta.validation.constraints.*;\n"); } - // A value object is stored as jsonb; the api-contract wire omits an absent member, never null. - src.append("import com.fasterxml.jackson.annotation.JsonInclude;\n\n"); + // ADR-0019 Amendment 2: the NON_NULL annotation is emitted only when the project asks + // for omitted null members; native and emit leave Jackson's own null-with-key behaviour. + boolean omitNulls = wire.omitNullMembers(); + if (omitNulls) src.append("import com.fasterxml.jackson.annotation.JsonInclude;\n"); + if (!src.toString().endsWith("\n\n")) src.append('\n'); src.append("/** GENERATED — value object ").append(recordName) .append(". Do not hand-edit; regenerated from metadata. */\n"); - src.append("@JsonInclude(JsonInclude.Include.NON_NULL)\n"); + if (omitNulls) src.append("@JsonInclude(JsonInclude.Include.NON_NULL)\n"); src.append("public record ").append(recordName).append("(\n"); List components = new ArrayList<>(fields.size()); for (int i = 0; i < fields.size(); i++) { @@ -178,7 +186,7 @@ private void emit(MetaObject vo, Path outRoot) { String jsonName = SpringNaming.jsonPropertyAnnotation(field.getName()); src.append(" "); if (!a.isEmpty()) src.append(a).append(' '); - String decimalJson = SpringGeneratorSupport.decimalJsonAnnotations(type, runtimePackage, field); + String decimalJson = SpringGeneratorSupport.decimalJsonAnnotations(type, runtimePackage, field, wire); if (!decimalJson.isEmpty()) src.append(decimalJson).append(' '); if (!jsonName.isEmpty()) src.append(jsonName).append(' '); src.append(type).append(' ').append(component); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDecimalWireCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDecimalWireCompileRunTest.java index 47d350b77..cfdc53deb 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDecimalWireCompileRunTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDecimalWireCompileRunTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.config.WireFormat; import com.metaobjects.loader.MetaDataLoader; import org.junit.Rule; import org.junit.Test; @@ -75,6 +76,8 @@ public void generatedDtoPatchAndAllowlistFollowTheDecimalWireRule() throws Excep Path srcDir = tmp.newFolder("src").toPath(); Path classesDir = tmp.newFolder("classes").toPath(); MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder("fx").toPath(), "ledger", META); + // The canonical output form is opt-in (ADR-0019 Amendment 2): pin the form the corpora pin. + loader.getRoot().setWireFormat(new WireFormat(WireFormat.Decimals.STRING, WireFormat.NullMembers.OMIT)); runGenerator(new SpringDtoGenerator(), loader, srcDir); runGenerator(new SpringValueObjectGenerator(), loader, srcDir); runGenerator(new SpringFilterAllowlistGenerator(), loader, srcDir); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java index be4bcffef..4b627b7ce 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportRestSurfaceTest.java @@ -216,10 +216,12 @@ public void aDerivedEnumFieldIsTypedByAnEnumNestedInTheReportsOwnDto() throws Ex * {@code @spine: "Week.fkProgram"}; {@code totalMinutesOrZero} / {@code longShareOrZero} * are {@code totalMinutes} / {@code longShare} with {@code @default: 0}. */ - /** ADR-0019 Amendment 1: a decimal component binds the canonical serializer and strict deserializer. */ + /** + * ADR-0019 Amendment 1/2: a decimal component always binds the strict deserializer; the + * canonical serializer is emitted only under {@code wire.decimals: string} (default is native). + */ private static final String DECIMAL_WIRE = - "@JsonSerialize(using = CanonicalDecimalSerializer.class) " - + "@JsonDeserialize(using = CanonicalDecimalDeserializer.class) "; + "@JsonDeserialize(using = CanonicalDecimalDeserializer.class) "; @Test public void aSpineKeyAndADefaultedMeasureAreNotNullAndTheSameMeasureWithoutADefaultIsNullable() throws Exception { diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWireFormatCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWireFormatCompileRunTest.java new file mode 100644 index 000000000..19dbfbe38 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringWireFormatCompileRunTest.java @@ -0,0 +1,153 @@ +package com.metaobjects.generator.spring; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.config.WireFormat; +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * ADR-0019 Amendment 2, end to end over GENERATED code: the {@code wire} setting chooses the + * OUTPUT form. The default (native) is Jackson's own behaviour as the host ObjectMapper + * configures it (Spring Boot's default mapper writes a BigDecimal with {@code toString()}, so a + * scale-2 value is {@code 12.50} and a tiny value is {@code 1E-7}); {@code decimals: string} + * writes the W1 canonical string; {@code nullMembers: omit} leaves a value object's null member + * out and {@code emit} writes it as {@code null}. The input rules (W2/W3) hold in every mode. + */ +public class SpringWireFormatCompileRunTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String PKG = "acme.wire"; + + private static final String META = """ + { "metadata.root": { "package": "acme::wire", "children": [ + { "object.value": { "name": "Price", "children": [ + { "field.decimal": { "name": "net", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "note" } } + ]}}, + { "object.entity": { "name": "Item", "children": [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "amount", "@precision": 20, "@scale": 10 } }, + { "field.decimal": { "name": "tiers", "isArray": true, "@precision": 12, "@scale": 2 } }, + { "field.object": { "name": "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ]}} + ]}} + """; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private interface Check { void run(Path srcDir, ClassLoader cl) throws Exception; } + + private void generate(WireFormat wire, Check check) throws Exception { + Path srcDir = tmp.newFolder().toPath(); + Path classesDir = tmp.newFolder().toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder().toPath(), "wire", META); + if (wire != null) loader.getRoot().setWireFormat(wire); + Map args = new HashMap<>(); + args.put("outputDir", srcDir.toString()); + for (var g : List.of(new SpringDtoGenerator(), new SpringValueObjectGenerator())) { + g.setArgs(args); + g.execute(loader); + } + SpringTestFixtures.compileGenerated(srcDir, classesDir); + try (URLClassLoader cl = new URLClassLoader( + new URL[]{ classesDir.toUri().toURL() }, getClass().getClassLoader())) { + check.run(srcDir, cl); + } + } + + /** An ItemDto holding amount 12.50, tiers [1.50, 0.0000001] and a Price whose note is null. */ + private static String writtenBytes(ClassLoader cl) throws Exception { + Class priceClass = cl.loadClass(PKG + ".Price"); + Object price = priceClass.getMethod("builder").invoke(null); + price = price.getClass().getMethod("net", BigDecimal.class).invoke(price, new BigDecimal("12.50")); + price = price.getClass().getMethod("build").invoke(price); + Class dto = cl.loadClass(PKG + ".ItemDto"); + Object b = dto.getMethod("builder").invoke(null); + Class bc = b.getClass(); + b = bc.getMethod("amount", BigDecimal.class).invoke(b, new BigDecimal("12.50")); + b = bc.getMethod("tiers", List.class).invoke(b, List.of(new BigDecimal("1.50"), new BigDecimal("0.0000001"))); + b = bc.getMethod("price", priceClass).invoke(b, price); + return MAPPER.writeValueAsString(bc.getMethod("build").invoke(b)); + } + + @Test + public void theDefaultIsJacksonsOwnShape() throws Exception { + generate(null, (src, cl) -> { + String dtoSrc = Files.readString(src.resolve("acme/wire/ItemDto.java")); + assertFalse(dtoSrc, dtoSrc.contains("CanonicalDecimalSerializer")); + assertTrue("the strict READ side stays", dtoSrc.contains("CanonicalDecimalDeserializer")); + assertFalse(Files.readString(src.resolve("acme/wire/Price.java")).contains("JsonInclude")); + + String raw = writtenBytes(cl); + JsonNode out = MAPPER.readTree(raw); + // Jackson (and Spring Boot's default mapper) writes a BigDecimal as a number, toString() form. + assertTrue("a JSON number", out.get("amount").isNumber()); + assertTrue(raw, raw.contains("\"amount\":12.50,")); + assertTrue(raw, raw.contains("\"tiers\":[1.50,1E-7]")); + assertTrue(out.get("tiers").get(0).isNumber()); + // A value object's null member is written null, key present. + assertTrue(out.get("price").has("note")); + assertTrue(out.get("price").get("note").isNull()); + assertTrue(raw, raw.contains("\"net\":12.50")); + assertTrue(raw, raw.contains("\"note\":null")); + + // Input rules hold under native: a string or a number reads, garbage and over-scale refuse. + Class dto = cl.loadClass(PKG + ".ItemDto"); + MAPPER.readValue("{\"amount\":\"1e-7\"}", dto); + MAPPER.readValue("{\"amount\":12.5}", dto); + for (String bad : new String[]{"{\"amount\":\"\"}", "{\"amount\":\"NaN\"}", + "{\"price\":{\"net\":\"1.001\"}}", "{\"price\":{\"net\":1.001}}"}) { + try { + MAPPER.readValue(bad, dto); + fail("must be refused under native: " + bad); + } catch (com.fasterxml.jackson.core.JsonProcessingException expected) { + // Spring maps this to 400 validation + } + } + }); + } + + @Test + public void stringAndOmitGiveTheCanonicalCrossPortForm() throws Exception { + generate(new WireFormat(WireFormat.Decimals.STRING, WireFormat.NullMembers.OMIT), (src, cl) -> { + String raw = writtenBytes(cl); + JsonNode out = MAPPER.readTree(raw); + assertEquals("12.5", out.get("amount").asText()); + assertTrue(out.get("amount").isTextual()); + assertEquals("[\"1.5\",\"0.0000001\"]", MAPPER.writeValueAsString(out.get("tiers"))); + assertEquals("12.5", out.get("price").get("net").asText()); + assertFalse("a null member is omitted", out.get("price").has("note")); + }); + } + + @Test + public void emitWritesTheNullMemberLikeNative() throws Exception { + generate(new WireFormat(WireFormat.Decimals.NATIVE, WireFormat.NullMembers.EMIT), (src, cl) -> { + String raw = writtenBytes(cl); + JsonNode out = MAPPER.readTree(raw); + assertTrue(out.get("price").has("note")); + assertTrue(out.get("price").get("note").isNull()); + assertTrue(out.get("amount").isNumber()); + }); + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java index c6de89854..6b095b6f3 100644 --- a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java @@ -1,10 +1,8 @@ package acme.shipping; import jakarta.validation.constraints.*; -import com.fasterxml.jackson.annotation.JsonInclude; /** GENERATED — value object ShipmentNote. Do not hand-edit; regenerated from metadata. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record ShipmentNote( @NotNull @Size(min = 1) String text, ShipmentNoteKind kind, 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 19be047c1..13c4a0983 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 @@ -119,6 +119,7 @@ object QueryScenarioRunner { // naming.columns: literal. Read with the SAME NeutralConfig reader every port uses, // rather than passing a strategy. root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpus) + root.wireFormat = com.metaobjects.config.SourceResolver.resolveWireFormat(corpus) // 4. Run queries; each gets its own Exposed transaction. for (spec in scenario.queries) { diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt index 988f2c9b1..e4d3886a4 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt @@ -118,6 +118,12 @@ class GeneratedAuthorControllerHarness( // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) // 2. Run the four generators — emit the GENERATED controller + DTO + table + filter // allowlist into srcDir, UNMODIFIED. diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt index c74065f5b..c258d76f9 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt @@ -101,6 +101,12 @@ class GeneratedDocumentControllerHarness( // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) // 2. Run the four generators — emit the GENERATED controller + DTO + table + filter // allowlist, UNMODIFIED. diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/m2m/generated/GeneratedM2mControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/m2m/generated/GeneratedM2mControllerHarness.kt index 420fe227d..c1c19a62f 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/m2m/generated/GeneratedM2mControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/m2m/generated/GeneratedM2mControllerHarness.kt @@ -106,6 +106,12 @@ class GeneratedM2mControllerHarness( // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) // 2. Generate entity data classes + Exposed tables + relation join helpers + filter // allowlists + controllers, UNMODIFIED. diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt index 91de033af..96c3149bf 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt @@ -79,6 +79,12 @@ class GeneratedProjectionControllerHarness( // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) columnsRule = loader.root.physicalNaming.columns() val srcDir = genDir.resolve("src") 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 ade72c81c..14792ae2c 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 @@ -78,6 +78,12 @@ class GeneratedReportControllerHarness( // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) val srcDir = genDir.resolve("src") Files.createDirectories(srcDir) diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/generated/GeneratedTphControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/generated/GeneratedTphControllerHarness.kt index 84ae87055..df760476b 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/generated/GeneratedTphControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/generated/GeneratedTphControllerHarness.kt @@ -75,6 +75,12 @@ class GeneratedTphControllerHarness( // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) val srcDir = genDir.resolve("src") Files.createDirectories(srcDir) diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/writethrough/generated/GeneratedWriteThroughControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/writethrough/generated/GeneratedWriteThroughControllerHarness.kt index cafbd9eef..263e949d3 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/writethrough/generated/GeneratedWriteThroughControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/writethrough/generated/GeneratedWriteThroughControllerHarness.kt @@ -77,6 +77,12 @@ class GeneratedWriteThroughControllerHarness( // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.root.physicalNaming = com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) // The physical name `Order.customerId` resolves to, under the SAME rule the // generators below resolve it through — the hand-exec'd view/seed SQL in reset() diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAuthorControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAuthorControllerHarness.java index 645b28934..f10c44300 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAuthorControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAuthorControllerHarness.java @@ -145,6 +145,10 @@ public Instant deserialize(JsonParser p, DeserializationContext ctx) // depend on this. Read with the SAME NeutralConfig reader every port uses. loader.getRoot().setPhysicalNaming( com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot)); + // ADR-0019 Amendment 2 - the corpus's config.json (found by walking up from the corpus dir) pins the wire form (string / omit). + loader.getRoot().setWireFormat( + com.metaobjects.config.SourceResolver.discoverWireFormat(java.util.List.of((corpusRoot).toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT)); // 2. Run the four generators — emit the GENERATED controller + DTO + repo interface + // filter allowlist into srcDir, UNMODIFIED. diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedDecimalControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedDecimalControllerHarness.java index a311898a9..080813283 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedDecimalControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedDecimalControllerHarness.java @@ -72,6 +72,10 @@ public GeneratedDecimalControllerHarness(Path corpusRoot, Path genDir) throws Ex // ADR-0058: the committed config at the api-contract corpus root declares naming.columns: literal. loader.getRoot().setPhysicalNaming( com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot.getParent())); + // ADR-0019 Amendment 2 - the corpus's config.json (found by walking up from the corpus dir) pins the wire form (string / omit). + loader.getRoot().setWireFormat( + com.metaobjects.config.SourceResolver.discoverWireFormat(java.util.List.of((corpusRoot.getParent()).toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT)); runGenerator(new SpringControllerGenerator(), loader, srcDir); runGenerator(new SpringDtoGenerator(), loader, srcDir); diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java index 49c3e53f3..0f027fc7d 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java @@ -87,6 +87,10 @@ public GeneratedJsonbControllerHarness(Path corpusRoot, Path genDir, List Create Loader: " + loader.toString()); @@ -912,17 +916,27 @@ protected File getSourceDir() { * when no such ancestor exists, the module basedir's config (or the default). */ protected com.metaobjects.database.PhysicalNaming resolvePhysicalNaming(File srcDir) { + return resolveProjectConfig(srcDir) + .map(com.metaobjects.config.NeutralConfig::getPhysicalNaming) + .orElse(com.metaobjects.database.PhysicalNaming.DEFAULT); + } + + /** + * The {@code .metaobjects/config.json} of the project that holds this goal's metadata, found + * as {@link #resolvePhysicalNaming(File)} describes; empty when there is none. + */ + protected java.util.Optional resolveProjectConfig(File srcDir) { Path basedir = getProjectBaseDir().toPath(); Path location = srcDir != null ? srcDir.toPath() : localSourceLocation(basedir); if (location != null) { for (Path dir = location.toAbsolutePath().normalize(); dir != null; dir = dir.getParent()) { java.util.Optional cfg = com.metaobjects.config.NeutralConfig.read(dir); - if (cfg.isPresent()) return cfg.get().getPhysicalNaming(); + if (cfg.isPresent()) return cfg; if (Files.exists(dir.resolve(".git"))) break; } } - return com.metaobjects.config.SourceResolver.resolvePhysicalNaming(basedir); + return com.metaobjects.config.NeutralConfig.read(basedir.toAbsolutePath().normalize()); } /** The first {@code } entry that is an existing local path, as a directory; else null. */ diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java index 42219d552..43c6a3be9 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java @@ -1,5 +1,6 @@ package com.metaobjects.mojo; +import com.metaobjects.config.WireFormat; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.util.HashManifest; @@ -429,6 +430,13 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } if (!drift.isEmpty()) { + // ADR-0019 Amendment 2: committed output generated under another `wire` setting is + // drift that regenerating would "fix" by changing the API's JSON. Name the setting. + if (drift.size() > editedOrphans.size()) { + WireFormat used = loader.getRoot().getWireFormat(); + WireFormat committed = matchingWire(loader, projectClassLoader, overrides, targets, ownHashes, projectRoot); + if (committed != null) throw new MojoFailureException(wireDiffers(committed, used)); + } StringBuilder sb = new StringBuilder(); if (drift.size() > editedOrphans.size()) { sb.append("generated code is stale — run `mvn ").append(genGoal()) @@ -461,6 +469,61 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException failOnRequirementErrors(requirementErrors); } + /** + * ADR-0019 Amendment 2: the other {@code wire} setting under which the generators reproduce the + * committed output exactly, or {@code null} when none does. Probes decimals {native, string} x + * nullMembers {native, omit} ({@code emit} writes what {@code native} writes on the JVM), skipping + * the setting this run used, then puts that setting back on the root. Each probe runs fresh + * generator instances, since a generator need not support a second {@code execute}. A probe that + * fails answers "no match", so the ordinary drift report still prints. + */ + private WireFormat matchingWire(MetaDataLoader loader, ClassLoader projectClassLoader, + Map> overrides, List targets, + Map ownHashes, Path projectRoot) throws MojoExecutionException { + WireFormat used = loader.getRoot().getWireFormat(); + WireFormat.NullMembers usedNulls = used.nullMembers() == WireFormat.NullMembers.EMIT + ? WireFormat.NullMembers.NATIVE : used.nullMembers(); + try { + for (WireFormat.Decimals d : WireFormat.Decimals.values()) { + for (WireFormat.NullMembers n : List.of(WireFormat.NullMembers.NATIVE, WireFormat.NullMembers.OMIT)) { + if (d == used.decimals() && n == usedNulls) continue; + loader.getRoot().setWireFormat(new WireFormat(d, n)); + for (GenTarget t : targets) deleteRecursively(t.tempDir); + try { + for (Generator gen : buildGenerators(projectClassLoader, overrides)) gen.execute(loader); + } catch (RuntimeException e) { + getLog().debug("MetaData Verify Mojo > wire probe failed: " + e); + return null; + } + List drift = new ArrayList<>(); + for (GenTarget t : targets) drift.addAll(compareTrees(t, ownHashes, projectRoot, new ArrayList<>())); + if (ownHashes != null) { + drift.addAll(strandedOutsideTargets(ownHashes, targets, projectRoot, new ArrayList<>())); + } + if (drift.isEmpty()) return new WireFormat(d, n); + } + } + return null; + } finally { + loader.getRoot().setWireFormat(used); + } + } + + /** The {@code wire} block that declares {@code wire}. */ + static String wireBlock(WireFormat wire) { + return "\"wire\": { \"decimals\": \"" + wire.decimals().wireName() + + "\", \"nullMembers\": \"" + wire.nullMembers().wireName() + "\" }"; + } + + /** The verify failure for committed output that another {@code wire} setting reproduces. */ + static String wireDiffers(WireFormat committed, WireFormat used) { + return "The committed output was generated with " + wireBlock(committed) + "; this run used " + + wireBlock(used) + " (.metaobjects/config.json; each key defaults to native). Do not " + + "regenerate: that changes the JSON your API writes for decimals or for a value object's " + + "null members. Declare " + wireBlock(committed) + " in .metaobjects/config.json, and " + + "generate and verify both read it."; + } + /** * The manifest entries {@code generate} would judge as orphans for the generators this * verify runs ({@link OrphanSweep#isCandidate}): their own entries, and entries whose diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java index b1d648c1e..543b261d3 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java @@ -91,6 +91,16 @@ public void setUp() { MetaDataLoader.SUBTYPE_MANUAL, "eject-runtime-test"); loader.setSourceURIs(List.of(uri)); loader.init(); + loader.getRoot().setWireFormat(corpusWire()); + } + + /** + * The corpus's own pinned wire form (string / omit): the declared runtime set is the superset + * a project can need, and the output names the canonical serializer only under that form. + */ + private com.metaobjects.config.WireFormat corpusWire() { + return com.metaobjects.config.SourceResolver.discoverWireFormat(List.of(canonical.toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT); } private static Path findCanonical() { @@ -183,6 +193,7 @@ private MetaDataLoader decimalValueObjectLoader() throws IOException { MetaDataLoader.SUBTYPE_MANUAL, "eject-decimal-vo"); l.setSourceURIs(List.of(file.toUri())); l.init(); + l.getRoot().setWireFormat(corpusWire()); return l; } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyWireTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyWireTest.java new file mode 100644 index 000000000..2433530c7 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyWireTest.java @@ -0,0 +1,123 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.kotlin.KotlinEntityGenerator; +import org.apache.maven.plugin.MojoFailureException; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * ADR-0019 Amendment 2: committed output generated under another {@code wire} setting is not + * "stale". Regenerating would change the JSON the API writes, so {@code verify} names the + * {@code wire} block that reproduces the committed output instead. + */ +public class MetaDataVerifyWireTest { + + private static final String METADATA = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Price", "children": [ + { "field.decimal": { "name": "net", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "note" } } + ]}}, + { "object.entity": { "name": "Item", "children": [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "amount", "@precision": 10, "@scale": 2 } }, + { "field.object": { "name": "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + private Path project; + private Path outDir; + + @Before + public void setUp() throws Exception { + project = Files.createTempDirectory("verify-wire"); + Files.writeString(project.resolve("meta.acme.json"), METADATA, StandardCharsets.UTF_8); + outDir = project.resolve("generated"); + } + + private void writeWire(String wire) throws Exception { + Path cfg = project.resolve(".metaobjects"); + Files.createDirectories(cfg); + String body = wire == null ? "{ \"schema_version\": 1 }" + : "{ \"schema_version\": 1, \"wire\": { " + wire + " } }"; + Files.writeString(cfg.resolve("config.json"), body, StandardCharsets.UTF_8); + } + + private void configure(AbstractMetaDataMojo mojo) { + mojo.setLoader(LoaderParam.builder("verify-wire") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir(project.toString()) + .withSource("meta.acme.json") + .build()); + mojo.setGenerators(List.of(GeneratorParam.builder(KotlinEntityGenerator.class.getName()) + .withArg("outputDir", outDir.toString()).build())); + mojo.setGlobals(Collections.emptyMap()); + } + + private void generate() throws Exception { + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + configure(gen); + gen.execute(); + } + + @Test + public void verifyNamesTheWireSettingTheCommittedOutputWasGeneratedWith() throws Exception { + writeWire("\"decimals\": \"string\", \"nullMembers\": \"omit\""); + generate(); + + writeWire(null); + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + configure(verify); + try { + verify.execute(); + fail("expected verify to fail under the default setting"); + } catch (MojoFailureException e) { + String msg = e.getMessage(); + assertTrue(msg, msg.contains("The committed output was generated with " + + "\"wire\": { \"decimals\": \"string\", \"nullMembers\": \"omit\" }; this run used " + + "\"wire\": { \"decimals\": \"native\", \"nullMembers\": \"native\" }")); + assertTrue(msg, msg.contains("Do not regenerate")); + assertFalse(msg, msg.contains("generated code is stale")); + } + } + + @Test + public void verifyPassesWhenTheSettingMatches() throws Exception { + writeWire("\"decimals\": \"string\""); + generate(); + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + configure(verify); + verify.execute(); + } + + @Test + public void aHandEditIsStillReportedAsStale() throws Exception { + generate(); + Path item; + try (var s = Files.walk(outDir)) { + item = s.filter(p -> p.getFileName().toString().equals("Item.kt")).findFirst().orElseThrow(); + } + Files.writeString(item, "// HAND-EDITED\n", StandardCharsets.UTF_8); + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + configure(verify); + try { + verify.execute(); + fail("expected drift"); + } catch (MojoFailureException e) { + assertTrue(e.getMessage(), e.getMessage().contains("generated code is stale")); + } + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java index 52b042a7f..10748d363 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java +++ b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java @@ -203,6 +203,26 @@ public void setPhysicalNaming(com.metaobjects.database.PhysicalNaming physicalNa : com.metaobjects.database.PhysicalNaming.DEFAULT; } + /** + * ADR-0019 Amendment 2 — the project's wire-format setting ({@code wire} in + * {@code .metaobjects/config.json}), set by the same project-aware load that sets the + * physical-naming rules and read by codegen through + * {@link com.metaobjects.config.WireFormat#of(MetaData)}. A tree loaded with no project keeps + * the {@code native} default. + */ + private transient com.metaobjects.config.WireFormat wireFormat = + com.metaobjects.config.WireFormat.DEFAULT; + + /** The project's wire-format setting for this tree. Never {@code null}. */ + public com.metaobjects.config.WireFormat getWireFormat() { + return wireFormat; + } + + /** Sets the project's wire-format setting on this root ({@code null} restores the default). */ + public void setWireFormat(com.metaobjects.config.WireFormat wireFormat) { + this.wireFormat = (wireFormat != null) ? wireFormat : com.metaobjects.config.WireFormat.DEFAULT; + } + /** * Returns all {@code object} children of this root node. * diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java index 5ba3c5efc..64e29d5dc 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java @@ -40,8 +40,9 @@ /** * The port-neutral subset of {@code .metaobjects/config.json}. * - *

Reads only {@code schema_version}, {@code sources}, and {@code naming} (ADR-0058, the - * project's physical-naming rules). The file also carries TypeScript-owned keys + *

Reads only {@code schema_version}, {@code sources}, {@code naming} (ADR-0058, the + * project's physical-naming rules) and {@code wire} (ADR-0019 Amendment 2, the output form of a + * generated REST surface). The file also carries TypeScript-owned keys * ({@code pending_in_git}, {@code confidence_thresholds}, {@code extract}, {@code migrate}, * {@code scope}); those are IGNORED rather than modeled, so a new TS-only key never becomes * a four-port change. {@code scope} in particular is entirely out of scope for this reader @@ -61,10 +62,13 @@ public final class NeutralConfig { private final List> sources; private final PhysicalNaming physicalNaming; + private final WireFormat wireFormat; - private NeutralConfig(List> sources, PhysicalNaming physicalNaming) { + private NeutralConfig(List> sources, PhysicalNaming physicalNaming, + WireFormat wireFormat) { this.sources = List.copyOf(sources); this.physicalNaming = physicalNaming; + this.wireFormat = wireFormat; } /** @@ -84,6 +88,14 @@ public PhysicalNaming getPhysicalNaming() { return physicalNaming; } + /** + * ADR-0019 Amendment 2 — the project's wire-format setting ({@code wire} in this file), each + * absent key at its {@code native} default. Never {@code null}. + */ + public WireFormat getWireFormat() { + return wireFormat; + } + /** * Returns empty when {@code /.metaobjects/config.json} does not exist. * A file that EXISTS but is malformed throws — swallowing it would make a typo'd @@ -270,8 +282,51 @@ public static NeutralConfig parse(String content, String origin) { // PhysicalNaming.of validates the values themselves (unknown columns/tables rule). PhysicalNaming physicalNaming = PhysicalNaming.of(columns, tables); + WireFormat wireFormat = parseWire(root.get("wire"), origin); + // Unknown top-level keys (including `scope`/`migrate`) are IGNORED by // design — see the class javadoc. - return new NeutralConfig(specs, physicalNaming); + return new NeutralConfig(specs, physicalNaming, wireFormat); + } + + /** + * ADR-0019 Amendment 2 — the {@code wire} block. Refused exactly as {@code naming} is: an + * unknown key, an unknown value, or a non-object block. + */ + private static WireFormat parseWire(JsonElement wire, String origin) { + if (wire == null) return WireFormat.DEFAULT; + if (!wire.isJsonObject()) { + throw new MetaDataException(origin + ": \"wire\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); + } + JsonObject wireObj = wire.getAsJsonObject(); + WireFormat.Decimals decimals = WireFormat.Decimals.NATIVE; + WireFormat.NullMembers nullMembers = WireFormat.NullMembers.NATIVE; + for (String key : wireObj.keySet()) { + JsonElement el = wireObj.get(key); + String raw = el.isJsonPrimitive() && el.getAsJsonPrimitive().isString() ? el.getAsString() : null; + switch (key) { + case "decimals" -> { + decimals = raw == null ? null : WireFormat.decimalsOf(raw); + if (decimals == null) { + throw new MetaDataException( + origin + ": \"wire.decimals\" must be one of \"native\", \"string\"", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + } + case "nullMembers" -> { + nullMembers = raw == null ? null : WireFormat.nullMembersOf(raw); + if (nullMembers == null) { + throw new MetaDataException( + origin + ": \"wire.nullMembers\" must be one of \"native\", \"emit\", \"omit\"", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + } + default -> throw new MetaDataException( + origin + ": \"wire\" has unknown key \"" + key + + "\"; expected \"decimals\" and/or \"nullMembers\"", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + } + return new WireFormat(decimals, nullMembers); } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java b/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java index ac7275817..ee31355af 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java @@ -171,6 +171,26 @@ public static PhysicalNaming resolvePhysicalNaming(Path root) { .orElse(PhysicalNaming.DEFAULT); } + /** + * ADR-0019 Amendment 2 — the wire-format setting declared in {@code root}'s + * {@code .metaobjects/config.json}, or {@link WireFormat#DEFAULT} when the file is absent. + * Set on the loaded root beside {@link #resolvePhysicalNaming(Path)}. + */ + public static WireFormat resolveWireFormat(Path root) { + Path base = root.toAbsolutePath().normalize(); + return NeutralConfig.read(base) + .map(NeutralConfig::getWireFormat) + .orElse(WireFormat.DEFAULT); + } + + /** + * ADR-0019 Amendment 2 — the wire-format setting of the {@code .metaobjects/config.json} + * found by the same walk as {@link #discoverPhysicalNaming(List)}, or empty when there is none. + */ + public static java.util.Optional discoverWireFormat(List uris) { + return discoverConfig(uris).map(NeutralConfig::getWireFormat); + } + /** * ADR-0058 — the physical-naming rules of the {@code .metaobjects/config.json} found by * walking up from the first {@code file:} URI (stopping after a directory holding @@ -178,6 +198,11 @@ public static PhysicalNaming resolvePhysicalNaming(Path root) { * The same walk the Maven plugin does for its source location. */ public static java.util.Optional discoverPhysicalNaming(List uris) { + return discoverConfig(uris).map(NeutralConfig::getPhysicalNaming); + } + + /** The {@code .metaobjects/config.json} the naming and wire discovery walk finds, if any. */ + private static java.util.Optional discoverConfig(List uris) { for (java.net.URI uri : uris) { if (!"file".equalsIgnoreCase(uri.getScheme())) continue; Path location; @@ -188,7 +213,7 @@ public static java.util.Optional discoverPhysicalNaming(List cfg = NeutralConfig.read(dir); - if (cfg.isPresent()) return java.util.Optional.of(cfg.get().getPhysicalNaming()); + if (cfg.isPresent()) return cfg; if (Files.exists(dir.resolve(".git"))) break; } return java.util.Optional.empty(); diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java b/server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java new file mode 100644 index 000000000..044dccfb4 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java @@ -0,0 +1,102 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaRoot; + +/** + * ADR-0019 Amendment 2 — the project's wire-format setting ({@code wire} in + * {@code .metaobjects/config.json}): the OUTPUT form of a generated REST surface. Input rules + * (W2, W3) never change with it. + * + *

Both keys default to {@code native}: what the language's standard serializer writes (for + * Java and Kotlin, Jackson as the host configures it: a decimal is a JSON number, a value + * object's null member is {@code null} with the key present). {@code "decimals": "string"} and + * {@code "nullMembers": "omit"} are the cross-port form the conformance corpora pin.

+ */ +public record WireFormat(Decimals decimals, NullMembers nullMembers) { + + /** How a {@code field.decimal} is written. */ + public enum Decimals { + /** The language's standard serializer: a JSON number for {@code BigDecimal}. */ + NATIVE("native"), + /** The canonical string of ADR-0019 Amendment 1, W1. */ + STRING("string"); + + private final String wireName; + + Decimals(String wireName) { this.wireName = wireName; } + + /** The value as spelled in {@code .metaobjects/config.json}. */ + public String wireName() { return wireName; } + } + + /** How a value object's null member is written. */ + public enum NullMembers { + /** The language's standard serializer: for Jackson, {@code null} with the key present. */ + NATIVE("native"), + /** {@code null} with the key present. */ + EMIT("emit"), + /** Left out. */ + OMIT("omit"); + + private final String wireName; + + NullMembers(String wireName) { this.wireName = wireName; } + + /** The value as spelled in {@code .metaobjects/config.json}. */ + public String wireName() { return wireName; } + } + + /** Both keys at {@code native}: what a tree loaded with no project carries. */ + public static final WireFormat DEFAULT = new WireFormat(Decimals.NATIVE, NullMembers.NATIVE); + + public WireFormat { + if (decimals == null) decimals = Decimals.NATIVE; + if (nullMembers == null) nullMembers = NullMembers.NATIVE; + } + + /** True when a decimal is written as the canonical string (the serializer annotation is emitted). */ + public boolean decimalsAsString() { + return decimals == Decimals.STRING; + } + + /** True when a value object's null member is left out (Jackson's {@code NON_NULL} is emitted). */ + public boolean omitNullMembers() { + return nullMembers == NullMembers.OMIT; + } + + /** The {@code decimals} value spelled {@code raw}, or {@code null} when it names none. */ + public static Decimals decimalsOf(String raw) { + for (Decimals d : Decimals.values()) if (d.wireName.equals(raw)) return d; + return null; + } + + /** The {@code nullMembers} value spelled {@code raw}, or {@code null} when it names none. */ + public static NullMembers nullMembersOf(String raw) { + for (NullMembers n : NullMembers.values()) if (n.wireName.equals(raw)) return n; + return null; + } + + /** The wire setting {@code node}'s root carries, or {@link #DEFAULT} for a detached node. */ + public static WireFormat of(MetaData node) { + for (MetaData n = node; n != null; n = n.getParent()) { + if (n instanceof MetaRoot root) return root.getWireFormat(); + } + return DEFAULT; + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java index 2fb938ffb..7c49c2277 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java @@ -724,6 +724,10 @@ public static MetaDataLoader fromDirectory(String name, Path directory, // ADR-0058: the same naming discovery fromUris does, walking up from the directory. loader.getRoot().setPhysicalNaming( discoverNamingOrWarn(name, List.of(directory.toAbsolutePath().toUri()))); + // ADR-0019 Amendment 2: the wire setting from the same file, else the native default. + loader.getRoot().setWireFormat(com.metaobjects.config.SourceResolver + .discoverWireFormat(List.of(directory.toAbsolutePath().toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT)); } catch (MetaDataLoadingException e) { throw e; } catch (Exception e) { @@ -808,6 +812,9 @@ public static MetaDataLoader fromUris(String name, List uris, LoaderOptions com.metaobjects.database.PhysicalNaming naming = physicalNaming != null ? physicalNaming : discoverNamingOrWarn(name, uris); loader.getRoot().setPhysicalNaming(naming); + // ADR-0019 Amendment 2: the wire setting from the same file, else the native default. + loader.getRoot().setWireFormat(com.metaobjects.config.SourceResolver + .discoverWireFormat(uris).orElse(com.metaobjects.config.WireFormat.DEFAULT)); } catch (MetaDataLoadingException e) { throw e; } catch (Exception e) { diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java new file mode 100644 index 000000000..9333e30ae --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.MetaDataException; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** + * ADR-0019 Amendment 2 — runs the shared wire-setting corpus + * ({@code fixtures/wire-conformance/config-blocks.json}) through {@link NeutralConfig#parse}, + * the reader a project's {@code .metaobjects/config.json} goes through. + */ +public class WireConformanceTest { + + private static JsonObject readCorpus() throws IOException { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + if (dir == null) throw new IllegalStateException("could not locate the repository fixtures/ directory"); + String content = new String( + Files.readAllBytes(dir.resolve("fixtures/wire-conformance/config-blocks.json")), StandardCharsets.UTF_8); + return JsonParser.parseString(content).getAsJsonObject(); + } + + /** A JSON {@code null} case value means the {@code wire} key is ABSENT. */ + private static WireFormat parse(JsonElement wire, String origin) { + JsonObject doc = new JsonObject(); + doc.addProperty("schema_version", 1); + if (wire != null && !wire.isJsonNull()) doc.add("wire", wire); + return NeutralConfig.parse(doc.toString(), origin).getWireFormat(); + } + + @Test + public void everyValidBlockResolvesToItsValues() throws IOException { + int n = 0; + for (JsonElement el : readCorpus().getAsJsonArray("cases")) { + JsonObject c = el.getAsJsonObject(); + String name = c.get("name").getAsString(); + WireFormat wire = parse(c.get("wire"), "case:" + name); + assertEquals(name + ": decimals", c.get("decimals").getAsString(), wire.decimals().wireName()); + assertEquals(name + ": nullMembers", c.get("nullMembers").getAsString(), wire.nullMembers().wireName()); + n++; + } + assertEquals(6, n); + } + + @Test + public void everyInvalidBlockIsRefused() throws IOException { + int n = 0; + for (JsonElement el : readCorpus().getAsJsonArray("invalid")) { + JsonObject c = el.getAsJsonObject(); + String name = c.get("name").getAsString(); + assertThrows(name, MetaDataException.class, () -> parse(c.get("wire"), "invalid-case:" + name)); + n++; + } + assertEquals(8, n); + } +} diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index ebbd7ba28..bdbf69870 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -148,6 +148,7 @@ verify as render_verify, ) from metaobjects.shared.base_types import TYPE_OBJECT, TYPE_TEMPLATE +from metaobjects.wire import WireFormat, wire_block, wire_candidates #: The error a run with no generator selection reports. @@ -505,6 +506,7 @@ def load(strict_load: bool) -> LoadResult: # so codegen and the runtime (both reached from an explicit-`metadata_dir` CLI # invocation) read the same values `gen`/`verify --codegen` would. result.root.naming = neutral.naming if neutral is not None else NamingRules() + result.root.wire = neutral.wire if neutral is not None else WireFormat() return result.root, [] @@ -567,6 +569,7 @@ def _load_collection_result( # project's ONE naming setting (read at the same rung as `libraries`); attach # it to the loaded root so codegen, the runtime and `verify` agree. result.root.naming = collection.naming + result.root.wire = collection.wire return result @@ -2151,6 +2154,20 @@ def compare(naming: str) -> tuple[list[str], list[_DirReport]]: ): print(naming_differs_message(candidate, used), file=sys.stderr) return 1 + # The same question for the `wire` setting: it reaches the JSON every generated route + # writes, so "regenerate" would change the contract existing clients read. + used_wire = root.wire + for wire_candidate in wire_candidates(used_wire): + root.wire = wire_candidate + try: + other_errors, other_reports = compare(column_naming) + finally: + root.wire = used_wire + if not other_errors and all( + _report_clean(rep.changed, rep.missing, rep.extra) for rep in other_reports + ): + print(wire_differs_message(wire_candidate, used_wire), file=sys.stderr) + return 1 if regenerable: print("regenerate (metaobjects gen) and commit the result.", file=sys.stderr) if edited_orphans: @@ -2204,6 +2221,20 @@ def naming_differs_message(committed: NamingRules, used: NamingRules) -> str: ) +def wire_differs_message(committed: WireFormat, used: WireFormat) -> str: + """What ``verify --codegen`` says, in place of "regenerate", when the committed output was + generated with the ``committed`` wire format and this run's loaded root resolved ``used`` + (ADR-0019 Amendment 2: the ``wire`` block of ``.metaobjects/config.json``).""" + return ( + f"The committed output was generated with {wire_block(committed)}; this run used " + f"{wire_block(used)} (the wire block of .metaobjects/config.json; each key defaults to " + f"native). Do not regenerate: that changes the JSON the generated routes write " + f"(decimals, value-object null members) for clients already reading it. " + f"Declare {wire_block(committed)} in .metaobjects/config.json, and gen and verify " + f"--codegen both read it." + ) + + #: The text-ref attrs a ``template.*`` node may carry. Each is resolved through #: the filesystem provider and run through the render ``verify()`` gate. A prompt #: / document uses ``@textRef``; an email uses subject / html / (optional) text diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index 865e9a05c..eabce2ce6 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -72,6 +72,7 @@ ) from metaobjects.naming import DEFAULT_COLUMN_NAMING from metaobjects.naming_refs import ref_target +from metaobjects.wire import WIRE_NATIVE, WireFormat, wire_of from metaobjects.shared.base_types import TYPE_IDENTITY from ..runtime.fr010_field_mapping import is_required @@ -261,6 +262,45 @@ def _nested_decimals(entities: list[MetaObject]) -> dict[str, Any]: return out +def _vo_shape(vo: MetaObject, seen: frozenset[str]) -> dict[str, Any]: + """The declared members of the value object *vo*, as ``{"f": [member, ...], "o": {member: + shape}}`` (``o`` for the members that are themselves value objects or maps of them). What + ``nullMembers: emit`` fills in; ``omit`` walks it to reach nested value objects.""" + seen = seen | {vo.name} + shape: dict[str, Any] = {"f": [f.name for f in vo.fields()], "o": {}} + for f in vo.fields(): + inner = _field_shape(f, seen) + if inner is not None: + shape["o"][f.name] = inner + return shape + + +def _field_shape(f: MetaField, seen: frozenset[str]) -> dict[str, Any] | None: + if f.sub_type not in (fc.FIELD_SUBTYPE_OBJECT, fc.FIELD_SUBTYPE_MAP): + return None + target = object_ref_target(f) + if target is None or target.name in seen: + return None + shape = _vo_shape(target, seen) + return {"m": shape} if f.sub_type == fc.FIELD_SUBTYPE_MAP else shape + + +def _wire_lines(entities: list[MetaObject], wire: WireFormat) -> list[str]: + """The ``_WIRE`` constant every response is written through: the project's ``wire`` + setting (ADR-0019 Amendment 2) and, when ``nullMembers`` is not ``native``, the declared + members of each value-object field of *entities* (the first declaration of a name wins).""" + shapes: dict[str, Any] = {} + if wire.null_members != WIRE_NATIVE: + for entity in entities: + for f in entity.fields(): + if f.name not in shapes and (shape := _field_shape(f, frozenset())) is not None: + shapes[f.name] = shape + args = f'decimals="{wire.decimals}", null_members="{wire.null_members}"' + if shapes: + args += f", shapes={shapes!r}" + return ["", "", f"_WIRE = WireOutput({args})"] + + def _decimal_limits_lines(entities: list[MetaObject]) -> list[str]: limits = _decimal_limits(entities) body = "{" + ", ".join(f'"{n}": ({p}, {sc})' for n, (p, sc) in limits.items()) + "}" @@ -734,8 +774,8 @@ def _emit_route_handler( " rows = repo.list(actual_limit, actual_offset, sort_clause, predicates)", " if with_count == 1:", " total = repo.count(predicates)", - ' return {"rows": to_wire(rows), "total": total}', - " return to_wire(rows)", + ' return {"rows": render_wire(rows, _WIRE), "total": total}', + " return render_wire(rows, _WIRE)", ] if name == "get": return [ @@ -747,7 +787,7 @@ def _emit_route_handler( f" row = repo.find_by_id({pk_param})", " if row is None:", ' return JSONResponse(status_code=404, content={"error": "not_found"})', - " return to_wire(row)", + " return render_wire(row, _WIRE)", ] if name == "create": return [ @@ -768,7 +808,7 @@ def _emit_route_handler( # (a fresh row's createdAt == updatedAt), ignoring any caller-supplied value. *create_autoset, " try:", - " return to_wire(repo.create(dto))", + " return render_wire(repo.create(dto), _WIRE)", *_CONSTRAINT_EXCEPT, ] if name == "update": @@ -803,7 +843,7 @@ def _emit_route_handler( *_CONSTRAINT_EXCEPT, " if saved is None:", ' return JSONResponse(status_code=404, content={"error": "not_found"})', - " return to_wire(saved)", + " return render_wire(saved, _WIRE)", ] if name == "delete": return [ @@ -836,8 +876,8 @@ def _emit_m2m_route( f" {pk_param}: {pk_type},", f" repo: Annotated[{repo_class}, Depends(get_repository)],", ") -> list[Any]:", - f" return to_wire(repo.find_related_{d.relation_name}" - f"({pk_param}{_target_subtype_arg(d, {d.target_discriminator})}))", + f" return render_wire(repo.find_related_{d.relation_name}" + f"({pk_param}{_target_subtype_arg(d, {d.target_discriminator})}), _WIRE)", ] def _emit_tph_m2m_route( @@ -893,8 +933,8 @@ def _emit_tph_m2m_route( " return []", ] lines.append( - f" return to_wire(repo.find_related_{d.relation_name}" - f"({subtype_expr}, {pk_param}{_target_subtype_arg(d, target_discs)}))" + f" return render_wire(repo.find_related_{d.relation_name}" + f"({subtype_expr}, {pk_param}{_target_subtype_arg(d, target_discs)}), _WIRE)" ) return lines @@ -923,8 +963,8 @@ def _emit_tph_list_body( f" rows = {repo_var}.list({subtype_expr}, actual_limit, actual_offset, sort_clause, predicates)", " if with_count == 1:", f" total = {repo_var}.count({subtype_expr}, predicates)", - ' return {"rows": to_wire(rows), "total": total}', - " return to_wire(rows)", + ' return {"rows": render_wire(rows, _WIRE), "total": total}', + " return render_wire(rows, _WIRE)", ] def _render_tph_router( @@ -933,6 +973,7 @@ def _render_tph_router( plan: TphPlan, object_index: dict[str, MetaObject], column_naming: str = DEFAULT_COLUMN_NAMING, + wire: WireFormat = WireFormat(), ) -> str: """FR-017 TPH: emit the discriminator base's router — a polymorphic collection at the base path + a full per-subtype CRUD set at //. The repo @@ -1090,7 +1131,8 @@ def _render_tph_router( parts.append(" coerce_decimal_fields,") parts.append(" coerce_decimal_predicates,") parts.append(" parse_json_object,") - parts.append(" to_wire,") + parts.append(" WireOutput,") + parts.append(" render_wire,") parts.append(")") parts.append(f"from {_runtime_module('filter_parser')} import (") parts.append(" FilterPredicate,") @@ -1116,6 +1158,7 @@ def _render_tph_router( parts.append("") parts.append(f"_DECIMAL_FIELDS: frozenset[str] = {decimal_set_body}") parts.extend(_decimal_limits_lines([entity, *(st.entity for st in plan.subtypes)])) + parts.extend(_wire_lines([entity, *(st.entity for st in plan.subtypes)], wire)) parts.append("") parts.append("") parts.extend(_JSON_BODY_LINES) @@ -1207,7 +1250,7 @@ def list_sig(fn: str, route: str) -> list[str]: # non-@autoSet hierarchy — byte-identical output). parts.extend(create_autoset) parts.append(" try:") - parts.append(f' return to_wire(repo.create("{val}", dto))') + parts.append(f' return render_wire(repo.create("{val}", dto), _WIRE)') parts.extend(_CONSTRAINT_EXCEPT) parts.append("") parts.append("") @@ -1219,7 +1262,7 @@ def list_sig(fn: str, route: str) -> list[str]: parts.append(f' row = repo.find_by_id("{val}", {pk_param})') parts.append(" if row is None:") parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})') - parts.append(" return to_wire(row)") + parts.append(" return render_wire(row, _WIRE)") parts.append("") parts.append("") parts.append(f'@router.patch("/{seg}/{{{pk_param}}}")') @@ -1248,7 +1291,7 @@ def list_sig(fn: str, route: str) -> list[str]: parts.extend(_CONSTRAINT_EXCEPT) parts.append(" if saved is None:") parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})') - parts.append(" return to_wire(saved)") + parts.append(" return render_wire(saved, _WIRE)") parts.append("") parts.append("") parts.append(f'@router.delete("/{seg}/{{{pk_param}}}", status_code=status.HTTP_204_NO_CONTENT)') @@ -1298,7 +1341,7 @@ def list_sig(fn: str, route: str) -> list[str]: parts.append(f" row = repo.find_by_id(None, {pk_param})") parts.append(" if row is None:") parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})') - parts.append(" return to_wire(row)") + parts.append(" return render_wire(row, _WIRE)") parts.append("") # FW-8 rule (a): every relationship the BASE resolves — every row of the @@ -1326,6 +1369,7 @@ def render_router( entity: MetaObject, object_index: dict[str, MetaObject] | None = None, column_naming: str = DEFAULT_COLUMN_NAMING, + wire: WireFormat | None = None, ) -> str | None: """Render an entity as a FastAPI ``APIRouter`` module. @@ -1342,6 +1386,8 @@ def render_router( source URL segment is the ENTITY name pluralized (cross-port grammar), NOT the physical ``@table``. Without an index, only CRUD is emitted (back-compat). + *wire* is the run's ``wire`` setting; omitted, it is read from the loaded root. + *column_naming* is ``GenConfig.column_naming`` for this run — passed through to :func:`resolve_m2m_descriptors` so the M:N descriptor's physical junction columns are resolved by the SAME strategy the ``names`` generator applies to @@ -1353,6 +1399,9 @@ def render_router( """ if not emits_router(entity): return None + # ADR-0019 Amendment 2: the project's `wire` setting, from the loaded root. A caller + # that holds an object detached from the root (a report's read model) passes it. + wire = wire if wire is not None else wire_of(entity) # FR-017 TPH: a concrete subtype is folded into its base's single table — it # emits no standalone router (its CRUD lives under the base's per-subtype segment). if object_index is not None and is_tph_subtype(entity): @@ -1371,7 +1420,7 @@ def render_router( # answering the cross-port 405 envelope. Gated by # fixtures/api-contract-conformance/projection/. if is_read_only_routed(entity): - return self._render_readonly_router(entity, column_naming) + return self._render_readonly_router(entity, column_naming, wire) return None # FR-017 TPH: a discriminator base emits a polymorphic collection at the base @@ -1379,7 +1428,7 @@ def render_router( if object_index is not None: plan = tph_plan_for(entity, object_index) if plan is not None: - return self._render_tph_router(entity, plan, object_index, column_naming) + return self._render_tph_router(entity, plan, object_index, column_naming, wire) m2m: list[M2mDescriptor] = ( resolve_m2m_descriptors(entity, object_index, column_naming) @@ -1458,7 +1507,8 @@ def render_router( parts.append(" coerce_decimal_fields,") parts.append(" coerce_decimal_predicates,") parts.append(" parse_json_object,") - parts.append(" to_wire,") + parts.append(" WireOutput,") + parts.append(" render_wire,") parts.append(")") parts.append(f"from {_runtime_module('filter_parser')} import (") parts.append(" FilterPredicate,") @@ -1485,6 +1535,7 @@ def render_router( parts.append("") parts.append(f"_DECIMAL_FIELDS: frozenset[str] = {decimal_set_body}") parts.extend(_decimal_limits_lines([entity])) + parts.extend(_wire_lines([entity], wire)) parts.append("") parts.append("") parts.extend(_JSON_BODY_LINES) @@ -1599,6 +1650,7 @@ def _render_readonly_router( self, entity: MetaObject, column_naming: str = DEFAULT_COLUMN_NAMING, + wire: WireFormat = WireFormat(), ) -> str: """Render a read-only (`@kind: view` / `materializedView`) object as a FastAPI ``APIRouter``: GET list + GET by id, and the four write verbs answering 405. An @@ -1663,7 +1715,8 @@ def _render_readonly_router( parts.append("") parts.append(f"from {_runtime_module('decimal_wire')} import (") parts.append(" coerce_decimal_predicates,") - parts.append(" to_wire,") + parts.append(" WireOutput,") + parts.append(" render_wire,") parts.append(")") parts.append(f"from {_runtime_module('filter_parser')} import (") parts.append(" FilterPredicate,") @@ -1682,6 +1735,7 @@ def _render_readonly_router( "_DECIMAL_FIELDS: frozenset[str] = " + _py_set_literal(_decimal_field_names([entity]), frozen=True) ) + parts.extend(_wire_lines([entity], wire)) parts.append("") parts.append("") parts.extend(_sort_parse_fn_lines()) @@ -1746,7 +1800,8 @@ def emit(entity: MetaObject, c: GenContext) -> list[EmittedFile]: # reads (`c.config.column_naming`) — so the M:N descriptor built here # and the per-field constants built there can't disagree about a # column name within one run. - source = self.render_router(entity, index, c.config.column_naming) + wire = wire_of(c.loaded_root) if c.loaded_root is not None else None + source = self.render_router(entity, index, c.config.column_naming, wire) if source is None: return [] snake = _snake_case(entity.name) diff --git a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py index 03ac96332..46d8cec1d 100644 --- a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py +++ b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py @@ -6,6 +6,8 @@ * **W1, emit** -- :func:`format_decimal` / :func:`to_wire`: every ``Decimal`` leaves as a JSON string in canonical form (plain notation, no exponent, no trailing fractional zeros, zero is ``"0"``). + Which form leaves is the project's ``wire`` setting (:func:`render_wire`): ``string`` is W1, + ``native`` is :func:`to_native`. * **W2, accept** -- :func:`parse_decimal` (a request-body value) and :func:`parse_decimal_operand` (a filter operand): a string in the JSON-number grammar, or a JSON number; anything else is a ``ValueError``. Nothing goes through a ``float``: :func:`parse_json_object` reads a body with @@ -20,7 +22,7 @@ import json import re from decimal import Context, Decimal, InvalidOperation -from typing import Any, Iterable +from typing import Any, Callable, Iterable #: A string body value and a filter operand: the JSON-number grammar. An exponent is allowed on #: input only. No sign other than ``-``, no leading zeros, no bare point, no whitespace. @@ -245,17 +247,81 @@ def coerce_decimal_predicates( return out, None -def to_wire(value: Any) -> Any: - """W1 over a whole response: every ``Decimal`` in *value* (a row, a list of rows, the - ``{"rows", "total"}`` envelope, a Pydantic model) as its canonical string. Everything else - is left for the framework's encoder.""" +def _render(value: Any, decimal: Callable[[Decimal], str]) -> Any: + """*value* with every ``Decimal`` (at any depth of a dict, list or Pydantic model) as the + string *decimal* makes of it; everything else is left for the framework's encoder.""" if isinstance(value, Decimal): - return format_decimal(value) + return decimal(value) if isinstance(value, dict): - return {k: to_wire(v) for k, v in value.items()} + return {k: _render(v, decimal) for k, v in value.items()} if isinstance(value, (list, tuple)): - return [to_wire(v) for v in value] + return [_render(v, decimal) for v in value] dump = getattr(value, "model_dump", None) if callable(dump): - return to_wire(dump()) + return _render(dump(), decimal) return value + + +def to_wire(value: Any) -> Any: + """W1 over a whole response: every ``Decimal`` in *value* (a row, a list of rows, the + ``{"rows", "total"}`` envelope, a Pydantic model) as its canonical string.""" + return _render(value, format_decimal) + + +def to_native(value: Any) -> Any: + """The ``native`` decimal form over a whole response: every ``Decimal`` as ``str(value)``, + which is what Pydantic v2 writes for one and keeps the value's scale (``Decimal("12.50")`` + is ``"12.50"``). FastAPI's own encoder would turn a ``Decimal`` in a plain dict row into a + ``float``, which is lossy; a string is the exact form.""" + return _render(value, str) + + +@dataclasses.dataclass(frozen=True) +class WireOutput: + """The ``wire`` project setting (ADR-0019 Amendment 2) a generated router writes with. + + *decimals* is ``native`` or ``string``; *null_members* is ``native``, ``emit`` or ``omit``. + *shapes* names, per value-object field of the entity, its declared members: + ``{"f": [member, ...], "o": {member: shape}}``, or ``{"m": shape}`` for a ``field.map`` of + value objects (the shape applies to each value).""" + + decimals: str = "native" + null_members: str = "native" + shapes: dict[str, Any] = dataclasses.field(default_factory=dict) + + +def render_wire(value: Any, wire: WireOutput) -> Any: + """A response *value* (a row, a list of rows, the ``{"rows", "total"}`` envelope, a + Pydantic model) in the form *wire* asks for. Input rules do not depend on *wire*.""" + if wire.null_members != "native" and wire.shapes: + value = _shape_members(value, wire) + return to_wire(value) if wire.decimals == "string" else to_native(value) + + +def _shape_members(value: Any, wire: WireOutput) -> Any: + """Apply ``omit`` / ``emit`` to the value-object fields of every row in *value*. A row's + other fields (a top-level ``null`` among them) are untouched.""" + if isinstance(value, (list, tuple)): + return [_shape_members(v, wire) for v in value] + dump = getattr(value, "model_dump", None) + if callable(dump): + value = dump() + if not isinstance(value, dict): + return value + out: dict[str, Any] = {} + for k, v in value.items(): + out[k] = _shape_vo(v, wire.shapes[k], wire.null_members) if k in wire.shapes else _shape_members(v, wire) + return out + + +def _shape_vo(value: Any, shape: dict[str, Any], mode: str) -> Any: + if isinstance(value, list): + return [_shape_vo(v, shape, mode) for v in value] + if not isinstance(value, dict): + return value + if "m" in shape: + return {k: _shape_vo(v, shape["m"], mode) for k, v in value.items()} + members = {k: _shape_vo(v, shape["o"][k], mode) if k in shape["o"] else v for k, v in value.items()} + if mode == "omit": + return {k: v for k, v in members.items() if v is not None} + return {**{name: None for name in shape["f"]}, **members} diff --git a/server/python/src/metaobjects/config/dependencies.py b/server/python/src/metaobjects/config/dependencies.py index f633ddeba..039baabc8 100644 --- a/server/python/src/metaobjects/config/dependencies.py +++ b/server/python/src/metaobjects/config/dependencies.py @@ -17,6 +17,7 @@ from metaobjects.errors import ErrorCode, ParseError from metaobjects.meta.meta_data import MetaData from metaobjects.naming import NamingRules, package_of_resolution_key +from metaobjects.wire import WireFormat from metaobjects.registry_manifest import METAMODEL_VERSION from metaobjects.shared.base_types import TYPE_OBJECT @@ -659,6 +660,10 @@ class Collection: #: codegen, the runtime and `verify` all read the same values. naming: NamingRules + #: ADR-0019 Amendment 2 — this project's `wire` setting, read at the same rung; + #: attached to the loaded root beside `naming`. + wire: WireFormat = WireFormat() + def imported(self, fqn: str) -> bool: """Is `fqn` in a package one of this project's dependencies owns?""" return package_of_resolution_key(fqn) in self.imported_packages diff --git a/server/python/src/metaobjects/config/neutral_config.py b/server/python/src/metaobjects/config/neutral_config.py index 2de4c5241..93bfd7495 100644 --- a/server/python/src/metaobjects/config/neutral_config.py +++ b/server/python/src/metaobjects/config/neutral_config.py @@ -7,6 +7,7 @@ from metaobjects.errors import ErrorCode, ParseError from metaobjects.naming import COLUMN_NAMING_STRATEGIES, TABLE_NAMING_RULES, NamingRules +from metaobjects.wire import WIRE_DECIMALS, WIRE_NULL_MEMBERS, WireFormat #: The DEFAULT value of `sources` when the key is absent or empty — never a #: requirement, and never assumed to exist by any other code path. @@ -75,6 +76,12 @@ class NeutralConfig: #: every column. naming: NamingRules + #: ADR-0019 Amendment 2 — the `wire` block: the OUTPUT form of a generated REST + #: surface (decimals, value-object null members). Absent or `{}` ⇒ `native` for both. + #: Attached to the loaded root (`MetaRoot.wire`) beside `naming`; validated HERE + #: for the same reason `naming` is. + wire: WireFormat = WireFormat() + def find_project_dir(start: Path) -> Path | None: """The nearest directory at or above *start* that holds ``.metaobjects/config.json``, @@ -167,6 +174,7 @@ def read_neutral_config(config_dir: Path) -> NeutralConfig | None: migrate_scope = _read_migrate_scope(raw, path) libraries = _read_libraries(raw, path) naming = _read_naming(raw, path) + wire = _read_wire(raw, path) # Unknown top-level keys are IGNORED by design — see the module docstring. return NeutralConfig( @@ -176,6 +184,7 @@ def read_neutral_config(config_dir: Path) -> NeutralConfig | None: migrate_scope=migrate_scope, libraries=libraries, naming=naming, + wire=wire, ) @@ -296,6 +305,39 @@ def _read_naming(raw: dict[str, object], path: Path) -> NamingRules: return NamingRules(columns=columns, tables=tables) +#: Every key the `wire` block accepts, and the ONLY ones it accepts (refused, not ignored, like `naming`). +_WIRE_KEYS = ("decimals", "nullMembers") + + +def _read_wire(raw: dict[str, object], path: Path) -> WireFormat: + """ADR-0019 Amendment 2 — the `wire` block. Absent or `{}` ⇒ `native` for both keys. + Refuses a non-object `wire`, an unknown key, or a value outside the closed set a key + accepts: a mistake must not silently fall back to the default and change the JSON an + API writes.""" + if "wire" not in raw: + return WireFormat() + wire = raw["wire"] + if not isinstance(wire, dict): + raise ParseError(f'{path}: "wire" must be an object', code=ErrorCode.ERR_COLLECTION_NOT_FOUND) + for key in wire: + if key not in _WIRE_KEYS: + raise ParseError( + f'{path}: "wire" has an unknown key "{key}" (expected "decimals" and/or "nullMembers")', + code=ErrorCode.ERR_COLLECTION_NOT_FOUND, + ) + values: dict[str, str] = {} + for key, allowed in (("decimals", WIRE_DECIMALS), ("nullMembers", WIRE_NULL_MEMBERS)): + value = wire.get(key, allowed[0]) + if not isinstance(value, str) or value not in allowed: + choices = ", ".join(f'"{a}"' for a in allowed) + raise ParseError( + f'{path}: "wire.{key}" must be one of {choices}, got {json.dumps(value)}', + code=ErrorCode.ERR_COLLECTION_NOT_FOUND, + ) + values[key] = value + return WireFormat(decimals=values["decimals"], null_members=values["nullMembers"]) + + def _validate_dependency_spec(dep: object, path: Path) -> dict[str, str]: """Validate one entry of `dependencies` (DESIGN §3.1) and return it normalized. Mirrors the TS `DependencySpecSchema` union in diff --git a/server/python/src/metaobjects/config/source_resolver.py b/server/python/src/metaobjects/config/source_resolver.py index 48f1b9c47..87c4879da 100644 --- a/server/python/src/metaobjects/config/source_resolver.py +++ b/server/python/src/metaobjects/config/source_resolver.py @@ -7,6 +7,7 @@ from metaobjects.errors import ErrorCode, ParseError from metaobjects.loader.sources import DirectorySource from metaobjects.naming import NamingRules, package_of_resolution_key +from metaobjects.wire import WireFormat from metaobjects.scope import compile_scope, matches_scope from .dependencies import ( @@ -155,6 +156,7 @@ def _collection_from_own_files( migrate_scope: list[str] | None = cfg.migrate_scope if cfg is not None else None libraries: list[str] = cfg.libraries if cfg is not None else [] naming: NamingRules = cfg.naming if cfg is not None else NamingRules() + wire: WireFormat = cfg.wire if cfg is not None else WireFormat() lock = read_lock(root) dependencies: list[ResolvedDependency] = ( @@ -185,6 +187,7 @@ def _collection_from_own_files( ), libraries=tuple(libraries), naming=naming, + wire=wire, ) diff --git a/server/python/src/metaobjects/loader/meta_data_loader.py b/server/python/src/metaobjects/loader/meta_data_loader.py index 0c4ba37c4..4589a0400 100644 --- a/server/python/src/metaobjects/loader/meta_data_loader.py +++ b/server/python/src/metaobjects/loader/meta_data_loader.py @@ -59,10 +59,12 @@ def _apply_discovered_naming(result: "LoadResult", start: Path) -> None: *start*) to the loaded root; warn once when there is none and the default applies.""" from ..config.neutral_config import find_project_dir from ..naming import attach_naming + from ..wire import attach_wire project = find_project_dir(start if start.is_dir() else start.parent) if project is not None: attach_naming(result.root, project) + attach_wire(result.root, project) return warnings.warn( f"no .metaobjects/config.json found at or above {start}: physical names use the " diff --git a/server/python/src/metaobjects/meta/meta_root.py b/server/python/src/metaobjects/meta/meta_root.py index f3291ba86..81f0156d0 100644 --- a/server/python/src/metaobjects/meta/meta_root.py +++ b/server/python/src/metaobjects/meta/meta_root.py @@ -2,6 +2,7 @@ from __future__ import annotations from ..naming import NamingRules +from ..wire import WireFormat from .meta_data import MetaData @@ -16,3 +17,7 @@ def __init__(self, type_: str, sub_type: str, name: str) -> None: # after loading — never during construction, since the loader seeds an empty # root before any file is read. self.naming: NamingRules = NamingRules() + # ADR-0019 Amendment 2 — the project `wire` setting (output form of the generated + # REST surface), attached by the same project-aware loads as `naming`; read with + # `wire_of()`. `native` for both keys until a load attaches the project's own. + self.wire: WireFormat = WireFormat() diff --git a/server/python/src/metaobjects/wire.py b/server/python/src/metaobjects/wire.py new file mode 100644 index 000000000..3da898672 --- /dev/null +++ b/server/python/src/metaobjects/wire.py @@ -0,0 +1,70 @@ +"""The project ``wire`` setting (ADR-0019 Amendment 2): the OUTPUT form of a generated REST surface. + +``wire`` in ``.metaobjects/config.json`` (``{"decimals": ..., "nullMembers": ...}``) is read by +the same port-neutral reader as ``naming`` and carried by the loaded root (``MetaRoot.wire``); +codegen reads it with :func:`wire_of`. The default is ``native`` for both keys: what this +port's own serializer writes (Pydantic's ``str(Decimal)``, a value object as the runtime holds +it). ``"decimals": "string"`` and ``"nullMembers": "omit"`` give the portable form the +conformance corpora pin. INPUT rules (W2/W3) do not depend on this setting. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from metaobjects.meta.meta_data import MetaData + +WIRE_NATIVE = "native" +WIRE_DECIMALS_STRING = "string" +WIRE_NULLS_EMIT = "emit" +WIRE_NULLS_OMIT = "omit" + +#: The closed sets each key accepts, in the order the refusal message lists them. +WIRE_DECIMALS = (WIRE_NATIVE, WIRE_DECIMALS_STRING) +WIRE_NULL_MEMBERS = (WIRE_NATIVE, WIRE_NULLS_EMIT, WIRE_NULLS_OMIT) + + +@dataclass(frozen=True) +class WireFormat: + """The resolved ``wire`` block. Trusted input: validation happens where the raw JSON is + parsed (``metaobjects.config.neutral_config``).""" + + decimals: str = WIRE_NATIVE + null_members: str = WIRE_NATIVE + + +def wire_of(node: "MetaData") -> WireFormat: + """The :class:`WireFormat` in force for *node*: the loaded root's ``wire``, or the default + for a root nobody attached project config to.""" + cur = node + while cur.parent is not None: + cur = cur.parent + wire = getattr(cur, "wire", None) + return wire if isinstance(wire, WireFormat) else WireFormat() + + +def wire_block(wire: WireFormat) -> str: + """A wire format as the ``wire`` block that declares it.""" + return f'"wire": {{ "decimals": "{wire.decimals}", "nullMembers": "{wire.null_members}" }}' + + +def wire_candidates(used: WireFormat) -> list[WireFormat]: + """Every wire format other than ``used``, in the order ``verify --codegen`` probes them.""" + return [ + candidate + for d in WIRE_DECIMALS + for n in WIRE_NULL_MEMBERS + if (candidate := WireFormat(decimals=d, null_members=n)) != used + ] + + +def attach_wire(root: "MetaData", project_dir: Path) -> "MetaData": + """Set ``root.wire`` from *project_dir*'s ``.metaobjects/config.json`` and return *root*, + for a load site that bypasses the CLI's collection ladder (a conformance harness).""" + from metaobjects.config.neutral_config import read_neutral_config + + cfg = read_neutral_config(project_dir) + root.wire = cfg.wire if cfg is not None else WireFormat() # type: ignore[attr-defined] + return root diff --git a/server/python/tests/codegen/test_config_run_settings.py b/server/python/tests/codegen/test_config_run_settings.py index 2478cbf18..4e7377e3f 100644 --- a/server/python/tests/codegen/test_config_run_settings.py +++ b/server/python/tests/codegen/test_config_run_settings.py @@ -254,6 +254,96 @@ def test_real_drift_still_says_regenerate( assert "The committed output was generated with" not in err +# --- wire (ADR-0019 Amendment 2) ---------------------------------------------------------- + +PRICED_MODEL = { + "metadata.root": { + "package": "acme", + "children": [ + {"object.entity": {"name": "Invoice", "children": [ + {"source.rdb": {"@table": "invoices"}}, + {"field.long": {"name": "id"}}, + {"field.decimal": {"name": "amount", "@precision": 12, "@scale": 2}}, + {"identity.primary": {"@fields": "id"}}, + ]}}, + ], + } +} + + +def _wire_project(root: Path, wire: dict[str, str] | None) -> Path: + meta = root / "metaobjects" + meta.mkdir(parents=True, exist_ok=True) + (meta / "meta.acme.json").write_text(json.dumps(PRICED_MODEL), encoding="utf-8") + (root / "metaobjects.config.yaml").write_text( + "targets:\n app:\n outDir: gen\n generators: [entity, filter-allowlist, routes]\n", + encoding="utf-8", + ) + _write_wire(root, wire) + return root / "gen" + + +def _write_wire(root: Path, wire: dict[str, str] | None) -> None: + dot = root / ".metaobjects" + dot.mkdir(parents=True, exist_ok=True) + raw: dict[str, object] = {"schema_version": 1, "naming": {"columns": "literal"}} + if wire is not None: + raw["wire"] = wire + (dot / "config.json").write_text(json.dumps(raw), encoding="utf-8") + + +def test_wire_in_dot_metaobjects_config_reaches_the_generated_router( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + out = _wire_project(tmp_path, {"decimals": "string", "nullMembers": "omit"}) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + [router] = list(out.rglob("invoice_router.py")) + assert '_WIRE = WireOutput(decimals="string", null_members="omit")' in router.read_text(encoding="utf-8") + capsys.readouterr() + assert main(["verify", "--codegen"]) == 0 + + +def test_verify_with_another_wire_says_the_wire_block_differs_not_regenerate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + # Committed under the portable form; then the config drops the wire block, so the native + # default disagrees. Regenerating would change the JSON the routes write to existing clients. + _wire_project(tmp_path, {"decimals": "string", "nullMembers": "omit"}) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + _write_wire(tmp_path, None) + capsys.readouterr() + assert main(["verify", "--codegen"]) == 1 + err = capsys.readouterr().err + assert ( + 'The committed output was generated with "wire": { "decimals": "string", ' + '"nullMembers": "omit" }; this run used "wire": { "decimals": "native", ' + '"nullMembers": "native" } (the wire block of .metaobjects/config.json; each key ' + "defaults to native). Do not regenerate" in err + ) + assert ( + 'Declare "wire": { "decimals": "string", "nullMembers": "omit" } in ' + ".metaobjects/config.json" in err + ) + assert "regenerate (metaobjects gen)" not in err + + +def test_verify_with_unrelated_drift_next_to_a_wire_change_still_says_regenerate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + out = _wire_project(tmp_path, {"decimals": "string"}) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + [router] = list(out.rglob("invoice_router.py")) + router.write_text(router.read_text(encoding="utf-8") + "# edited\n", encoding="utf-8") + capsys.readouterr() + assert main(["verify", "--codegen"]) == 1 + err = capsys.readouterr().err + assert "regenerate (metaobjects gen) and commit the result." in err + assert "this run used \"wire\"" not in err + + # --- templates ---------------------------------------------------------------------------- diff --git a/server/python/tests/codegen/test_router_generator.py b/server/python/tests/codegen/test_router_generator.py index 7440bec47..1a69c12e2 100644 --- a/server/python/tests/codegen/test_router_generator.py +++ b/server/python/tests/codegen/test_router_generator.py @@ -120,9 +120,9 @@ def test_per_entity_sort_allowlist_constant_emitted() -> None: def test_with_count_returns_envelope_bare_list_otherwise() -> None: out = _require(_author()) # withCount=1 → {"rows": ..., "total": N} - assert 'return {"rows": to_wire(rows), "total": total}' in out + assert 'return {"rows": render_wire(rows, _WIRE), "total": total}' in out # default → bare list - assert "return to_wire(rows)" in out + assert "return render_wire(rows, _WIRE)" in out # The 404 envelope and 400 invalid_sort envelope are part of the contract. assert '"error": "not_found"' in out # invalid_sort NAMES the rejected field — required cross-port on every diff --git a/server/python/tests/codegen/test_router_generator_m2m_target_tph.py b/server/python/tests/codegen/test_router_generator_m2m_target_tph.py index 6bec72af5..5b6424114 100644 --- a/server/python/tests/codegen/test_router_generator_m2m_target_tph.py +++ b/server/python/tests/codegen/test_router_generator_m2m_target_tph.py @@ -139,7 +139,7 @@ def test_route_body_passes_the_resolved_discriminator_value_literal() -> None: " sponsor_id: int,\n" " repo: Annotated[SponsorRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - ' return to_wire(repo.find_related_bridgeAuths(sponsor_id, "Bridge"))' + ' return render_wire(repo.find_related_bridgeAuths(sponsor_id, "Bridge"), _WIRE)' ) in out diff --git a/server/python/tests/codegen/test_router_generator_tph_m2m.py b/server/python/tests/codegen/test_router_generator_tph_m2m.py index 2692881a8..dbdba0b05 100644 --- a/server/python/tests/codegen/test_router_generator_tph_m2m.py +++ b/server/python/tests/codegen/test_router_generator_tph_m2m.py @@ -254,7 +254,7 @@ def test_base_declared_m2m_mounted_at_base_path_with_no_subtype_gate() -> None: " auth_id: int,\n" " repo: Annotated[AuthRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - " return to_wire(repo.find_related_tags(None, auth_id))" + " return render_wire(repo.find_related_tags(None, auth_id), _WIRE)" ) in out # No gate anywhere near this handler — find_by_id is never called with None. assert 'repo.find_by_id(None, auth_id) is None' not in out @@ -275,7 +275,7 @@ def _gated_body( return ( f' if repo.find_by_id("{subtype}", {pk_param}) is None:\n' " return []\n" - f' return to_wire(repo.find_related_{relation}("{subtype}", {pk_param}{tail}))' + f' return render_wire(repo.find_related_{relation}("{subtype}", {pk_param}{tail}), _WIRE)' ) @@ -322,7 +322,7 @@ def test_priority_tickets_target_discriminator_independent_of_source_gate() -> N " auth_id: int,\n" " repo: Annotated[AuthRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - ' return to_wire(repo.find_related_priorityTickets(None, auth_id, "Priority"))' + ' return render_wire(repo.find_related_priorityTickets(None, auth_id, "Priority"), _WIRE)' ) in out # Inherited by every subtype too (rule b) — each subtype's OWN gate applies, # while the target literal stays fixed at "Priority" regardless. diff --git a/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py b/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py index c61f8fc2d..f21001ae1 100644 --- a/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py +++ b/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py @@ -181,18 +181,18 @@ def test_shadowed_name_seam_threads_target_subtype_for_the_whole_name() -> None: def test_every_mount_of_the_shadowed_name_passes_the_threaded_parameter() -> None: # Base mount (rule a — ungated): explicit None literal, not a dropped argument. out = _render() - assert " return to_wire(repo.find_related_helpers(None, auth_id, None))" in out + assert " return render_wire(repo.find_related_helpers(None, auth_id, None), _WIRE)" in out # Non-shadowing subtype (inherits the base's vanilla target): gated, None literal. assert ( ' if repo.find_by_id("Copay", auth_id) is None:\n' " return []\n" - ' return to_wire(repo.find_related_helpers("Copay", auth_id, None))' + ' return render_wire(repo.find_related_helpers("Copay", auth_id, None), _WIRE)' ) in out # Shadowing subtype: its OWN target's discriminator literal. assert ( ' if repo.find_by_id("Bridge", auth_id) is None:\n' " return []\n" - ' return to_wire(repo.find_related_helpers("Bridge", auth_id, "Priority"))' + ' return render_wire(repo.find_related_helpers("Bridge", auth_id, "Priority"), _WIRE)' ) in out diff --git a/server/python/tests/codegen/test_tph_codegen.py b/server/python/tests/codegen/test_tph_codegen.py index 93528e27d..7379fa230 100644 --- a/server/python/tests/codegen/test_tph_codegen.py +++ b/server/python/tests/codegen/test_tph_codegen.py @@ -122,7 +122,7 @@ def test_base_router_polymorphic_plus_per_subtype(entities, index) -> None: assert f'@router.post("/{seg}", status_code=status.HTTP_201_CREATED)' in src assert f'@router.get("/{seg}/{{auth_id}}")' in src assert f'@router.delete("/{seg}/{{auth_id}}"' in src - assert f'return to_wire(repo.create("{val}", dto))' in src + assert f'return render_wire(repo.create("{val}", dto), _WIRE)' in src # the per-subtype literal routes are emitted before the polymorphic /{id} assert src.index('@router.get("/bridge")') < src.index('@router.get("/{auth_id}")') diff --git a/server/python/tests/conformance/test_wire_conformance.py b/server/python/tests/conformance/test_wire_conformance.py new file mode 100644 index 000000000..6ef0def70 --- /dev/null +++ b/server/python/tests/conformance/test_wire_conformance.py @@ -0,0 +1,54 @@ +"""ADR-0019 Amendment 2 cross-port conformance: ``fixtures/wire-conformance/config-blocks.json``. + +Every port parses each case's ``wire`` block with its OWN config reader +(``metaobjects.config.neutral_config``) and must resolve ``decimals`` and ``nullMembers`` to +the recorded values; every ``invalid`` block must be refused when the config is read. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects.config.neutral_config import read_neutral_config +from metaobjects.errors import ErrorCode, ParseError + +REPO = Path(__file__).parents[4] +_SPEC = json.loads((REPO / "fixtures" / "wire-conformance" / "config-blocks.json").read_text()) +_CASES: list[dict[str, object]] = _SPEC["cases"] +_INVALID: list[dict[str, object]] = _SPEC["invalid"] + + +def _project_with(tmp_path: Path, wire_block: object) -> Path: + """A project dir whose ``.metaobjects/config.json`` declares *wire_block* (``None`` omits the key).""" + raw: dict[str, object] = {"schema_version": 1} + if wire_block is not None: + raw["wire"] = wire_block + dot = tmp_path / ".metaobjects" + dot.mkdir(parents=True, exist_ok=True) + (dot / "config.json").write_text(json.dumps(raw), encoding="utf-8") + return tmp_path + + +@pytest.mark.parametrize("case", _CASES, ids=lambda c: c["name"]) +def test_wire_block_resolves(case: dict[str, object], tmp_path: Path) -> None: + cfg = read_neutral_config(_project_with(tmp_path, case["wire"])) + assert cfg is not None + assert cfg.wire.decimals == case["decimals"] + assert cfg.wire.null_members == case["nullMembers"] + + +@pytest.mark.parametrize("case", _INVALID, ids=lambda c: c["name"]) +def test_invalid_wire_block_is_refused(case: dict[str, object], tmp_path: Path) -> None: + with pytest.raises(ParseError) as raised: + read_neutral_config(_project_with(tmp_path, case["wire"])) + assert raised.value.code == ErrorCode.ERR_COLLECTION_NOT_FOUND + assert "wire" in str(raised.value) + + +def test_the_corpora_pin_the_portable_form() -> None: + for corpus in ("api-contract-conformance", "persistence-conformance"): + cfg = read_neutral_config(REPO / "fixtures" / corpus) + assert cfg is not None + assert (cfg.wire.decimals, cfg.wire.null_members) == ("string", "omit"), corpus diff --git a/server/python/tests/integration/corpus_config.py b/server/python/tests/integration/corpus_config.py new file mode 100644 index 000000000..25746af24 --- /dev/null +++ b/server/python/tests/integration/corpus_config.py @@ -0,0 +1,18 @@ +"""The project config a conformance corpus pins in its own ``.metaobjects/config.json``.""" +from __future__ import annotations + +from pathlib import Path + +from metaobjects.config.neutral_config import find_project_dir +from metaobjects.meta.meta_data import MetaData +from metaobjects.wire import attach_wire + + +def attach_corpus_wire(root: MetaData, meta_json: Path) -> MetaData: + """Set the ``wire`` setting of the corpus holding *meta_json* on *root*, read from the + nearest ``.metaobjects/config.json`` above it (never a literal), so a generated lane + writes the form the corpus pins.""" + project = find_project_dir(meta_json.parent) + if project is None: + raise RuntimeError(f"no .metaobjects/config.json at or above {meta_json}") + return attach_wire(root, project) diff --git a/server/python/tests/integration/generated_jsonb_app.py b/server/python/tests/integration/generated_jsonb_app.py index c345cdf12..28f59f101 100644 --- a/server/python/tests/integration/generated_jsonb_app.py +++ b/server/python/tests/integration/generated_jsonb_app.py @@ -24,6 +24,7 @@ from fastapi import FastAPI from metaobjects import MetaDataLoader +from .corpus_config import attach_corpus_wire from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist from metaobjects.codegen.generators.entity_model import render_entity_model from metaobjects.codegen.generators.router_generator import render_router @@ -43,6 +44,7 @@ def _load_corpus_objects(meta_json: Path) -> tuple[MetaObject, list[MetaObject]] tmp = Path(tempfile.mkdtemp(prefix="apic-jsonb-meta-")) shutil.copy(meta_json, tmp / "meta.json") result = MetaDataLoader.from_directory(str(tmp)) + attach_corpus_wire(result.root, meta_json) if result.errors: msgs = "; ".join(f"{e.code}: {e.message}" for e in result.errors) raise RuntimeError(f"corpus meta.json failed to load: {msgs}") diff --git a/server/python/tests/integration/generated_m2m_app.py b/server/python/tests/integration/generated_m2m_app.py index 9c916e269..557f462ef 100644 --- a/server/python/tests/integration/generated_m2m_app.py +++ b/server/python/tests/integration/generated_m2m_app.py @@ -51,6 +51,7 @@ from fastapi import FastAPI from metaobjects import MetaDataLoader +from .corpus_config import attach_corpus_wire from metaobjects.codegen.generators.entity_model import render_entity_model from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist from metaobjects.codegen.runtime.m2m_codegen import ( @@ -93,6 +94,7 @@ def _load_entities(meta_json: Path) -> dict[str, MetaObject]: try: shutil.copy(meta_json, tmp / "meta.json") result = MetaDataLoader.from_directory(str(tmp)) + attach_corpus_wire(result.root, meta_json) if result.errors: msgs = "; ".join(f"{e.code}: {e.message}" for e in result.errors) raise RuntimeError(f"m2m meta.json failed to load: {msgs}") diff --git a/server/python/tests/integration/generated_projection_app.py b/server/python/tests/integration/generated_projection_app.py index 011e98142..674e74100 100644 --- a/server/python/tests/integration/generated_projection_app.py +++ b/server/python/tests/integration/generated_projection_app.py @@ -32,6 +32,7 @@ from fastapi import FastAPI from metaobjects import MetaDataLoader +from .corpus_config import attach_corpus_wire from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist from metaobjects.codegen.generators.router_generator import render_router from metaobjects.codegen.runtime.filter_parser import FilterPredicate @@ -67,6 +68,7 @@ def _load_projections(meta_json: Path) -> dict[str, MetaObject]: tmp = Path(tempfile.mkdtemp(prefix="apic-proj-meta-")) shutil.copy(meta_json, tmp / "meta.json") result = MetaDataLoader.from_directory(str(tmp)) + attach_corpus_wire(result.root, meta_json) if result.errors: msgs = "; ".join(f"{e.code}: {e.message}" for e in result.errors) raise RuntimeError(f"projection meta.json failed to load: {msgs}") diff --git a/server/python/tests/integration/generated_report_app.py b/server/python/tests/integration/generated_report_app.py index c3dfdde16..4c952339b 100644 --- a/server/python/tests/integration/generated_report_app.py +++ b/server/python/tests/integration/generated_report_app.py @@ -39,6 +39,7 @@ from fastapi import FastAPI from metaobjects import MetaDataLoader +from .corpus_config import attach_corpus_wire from metaobjects.codegen.config import GenConfig from metaobjects.codegen.generator_registry import GeneratorBuildContext, list_generators from metaobjects.codegen.runner import run_gen @@ -72,6 +73,7 @@ def _load_root(meta_json: Path): tmp = Path(tempfile.mkdtemp(prefix="apic-report-meta-")) shutil.copy(meta_json, tmp / "meta.json") result = MetaDataLoader.from_directory(str(tmp)) + attach_corpus_wire(result.root, meta_json) if result.errors: msgs = "; ".join(f"{e.code}: {e.message}" for e in result.errors) raise RuntimeError(f"report meta.json failed to load: {msgs}") diff --git a/server/python/tests/integration/generated_router_app.py b/server/python/tests/integration/generated_router_app.py index 4b7d272db..adfc83469 100644 --- a/server/python/tests/integration/generated_router_app.py +++ b/server/python/tests/integration/generated_router_app.py @@ -35,6 +35,7 @@ from fastapi import FastAPI from metaobjects import MetaDataLoader +from .corpus_config import attach_corpus_wire from metaobjects.codegen.generators.entity_model import render_entity_model from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist from metaobjects.codegen.generators.router_generator import render_router @@ -55,6 +56,7 @@ def _find_author_entity(meta_json: Path) -> MetaObject: tmp = Path(tempfile.mkdtemp(prefix="apic-meta-")) shutil.copy(meta_json, tmp / "meta.json") result = MetaDataLoader.from_directory(str(tmp)) + attach_corpus_wire(result.root, meta_json) if result.errors: msgs = "; ".join(f"{e.code}: {e.message}" for e in result.errors) raise RuntimeError(f"corpus meta.json failed to load: {msgs}") diff --git a/server/python/tests/integration/generated_tph_app.py b/server/python/tests/integration/generated_tph_app.py index 3bb431004..cecbac6e4 100644 --- a/server/python/tests/integration/generated_tph_app.py +++ b/server/python/tests/integration/generated_tph_app.py @@ -30,6 +30,7 @@ from fastapi import FastAPI from metaobjects import MetaDataLoader +from .corpus_config import attach_corpus_wire from metaobjects.codegen.generators.entity_model import render_entity_model from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist from metaobjects.codegen.runtime.m2m_codegen import build_object_index @@ -47,6 +48,7 @@ def _load_entities(meta_json: Path) -> dict[str, MetaObject]: try: shutil.copy(meta_json, tmp / "meta.json") result = MetaDataLoader.from_directory(str(tmp)) + attach_corpus_wire(result.root, meta_json) if result.errors: msgs = "; ".join(f"{e.code}: {e.message}" for e in result.errors) raise RuntimeError(f"tph meta.json failed to load: {msgs}") diff --git a/server/python/tests/integration/test_generated_decimal_wire.py b/server/python/tests/integration/test_generated_decimal_wire.py index 5b6410bc7..5162b5a8b 100644 --- a/server/python/tests/integration/test_generated_decimal_wire.py +++ b/server/python/tests/integration/test_generated_decimal_wire.py @@ -27,6 +27,7 @@ from metaobjects.codegen.generators.router_generator import render_router from metaobjects.codegen.runtime.filter_parser import FilterPredicate from metaobjects.runtime import ObjectManager, SqliteDriver +from metaobjects.wire import WireFormat _META = { "metadata.root": { @@ -78,9 +79,18 @@ def delete(self, id: int) -> bool: return self.om.delete("Entry", id) +#: The portable form the cross-port corpora pin; the canonical-output tests below run in it. +_PORTABLE = WireFormat(decimals="string", null_members="omit") + + @pytest.fixture() def client() -> TestClient: + return _make_client(_PORTABLE) + + +def _make_client(wire: WireFormat) -> TestClient: root = load_string(json.dumps(_META)).root + root.wire = wire # type: ignore[attr-defined] entry = next(c for c in root.children() if c.name == "Entry") pkg = f"gendec_{uuid.uuid4().hex[:8]}" tmp = Path(tempfile.mkdtemp(prefix="gendec-")) @@ -225,7 +235,13 @@ def test_a_value_beyond_the_declared_range_is_a_400_and_nothing_is_rounded(clien @pytest.fixture() def float_client() -> TestClient: + # Canonical decimals; a null member stays as stored (`native`), which this test sends. + return _make_float_client(WireFormat(decimals="string")) + + +def _make_float_client(wire: WireFormat) -> TestClient: root = load_string(json.dumps(_FLOAT_META)).root + root.wire = wire # type: ignore[attr-defined] objs = {c.name.split("::")[-1]: c for c in root.children()} pkg = f"genflt_{uuid.uuid4().hex[:8]}" tmp = Path(tempfile.mkdtemp(prefix="genflt-")) @@ -342,3 +358,134 @@ def test_a_decimal_array_is_range_checked_element_wise_and_coerced_for_the_drive from metaobjects.runtime.object_manager import _coerce_write_value assert _coerce_write_value(series, [Decimal("1.5"), "2", 3, None]) == [Decimal("1.5"), Decimal("2"), Decimal("3"), None] + + +# --- the `wire` setting (ADR-0019 Amendment 2): the DEFAULT is native ------------------------- + + +def _row_client(wire: WireFormat, rows: list[dict[str, Any]]) -> TestClient: + """The Sample router over a repository that returns *rows* verbatim (a driver that keeps a + decimal's scale, as Postgres and MySQL do; SQLite cannot), under the given wire setting.""" + root = load_string(json.dumps(_FLOAT_META)).root + root.wire = wire # type: ignore[attr-defined] + objs = {c.name.split("::")[-1]: c for c in root.children()} + pkg = f"genwire_{uuid.uuid4().hex[:8]}" + tmp = Path(tempfile.mkdtemp(prefix="genwire-")) + pkg_dir = tmp / pkg + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + for vo in ("Fix", "Tier", "Pricing"): + (pkg_dir / f"{vo}.py").write_text(render_entity_model(objs[vo])) + sample = objs["Sample"] + (pkg_dir / "sample_filter_allowlist.py").write_text(render_filter_allowlist(sample) or "") + (pkg_dir / "Sample.py").write_text(render_entity_model(sample)) + (pkg_dir / "sample_router.py").write_text(render_router(sample) or "") + sys.path.insert(0, str(tmp)) + try: + mod = importlib.import_module(f"{pkg}.sample_router") + finally: + sys.path.remove(str(tmp)) + + class Repo: + def list(self, limit: int, offset: int, sort: Any, filters: list[FilterPredicate]) -> list[Any]: + return rows + + def count(self, filters: list[FilterPredicate]) -> int: + return len(rows) + + def find_by_id(self, id: int) -> Any: + return rows[0] + + def create(self, dto: Any) -> Any: + return rows[0] + + app = FastAPI() + app.include_router(mod.router) + app.dependency_overrides[mod.get_repository] = lambda: Repo() + return TestClient(app) + + +_DECIMAL_ROW = { + "id": 1, + "score": 1.5, + "series": [Decimal("12.50"), Decimal("0.0000001"), Decimal("100")], + "fix": None, + "fixes": None, + "tiers": None, + "pricing": None, +} + + +def test_native_is_the_default_and_writes_a_decimal_as_pydantic_does() -> None: + """`str(Decimal)`: the scale is kept and a small value keeps its exponent; not the float a + plain-dict row would become under FastAPI's own encoder.""" + default = _row_client(WireFormat(), [_DECIMAL_ROW]) + assert default.get("/api/samples/1").json()["series"] == ["12.50", "1E-7", "100"] + assert default.get("/api/samples").json()[0]["series"] == ["12.50", "1E-7", "100"] + assert default.get("/api/samples?withCount=1").json()["rows"][0]["series"] == ["12.50", "1E-7", "100"] + + +def test_string_writes_the_canonical_form() -> None: + string = _row_client(WireFormat(decimals="string"), [_DECIMAL_ROW]) + assert string.get("/api/samples/1").json()["series"] == ["12.5", "0.0000001", "100"] + + +def test_input_refusals_hold_under_native() -> None: + native = _row_client(WireFormat(), [_DECIMAL_ROW]) + hdr = {"content-type": "application/json"} + for bad in ('{"series": ["abc"]}', '{"series": ["0.12345"]}', "[1]", "{"): + resp = native.post("/api/samples", content=bad, headers=hdr) + assert resp.status_code == 400 and resp.json() == {"error": "validation"}, (bad, resp.text) + assert native.post("/api/samples", content='{"series": ["1.5", 2]}', headers=hdr).status_code == 201 + bad_filter = native.get("/api/samples", params={"filter[series][gt]": "oops"}) + assert bad_filter.status_code == 400 + + +#: A stored value object: `latitude` set, `readings` explicitly null, `amount` absent. +_VO_ROW = {**_DECIMAL_ROW, "series": None, "fix": {"latitude": 51.25, "readings": None}} + + +def test_null_members_native_passes_the_stored_value_object_through() -> None: + fix = _row_client(WireFormat(), [_VO_ROW]).get("/api/samples/1").json()["fix"] + assert fix == {"latitude": 51.25, "readings": None} + + +def test_null_members_omit_drops_a_null_member_but_not_a_null_field() -> None: + body = _row_client(WireFormat(null_members="omit"), [_VO_ROW]).get("/api/samples/1").json() + assert body["fix"] == {"latitude": 51.25} + assert body["tiers"] is None and "tiers" in body # a top-level field is untouched + + +def test_null_members_emit_writes_every_declared_member() -> None: + body = _row_client(WireFormat(null_members="emit"), [_VO_ROW]).get("/api/samples/1").json() + assert body["fix"] == {"latitude": 51.25, "readings": None, "amount": None} + + +def test_null_members_reach_arrays_maps_and_nested_value_objects() -> None: + row = { + **_DECIMAL_ROW, + "series": None, + "fixes": [{"latitude": 1.0}, {"latitude": 2.0, "amount": None}], + "tiers": {"gold": {"amount": None}}, + "pricing": {"tiers": {"silver": {}}}, + } + emit = _row_client(WireFormat(null_members="emit"), [row]).get("/api/samples/1").json() + assert emit["fixes"] == [ + {"latitude": 1.0, "readings": None, "amount": None}, + {"latitude": 2.0, "readings": None, "amount": None}, + ] + assert emit["tiers"] == {"gold": {"amount": None}} + assert emit["pricing"] == {"tiers": {"silver": {"amount": None}}} + omit = _row_client(WireFormat(null_members="omit"), [row]).get("/api/samples/1").json() + assert omit["fixes"] == [{"latitude": 1.0}, {"latitude": 2.0}] + assert omit["tiers"] == {"gold": {}} + assert omit["pricing"] == {"tiers": {"silver": {}}} + + +def test_a_generated_router_carries_the_setting_only_as_data() -> None: + root = load_string(json.dumps(_FLOAT_META)).root + sample = next(c for c in root.children() if c.name.endswith("Sample")) + assert '_WIRE = WireOutput(decimals="native", null_members="native")' in (render_router(sample) or "") + root.wire = _PORTABLE # type: ignore[attr-defined] + src = render_router(sample) or "" + assert '_WIRE = WireOutput(decimals="string", null_members="omit", shapes=' in src diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 63accb24e..0bc013a8c 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -113,6 +113,11 @@ import { TEMPLATE_ATTR_TEXT_BODY_REF, REQUIREMENT_STATUSES, physicalNamingOf, + WIRE_DECIMALS, + WIRE_NULL_MEMBERS, + WIRE_KEY_DECIMALS, + WIRE_KEY_NULL_MEMBERS, + type WireFormat, } from "@metaobjectsdev/metadata"; import { verify, ERR_REQUIRED_SLOT_UNUSED, ERR_PARTIAL_UNRESOLVED } from "@metaobjectsdev/render"; import { describeLoadError, reportLoadError } from "../lib/load-error.js"; @@ -1640,6 +1645,29 @@ export async function verifyCommand( return 2; } + // ADR-0019 Amendment 2: committed output generated under another `wire` setting is drift + // that regenerating would "fix" by changing the JSON the API writes. Name the setting. + if (!result.clean && result.driftedFiles.length > result.handEditedNames.length + result.editedOrphans.length) { + const used = codegenRoot.wireFormat; + const committed = await matchingWire(used, async (wire) => { + codegenRoot.wireFormat = wire; + try { + const probe = await computeCodegenDrift(config, codegenRoot, genConfigDir, genCollection.inScope, + genCollection.ownFiles, { + sourceRoots: sourceRootsOf(genCollection), + agentSchema: () => buildAgentSchemaInput(codegenRoot, config.dialect, () => {}), + }); + return probe.error === undefined && probe.clean; + } finally { + codegenRoot.wireFormat = used; + } + }); + if (committed !== undefined) { + log.error(wireDiffersMessage(committed, used)); + return 1; + } + } + // Hand edits are not drift, but they must never be INVISIBLE: a validator weakened // inside a generated file used to pass this gate with "every file's generated // contribution is current" and nothing else. Reported on every run; fails the build @@ -1863,6 +1891,40 @@ interface VerifyGateRow { * prove a file was edited but cannot show the edit. Deleting a generated file and * re-running `meta gen` re-emits its pristine generation; git then shows the difference. */ +/** A `wire` setting as the block that declares it in `.metaobjects/config.json`. */ +export function wireBlock(wire: WireFormat): string { + return `"wire": { "${WIRE_KEY_DECIMALS}": "${wire.decimals}", "${WIRE_KEY_NULL_MEMBERS}": "${wire.nullMembers}" }`; +} + +/** + * ADR-0019 Amendment 2: the other `wire` setting under which `reproduces` says the generators + * write the committed output exactly, or `undefined` when none does. Probes decimals {native, + * string} x nullMembers {native, emit, omit}, skipping the setting this run used. + */ +export async function matchingWire( + used: WireFormat, + reproduces: (wire: WireFormat) => Promise, +): Promise { + for (const decimals of WIRE_DECIMALS) { + for (const nullMembers of WIRE_NULL_MEMBERS) { + if (decimals === used.decimals && nullMembers === used.nullMembers) continue; + const wire: WireFormat = { decimals, nullMembers }; + if (await reproduces(wire)) return wire; + } + } + return undefined; +} + +/** The verify failure for committed output that another `wire` setting reproduces. */ +export function wireDiffersMessage(committed: WireFormat, used: WireFormat): string { + return ( + `The committed output was generated with ${wireBlock(committed)}; this run used ${wireBlock(used)} ` + + "(.metaobjects/config.json; each key defaults to native). Do not regenerate: that changes the JSON " + + "your API writes for decimals or for a value object's null members. Declare " + + `${wireBlock(committed)} in .metaobjects/config.json, and 'meta gen' and 'meta verify --codegen' both read it.` + ); +} + export function handEditNotice(paths: readonly string[], forbidden: boolean): string[] { const header = forbidden ? `meta verify — ${paths.length} generated file(s) carry hand edits, and --forbid-hand-edits is set:` diff --git a/server/typescript/packages/cli/src/lib/collection-load-options.ts b/server/typescript/packages/cli/src/lib/collection-load-options.ts index e90c6d9a8..c86d74433 100644 --- a/server/typescript/packages/cli/src/lib/collection-load-options.ts +++ b/server/typescript/packages/cli/src/lib/collection-load-options.ts @@ -26,7 +26,7 @@ import type { Collection, LoadMemoryOptions } from "@metaobjectsdev/sdk"; */ export function collectionLoadOptions( collection: Collection, -): Required> { +): Required> { return { files: collection.files, fileIds: collection.fileIds, @@ -39,5 +39,6 @@ export function collectionLoadOptions( // bug where one of the two reached every command and the other reached none. libraries: collection.libraries, physicalNaming: collection.physicalNaming, + wireFormat: collection.wireFormat, }; } diff --git a/server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts b/server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts new file mode 100644 index 000000000..40ca34b00 --- /dev/null +++ b/server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts @@ -0,0 +1,125 @@ +/** + * ADR-0019 Amendment 2: committed output generated under another `wire` setting is not "stale". + * Regenerating would change the JSON the API writes, so `verify --codegen` names the `wire` block + * that reproduces the committed output instead of prescribing `meta gen`. + */ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { run } from "../../src/index.js"; + +const WORKSPACE_TMP = resolve(import.meta.dirname, "../fixtures/__tmp__"); + +const MODEL = { "metadata.root": { package: "acme", children: [ + { "object.value": { name: "Price", children: [ + { "field.decimal": { name: "net", "@precision": 12, "@scale": 2 } }, + { "field.string": { name: "note" } }, + ] } }, + { "object.entity": { name: "Item", children: [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { name: "id" } }, + { "field.decimal": { name: "amount", "@precision": 10, "@scale": 2 } }, + { "field.object": { name: "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } }, + ] } }, +] } }; + +function setupRepo(): string { + mkdirSync(WORKSPACE_TMP, { recursive: true }); + const root = mkdtempSync(join(WORKSPACE_TMP, "verify-wire-")); + mkdirSync(join(root, "metaobjects")); + writeFileSync(join(root, "metaobjects", "meta.acme.json"), JSON.stringify(MODEL)); + writeFileSync( + join(root, "metaobjects.config.ts"), + ` +import { defineConfig } from "@metaobjectsdev/codegen-ts"; +export default defineConfig({ + outDir: ${JSON.stringify(join(root, "generated"))}, + dialect: "postgres", + dbImport: "~/db", + extStyle: "none", + generators: ["entity", "queries", "routes"], +}); +`, + ); + return root; +} + +function writeWire(root: string, wire: Record | null): void { + mkdirSync(join(root, ".metaobjects"), { recursive: true }); + writeFileSync( + join(root, ".metaobjects", "config.json"), + JSON.stringify(wire === null ? { schema_version: 1 } : { schema_version: 1, wire }), + ); +} + +let out: string[]; +let err: string[]; +let origLog: typeof console.log; +let origErr: typeof console.error; + +beforeEach(() => { + out = []; + err = []; + origLog = console.log; + origErr = console.error; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); }; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); }; +}); +afterEach(() => { + console.log = origLog; + console.error = origErr; +}); + +const all = (): string => [...out, ...err].join("\n"); + +describe("meta verify --codegen — the wire setting", () => { + test("output committed under another wire setting names that setting, not 'meta gen'", async () => { + const root = setupRepo(); + try { + writeWire(root, { decimals: "string", nullMembers: "omit" }); + expect(await run(["gen", "--cwd", root])).toBe(0); + + writeWire(root, null); + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); + expect(all()).toContain( + 'The committed output was generated with "wire": { "decimals": "string", "nullMembers": "omit" }; ' + + 'this run used "wire": { "decimals": "native", "nullMembers": "native" }', + ); + expect(all()).toContain("Do not regenerate"); + expect(all()).not.toContain("Run 'meta gen' to regenerate"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); + + test("the matching setting passes", async () => { + const root = setupRepo(); + try { + writeWire(root, { decimals: "string" }); + expect(await run(["gen", "--cwd", root])).toBe(0); + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); + + test("a model change is still reported as stale", async () => { + const root = setupRepo(); + try { + writeWire(root, null); + expect(await run(["gen", "--cwd", root])).toBe(0); + const changed = JSON.parse(JSON.stringify(MODEL)); + changed["metadata.root"].children[1]["object.entity"].children.push({ "field.string": { name: "sku" } }); + writeFileSync(join(root, "metaobjects", "meta.acme.json"), JSON.stringify(changed)); + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); + expect(all()).toContain("Run 'meta gen' to regenerate"); + expect(all()).not.toContain("The committed output was generated with"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/server/typescript/packages/codegen-ts/src/api-surface.ts b/server/typescript/packages/codegen-ts/src/api-surface.ts index c779dbf63..bcb1bac2b 100644 --- a/server/typescript/packages/codegen-ts/src/api-surface.ts +++ b/server/typescript/packages/codegen-ts/src/api-surface.ts @@ -24,7 +24,20 @@ // grid, its grid hook, the Angular service) is. They differ for a served report, which has // a route and a list hook and no grid, form or detail hook. -import { FIELD_SUBTYPE_DECIMAL, type MetaObject } from "@metaobjectsdev/metadata"; +import { + FIELD_ATTR_OBJECT_REF, + FIELD_SUBTYPE_DECIMAL, + FIELD_SUBTYPE_MAP, + FIELD_SUBTYPE_OBJECT, + OBJECT_SUBTYPE_VALUE, + TYPE_FIELD, + WIRE_DECIMALS_STRING, + WIRE_NULL_MEMBERS_NATIVE, + refTarget, + wireFormatOf, + type MetaData, + type MetaObject, +} from "@metaobjectsdev/metadata"; import { isAbstract } from "./instance-artifacts.js"; import { isProjection } from "./projection/projection-detector.js"; import { hasAnyRdbSource, hasWritableRdbSource, isReport, servedReport } from "./source-detect.js"; @@ -131,13 +144,82 @@ export function decimalFieldNames(entity: MetaObject): readonly string[] { return entity.fields().filter((f) => f.subType === FIELD_SUBTYPE_DECIMAL).map((f) => f.name); } -/** `\ndecimalColumns: [...],` for a mount option list, or "" when the object has no decimal. - * Several objects share one mount when a single-table (TPH) base's polymorphic route serves its - * subtypes' rows too: pass them all, so a decimal only a subtype declares is still a decimal. */ -export function decimalColumnsLine(entity: MetaObject | readonly MetaObject[], indent: string): string { +/** The declared shape of a value object, as a generated mount takes it (`ValueObjectShape` in runtime-ts). */ +interface ValueObjectShapeData { + members: string[]; + map?: true; + nested?: Record; +} + +/** The value object a `field.object` / `field.map` field references, or undefined. */ +function valueObjectOf(field: MetaData): MetaData | undefined { + if (field.subType !== FIELD_SUBTYPE_OBJECT && field.subType !== FIELD_SUBTYPE_MAP) return undefined; + const target = refTarget(field, FIELD_ATTR_OBJECT_REF); + return target?.subType === OBJECT_SUBTYPE_VALUE ? target : undefined; +} + +function shapeOf(field: MetaData, vo: MetaData, path: readonly MetaData[]): ValueObjectShapeData { + // ADR-0039: resolving. A value object's members may be inherited via extends. + const fields = vo.children().filter((c) => c.type === TYPE_FIELD); + const shape: ValueObjectShapeData = { members: fields.map((f) => f.name) }; + if (field.subType === FIELD_SUBTYPE_MAP) shape.map = true; + for (const f of fields) { + const inner = valueObjectOf(f); + // A value object that contains itself is walked once: the stored value is finite, and + // shaping stops where the declaration repeats. + if (inner === undefined || path.includes(inner)) continue; + (shape.nested ??= {})[f.name] = shapeOf(f, inner, [...path, inner]); + } + return shape; +} + +/** The jsonb columns of `entity` that hold a value object (or an array / map of them), with their declared shape. */ +export function valueObjectShapes(entity: MetaObject): Record { + const out: Record = {}; + for (const f of entity.fields()) { + const vo = valueObjectOf(f); + if (vo !== undefined) out[f.name] = shapeOf(f, vo, [vo]); + } + return out; +} + +/** + * The wire options a generated mount takes (ADR-0019 Amendment 2), one `\nkey: value,` + * line each, or "" when the object needs none: + * + * - `decimalColumns`: the object's `field.decimal` names (always, when it has any: under + * `native` the mount still renders a SQLite number at 15 digits); + * - `decimalWire: "string"`: only when the project asks for canonical strings; + * - `nullMembers` + `valueObjects`: only when the project asks to `emit` or `omit` null + * members and the object has a value-object column. + * + * The setting is the project's, read from the root. Several objects share one mount when a + * single-table (TPH) base's polymorphic route serves its subtypes' rows too: pass them all, + * so a decimal or value object only a subtype declares is still covered. `root` is the loaded + * root the setting is read from (`ctx.loadedRoot`). + */ +export function decimalColumnsLine(entity: MetaObject | readonly MetaObject[], indent: string, root?: MetaData): string { const objects: readonly MetaObject[] = Array.isArray(entity) ? entity : [entity as MetaObject]; + const first = objects[0]; + if (first === undefined) return ""; + // A report's read model is a detached object with no root of its own, so a generator passes the + // loaded root; without one the object's own root answers. + const wire = wireFormatOf(root ?? first); const names = [...new Set(objects.flatMap((o) => decimalFieldNames(o)))]; - return names.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(names)},` : ""; + let lines = ""; + if (names.length > 0) { + lines += `\n${indent}decimalColumns: ${JSON.stringify(names)},`; + if (wire.decimals === WIRE_DECIMALS_STRING) lines += `\n${indent}decimalWire: ${JSON.stringify(wire.decimals)},`; + } + if (wire.nullMembers !== WIRE_NULL_MEMBERS_NATIVE) { + const shapes: Record = {}; + for (const o of objects) for (const [column, shape] of Object.entries(valueObjectShapes(o))) shapes[column] ??= shape; + if (Object.keys(shapes).length > 0) { + lines += `\n${indent}nullMembers: ${JSON.stringify(wire.nullMembers)},`; + lines += `\n${indent}valueObjects: ${JSON.stringify(shapes)},`; + } + } + return lines; } /** diff --git a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts index 821d095ed..ad7b81d60 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts @@ -59,7 +59,6 @@ import { isReport, itemRouteField, decimalColumnsLine, - decimalFieldNames, servesReadApi, formatTs, entityOutputPath, @@ -137,15 +136,13 @@ function renderRoutesHono( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. - // A decimal is a canonical string on the wire on every dialect (ADR-0019 Amendment 1). - const decimalColumns = decimalFieldNames(entity); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + - (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); + decimalColumnsLine(entity, indent, ctx.loadedRoot); const HonoSym = imp("t:Hono@hono"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); @@ -233,7 +230,7 @@ export function ${handlerName}(app: ${HonoSym}, deps: { db: unkno updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLineHono} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineHono} }); } `; diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index d9cc73bbf..69e2521b8 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -69,7 +69,6 @@ import { isReport, itemRouteField, decimalColumnsLine, - decimalFieldNames, servesReadApi, formatTs, entityOutputPath, @@ -155,15 +154,13 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. - // A decimal is a canonical string on the wire on every dialect (ADR-0019 Amendment 1). - const decimalColumns = decimalFieldNames(entity); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + - (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — @@ -298,7 +295,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -323,7 +320,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -479,7 +476,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ")}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } @@ -580,7 +577,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ")}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -623,7 +620,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts index 3a34b9d70..0e7c07a69 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts @@ -31,7 +31,7 @@ import { entityModuleSpecifier } from "../import-path.js"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants.js"; import { isProjection, isWriteThrough } from "../projection/projection-detector.js"; import { isReport } from "../source-detect.js"; -import { decimalColumnsLine, decimalFieldNames, itemRouteField } from "../api-surface.js"; +import { decimalColumnsLine, itemRouteField } from "../api-surface.js"; import { DEFAULT_ID_FIELD } from "../pk-resolver.js"; import { authSeamJsDoc, type CrudVerb, exposeLine } from "../routes-expose.js"; import { effectivePackage } from "../docs-paths.js"; @@ -94,15 +94,13 @@ export function renderRoutesFileHono( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. - // A decimal is a canonical string on the wire on every dialect (ADR-0019 Amendment 1). - const decimalColumns = decimalFieldNames(entity); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + - (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); + decimalColumnsLine(entity, indent, ctx.loadedRoot); const HonoSym = imp("t:Hono@hono"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); @@ -190,7 +188,7 @@ export function ${handlerName}(app: ${HonoSym}, deps: { db: unkno updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLineHono} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineHono} }); } `; diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts index c44c52d06..32194e23d 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts @@ -29,7 +29,7 @@ import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants import { routesHandlerName } from "../naming.js"; import { isProjection, isWriteThrough } from "../projection/projection-detector.js"; import { isReport } from "../source-detect.js"; -import { decimalColumnsLine, decimalFieldNames, itemRouteField } from "../api-surface.js"; +import { decimalColumnsLine, itemRouteField } from "../api-surface.js"; import { DEFAULT_ID_FIELD, pkInfoOf } from "../pk-resolver.js"; import type { RelationEntry } from "../relation-resolver.js"; import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js"; @@ -93,15 +93,13 @@ export function renderRoutesFile( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. - // A decimal is a canonical string on the wire on every dialect (ADR-0019 Amendment 1). - const decimalColumns = decimalFieldNames(entity); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + - (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — @@ -236,7 +234,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -261,7 +259,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -412,7 +410,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ")}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } @@ -510,7 +508,7 @@ function renderTphRoutesFile( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ")}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -553,7 +551,7 @@ function renderTphRoutesFile( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/server/typescript/packages/codegen-ts/test/decimal-wire-e2e.test.ts b/server/typescript/packages/codegen-ts/test/decimal-wire-e2e.test.ts index 2111adb62..19365ae88 100644 --- a/server/typescript/packages/codegen-ts/test/decimal-wire-e2e.test.ts +++ b/server/typescript/packages/codegen-ts/test/decimal-wire-e2e.test.ts @@ -1,4 +1,5 @@ -// A field.decimal end to end on SQLite, GENERATED code executed (ADR-0019 Amendment 1). +// A field.decimal end to end on SQLite, GENERATED code executed (ADR-0019 Amendment 1), +// with `wire.decimals: string` pinned. // // The Zod module and the filter allowlist rendered from metadata, mounted with runtime-ts's Hono // helper over bun:sqlite with the `decimalColumns` option the generated route passes. Covers the @@ -75,6 +76,9 @@ beforeAll(async () => { filterAllowlist: allowlists["EntryFilterAllowlist"] as never, sortAllowlist: {}, dialect: "sqlite", decimalColumns: decimalFieldNames(entry), + // This file pins the 1.1 canonical form (`wire.decimals: string`); wire-format-e2e.test.ts + // covers the native default. + decimalWire: "string", }); }); diff --git a/server/typescript/packages/codegen-ts/test/wire-format-e2e.test.ts b/server/typescript/packages/codegen-ts/test/wire-format-e2e.test.ts new file mode 100644 index 000000000..5332cc8d5 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/wire-format-e2e.test.ts @@ -0,0 +1,189 @@ +// The `wire` project setting end to end (ADR-0019 Amendment 2): the options the generated routes +// pass (`decimalColumnsLine`, read from the root's wire format) evaluated and handed to +// runtime-ts's Hono mount over bun:sqlite, and the HTTP bodies it answers asserted. +// +// `amount` is a TEXT column here so a row holds the string a Postgres or MySQL driver would hand +// back; `weight` is a NUMERIC column so a row holds the number SQLite or D1 would. + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Database } from "bun:sqlite"; +import { Hono } from "hono"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { sqliteTable, integer, text, numeric } from "drizzle-orm/sqlite-core"; +import { MetaDataLoader, InMemoryStringSource, type MetaObject, type WireFormat } from "@metaobjectsdev/metadata"; +import { mountCrudRoutes } from "@metaobjectsdev/runtime-ts/hono"; +import { renderZodValidators, renderInsertSchemaOnly } from "../src/templates/zod-validators.js"; +import { renderFilterAllowlist } from "../src/templates/filter-allowlist.js"; +import { decimalColumnsLine } from "../src/api-surface.js"; + +const MODEL = { + "metadata.root": { package: "ledger", children: [ + { "object.value": { name: "Money", children: [ + { "field.string": { name: "label" } }, + { "field.decimal": { name: "amount", "@precision": 9, "@scale": 4 } }, + { "field.string": { name: "note" } }, + ]}}, + { "object.entity": { name: "Entry", children: [ + { "source.rdb": { "@table": "entries" } }, + { "field.long": { name: "id" } }, + { "field.decimal": { name: "amount", "@precision": 9, "@scale": 4 } }, + { "field.decimal": { name: "weight" } }, + { "field.decimal": { name: "readings", isArray: true, "@precision": 9, "@scale": 4 } }, + { "field.object": { name: "total", "@objectRef": "Money", "@storage": "jsonb" } }, + { "field.object": { name: "lines", "@objectRef": "Money", isArray: true, "@storage": "jsonb" } }, + { "field.map": { name: "byKey", "@objectRef": "Money" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ]}}, + ]}, +}; + +const entries = sqliteTable("entries", { + id: integer("id").primaryKey({ autoIncrement: true }), + amount: text("amount"), + weight: numeric("weight"), + readings: text("readings", { mode: "json" }), + total: text("total", { mode: "json" }), + lines: text("lines", { mode: "json" }), + byKey: text("by_key", { mode: "json" }), +}); + +const dir = mkdtempSync(join(import.meta.dir, "tmp-wire-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +async function execute(name: string, source: string): Promise> { + const file = join(dir, `${name}.ts`); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, source); + return import(pathToFileURL(file).href); +} + +let seq = 0; +/** A server mounted with exactly the wire options the generated route would pass under `wire`. */ +async function serverFor(wire: WireFormat | undefined): Promise<{ app: Hono; sqlite: Database; options: string }> { + const { root, errors } = await new MetaDataLoader({ strict: true }).load([ + new InMemoryStringSource(JSON.stringify(MODEL), { id: "ledger.json" }), + ]); + expect(errors).toEqual([]); + if (wire !== undefined) root.wireFormat = wire; + const entry = root.objects().find((o) => o.name === "Entry") as MetaObject; + const money = root.objects().find((o) => o.name === "Money") as MetaObject; + const n = seq++; + await execute(`s${n}/Money`, renderInsertSchemaOnly(money).toString()); + const schemas = await execute(`s${n}/schemas`, renderZodValidators(entry).toString()); + const allowlists = await execute(`s${n}/allowlist`, renderFilterAllowlist(entry).toString()); + // The generated route's option lines, evaluated as the object literal they are. + const options = decimalColumnsLine(entry, ""); + const wireOptions = new Function(`return {${options}}`)() as Record; + const sqlite = new Database(":memory:"); + sqlite.run("CREATE TABLE entries (id INTEGER PRIMARY KEY AUTOINCREMENT, amount TEXT, weight NUMERIC, readings TEXT, total TEXT, lines TEXT, by_key TEXT)"); + const app = new Hono(); + mountCrudRoutes({ + app, path: "/entries", db: drizzle(sqlite), table: entries, + insertSchema: schemas["EntryInsertSchema"] as never, + updateSchema: schemas["EntryUpdateSchema"] as never, + filterAllowlist: allowlists["EntryFilterAllowlist"] as never, + sortAllowlist: {}, dialect: "sqlite", + ...wireOptions, + }); + return { app, sqlite, options }; +} + +const seed = (sqlite: Database): void => { + sqlite.run( + `INSERT INTO entries (amount, weight, readings, total, lines, by_key) VALUES (?, ?, ?, ?, ?, ?)`, + [ + "12.50", 0.1 + 0.2, '["1.10","2"]', + '{"label":"a","amount":null}', + '[{"label":null,"amount":"1.5"},{"amount":"2"}]', + '{"k":{"label":null,"amount":"3"}}', + ], + ); +}; + +const get = async (app: Hono): Promise> => { + const res = await app.request("/entries/1"); + expect(res.status).toBe(200); + return (await res.json()) as Record; +}; + +describe("wire.decimals", () => { + test("native (the default): a driver string is written as it is, a SQLite number at 15 digits, an array element-wise", async () => { + const { app, sqlite, options } = await serverFor(undefined); + expect(options).toContain('decimalColumns: ["amount","weight","readings"]'); + expect(options).not.toContain("decimalWire"); + seed(sqlite); + const row = await get(app); + expect(row["amount"]).toBe("12.50"); + expect(row["weight"]).toBe("0.3"); + expect(row["readings"]).toEqual(["1.10", "2"]); + }); + + test("string: the canonical form, exactly as 1.1.1", async () => { + const { app, sqlite, options } = await serverFor({ decimals: "string", nullMembers: "native" }); + expect(options).toContain('decimalWire: "string"'); + seed(sqlite); + const row = await get(app); + expect(row["amount"]).toBe("12.5"); + expect(row["weight"]).toBe("0.3"); + expect(row["readings"]).toEqual(["1.1", "2"]); + }); + + test("input rules hold under native: a number is accepted, over-scale and garbage are 400", async () => { + const { app } = await serverFor(undefined); + const post = (body: unknown) => + app.request("/entries", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const ok = await post({ amount: 0.5, weight: "1.50" }); + expect(ok.status).toBe(201); + for (const amount of ["12abc", "0.12345", "100000", true]) { + const res = await post({ amount }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe("validation"); + } + }); +}); + +describe("wire.nullMembers", () => { + test("native (the default): a stored null is written, an absent member stays absent", async () => { + const { app, sqlite, options } = await serverFor(undefined); + expect(options).not.toContain("nullMembers"); + seed(sqlite); + const row = await get(app); + expect(row["total"]).toEqual({ label: "a", amount: null }); + expect(row["lines"]).toEqual([{ label: null, amount: "1.5" }, { amount: "2" }]); + expect(row["byKey"]).toEqual({ k: { label: null, amount: "3" } }); + }); + + test("omit: null members are dropped from a value object, an array of them and a map of them", async () => { + const { app, sqlite, options } = await serverFor({ decimals: "native", nullMembers: "omit" }); + expect(options).toContain('nullMembers: "omit"'); + seed(sqlite); + const row = await get(app); + expect(row["total"]).toEqual({ label: "a" }); + expect(row["lines"]).toEqual([{ amount: "1.5" }, { amount: "2" }]); + expect(row["byKey"]).toEqual({ k: { amount: "3" } }); + }); + + test("emit: every declared member is present, null when unset", async () => { + const { app, sqlite, options } = await serverFor({ decimals: "native", nullMembers: "emit" }); + expect(options).toContain('nullMembers: "emit"'); + seed(sqlite); + const row = await get(app); + expect(row["total"]).toEqual({ label: "a", amount: null, note: null }); + expect(row["lines"]).toEqual([{ label: null, amount: "1.5", note: null }, { label: null, amount: "2", note: null }]); + expect(row["byKey"]).toEqual({ k: { label: null, amount: "3", note: null } }); + expect(row["id"]).toBe(1); + }); + + test("a top-level null field is unchanged in every mode", async () => { + for (const nullMembers of ["native", "emit", "omit"] as const) { + const { app, sqlite } = await serverFor({ decimals: "native", nullMembers }); + sqlite.run(`INSERT INTO entries (amount) VALUES (NULL)`); + const row = await get(app); + expect(row["amount"]).toBeNull(); + expect(row["total"]).toBeNull(); + } + }); +}); diff --git a/server/typescript/packages/integration-tests/src/api-contract-tph-server.ts b/server/typescript/packages/integration-tests/src/api-contract-tph-server.ts index d28b0e67a..bbf17f496 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-tph-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-tph-server.ts @@ -19,6 +19,8 @@ import { type SortAllowlist, } from "@metaobjectsdev/runtime-ts/drizzle-fastify"; import { bigintAsNumberTypes } from "./pg-bigint-number-types.ts"; +import { nearestWireFormat } from "./load-metadata.ts"; +import { API_CONTRACT_TPH_DIR } from "./paths.ts"; import { seedTph, type TphSeed } from "./api-contract-tph-generated-server.ts"; export interface TphServerHandle { @@ -107,6 +109,9 @@ const copayAmount = z .nullable() .optional(); const DECIMAL_COLUMNS = ["copayAmount"] as const; +// The decimal output form is the corpus's own `wire` setting (ADR-0019 Amendment 2), read from +// its .metaobjects/config.json like the generated lanes, never a literal here. +const DECIMAL_WIRE = nearestWireFormat(API_CONTRACT_TPH_DIR).decimals; // Per-subtype insert schemas (discriminator pinned, omitted at the route boundary). const BridgeInsert = z.object({ reference: z.string().min(1).max(80), quantity: z.number().int(), ...writeOnceInsertShape, ...autoSetInsertShape }); @@ -166,7 +171,7 @@ export async function startTphServer(connectionUri: string): Promise { const result = await loadDirectory(dir); if (result.errors.length > 0) { diff --git a/server/typescript/packages/integration-tests/test/mysql-generated-app.test.ts b/server/typescript/packages/integration-tests/test/mysql-generated-app.test.ts index b5817ecff..6b918a74b 100644 --- a/server/typescript/packages/integration-tests/test/mysql-generated-app.test.ts +++ b/server/typescript/packages/integration-tests/test/mysql-generated-app.test.ts @@ -147,7 +147,7 @@ describe("generated TypeScript tier on MySQL", () => { expect(body.rank).toBe(10); expect(body.active).toBe(true); expect(body.tags).toEqual(["poetry", "essays"]); - expect(body.score).toBe("12.5"); // canonical (ADR-0019 Amendment 1): no trailing fraction zero + expect(body.score).toBe("12.50"); // wire.decimals native (the default): the MySQL driver string as it is (ADR-0019 Amendment 2) expect(body.externalId).toBe("0f8fad5b-d9cb-469f-a165-70867728950e"); expect(body.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 25fdc5357..6ddc3032e 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -379,6 +379,15 @@ export { } from "./naming.js"; export type { EntityNameMap, ColumnNamingStrategy, PhysicalNaming, TableNamingRule } from "./naming.js"; +// Wire format (ADR-0019 Amendment 2): the `wire` block of .metaobjects/config.json. +export { + WIRE_DECIMALS, WIRE_NULL_MEMBERS, WIRE_KEY_DECIMALS, WIRE_KEY_NULL_MEMBERS, + WIRE_DECIMALS_NATIVE, WIRE_DECIMALS_STRING, + WIRE_NULL_MEMBERS_NATIVE, WIRE_NULL_MEMBERS_EMIT, WIRE_NULL_MEMBERS_OMIT, + DEFAULT_WIRE_FORMAT, wireFormatOf, +} from "./wire.js"; +export type { WireFormat, WireDecimals, WireNullMembers } from "./wire.js"; + // Retired vocabulary: the map the loader reads to explain a retirement, and the raw-document // rewriter `meta upgrade` drives from the SAME entries — so the error message and the fix // cannot drift apart. diff --git a/server/typescript/packages/metadata/src/shared/meta-root.ts b/server/typescript/packages/metadata/src/shared/meta-root.ts index 21e6ba4cd..3fd83218a 100644 --- a/server/typescript/packages/metadata/src/shared/meta-root.ts +++ b/server/typescript/packages/metadata/src/shared/meta-root.ts @@ -8,6 +8,7 @@ import { TYPE_OBJECT, TYPE_FIELD } from "./base-types.js"; import type { MetaObject } from "../core/object/meta-object.js"; import type { MetaField } from "../core/field/meta-field.js"; import { DEFAULT_PHYSICAL_NAMING, type PhysicalNaming } from "../naming.js"; +import { DEFAULT_WIRE_FORMAT, type WireFormat } from "../wire.js"; import { PACKAGE_SEPARATOR } from "./structural.js"; import { lookupCanonical } from "../naming-refs.js"; @@ -19,6 +20,12 @@ export class MetaRoot extends MetaData { */ physicalNaming: PhysicalNaming = DEFAULT_PHYSICAL_NAMING; + /** + * The project's wire format (`wire` in `.metaobjects/config.json`, ADR-0019 Amendment 2), + * set by the project-aware load beside `physicalNaming` and read through `wireFormatOf`. + */ + wireFormat: WireFormat = DEFAULT_WIRE_FORMAT; + /** Object entities defined at this root level. */ objects(): MetaObject[] { return this.cached("objects", () => diff --git a/server/typescript/packages/metadata/src/wire.ts b/server/typescript/packages/metadata/src/wire.ts new file mode 100644 index 000000000..1b7d8bbda --- /dev/null +++ b/server/typescript/packages/metadata/src/wire.ts @@ -0,0 +1,43 @@ +import type { MetaData } from "./shared/meta-data.js"; + +/** + * The output form of a generated REST surface: the `wire` block of `.metaobjects/config.json` + * (ADR-0019 Amendment 2). Like `naming`, the project-aware load sets it on the loaded root and + * codegen reads it back through {@link wireFormatOf}. Input rules (a decimal body value is a + * string or an exact number, over-scale is refused) do NOT follow it; only what is written does. + */ + +/** `wire.decimals`: `native` = the string the in-process type holds, `string` = the canonical W1 string. */ +export const WIRE_DECIMALS = ["native", "string"] as const; +export type WireDecimals = (typeof WIRE_DECIMALS)[number]; + +/** `wire.nullMembers`: how a null member of a value object is written. */ +export const WIRE_NULL_MEMBERS = ["native", "emit", "omit"] as const; +export type WireNullMembers = (typeof WIRE_NULL_MEMBERS)[number]; + +/** The keys of the `wire` block. */ +export const WIRE_KEY_DECIMALS = "decimals"; +export const WIRE_KEY_NULL_MEMBERS = "nullMembers"; + +export const WIRE_DECIMALS_NATIVE: WireDecimals = "native"; +export const WIRE_DECIMALS_STRING: WireDecimals = "string"; +export const WIRE_NULL_MEMBERS_NATIVE: WireNullMembers = "native"; +export const WIRE_NULL_MEMBERS_EMIT: WireNullMembers = "emit"; +export const WIRE_NULL_MEMBERS_OMIT: WireNullMembers = "omit"; + +export interface WireFormat { + readonly decimals: WireDecimals; + readonly nullMembers: WireNullMembers; +} + +/** The default in every port: each language's own standard output. */ +export const DEFAULT_WIRE_FORMAT: WireFormat = Object.freeze({ + decimals: WIRE_DECIMALS_NATIVE, + nullMembers: WIRE_NULL_MEMBERS_NATIVE, +}); + +/** The wire format for the tree `node` belongs to: what its root carries, else the default. */ +export function wireFormatOf(node: MetaData): WireFormat { + const wire = (node.root() as { wireFormat?: WireFormat }).wireFormat; + return wire ?? DEFAULT_WIRE_FORMAT; +} diff --git a/server/typescript/packages/runtime-ts/src/decimal-wire.ts b/server/typescript/packages/runtime-ts/src/decimal-wire.ts index 8b4f4f349..3f6e0a41c 100644 --- a/server/typescript/packages/runtime-ts/src/decimal-wire.ts +++ b/server/typescript/packages/runtime-ts/src/decimal-wire.ts @@ -125,20 +125,39 @@ export function decimalToWire(value: unknown): unknown { } /** - * Rewrite the values under `columns` to their canonical string. Returns `undefined` when there is - * nothing to do. Which keys are decimals comes from the caller (the generated route passes the - * names of the entity's `field.decimal` fields), never from the shape of a value: an integer - * column is not a decimal because it is a number. + * The `native` output form (`wire.decimals: native`, ADR-0019 Amendment 2): the string the + * in-process type holds, NOT canonicalized. A string a driver handed back (Postgres `numeric`, + * MySQL `DECIMAL`, SQLite text) passes through as it is (`"12.50"` stays `"12.50"`), a number + * (a SQLite or D1 REAL or INTEGER) is rendered at 15 significant digits (W4, because the TS type + * is `string`), a bigint is its digits, and an array is rewritten element by element. + */ +export function decimalToNative(value: unknown): unknown { + if (Array.isArray(value)) return value.map(decimalToNative); + if (typeof value === "number") return decimalFromDouble15(value) ?? value; + if (typeof value === "bigint") return value.toString(); + return value; +} + +/** How a decimal is written: `native` (default) or the canonical `string` (`wire.decimals`). */ +export type DecimalWireMode = "native" | "string"; + +/** + * Rewrite the values under `columns` to their wire form (`mode`, default `native`). Returns + * `undefined` when there is nothing to do. Which keys are decimals comes from the caller (the + * generated route passes the names of the entity's `field.decimal` fields), never from the shape + * of a value: an integer column is not a decimal because it is a number. */ export function decimalWire( columns: readonly string[] | undefined, + mode: DecimalWireMode = "native", ): ((row: unknown) => unknown) | undefined { if (columns === undefined || columns.length === 0) return undefined; + const convert = mode === "string" ? decimalToWire : decimalToNative; return (row) => { if (row === null || typeof row !== "object") return row; const out = { ...(row as Record) }; for (const c of columns) { - if (c in out) out[c] = decimalToWire(out[c]); + if (c in out) out[c] = convert(out[c]); } return out; }; @@ -148,7 +167,8 @@ export function decimalWire( export function withDecimals( base: (row: unknown) => unknown, columns: readonly string[] | undefined, + mode: DecimalWireMode = "native", ): (row: unknown) => unknown { - const decimals = decimalWire(columns); + const decimals = decimalWire(columns, mode); return decimals === undefined ? base : (row) => decimals(base(row)); } diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts index d159a176a..6c6636ec8 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts @@ -29,13 +29,15 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; -export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { decimalWire, withDecimals, decimalToNative, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { withWire, nullMembersWire } from "../wire-format.js"; +export type { WireOptions, ValueObjectShape, NullMembersMode } from "../wire-format.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper @@ -49,7 +51,7 @@ type AnyTable = any; export type CrudVerb = "list" | "get" | "create" | "update" | "delete"; -export interface CrudRoutesOptions { +export interface CrudRoutesOptions extends WireOptions { fastify: FastifyInstance; /** REST resource path, e.g. "/subscribers". */ path: string; @@ -86,13 +88,6 @@ export interface CrudRoutesOptions { updateMethod?: "patch" | "put"; filterAllowlist?: FilterAllowlist; sortAllowlist?: SortAllowlist; - /** - * The names of the entity's `field.decimal` fields. A decimal is a canonical string on the - * wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten - * under these keys. The generated route passes them; absent, rows are sent as read. - */ - decimalColumns?: readonly string[]; /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */ dialect?: SqlDialect; /** @@ -131,7 +126,7 @@ function readSource(opts: VerbOptions): AnyTable { * non-timestamp of the same name. */ function readWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns); + return withWire(timestampWire(readSource(opts)), opts); } /** @@ -140,7 +135,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown { * against the union of both sources. */ function echoWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns); + return withWire(timestampWire(opts.table, opts.readView), opts); } /** #214 — after a write to the base table, re-read the row THROUGH the replica diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-m2m.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-m2m.ts index 31e1e4e8c..b975e61b6 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-m2m.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-m2m.ts @@ -34,7 +34,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify"; import { and, eq, or, inArray } from "drizzle-orm"; import { coerceIdForColumn } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg. @@ -43,7 +43,7 @@ type AnyDrizzle = any; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table type AnyTable = any; -export interface M2mRouteOptions { +export interface M2mRouteOptions extends WireOptions { fastify: FastifyInstance; /** Source resource path, e.g. "/posts". The route mounts at `{path}/:id/{relationName}`. */ path: string; @@ -61,8 +61,6 @@ export interface M2mRouteOptions { targetColumn: string; /** Target entity PK column (physical column name, e.g. "id"). Defaults to "id". */ targetPkColumn?: string; - /** The names of the TARGET entity's `field.decimal` fields, sent as canonical strings (ADR-0019 Amendment 1). */ - decimalColumns?: readonly string[]; /** Undirected self-join: union both junction FK columns on read. */ symmetric: boolean; /** @@ -90,7 +88,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void { const route = `${opts.path}/:id/${opts.relationName}`; // Route-scoped contract error handler — see route-error-handler.ts. const ro = withContractErrorHandler(opts.routeOptions); - const toWire = withDecimals(timestampWire(opts.targetTable), opts.decimalColumns); + const toWire = withWire(timestampWire(opts.targetTable), opts); opts.fastify.get(route, ro, async (req, reply) => { const { id } = req.params as { id: string }; diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts index 471334410..2451dfebb 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts @@ -6,13 +6,13 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; import { withContractErrorHandler } from "./route-error-handler.js"; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views type AnyView = any; -export interface MountReadOnlyOptions { +export interface MountReadOnlyOptions extends WireOptions { readonly fastify: FastifyInstance; readonly path: string; // biome-ignore lint/suspicious/noExplicitAny: dynamic Drizzle client @@ -50,14 +50,6 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; - /** - * The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire - * on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio, - * average or sum). Each row is rewritten under these keys. The generated route passes them; - * absent, rows are sent as read. Default none. - */ - readonly decimalColumns?: readonly string[]; } const rejectMutation = (resource: string) => async ( @@ -160,7 +152,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. const timestamps = timestampWire(view); - const toWire = withDecimals(timestamps, opts.decimalColumns); + const toWire = withWire(timestamps, opts); // ── List ────────────────────────────────────────────────────────────────── fastify.get(path, ro, async (req, reply) => { diff --git a/server/typescript/packages/runtime-ts/src/hono/index.ts b/server/typescript/packages/runtime-ts/src/hono/index.ts index f403a7f6e..64c0e00ef 100644 --- a/server/typescript/packages/runtime-ts/src/hono/index.ts +++ b/server/typescript/packages/runtime-ts/src/hono/index.ts @@ -45,7 +45,7 @@ export type { import { isTruthyFlag, coerceIdForColumn, firstRow, extractRowCount } from "../drizzle-fastify/util.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; // Every handler below is wrapped in guardRoute (an unexpected error answers // `500 { error: "internal" }`, logged server-side) and reads its body through // readJsonBody (a malformed body answers `400 { error: "invalid_json" }`). @@ -54,7 +54,9 @@ import { guardRoute, readJsonBody } from "./route-guard.js"; // shared (deprecated) helper so the three adapters can't silently diverge. export { parseId } from "../drizzle-fastify/util.js"; export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js"; -export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { decimalWire, withDecimals, decimalToNative, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js"; +export { withWire, nullMembersWire } from "../wire-format.js"; +export type { WireOptions, ValueObjectShape, NullMembersMode } from "../wire-format.js"; // --------------------------------------------------------------------------- // Loose types — we don't bind to a specific Drizzle backend so the helper @@ -70,7 +72,7 @@ type AnyHono = Hono; export type CrudVerb = "list" | "get" | "create" | "update" | "delete"; -export interface CrudRoutesOptions { +export interface CrudRoutesOptions extends WireOptions { /** Hono app instance. Bindings type is the consumer's — kept generic here. */ app: AnyHono; /** REST resource path, e.g. "/subscribers". */ @@ -106,13 +108,6 @@ export interface CrudRoutesOptions { updateMethod?: "patch" | "put"; filterAllowlist?: FilterAllowlist; sortAllowlist?: SortAllowlist; - /** - * The names of the entity's `field.decimal` fields. A decimal is a canonical string on the - * wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten - * under these keys. The generated route passes them; absent, rows are sent as read. - */ - decimalColumns?: readonly string[]; /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */ dialect?: SqlDialect; } @@ -159,7 +154,7 @@ function readSource(opts: VerbOptions): AnyTable { * non-timestamp of the same name. */ function readWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns); + return withWire(timestampWire(readSource(opts)), opts); } /** @@ -168,7 +163,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown { * against the union of both sources. */ function echoWire(opts: VerbOptions): (row: unknown) => unknown { - return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns); + return withWire(timestampWire(opts.table, opts.readView), opts); } /** Re-read a just-written row through the replica view so the response carries the diff --git a/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts b/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts index bda2cdec2..41e49daa6 100644 --- a/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts +++ b/server/typescript/packages/runtime-ts/src/hono/mount-read-only.ts @@ -13,7 +13,7 @@ import type { } from "../drizzle-fastify/filter-allowlist.js"; import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withDecimals } from "../decimal-wire.js"; +import { withWire, type WireOptions } from "../wire-format.js"; // An unexpected error on a mounted route answers `500 { error: "internal" }`. import { guardRoute } from "./route-guard.js"; @@ -22,7 +22,7 @@ type AnyView = any; // biome-ignore lint/suspicious/noExplicitAny: generic Hono app type AnyHono = Hono; -export interface MountReadOnlyOptions { +export interface MountReadOnlyOptions extends WireOptions { readonly app: AnyHono; readonly path: string; // biome-ignore lint/suspicious/noExplicitAny: dynamic Drizzle client @@ -49,14 +49,6 @@ export interface MountReadOnlyOptions { readonly itemRoutes?: boolean; /** The noun in the 405 message, which is free prose. Default "projection". */ readonly resource?: "projection" | "report"; - /** - * The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire - * on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale - * (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio, - * average or sum). Each row is rewritten under these keys. The generated route passes them; - * absent, rows are sent as read. Default none. - */ - readonly decimalColumns?: readonly string[]; } function resolveViewName(view: AnyView): string | undefined { @@ -139,7 +131,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void { const useRawSql = isEmptyColumnView(view) && !!viewName; // The raw-SQL branch has no declared columns, so nothing names a timestamp there. const timestamps = timestampWire(view); - const toWire = withDecimals(timestamps, opts.decimalColumns); + const toWire = withWire(timestamps, opts); // ── List ────────────────────────────────────────────────────────────────── app.get(path, guardRoute(async (c) => { diff --git a/server/typescript/packages/runtime-ts/src/wire-format.ts b/server/typescript/packages/runtime-ts/src/wire-format.ts new file mode 100644 index 000000000..086147c55 --- /dev/null +++ b/server/typescript/packages/runtime-ts/src/wire-format.ts @@ -0,0 +1,103 @@ +// The OUTPUT form of a generated REST surface: the `wire` project setting (ADR-0019 Amendment 2). +// A generated route passes the options below to a mount helper; the helper rewrites each row it +// sends. Input rules do not follow the setting (decimal bodies are parsed by the generated write +// schemas, filter operands by the filter parser). +// +// decimals `native` (default): the string the TS type holds, not canonicalized +// (`decimalToNative`); `string`: the canonical W1 string (`decimalToWire`). +// nullMembers a value object's members. `native` (default): as stored. `omit`: a null member +// is dropped. `emit`: every DECLARED member is present, `null` when unset. +// +// Which columns are decimals and which hold value objects comes from the model, never from the +// shape of a value, so the generated route carries both as static data. + +import { withDecimals, type DecimalWireMode } from "./decimal-wire.js"; + +/** `wire.nullMembers`. */ +export type NullMembersMode = "native" | "emit" | "omit"; + +/** + * The declared shape of a value object held by a jsonb column: its member names, and the shape + * of any member that is itself a value object. `map` marks a `field.map` of value objects (the + * column's value is an object keyed by arbitrary text, each value a value object). An array + * (`isArray`) needs no flag: the walk reads the value's own shape. + */ +export interface ValueObjectShape { + readonly members: readonly string[]; + readonly map?: boolean; + readonly nested?: Readonly>; +} + +/** The wire options every read-side mount takes beside its other options. */ +export interface WireOptions { + /** + * The names of the entity's `field.decimal` fields. Each row sent is rewritten under these + * keys: `native` (default) leaves the string a driver returned and renders a SQLite number at + * 15 significant digits; `decimalWire: "string"` sends the canonical string (ADR-0019 + * Amendment 1). The generated route passes them; absent, rows are sent as read. + */ + readonly decimalColumns?: readonly string[]; + /** `wire.decimals`. Default `native`. */ + readonly decimalWire?: DecimalWireMode; + /** `wire.nullMembers` for the members of the value objects under `valueObjects`. Default `native`. */ + readonly nullMembers?: NullMembersMode; + /** The jsonb columns that hold a value object (or an array / map of them), by column name. */ + readonly valueObjects?: Readonly>; +} + +function isPlainObject(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function shapeMember(value: Record, shape: ValueObjectShape, mode: NullMembersMode): Record { + const out: Record = {}; + if (mode === "emit") { + // Every declared member, in declaration order, null when unset. + for (const m of shape.members) out[m] = m in value && value[m] !== undefined ? value[m] : null; + for (const [k, v] of Object.entries(value)) if (!(k in out) && v !== undefined) out[k] = v; + } else { + for (const [k, v] of Object.entries(value)) if (v !== null && v !== undefined) out[k] = v; + } + for (const [name, inner] of Object.entries(shape.nested ?? {})) { + if (name in out && out[name] !== null) out[name] = shapeValue(out[name], inner, mode); + } + return out; +} + +/** Apply `mode` to a stored value object, an array of them, or (for `shape.map`) a map of them. */ +function shapeValue(value: unknown, shape: ValueObjectShape, mode: NullMembersMode): unknown { + if (Array.isArray(value)) return value.map((el) => shapeValue(el, shape, mode)); + if (!isPlainObject(value)) return value; + if (shape.map === true) { + // Keys are arbitrary text, never compared with the value object's member names. + const out: Record = {}; + for (const [k, el] of Object.entries(value)) out[k] = isPlainObject(el) ? shapeMember(el, shape, mode) : el; + return out; + } + return shapeMember(value, shape, mode); +} + +/** Rewrite the value-object columns of a row per `mode`. `undefined` when there is nothing to do. */ +export function nullMembersWire( + shapes: Readonly> | undefined, + mode: NullMembersMode | undefined, +): ((row: unknown) => unknown) | undefined { + if (shapes === undefined || mode === undefined || mode === "native") return undefined; + const entries = Object.entries(shapes); + if (entries.length === 0) return undefined; + return (row) => { + if (!isPlainObject(row)) return row; + const out = { ...row }; + for (const [column, shape] of entries) { + if (column in out && out[column] !== null && out[column] !== undefined) out[column] = shapeValue(out[column], shape, mode); + } + return out; + }; +} + +/** A wire mapper: `base` (timestamps), then the decimal rewrite, then the null-member rewrite. */ +export function withWire(base: (row: unknown) => unknown, opts: WireOptions): (row: unknown) => unknown { + const decimals = withDecimals(base, opts.decimalColumns, opts.decimalWire); + const members = nullMembersWire(opts.valueObjects, opts.nullMembers); + return members === undefined ? decimals : (row) => members(decimals(row)); +} diff --git a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts index 604964137..feb5c30b5 100644 --- a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts +++ b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts @@ -19,7 +19,7 @@ describe("decimalWire", () => { }); test("a number under a named key becomes its string; everything else passes through", () => { - const wire = decimalWire(["share", "avg"])!; + const wire = decimalWire(["share", "avg"], "string")!; expect(wire({ share: 0.4, avg: 1.6666666666666667, n: 5, label: "a" })).toEqual({ share: "0.4", avg: "1.66666666666667", n: 5, label: "a", }); @@ -29,8 +29,15 @@ describe("decimalWire", () => { expect(wire(null)).toBeNull(); }); + test("native (the default) leaves a driver string as it is, and renders a number at 15 digits", () => { + const wire = decimalWire(["share", "avg", "big"])!; + expect(wire({ share: "12.50", avg: 0.1 + 0.2, big: 12n, n: 5 })).toEqual({ share: "12.50", avg: "0.3", big: "12", n: 5 }); + expect(wire({ share: ["1.10", 2.5], avg: null })).toEqual({ share: ["1.10", "2.5"], avg: null }); + expect(decimalWire(["share"], "native")!({ share: "0.0000001" })).toEqual({ share: "0.0000001" }); + }); + test("an integer-valued REAL keeps no decimal point, and a key not named stays a number", () => { - expect(decimalWire(["share"])!({ share: 1, other: 1 })).toEqual({ share: "1", other: 1 }); + expect(decimalWire(["share"], "string")!({ share: 1, other: 1 })).toEqual({ share: "1", other: 1 }); }); }); @@ -58,7 +65,7 @@ describe("read-only mounts send a named decimal as a string", () => { const base = { path: "/shares", db, view: view(), filterAllowlist: {}, sortAllowlist: {}, dialect: "sqlite" as const, itemRoutes: false, resource: "report" as const }; fastify = Fastify(); - mountFastify({ fastify, ...base, decimalColumns: ["share"] }); + mountFastify({ fastify, ...base, decimalColumns: ["share"], decimalWire: "string" as const }); await fastify.ready(); plainFastify = Fastify(); @@ -66,7 +73,7 @@ describe("read-only mounts send a named decimal as a string", () => { await plainFastify.ready(); hono = new Hono(); - mountHono({ app: hono, ...base, decimalColumns: ["share"] }); + mountHono({ app: hono, ...base, decimalColumns: ["share"], decimalWire: "string" as const }); }); afterAll(async () => { diff --git a/server/typescript/packages/runtime-ts/test/wire-format.test.ts b/server/typescript/packages/runtime-ts/test/wire-format.test.ts new file mode 100644 index 000000000..940d03abf --- /dev/null +++ b/server/typescript/packages/runtime-ts/test/wire-format.test.ts @@ -0,0 +1,65 @@ +// The null-member rewrite of a value object column (`wire.nullMembers`, ADR-0019 Amendment 2). + +import { describe, test, expect } from "bun:test"; +import { nullMembersWire, withWire, type ValueObjectShape } from "../src/wire-format.js"; + +const money: ValueObjectShape = { members: ["label", "amount", "note"] }; + +describe("nullMembersWire", () => { + test("native and absent do nothing", () => { + expect(nullMembersWire({ total: money }, "native")).toBeUndefined(); + expect(nullMembersWire({ total: money }, undefined)).toBeUndefined(); + expect(nullMembersWire({}, "omit")).toBeUndefined(); + }); + + test("omit drops null members, in a single value, an array and a map", () => { + const wire = nullMembersWire({ total: money, lines: money, byKey: { ...money, map: true } }, "omit")!; + expect( + wire({ + id: 1, + other: null, + total: { label: "a", amount: null }, + lines: [{ label: null, amount: "1.50" }, { amount: "2" }], + byKey: { amount: { label: null, amount: "3" } }, + }), + ).toEqual({ + id: 1, + other: null, + total: { label: "a" }, + lines: [{ amount: "1.50" }, { amount: "2" }], + byKey: { amount: { amount: "3" } }, + }); + }); + + test("emit writes every declared member, null when unset", () => { + const wire = nullMembersWire({ total: money, lines: money }, "emit")!; + const out = wire({ total: { label: "a" }, lines: [{ amount: "1.50", note: null }], untouched: null }) as Record; + expect(out["total"]).toEqual({ label: "a", amount: null, note: null }); + expect(out["lines"]).toEqual([{ label: null, amount: "1.50", note: null }]); + expect(out["untouched"]).toBeNull(); + expect(Object.keys(out["total"] as object)).toEqual(["label", "amount", "note"]); + }); + + test("a nested value object is shaped too, and a null column stays null", () => { + const outer: ValueObjectShape = { members: ["name", "addr"], nested: { addr: { members: ["city", "zip"] } } }; + const emit = nullMembersWire({ person: outer }, "emit")!; + expect(emit({ person: { name: "x", addr: { city: "Oslo" } } })).toEqual({ person: { name: "x", addr: { city: "Oslo", zip: null } } }); + expect(emit({ person: null })).toEqual({ person: null }); + const omit = nullMembersWire({ person: outer }, "omit")!; + expect(omit({ person: { name: null, addr: { city: "Oslo", zip: null } } })).toEqual({ person: { addr: { city: "Oslo" } } }); + }); +}); + +describe("withWire", () => { + test("composes the decimal and null-member rewrites after the base mapper", () => { + const toWire = withWire((r) => r, { + decimalColumns: ["price"], + decimalWire: "string", + nullMembers: "omit", + valueObjects: { total: money }, + }); + expect(toWire({ price: "12.50", total: { label: null, amount: "1" } })).toEqual({ price: "12.5", total: { amount: "1" } }); + const native = withWire((r) => r, { decimalColumns: ["price"], valueObjects: { total: money } }); + expect(native({ price: "12.50", total: { label: null } })).toEqual({ price: "12.50", total: { label: null } }); + }); +}); diff --git a/server/typescript/packages/sdk/src/collection.ts b/server/typescript/packages/sdk/src/collection.ts index e2ea6d919..b0f10e0ce 100644 --- a/server/typescript/packages/sdk/src/collection.ts +++ b/server/typescript/packages/sdk/src/collection.ts @@ -20,8 +20,10 @@ import { TYPE_METADATA, DEFAULT_PHYSICAL_NAMING, type PhysicalNaming, + type WireFormat, + DEFAULT_WIRE_FORMAT, } from "@metaobjectsdev/metadata"; -import { CONFIG_FILE, loadConfig, physicalNamingFromConfig, type Config } from "./config.js"; +import { CONFIG_FILE, loadConfig, physicalNamingFromConfig, wireFormatFromConfig, type Config } from "./config.js"; import { discoverCollectionRoot, exists, isDir } from "./discovery.js"; import { compileScope, matchesScope, type Scope } from "./scope.js"; import { DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR, isMetadataFile } from "./metadata-files.js"; @@ -81,6 +83,8 @@ export interface Collection { /** ADR-0058 — the project's physical-naming rules (`naming` in `.metaobjects/config.json`), * defaults applied. `loadMemory` sets them on the loaded root, so every resolver reads them. */ readonly physicalNaming: PhysicalNaming; + /** The `wire` block of the project config (ADR-0019 Amendment 2), default when absent. */ + readonly wireFormat: WireFormat; /** FR-023 — the `FileSource` id each file loads under, for the dependency * ARTIFACTS only (`dep:/`); own files are absent and keep the * default `basename(path)`. Threaded to `loadMemory` so every node an artifact @@ -267,6 +271,7 @@ export async function resolveCollection( let dependencySpecs: readonly DependencySpec[] = []; let libraries: readonly string[] = []; let physicalNaming: PhysicalNaming = DEFAULT_PHYSICAL_NAMING; + let wireFormat: WireFormat = DEFAULT_WIRE_FORMAT; if (hasConfig) { // No try/catch here: a config.json that EXISTS but fails to load @@ -282,6 +287,7 @@ export async function resolveCollection( dependencySpecs = cfg.dependencies; libraries = cfg.libraries; physicalNaming = physicalNamingFromConfig(cfg); + wireFormat = wireFormatFromConfig(cfg); // An unknown token is a hard config error naming the valid ones, while // `librarySources` keeps skipping one silently for a programmatic caller. The two // are deliberately different: an API caller asking for a library this version does @@ -395,6 +401,7 @@ export async function resolveCollection( dependencies, libraries, physicalNaming, + wireFormat, fileIds: new Map(dependencies.map((d) => [d.artifactPath, d.sourceId])), importedPackages: [...importedPackages].sort(), importedNodes, diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts index 5de2ed832..1b8f429a9 100644 --- a/server/typescript/packages/sdk/src/config.ts +++ b/server/typescript/packages/sdk/src/config.ts @@ -7,6 +7,14 @@ import { COLUMN_NAMING_STRATEGIES, DEFAULT_PHYSICAL_NAMING, TABLE_NAMING_RULES, + DEFAULT_WIRE_FORMAT, + WIRE_DECIMALS, + WIRE_NULL_MEMBERS, + WIRE_KEY_DECIMALS, + WIRE_KEY_NULL_MEMBERS, + type WireDecimals, + type WireFormat, + type WireNullMembers, type PhysicalNaming, type ColumnNamingStrategy, type TableNamingRule, @@ -197,6 +205,27 @@ export const ConfigSchema = z.object({ }) .strict() .optional(), + /** + * The OUTPUT form of a generated REST surface (ADR-0019 Amendment 2). `decimals`: `native` + * (default; the string the in-process type holds) or `string` (canonical W1 string). + * `nullMembers`: `native` (default; as stored), `emit` (every declared member, `null` when + * unset) or `omit` (drop null members) for a value object's members. Input rules do not + * follow it. Absent means the default for both. + */ + wire: z + .strictObject({ + decimals: z.enum(WIRE_DECIMALS as unknown as [WireDecimals, ...WireDecimals[]], { + message: `"wire.decimals" must be one of ${WIRE_DECIMALS.map((v) => `"${v}"`).join(", ")}`, + }).optional(), + nullMembers: z.enum(WIRE_NULL_MEMBERS as unknown as [WireNullMembers, ...WireNullMembers[]], { + message: `"wire.nullMembers" must be one of ${WIRE_NULL_MEMBERS.map((v) => `"${v}"`).join(", ")}`, + }).optional(), + }, { + error: (iss) => iss.code === "unrecognized_keys" + ? `"wire" has an unknown key "${iss.keys[0]}" (expected "${WIRE_KEY_DECIMALS}" and/or "${WIRE_KEY_NULL_MEMBERS}")` + : iss.code === "invalid_type" ? `"wire" must be an object` : undefined, + }) + .optional(), }).strict(); export type Config = z.infer; @@ -209,6 +238,14 @@ export function physicalNamingFromConfig(cfg: Pick): PhysicalN }; } +/** The wire format a config declares, each absent key at its default (ADR-0019 Amendment 2). */ +export function wireFormatFromConfig(cfg: Pick): WireFormat { + return { + decimals: cfg.wire?.decimals ?? DEFAULT_WIRE_FORMAT.decimals, + nullMembers: cfg.wire?.nullMembers ?? DEFAULT_WIRE_FORMAT.nullMembers, + }; +} + export const DEFAULT_CONFIG: Config = ConfigSchema.parse({ schema_version: 1 }); /** `config.json`'s basename — the single owner. `discovery.ts` and diff --git a/server/typescript/packages/sdk/src/index.ts b/server/typescript/packages/sdk/src/index.ts index adc2b17c9..38cb6c45a 100644 --- a/server/typescript/packages/sdk/src/index.ts +++ b/server/typescript/packages/sdk/src/index.ts @@ -16,7 +16,7 @@ // shows `resolveCollection`, the documented single authority for locating a project. // Config -export { ConfigSchema, DEFAULT_CONFIG, loadConfig, setConfigWarningSink, splitUnknownTopLevelKeys, CONFIG_TOP_LEVEL_KEYS, saveConfig, AllowTokenEnum, physicalNamingFromConfig } from "./config.js"; +export { ConfigSchema, DEFAULT_CONFIG, loadConfig, setConfigWarningSink, splitUnknownTopLevelKeys, CONFIG_TOP_LEVEL_KEYS, saveConfig, AllowTokenEnum, physicalNamingFromConfig, wireFormatFromConfig } from "./config.js"; export type { Config } from "./config.js"; // Metadata dependencies (FR-023) — the `dependencies` config key's schema, diff --git a/server/typescript/packages/sdk/src/memory.ts b/server/typescript/packages/sdk/src/memory.ts index 942498ed9..3ecbca9b3 100644 --- a/server/typescript/packages/sdk/src/memory.ts +++ b/server/typescript/packages/sdk/src/memory.ts @@ -14,6 +14,7 @@ import { type MetaDataTypeProvider, type MetaRoot, type PhysicalNaming, + type WireFormat, } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; // FR-043 — a node-only subpath (it reaches the filesystem), which is why it is imported @@ -111,6 +112,8 @@ export interface LoadMemoryOptions { * `files` passes them too (`resolveCollection(...).physicalNaming`), or gets the default. */ physicalNaming?: PhysicalNaming; + /** The project wire format; defaults to the collection's (`resolveCollection(...).wireFormat`) when `files` is not passed. */ + wireFormat?: WireFormat; } /** Default provider bundle threaded by {@link loadMemory} when no options @@ -166,7 +169,7 @@ export async function loadMemory( repoRoot: string, options?: LoadMemoryOptions, ): Promise { - const { result, importedPackages, importedNodes, libraries, physicalNaming } = + const { result, importedPackages, importedNodes, libraries, physicalNaming, wireFormat } = await loadCollectionRaw(repoRoot, options, options?.strict === true); if (result.errors.length > 0) { @@ -186,6 +189,7 @@ export async function loadMemory( refuseLibraryPackageMisuse(result.root, libraries); if (physicalNaming !== undefined) result.root.physicalNaming = physicalNaming; + if (wireFormat !== undefined) result.root.wireFormat = wireFormat; return result.root; } @@ -245,6 +249,7 @@ async function loadCollectionRaw( importedNodes: ReadonlySet | undefined; libraries: readonly string[] | undefined; physicalNaming: PhysicalNaming | undefined; + wireFormat: WireFormat | undefined; }> { const extra = options?.providers ?? []; let providers: readonly MetaDataTypeProvider[]; @@ -272,6 +277,7 @@ async function loadCollectionRaw( let importedNodes: ReadonlySet | undefined; let libraries: readonly string[] | undefined = options?.libraries; let physicalNaming: PhysicalNaming | undefined = options?.physicalNaming; + let wireFormat: WireFormat | undefined = options?.wireFormat; if (options?.files !== undefined) { paths = [...options.files]; fileIds = options.fileIds; @@ -291,6 +297,7 @@ async function loadCollectionRaw( importedNodes = collection.importedNodes; libraries ??= collection.libraries; physicalNaming ??= collection.physicalNaming; + wireFormat ??= collection.wireFormat; } const loader = new MetaDataLoader({ @@ -316,7 +323,7 @@ async function loadCollectionRaw( return id === undefined ? new FileSource(p) : new FileSource(p, { id }); }), ]); - return { result, registry, importedPackages, importedNodes, libraries, physicalNaming }; + return { result, registry, importedPackages, importedNodes, libraries, physicalNaming, wireFormat }; } /** Every source file that contributed to a node, across the envelope variants that diff --git a/server/typescript/packages/sdk/test/wire-conformance.test.ts b/server/typescript/packages/sdk/test/wire-conformance.test.ts new file mode 100644 index 000000000..366bdc764 --- /dev/null +++ b/server/typescript/packages/sdk/test/wire-conformance.test.ts @@ -0,0 +1,87 @@ +// ADR-0019 Amendment 2 — the cross-port `wire` setting, run through TypeScript's own config +// reader: every valid block resolves to the corpus's values, every invalid one is refused when +// the config is read, and the project-aware load sets the root. + +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { DEFAULT_WIRE_FORMAT, wireFormatOf, type WireDecimals, type WireNullMembers } from "@metaobjectsdev/metadata"; +import { ConfigSchema, wireFormatFromConfig } from "../src/config.js"; +import { loadMemory } from "../src/memory.js"; + +const CORPUS = resolve(import.meta.dir, "../../../../../fixtures/wire-conformance/config-blocks.json"); + +interface WireCase { name: string; wire: unknown; decimals: WireDecimals; nullMembers: WireNullMembers } +const corpus = JSON.parse(readFileSync(CORPUS, "utf8")) as { + cases: WireCase[]; + invalid: { name: string; wire: unknown }[]; +}; + +/** The config file a project would carry for this case. */ +function configFor(wire: unknown): Record { + return wire === null ? { schema_version: 1 } : { schema_version: 1, wire }; +} + +const MODEL = { "metadata.root": { package: "w", children: [ + { "object.entity": { name: "Thing", children: [{ "field.string": { name: "id" } }] } }, +] } }; + +describe("wire conformance (ADR-0019 Amendment 2)", () => { + for (const c of corpus.cases) { + test(`${c.name}: resolves to decimals=${c.decimals} nullMembers=${c.nullMembers}`, () => { + const wire = wireFormatFromConfig(ConfigSchema.parse(configFor(c.wire))); + expect(wire).toEqual({ decimals: c.decimals, nullMembers: c.nullMembers }); + }); + } + + for (const c of corpus.invalid) { + test(`${c.name}: the config is refused when read`, () => { + expect(ConfigSchema.safeParse(configFor(c.wire)).success).toBe(false); + }); + } + + test("the refusal names the key and the allowed values", () => { + const r = ConfigSchema.safeParse(configFor({ decimals: "number" })); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain('\\"wire.decimals\\" must be one of \\"native\\", \\"string\\"'); + }); + + test("an unknown key and a non-object block are refused by name", () => { + const unknown = ConfigSchema.safeParse(configFor({ dates: "iso" })); + expect(unknown.error?.issues.map((i) => i.message)).toContain( + '"wire" has an unknown key "dates" (expected "decimals" and/or "nullMembers")'); + const scalar = ConfigSchema.safeParse(configFor("string")); + expect(scalar.error?.issues.map((i) => i.message)).toContain('"wire" must be an object'); + }); + + test("the project-aware load sets the root from .metaobjects/config.json", async () => { + const portable = corpus.cases.find((c) => c.name.startsWith("portable form"))!; + const dir = mkdtempSync(join(tmpdir(), "mo-wire-")); + try { + mkdirSync(join(dir, ".metaobjects")); + mkdirSync(join(dir, "metaobjects")); + writeFileSync(join(dir, ".metaobjects", "config.json"), JSON.stringify(configFor(portable.wire))); + writeFileSync(join(dir, "metaobjects", "meta.w.json"), JSON.stringify(MODEL)); + const root = await loadMemory(dir); + expect(root.wireFormat).toEqual({ decimals: "string", nullMembers: "omit" }); + expect(wireFormatOf(root.findObject("Thing")!)).toEqual({ decimals: "string", nullMembers: "omit" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a project with no wire block loads with the native defaults", async () => { + const dir = mkdtempSync(join(tmpdir(), "mo-wire-")); + try { + mkdirSync(join(dir, ".metaobjects")); + mkdirSync(join(dir, "metaobjects")); + writeFileSync(join(dir, ".metaobjects", "config.json"), JSON.stringify(configFor(null))); + writeFileSync(join(dir, "metaobjects", "meta.w.json"), JSON.stringify(MODEL)); + const root = await loadMemory(dir); + expect(root.wireFormat).toEqual(DEFAULT_WIRE_FORMAT); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts index d9cc73bbf..69e2521b8 100644 --- a/server/typescript/packages/test-generators/src/generators/routes.ts +++ b/server/typescript/packages/test-generators/src/generators/routes.ts @@ -69,7 +69,6 @@ import { isReport, itemRouteField, decimalColumnsLine, - decimalFieldNames, servesReadApi, formatTs, entityOutputPath, @@ -155,15 +154,13 @@ function renderRoutes( // The mount addresses `id` by default. A projection keyed on another field names it // (the view's key for that column, which is the field name), so `GET /:id` reads the // same column the by-id query does. Absent for `id`, which keeps that output's bytes. - // A decimal is a canonical string on the wire on every dialect (ADR-0019 Amendment 1). - const decimalColumns = decimalFieldNames(entity); const keylessOpts = (indent: string): string => (idField !== undefined && idField !== DEFAULT_ID_FIELD ? `\n${indent}idColumn: ${JSON.stringify(idField)},` : "") + (keyless ? `\n${indent}itemRoutes: false,` : "") + (report ? `\n${indent}resource: "report",` : "") + - (decimalColumns.length > 0 ? `\n${indent}decimalColumns: ${JSON.stringify(decimalColumns)},` : ""); + decimalColumnsLine(entity, indent, ctx.loadedRoot); const FastifyInstanceSym = imp("t:FastifyInstance@fastify"); const mountReadOnlyCrudRoutesSym = imp(`mountReadOnlyCrudRoutes@${runtimeSpec}`); // A projection mount is read-only by construction, so `expose` cannot narrow it — @@ -298,7 +295,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -323,7 +320,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ")}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -479,7 +476,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ")}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } @@ -580,7 +577,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ")}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -623,7 +620,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/spec/decisions/ADR-0019-runtime-return-type-contract.md b/spec/decisions/ADR-0019-runtime-return-type-contract.md index 51ecbbc4e..5a843dd51 100644 --- a/spec/decisions/ADR-0019-runtime-return-type-contract.md +++ b/spec/decisions/ADR-0019-runtime-return-type-contract.md @@ -112,3 +112,85 @@ comparable across ports and engines, and it is the form the persistence corpus a CHANGELOG. The wire contract is part of `metamodelVersion` (`docs/compatibility-policy.md`), which moves to `1.1` in the same release. An owned (ejected) route or DTO generator keeps the old spelling until it is resynced. + +## Amendment 2 (2026-10-24): each port writes its language's standard JSON by default + +**Context.** Amendment 1 made the W1 string the only output, in every port, with no opt-out, and +1.1.0 shipped it on a minor release. It also shipped a second change no amendment recorded: C#, +Java and Kotlin started leaving a value object's null member out of the JSON +(`[JsonIgnore(WhenWritingNull)]`, `@JsonInclude(NON_NULL)`), because the api-contract corpus +asserted the omitted form. For an adopter with a live API this was a breaking change to its +clients on an upgrade within one major line. A C# adopter moving from 1.0.4 to 1.1.1 had its +decimals turn from `12.50` into `"12.5"` and its null members disappear. It could only get back +by editing the generated output and deleting the helper file. The defaults had been chosen to +make five ports agree for our own conformance corpora, not to make each port do what its +language does. + +**The rule.** Each port's default is its language's standard behaviour: what that language's +standard serializer writes for the port's in-process type. Identical output across ports is an +opt-in setting. Conformance corpora pin that setting explicitly and never decide a default. A +future change that would make a port's default output differ from its language's standard, +so that the ports agree, needs a new amendment that answers this one. + +**Decision.** The OUTPUT form of a generated REST surface is a project setting. It sits in +`.metaobjects/config.json` beside `naming` ([ADR-0058](ADR-0058-one-physical-naming-setting.md)), and +the same config reader reads it in every port: + +```json +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +``` + +- `wire.decimals`: `"native"` (default) or `"string"`. `string` is Amendment 1's W1, exactly as + 1.1.0 and 1.1.1 wrote it. `native` writes what the port's standard serializer writes for its + in-process decimal type, with no MetaObjects canonicalization on output. +- `wire.nullMembers`: `"native"` (default), `"emit"` or `"omit"`. It covers the members of a + value object (`object.value`) on the wire, and therefore in the jsonb that a typed serializer + writes. A top-level entity field is unchanged: its key is present and its value is `null`. + `omit` leaves a null member out. `emit` writes it as `null` with its key present, including a + member the stored value lacks. `native` is what the port's standard behaviour gives for its + in-process value-object representation. +- An absent block or key means the default. An unknown key, an unknown value or a `wire` that is + not an object is refused when the file is read (`ERR_BAD_ATTR_VALUE`, worded like `naming`'s + refusals). The shared cases are in `fixtures/wire-conformance/config-blocks.json`. +- The project-aware load carries the setting on the loaded root, as `naming` is carried. Codegen + reads it from there; there is no CLI flag and no generator argument. Every port's codegen + drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, `mvn metaobjects:verify`, + `metaobjects verify`) says when a different `wire` block reproduces the committed output + exactly, instead of telling you to regenerate. +- There is deliberately no `"number"` value. In C#, Java and Kotlin, `native` already is a JSON + number. In TypeScript (in-process `string`) and Python (`Decimal`, which Pydantic writes as a + string), a number would be a lossy spelling the language does not use. + +**Input does not follow the setting.** W2 (a decimal is a JSON string in the JSON-number grammar +or a JSON number, and anything else is `400 validation`), W3 (nothing is rounded: over-scale or +over-precision is `400 validation`), W4 (SQLite and D1 at 15 significant digits), exact filter +operands and `400 invalid_filter_value` apply in every mode. A generated input converter is kept +in every mode. Only output canonicalization, and the null-member annotation, depend on the +setting. + +**What each port writes.** + +| Port | `decimals: native` (default) | `decimals: string` | `nullMembers: native` (default) | `emit` | `omit` | +|---|---|---|---|---|---| +| C# | a JSON number, as System.Text.Json writes a `decimal`: its scale is kept (`12.50`, `0.0000001`) | `"12.5"` | `null`, key present | same as native | left out | +| Java | a JSON number, as the host's Jackson `ObjectMapper` writes a `BigDecimal` (Spring Boot's default: `12.50`, `1E-7`) | `"12.5"` | `null`, key present | same as native | left out | +| Kotlin | same as Java | `"12.5"` | `null`, key present | same as native | left out | +| Python | a string, as Pydantic v2 writes a `Decimal`: `str(value)` (`"12.50"`, `"1E-7"`) | `"12.5"` | as the runtime holds the value object (a dict): a stored `null` is `null`, a missing member is absent | every declared member present, `null` when unset | left out | +| TypeScript | a string, as the runtime holds it: the driver's text on Postgres and MySQL (`"12.50"`); on SQLite and D1, the stored number at 15 significant digits | `"12.5"` | as stored (`JSON.stringify`: a stored `null` is written, a missing member is absent) | every declared member present, `null` when unset | left out | + +In C#, Java and Kotlin, the 1.1.2 default restores the 1.0.x output. In TypeScript and Python it +differs from 1.1.1, which also canonicalized: a decimal is again the driver's or `str(Decimal)` +spelling (`"12.50"`, not `"12.5"`). A project that wants 1.1.1's output in every port declares +`"wire": { "decimals": "string", "nullMembers": "omit" }`. + +**Consequences.** + +- The api-contract and persistence corpora pin the portable form in their own + `.metaobjects/config.json` (`"wire": { "decimals": "string", "nullMembers": "omit" }`). Every + port's generated lane reads it from there, as it reads `naming`, so the corpora keep asserting + W1 bytes. Each port also tests that the default writes the language-standard shape on real + serialized output of its generated code. +- A browser client that must read a decimal exactly should use `"decimals": "string"`. + `JSON.parse` turns a number into a double before any user code sees it (Amendment 1, "Why a + string"). That is a choice the project makes; it is no longer the default. +- No vocabulary changes, so `metamodelVersion` does not move. The fix ships in 1.1.2 as a bug fix. From 6d38625573e85f9a18189347d6892e8d450a39cd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 13:49:28 -0400 Subject: [PATCH 2/4] docs: state plainly that Python 1.0.x float decimals have no restoring option (#454) --- CHANGELOG.md | 6 +++++- docs/features/migrations/upgrading-1.0-to-1.1.md | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a985174..18e30c32e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,11 @@ _Fixes #454 and #455. A bug-fix release: no vocabulary or `metamodelVersion` cha Java and Kotlin** write a decimal as a JSON number again (`12.50`), and a value object's null member as `null` with its key present: the 1.0.x output. **Python and TypeScript** write a decimal as their standard string, which keeps its scale (`"12.50"`). That is a change from - 1.1.1's `"12.5"`. + 1.1.1's `"12.5"`. **Python adopters coming from 1.0.x:** 1.0.x routers returned plain dicts, + and FastAPI's encoder wrote a `Decimal` as a float (`12.5`), losing digits past a double's + precision. From 1.1.2 a decimal is Pydantic's string, `str(Decimal)` (`"12.50"`), so a client + that read a number now reads a string. There is no float option, by decision: a float that + loses precision is not the Python-normal form, and Pydantic's string is. **To keep 1.1.0's output** in every port, declare `"wire": { "decimals": "string", "nullMembers": "omit" }` and regenerate. `nullMembers` also takes `"emit"`, which writes every declared member of a value object, `null` when unset, diff --git a/docs/features/migrations/upgrading-1.0-to-1.1.md b/docs/features/migrations/upgrading-1.0-to-1.1.md index c260dbf89..64b0b34e2 100644 --- a/docs/features/migrations/upgrading-1.0-to-1.1.md +++ b/docs/features/migrations/upgrading-1.0-to-1.1.md @@ -158,7 +158,9 @@ setting each port writes its language's standard JSON: - **Python:** a decimal is the string Pydantic writes for a `Decimal`, `str(value)` (`"12.50"`). 1.0.x routers returned plain dicts, and FastAPI's encoder turned a `Decimal` into a float (`12.5`, losing digits past a double's precision). A client that read a number now reads a - string. No setting restores the float, because it loses digits. + string. There is no float option, by decision: a float that loses precision is not the + Python-normal form, and Pydantic's `str(Decimal)` is. Update a client that parsed the number to + parse the string (`"decimals": "string"` gives the canonical `"12.5"` instead). To have every port write one form, which suits clients in several languages or a browser that must read decimals exactly, declare the form 1.1.0 wrote and regenerate: From e94eb18fa2f11019f8cd06e519fdb314ca474760 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 14:03:44 -0400 Subject: [PATCH 3/4] no-mistakes(review): Apply wire setting to M:N targets and FromDirectory --- .../references/wire-format.md | 4 + .../references/wire-format.md | 4 + .../references/wire-format.md | 4 + .../references/wire-format.md | 4 + .../references/wire-format.md | 4 + .../references/wire-format.md | 4 + .../MetadataLocationTests.cs | 12 +++ .../MetaObjects/Loader/MetaDataLoader.cs | 6 +- .../codegen/generators/router_generator.py | 37 ++++++-- .../codegen/runtime/decimal_wire.py | 6 +- .../test_router_generator_m2m_target_tph.py | 2 +- .../codegen/test_router_generator_tph_m2m.py | 16 +++- .../test_router_generator_tph_m2m_shadowed.py | 6 +- .../test_generated_decimal_wire.py | 87 +++++++++++++++++++ .../python/tests/runtime/test_decimal_wire.py | 12 +++ 15 files changed, 192 insertions(+), 16 deletions(-) diff --git a/agent-context/skills/metaobjects-codegen/references/wire-format.md b/agent-context/skills/metaobjects-codegen/references/wire-format.md index 17847c27c..392997b67 100644 --- a/agent-context/skills/metaobjects-codegen/references/wire-format.md +++ b/agent-context/skills/metaobjects-codegen/references/wire-format.md @@ -78,6 +78,10 @@ The generated input converters stay in every mode. - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can + lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the + Python-normal form. There is no float option, by decision. Update a client that read the + number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 17847c27c..392997b67 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -78,6 +78,10 @@ The generated input converters stay in every mode. - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can + lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the + Python-normal form. There is no float option, by decision. Update a client that read the + number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 17847c27c..392997b67 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -78,6 +78,10 @@ The generated input converters stay in every mode. - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can + lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the + Python-normal form. There is no float option, by decision. Update a client that read the + number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 17847c27c..392997b67 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -78,6 +78,10 @@ The generated input converters stay in every mode. - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can + lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the + Python-normal form. There is no float option, by decision. Update a client that read the + number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 17847c27c..392997b67 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -78,6 +78,10 @@ The generated input converters stay in every mode. - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can + lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the + Python-normal form. There is no float option, by decision. Update a client that read the + number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 17847c27c..392997b67 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -78,6 +78,10 @@ The generated input converters stay in every mode. - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). +- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can + lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the + Python-normal form. There is no float option, by decision. Update a client that read the + number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/server/csharp/MetaObjects.Conformance.Tests/MetadataLocationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/MetadataLocationTests.cs index 5a7807289..5c0c37baf 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/MetadataLocationTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/MetadataLocationTests.cs @@ -120,6 +120,17 @@ public void FromDirectory_with_a_registry_applies_the_projects_naming() Assert.Equal(new PhysicalNaming(ColumnNamingStrategy.Literal, TableNamingRule.Literal), result.Root.PhysicalNaming); } + [Fact] + public void FromDirectory_with_a_registry_applies_the_projects_wire() + { + WriteConfig("""{ "schema_version": 1, "wire": { "decimals": "string", "nullMembers": "omit" } }"""); + + var result = MetaObjects.Loader.MetaDataLoader.FromDirectory( + MetaDir, MetaObjects.Provider.ComposeRegistry(MetaObjects.CoreTypes.LibraryProviders), libraries: ["iam"], strict: true); + + Assert.Equal(new WireFormat(DecimalWireForm.String, NullMemberForm.Omit), result.Root.WireFormat); + } + [Fact] public void FromDirectory_without_a_project_config_keeps_the_default() { @@ -127,5 +138,6 @@ public void FromDirectory_without_a_project_config_keeps_the_default() MetaDir, MetaObjects.Provider.ComposeRegistry(MetaObjects.CoreTypes.LibraryProviders), libraries: ["iam"]); Assert.Equal(PhysicalNaming.Default, result.Root.PhysicalNaming); + Assert.Equal(WireFormat.Default, result.Root.WireFormat); } } diff --git a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs index d47461014..e0afab9eb 100644 --- a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs +++ b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs @@ -155,7 +155,11 @@ public static LoadResult FromDirectory(string directory, TypeRegistry registry, // ADR-0058: the project's `naming` applies to a directory load too, as the JVM // fromDirectory does. Without this, a host that composes its own registry (the // documented way to add a provider) silently got snake_case whatever the project says. - if (Config.MetadataLocation.ConfigFor(directory) is { } cfg) result.Root.PhysicalNaming = cfg.Naming; + if (Config.MetadataLocation.ConfigFor(directory) is { } cfg) + { + result.Root.PhysicalNaming = cfg.Naming; + result.Root.WireFormat = cfg.Wire; + } return result; } diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index eabce2ce6..770efd3b8 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -285,8 +285,8 @@ def _field_shape(f: MetaField, seen: frozenset[str]) -> dict[str, Any] | None: return {"m": shape} if f.sub_type == fc.FIELD_SUBTYPE_MAP else shape -def _wire_lines(entities: list[MetaObject], wire: WireFormat) -> list[str]: - """The ``_WIRE`` constant every response is written through: the project's ``wire`` +def _wire_lines(entities: list[MetaObject], wire: WireFormat, name: str = "_WIRE") -> list[str]: + """The *name* constant a response of *entities* is written through: the project's ``wire`` setting (ADR-0019 Amendment 2) and, when ``nullMembers`` is not ``native``, the declared members of each value-object field of *entities* (the first declaration of a name wins).""" shapes: dict[str, Any] = {} @@ -298,7 +298,26 @@ def _wire_lines(entities: list[MetaObject], wire: WireFormat) -> list[str]: args = f'decimals="{wire.decimals}", null_members="{wire.null_members}"' if shapes: args += f", shapes={shapes!r}" - return ["", "", f"_WIRE = WireOutput({args})"] + return ["", "", f"{name} = WireOutput({args})"] + + +def _m2m_wire_name(target: str) -> str: + """The wire constant an M:N traversal onto *target* writes the target's rows through.""" + return f"_WIRE_{_snake_case(target).upper()}" + + +def _m2m_wire_lines( + descriptors: list[M2mDescriptor], object_index: dict[str, MetaObject], wire: WireFormat +) -> list[str]: + """One :func:`_m2m_wire_name` constant per distinct M:N target, shaped by the TARGET's + value-object fields (and its TPH subtypes' when the target is a discriminator base).""" + lines: list[str] = [] + for name in dict.fromkeys(d.target_entity for d in descriptors): + target = next(o for o in object_index.values() if o.name == name) + plan = tph_plan_for(target, object_index) + entities = [target, *(st.entity for st in plan.subtypes)] if plan is not None else [target] + lines.extend(_wire_lines(entities, wire, _m2m_wire_name(name))) + return lines def _decimal_limits_lines(entities: list[MetaObject]) -> list[str]: @@ -877,7 +896,8 @@ def _emit_m2m_route( f" repo: Annotated[{repo_class}, Depends(get_repository)],", ") -> list[Any]:", f" return render_wire(repo.find_related_{d.relation_name}" - f"({pk_param}{_target_subtype_arg(d, {d.target_discriminator})}), _WIRE)", + f"({pk_param}{_target_subtype_arg(d, {d.target_discriminator})}), " + f"{_m2m_wire_name(d.target_entity)})", ] def _emit_tph_m2m_route( @@ -934,7 +954,8 @@ def _emit_tph_m2m_route( ] lines.append( f" return render_wire(repo.find_related_{d.relation_name}" - f"({subtype_expr}, {pk_param}{_target_subtype_arg(d, target_discs)}), _WIRE)" + f"({subtype_expr}, {pk_param}{_target_subtype_arg(d, target_discs)}), " + f"{_m2m_wire_name(d.target_entity)})" ) return lines @@ -1159,6 +1180,11 @@ def _render_tph_router( parts.append(f"_DECIMAL_FIELDS: frozenset[str] = {decimal_set_body}") parts.extend(_decimal_limits_lines([entity, *(st.entity for st in plan.subtypes)])) parts.extend(_wire_lines([entity, *(st.entity for st in plan.subtypes)], wire)) + parts.extend(_m2m_wire_lines( + [*base_m2m, *(d for st in plan.subtypes for d in sub_m2m[st.entity.name])], + object_index, + wire, + )) parts.append("") parts.append("") parts.extend(_JSON_BODY_LINES) @@ -1536,6 +1562,7 @@ def render_router( parts.append(f"_DECIMAL_FIELDS: frozenset[str] = {decimal_set_body}") parts.extend(_decimal_limits_lines([entity])) parts.extend(_wire_lines([entity], wire)) + parts.extend(_m2m_wire_lines(m2m, object_index or {}, wire)) parts.append("") parts.append("") parts.extend(_JSON_BODY_LINES) diff --git a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py index 46d8cec1d..92b209dfc 100644 --- a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py +++ b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py @@ -291,8 +291,8 @@ class WireOutput: def render_wire(value: Any, wire: WireOutput) -> Any: - """A response *value* (a row, a list of rows, the ``{"rows", "total"}`` envelope, a - Pydantic model) in the form *wire* asks for. Input rules do not depend on *wire*.""" + """A response *value* (a row, a list of rows, a Pydantic model) in the form *wire* asks + for. Input rules do not depend on *wire*.""" if wire.null_members != "native" and wire.shapes: value = _shape_members(value, wire) return to_wire(value) if wire.decimals == "string" else to_native(value) @@ -310,7 +310,7 @@ def _shape_members(value: Any, wire: WireOutput) -> Any: return value out: dict[str, Any] = {} for k, v in value.items(): - out[k] = _shape_vo(v, wire.shapes[k], wire.null_members) if k in wire.shapes else _shape_members(v, wire) + out[k] = _shape_vo(v, wire.shapes[k], wire.null_members) if k in wire.shapes else v return out diff --git a/server/python/tests/codegen/test_router_generator_m2m_target_tph.py b/server/python/tests/codegen/test_router_generator_m2m_target_tph.py index 5b6424114..bbd6f833c 100644 --- a/server/python/tests/codegen/test_router_generator_m2m_target_tph.py +++ b/server/python/tests/codegen/test_router_generator_m2m_target_tph.py @@ -139,7 +139,7 @@ def test_route_body_passes_the_resolved_discriminator_value_literal() -> None: " sponsor_id: int,\n" " repo: Annotated[SponsorRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - ' return render_wire(repo.find_related_bridgeAuths(sponsor_id, "Bridge"), _WIRE)' + ' return render_wire(repo.find_related_bridgeAuths(sponsor_id, "Bridge"), _WIRE_BRIDGE_AUTH)' ) in out diff --git a/server/python/tests/codegen/test_router_generator_tph_m2m.py b/server/python/tests/codegen/test_router_generator_tph_m2m.py index dbdba0b05..e8c05493c 100644 --- a/server/python/tests/codegen/test_router_generator_tph_m2m.py +++ b/server/python/tests/codegen/test_router_generator_tph_m2m.py @@ -254,12 +254,21 @@ def test_base_declared_m2m_mounted_at_base_path_with_no_subtype_gate() -> None: " auth_id: int,\n" " repo: Annotated[AuthRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - " return render_wire(repo.find_related_tags(None, auth_id), _WIRE)" + " return render_wire(repo.find_related_tags(None, auth_id), _WIRE_TAG)" ) in out # No gate anywhere near this handler — find_by_id is never called with None. assert 'repo.find_by_id(None, auth_id) is None' not in out +#: The wire constant each relation's TARGET rows are written through. +_TARGET_WIRE = { + "tags": "_WIRE_TAG", + "auditors": "_WIRE_TAG", + "linkedAuths": "_WIRE_BRIDGE_AUTH", + "priorityTickets": "_WIRE_PRIORITY_TICKET", +} + + def _gated_body( subtype: str, relation: str, pk_param: str = "auth_id", *, target_subtype: str | None = None ) -> str: @@ -275,7 +284,8 @@ def _gated_body( return ( f' if repo.find_by_id("{subtype}", {pk_param}) is None:\n' " return []\n" - f' return render_wire(repo.find_related_{relation}("{subtype}", {pk_param}{tail}), _WIRE)' + f' return render_wire(repo.find_related_{relation}("{subtype}", {pk_param}{tail}), ' + f"{_TARGET_WIRE[relation]})" ) @@ -322,7 +332,7 @@ def test_priority_tickets_target_discriminator_independent_of_source_gate() -> N " auth_id: int,\n" " repo: Annotated[AuthRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - ' return render_wire(repo.find_related_priorityTickets(None, auth_id, "Priority"), _WIRE)' + ' return render_wire(repo.find_related_priorityTickets(None, auth_id, "Priority"), _WIRE_PRIORITY_TICKET)' ) in out # Inherited by every subtype too (rule b) — each subtype's OWN gate applies, # while the target literal stays fixed at "Priority" regardless. diff --git a/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py b/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py index f21001ae1..32f0d57ea 100644 --- a/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py +++ b/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py @@ -181,18 +181,18 @@ def test_shadowed_name_seam_threads_target_subtype_for_the_whole_name() -> None: def test_every_mount_of_the_shadowed_name_passes_the_threaded_parameter() -> None: # Base mount (rule a — ungated): explicit None literal, not a dropped argument. out = _render() - assert " return render_wire(repo.find_related_helpers(None, auth_id, None), _WIRE)" in out + assert " return render_wire(repo.find_related_helpers(None, auth_id, None), _WIRE_TAG)" in out # Non-shadowing subtype (inherits the base's vanilla target): gated, None literal. assert ( ' if repo.find_by_id("Copay", auth_id) is None:\n' " return []\n" - ' return render_wire(repo.find_related_helpers("Copay", auth_id, None), _WIRE)' + ' return render_wire(repo.find_related_helpers("Copay", auth_id, None), _WIRE_TAG)' ) in out # Shadowing subtype: its OWN target's discriminator literal. assert ( ' if repo.find_by_id("Bridge", auth_id) is None:\n' " return []\n" - ' return render_wire(repo.find_related_helpers("Bridge", auth_id, "Priority"), _WIRE)' + ' return render_wire(repo.find_related_helpers("Bridge", auth_id, "Priority"), _WIRE_PRIORITY_TICKET)' ) in out diff --git a/server/python/tests/integration/test_generated_decimal_wire.py b/server/python/tests/integration/test_generated_decimal_wire.py index 5162b5a8b..e91bbe609 100644 --- a/server/python/tests/integration/test_generated_decimal_wire.py +++ b/server/python/tests/integration/test_generated_decimal_wire.py @@ -26,6 +26,7 @@ from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist from metaobjects.codegen.generators.router_generator import render_router from metaobjects.codegen.runtime.filter_parser import FilterPredicate +from metaobjects.codegen.runtime.m2m_codegen import build_object_index from metaobjects.runtime import ObjectManager, SqliteDriver from metaobjects.wire import WireFormat @@ -489,3 +490,89 @@ def test_a_generated_router_carries_the_setting_only_as_data() -> None: root.wire = _PORTABLE # type: ignore[attr-defined] src = render_router(sample) or "" assert '_WIRE = WireOutput(decimals="string", null_members="omit", shapes=' in src + + +# --- an M:N traversal writes the TARGET's rows, so the TARGET's value objects are shaped ---- + +#: Post and Tag both declare a value object named `meta`, with different members; only Tag has `info`. +_M2M_META = { + "metadata.root": { + "package": "acme::blog", + "children": [ + {"object.value": {"name": "PostMeta", "children": [ + {"field.string": {"name": "headline"}}, + ]}}, + {"object.value": {"name": "TagMeta", "children": [ + {"field.string": {"name": "label"}}, + {"field.string": {"name": "note"}}, + ]}}, + {"object.entity": {"name": "Post", "children": [ + {"source.rdb": {"@table": "posts"}}, + {"field.long": {"name": "id"}}, + {"field.object": {"name": "meta", "@objectRef": "PostMeta", "@storage": "jsonb"}}, + {"relationship.association": {"name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "PostTag"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"], "@generation": "increment"}}, + ]}}, + {"object.entity": {"name": "Tag", "children": [ + {"source.rdb": {"@table": "tags"}}, + {"field.long": {"name": "id"}}, + {"field.object": {"name": "meta", "@objectRef": "TagMeta", "@storage": "jsonb"}}, + {"field.object": {"name": "info", "@objectRef": "TagMeta", "@storage": "jsonb"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"], "@generation": "increment"}}, + ]}}, + {"object.entity": {"name": "PostTag", "children": [ + {"source.rdb": {"@table": "post_tags"}}, + {"field.long": {"name": "postId", "@required": True}}, + {"field.long": {"name": "tagId", "@required": True}}, + {"identity.primary": {"name": "pk", "@fields": ["postId", "tagId"]}}, + {"identity.reference": {"name": "fkPost", "@fields": "postId", "@references": "Post"}}, + {"identity.reference": {"name": "fkTag", "@fields": "tagId", "@references": "Tag"}}, + ]}}, + ], + } +} + + +def _m2m_client(wire: WireFormat, tags: list[dict[str, Any]]) -> TestClient: + root = load_string(json.dumps(_M2M_META)).root + root.wire = wire # type: ignore[attr-defined] + objs = {c.name.split("::")[-1]: c for c in root.children()} + index = build_object_index(list(objs.values())) + pkg = f"genm2m_{uuid.uuid4().hex[:8]}" + tmp = Path(tempfile.mkdtemp(prefix="genm2m-")) + pkg_dir = tmp / pkg + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + for name in ("PostMeta", "TagMeta", "Tag", "Post"): + (pkg_dir / f"{name}.py").write_text(render_entity_model(objs[name], index)) + post = objs["Post"] + (pkg_dir / "post_filter_allowlist.py").write_text(render_filter_allowlist(post) or "") + (pkg_dir / "post_router.py").write_text(render_router(post, index) or "") + sys.path.insert(0, str(tmp)) + try: + mod = importlib.import_module(f"{pkg}.post_router") + finally: + sys.path.remove(str(tmp)) + + class Repo: + def find_related_tags(self, id: int) -> list[Any]: + return tags + + app = FastAPI() + app.include_router(mod.router) + app.dependency_overrides[mod.get_repository] = lambda: Repo() + return TestClient(app) + + +_TAG_ROW = {"id": 7, "meta": {"label": "x", "note": None}, "info": {"label": "y", "note": None}} + + +def test_an_m2m_traversal_omits_a_null_member_of_the_targets_value_object() -> None: + body = _m2m_client(WireFormat(null_members="omit"), [_TAG_ROW]).get("/api/posts/1/tags").json() + assert body == [{"id": 7, "meta": {"label": "x"}, "info": {"label": "y"}}] + + +def test_an_m2m_traversal_emits_the_targets_members_not_the_sources() -> None: + row = {"id": 7, "meta": {"label": "x"}, "info": {"note": "n"}} + body = _m2m_client(WireFormat(null_members="emit"), [row]).get("/api/posts/1/tags").json() + assert body == [{"id": 7, "meta": {"label": "x", "note": None}, "info": {"label": None, "note": "n"}}] diff --git a/server/python/tests/runtime/test_decimal_wire.py b/server/python/tests/runtime/test_decimal_wire.py index 23a8a23fe..8c13fc692 100644 --- a/server/python/tests/runtime/test_decimal_wire.py +++ b/server/python/tests/runtime/test_decimal_wire.py @@ -14,7 +14,9 @@ parse_decimal, parse_decimal_operand, parse_json_object, + render_wire, to_wire, + WireOutput, ) _VECTORS = json.loads( @@ -185,3 +187,13 @@ def test_a_map_of_value_objects_applies_the_nested_rule_to_each_value_and_never_ # a null map, and a map value that is not an object, fall through assert coerce_decimal_fields({"tiers": None}, frozenset(), None, nested) == {"tiers": None} assert coerce_decimal_fields({"tiers": {"a": 1.5}}, frozenset(), None, nested) == {"tiers": {"a": 1.5}} + + +def test_only_a_rows_own_value_object_columns_are_shaped() -> None: + # `extras` is an untyped jsonb column; a key inside it named like the `address` column is data. + shapes = {"address": {"f": ["street", "city"], "o": {}}} + row = {"address": {"street": "Main"}, "extras": {"address": {"street": "Side"}}} + emit = render_wire([row], WireOutput(null_members="emit", shapes=shapes)) + assert emit == [{"address": {"street": "Main", "city": None}, "extras": {"address": {"street": "Side"}}}] + omit = render_wire({**row, "extras": {"address": {"street": None}}}, WireOutput(null_members="omit", shapes=shapes)) + assert omit == {"address": {"street": "Main"}, "extras": {"address": {"street": None}}} From fe687608f173307465d6121cbdf2cfee3340f24f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 24 Oct 2026 14:23:12 -0400 Subject: [PATCH 4/4] no-mistakes(document): Document Python 1.0.x float-to-string change in wire-format skill --- .../metaobjects-codegen/references/wire-format.md | 11 ++++++----- .../metaobjects-codegen/references/wire-format.md | 11 ++++++----- .../metaobjects-codegen/references/wire-format.md | 11 ++++++----- .../metaobjects-codegen/references/wire-format.md | 11 ++++++----- .../metaobjects-codegen/references/wire-format.md | 11 ++++++----- .../metaobjects-codegen/references/wire-format.md | 11 ++++++----- 6 files changed, 36 insertions(+), 30 deletions(-) diff --git a/agent-context/skills/metaobjects-codegen/references/wire-format.md b/agent-context/skills/metaobjects-codegen/references/wire-format.md index 392997b67..a27288b3d 100644 --- a/agent-context/skills/metaobjects-codegen/references/wire-format.md +++ b/agent-context/skills/metaobjects-codegen/references/wire-format.md @@ -77,11 +77,12 @@ The generated input converters stay in every mode. ## Upgrading, and drift - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x - output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). -- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can - lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the - Python-normal form. There is no float option, by decision. Update a client that read the - number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). + output. In TypeScript, a decimal keeps its scale (`"12.50"`). Python changed; see the next bullet. +- In Python this is a change, not a no-op. A 1.0.x router wrote a decimal as a JSON number, a float + that can lose digits (`12.5`). The 1.1.2 default writes a string, Pydantic's `str(Decimal)`, which + is the Python-normal form (`"12.50"` for a decimal stored at scale 2). There is no float option, by + decision. Update a Python client that read the number to read the string. `"decimals": "string"` + gives the canonical `"12.5"` form that 1.1.0 and 1.1.1 wrote. - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 392997b67..a27288b3d 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -77,11 +77,12 @@ The generated input converters stay in every mode. ## Upgrading, and drift - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x - output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). -- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can - lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the - Python-normal form. There is no float option, by decision. Update a client that read the - number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). + output. In TypeScript, a decimal keeps its scale (`"12.50"`). Python changed; see the next bullet. +- In Python this is a change, not a no-op. A 1.0.x router wrote a decimal as a JSON number, a float + that can lose digits (`12.5`). The 1.1.2 default writes a string, Pydantic's `str(Decimal)`, which + is the Python-normal form (`"12.50"` for a decimal stored at scale 2). There is no float option, by + decision. Update a Python client that read the number to read the string. `"decimals": "string"` + gives the canonical `"12.5"` form that 1.1.0 and 1.1.1 wrote. - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 392997b67..a27288b3d 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -77,11 +77,12 @@ The generated input converters stay in every mode. ## Upgrading, and drift - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x - output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). -- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can - lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the - Python-normal form. There is no float option, by decision. Update a client that read the - number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). + output. In TypeScript, a decimal keeps its scale (`"12.50"`). Python changed; see the next bullet. +- In Python this is a change, not a no-op. A 1.0.x router wrote a decimal as a JSON number, a float + that can lose digits (`12.5`). The 1.1.2 default writes a string, Pydantic's `str(Decimal)`, which + is the Python-normal form (`"12.50"` for a decimal stored at scale 2). There is no float option, by + decision. Update a Python client that read the number to read the string. `"decimals": "string"` + gives the canonical `"12.5"` form that 1.1.0 and 1.1.1 wrote. - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 392997b67..a27288b3d 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -77,11 +77,12 @@ The generated input converters stay in every mode. ## Upgrading, and drift - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x - output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). -- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can - lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the - Python-normal form. There is no float option, by decision. Update a client that read the - number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). + output. In TypeScript, a decimal keeps its scale (`"12.50"`). Python changed; see the next bullet. +- In Python this is a change, not a no-op. A 1.0.x router wrote a decimal as a JSON number, a float + that can lose digits (`12.5`). The 1.1.2 default writes a string, Pydantic's `str(Decimal)`, which + is the Python-normal form (`"12.50"` for a decimal stored at scale 2). There is no float option, by + decision. Update a Python client that read the number to read the string. `"decimals": "string"` + gives the canonical `"12.5"` form that 1.1.0 and 1.1.1 wrote. - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 392997b67..a27288b3d 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -77,11 +77,12 @@ The generated input converters stay in every mode. ## Upgrading, and drift - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x - output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). -- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can - lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the - Python-normal form. There is no float option, by decision. Update a client that read the - number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). + output. In TypeScript, a decimal keeps its scale (`"12.50"`). Python changed; see the next bullet. +- In Python this is a change, not a no-op. A 1.0.x router wrote a decimal as a JSON number, a float + that can lose digits (`12.5`). The 1.1.2 default writes a string, Pydantic's `str(Decimal)`, which + is the Python-normal form (`"12.50"` for a decimal stored at scale 2). There is no float option, by + decision. Update a Python client that read the number to read the string. `"decimals": "string"` + gives the canonical `"12.5"` form that 1.1.0 and 1.1.1 wrote. - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`, diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index 392997b67..a27288b3d 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -77,11 +77,12 @@ The generated input converters stay in every mode. ## Upgrading, and drift - From 1.0.x, upgrade to 1.1.2 or later. With no setting, C#, Java and Kotlin keep their 1.0.x - output. In Python and TypeScript, a decimal keeps its scale (`"12.50"`). -- In Python this is a change: a 1.0.x router wrote a decimal as a JSON number, a float that can - lose digits (`12.5`). It now writes a string, Pydantic's `str(Decimal)`, which is the - Python-normal form. There is no float option, by decision. Update a client that read the - number to read the string (`"decimals": "string"` gives the canonical `"12.5"`). + output. In TypeScript, a decimal keeps its scale (`"12.50"`). Python changed; see the next bullet. +- In Python this is a change, not a no-op. A 1.0.x router wrote a decimal as a JSON number, a float + that can lose digits (`12.5`). The 1.1.2 default writes a string, Pydantic's `str(Decimal)`, which + is the Python-normal form (`"12.50"` for a decimal stored at scale 2). There is no float option, by + decision. Update a Python client that read the number to read the string. `"decimals": "string"` + gives the canonical `"12.5"` form that 1.1.0 and 1.1.1 wrote. - A project already on 1.1.0 or 1.1.1 whose clients now expect strings and omitted members declares `"wire": { "decimals": "string", "nullMembers": "omit" }` before it moves to 1.1.2. - Every port's codegen drift gate (`meta verify --codegen`, `dotnet meta verify --codegen`,