diff --git a/CHANGELOG.md b/CHANGELOG.md index a2a363391..1e909cc17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,11 @@ here.** _npm `1.1.0` (all 14 `@metaobjectsdev/*` packages in lockstep), PyPI `1.1.0`, NuGet `1.1.0` and Maven Central `8.1.0`. **`metamodelVersion` moves `1.0` to `1.1`.** The vocabulary is additive: -a model that uses none of the new names loads as it did under 1.0, with two exceptions: a bare -`extends` naming an object in another package is refused in TypeScript, Python and C# (see -**Breaking** below), and the Java and Python parser fixes under **Fixed** bring those ports into -line with TypeScript. What it generates is NOT unchanged in every port: in C#, Java, Kotlin's runtime and +a model that uses none of the new names loads as it did under 1.0, with three exceptions: a bare +`extends` naming an object in another package is refused in TypeScript, Python and C#, a +fractional `validator.numeric` `@min` or `@max` is refused in TypeScript, C# and Python (both +under **Breaking** below), and the Java and Python parser fixes under **Fixed** bring those +ports into line with TypeScript. What it generates is NOT unchanged in every port: in C#, Java, Kotlin's runtime and Python the default for column and table names flips from the field name to `snake_case`, so a 1.0 estate whose columns are named like its fields must add `"naming": { "columns": "literal" }` (see **Breaking** below). Each entry says which models it @@ -35,7 +36,10 @@ those four). A database whose columns are named exactly like the fields adds (`--column-naming`, the `columnNaming` pom arg, `ObjectManager(column_naming=)`, `columnNamingStrategy`) are removed and refused with a message naming the setting. Nothing in the build fails if you skip this: the first failure is a query against a column that does not exist. -Migration: [physical-naming.md](docs/features/migrations/physical-naming.md). The entry is under +Upgrade every port of an estate together: a 1.1 TypeScript tool reads `naming`, a 1.0.x C#, Java +or Python tool silently ignores the unknown key and keeps the old column names, so two ports of +one estate disagree about the schema without any error (only a 1.0.x TypeScript tool refuses the +key). Migration: [physical-naming.md](docs/features/migrations/physical-naming.md). The entry is under **Changed**. **Breaking, in every port: documentation is generated the way code is.** Every generator, @@ -43,7 +47,7 @@ documentation included, is an entry in the project's generator list; one command list; nothing is generated that the list does not declare. The docs commands are removed: `meta docs` and `meta verify --docs` (TypeScript), `dotnet meta docs` (C#), `metaobjects docs` (Python) and the `metaobjects:docs` goal (Java and Kotlin, 8.1.0). Each prints where its work -went and exits 2. A gen command given a 1.0 docs configuration fails before it writes anything +went and exits 2 (the Maven goal fails the build, so `mvn` exits 1). A gen command given a 1.0 docs configuration fails before it writes anything rather than running and writing no documentation, and in TypeScript a project with no `metaobjects.config.ts` fails `meta gen` with a pointer instead of having its pages written from defaults. There is no upgrade script: [docs-are-generators.md](docs/features/migrations/docs-are-generators.md) @@ -77,14 +81,25 @@ package.** It now fails with `ERR_UNRESOLVED_SUPER`, as Java already did and ADR bare name binds the declaring file's own package, then the root. Qualify the reference: `extends: other::Base`. The entry is under **Changed** (ADR-0059). +**Breaking, TypeScript, C# and Python: `attr.int` and `attr.long` take whole numbers only, so a +fractional validator bound is refused.** A `validator.numeric` declares `@min` and `@max` as +integers, and a fraction such as `@min: 0.5` on a `decimal` or `double` field is now +`ERR_BAD_ATTR_VALUE` at load ("'@min' must be of type 'int' but got the fraction 0.5"), as Java +already refused it, so the five ports agree. 1.0.x TypeScript accepted it. Fix: write an integer +bound (`@min: 0`). A decimal-valued bound (for example "at least 0.01") cannot be declared in +1.1.0: that needs new metamodel vocabulary, which is 1.2 work. A whole number written `1.0` +still loads, as `1`. The entry is under **Changed** and the FR-044 report entries. + **Breaking, in every port: a `field.decimal` crosses the API as one canonical string (ADR-0019 Amendment 1).** Every decimal a generated route sends, on an entity, a projection or a report row (an `avg`, a ratio, a `sum` of a decimal), is a JSON string in plain notation with no exponent and no trailing fraction zeros: `12.50` is sent `"12.5"`, zero is `"0"`. Before 1.1.0 -each port sent its own form (TypeScript a string, the others a number), so a client that read a -number from C#, Java, Kotlin or Python now reads a string. Display formatting, such as two digits +each port sent its own form (TypeScript a string that kept trailing zeros, the others a number), so +a client that read a number from C#, Java, Kotlin or Python now reads a string, and TypeScript's +`"12.50"` is now `"12.5"`. Display formatting, such as two digits for an amount of money, is a view's job, never the wire's. On input a route takes a string in -JSON-number grammar or a JSON number, read exactly; anything else is `400 validation`, and so is +JSON-number grammar or a JSON number, read exactly (in TypeScript a JSON number is exact 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 decimal filter operand uses the same grammar, is compared exactly, and a bad one is `400 invalid_filter_value`. Postgres and MySQL keep the @@ -93,7 +108,7 @@ declared precision; SQLite and D1, which have no decimal type, keep 15 significa generator keeps its old decimal handling until you resync it. The per-port entries are under **Changed**. -**Nine diffs to expect when you upgrade.** +**Eleven diffs to expect when you upgrade.** - **Generated files no generator emits any more are deleted.** The first full `gen` after the upgrade, in every port, deletes a file the committed hash manifest records writing, that no @@ -167,6 +182,26 @@ generator keeps its old decimal handling until you resync it. The per-port entri code that does not compile. The **Fixed** entry lists each port's before and after, and the re-eject commands. +- **CLI exit codes changed.** `meta gen` on a model that does not load exits 1 (1.0.13 exited 2). + `meta init` in an initialised project is a no-op that exits 0 (it exited 1; `--force` still + re-scaffolds). The removed docs commands exit 2, except the Maven `docs` goal, which fails + the build, so `mvn` exits 1. A script that tests for a specific code needs the new one. +- **Smaller changes an adopter can hit.** Each has its own entry. + - TypeScript: `ObjectManager` now enforces numeric bounds, array bounds, uri/inet format and + an assigned primary key; `RequirementTestArgs` and `RequirementView` gained required + fields (a compile break for code that builds them by hand). + - Java: every regenerated class has a one-time header diff; `entity` no longer writes + `Extractor`; decimal filter operands are `BigDecimal`, not `String`, so a hand-written + repository that casts `(String) p.value()` breaks. + - Kotlin: also emits `MetaPortableColumnTypes.kt` and `Names`; `portableUuid` binds + text on MySQL and SQLite, which differs from an Exposed-created `BINARY(16)` column. + - Python: refuses a project module that shadows the standard library; answers 400, not 422, + for a malformed body. + - The `mermaid-er` generator is removed. + - C# and Python: `gen` and `verify --codegen` need a generator list (`codegen/Generators.cs`, + written by `dotnet meta init`; `metaobjects.config.yaml`), and exit 2 without one. Python's + `gen --out` form is gone. + **Not in 1.1.0.** Derived measures, the preview `reporting` library, the dbt exporter, the second requirements slice and the advisory lint port, the JSON Schema and OpenAPI generators, the generator catalog and the Atlas evaluation are 1.2 work. @@ -1483,6 +1518,26 @@ until you regenerate. ### Fixed - **Found by the 1.1.0 release review, in every port unless named.** + - **Python: writing a `field.decimal` array no longer answers 500.** The write coercion + converted the whole list as one decimal; it now converts each element and keeps `None`. + - **Kotlin: a `field.decimal` with no `@precision` is unbounded, as in the other four + ports.** It was an Exposed `decimal(19,4)` column that rounded to four places on read, and + the new range check refused values that column could not hold. An undeclared decimal now + has an unbounded column type and no range check; a declared `@precision` is checked as + before. + - **A `field.decimal` inside a jsonb value object follows the top-level decimal rule in all + five ports**: written as the canonical string, read from a string or a JSON number exactly. + The ports disagreed before (TypeScript refused a number, Python stored a float). A + `field.double` inside a value object stays a JSON number, and a fractional number that is + not a declared decimal stays a float in Python (it answered 500). A decimal inside a + `field.map` value object is not covered: it is not part of the rule and is not gated. + - **A `field.decimal` array works in C#, Java and Kotlin.** Java binds a Postgres `numeric[]` + as exact `BigDecimal` elements, a non-required C# decimal array is nullable, and each + element follows the scalar decimal rule (a bad element is `400 validation`). + - **C# and Java: a value object omits an unset member on the wire instead of sending `null`**, + as the other ports do. A C# value-object array property becomes nullable with no + initializer; an abstract shape's array keeps its `new List()` initializer. + - **C#: a malformed or empty JSON body on a decimal-bearing entity is `400 validation`.** - **The loader refuses four more report shapes**, with the cross-port code and a message naming the fix: a dimension or measure over a `field.object` or `@objectRef` field (D5, M10: `ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE`; a `field.map` with `@valueType` diff --git a/agent-context/skills/metaobjects-codegen/SKILL.md b/agent-context/skills/metaobjects-codegen/SKILL.md index f67f1978a..4332d7fc8 100644 --- a/agent-context/skills/metaobjects-codegen/SKILL.md +++ b/agent-context/skills/metaobjects-codegen/SKILL.md @@ -49,16 +49,19 @@ exact config, generator names and commands for this project; read it. The other ## Upgrading a project from 1.0 to 1.1 -Read both guides before the first `gen` of an upgrade, for every port the project uses; the -build can stay green while the result is wrong. - -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: the default column and table names are now `snake_case` in every - port (C#, Java, Kotlin's runtime and Python defaulted to the field names). A database whose - columns are named like its fields needs `"naming": { "columns": "literal" }` in - `.metaobjects/config.json`, or regenerated code queries columns that do not exist. The - per-port naming flags and generator args are removed. -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: documentation is a generator in the project's list, and the docs - commands are removed. +Read the one upgrade guide, https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md, before the first `gen`, for every +port the project uses; the build can stay green while the result is wrong. It lists the steps +in order and every change that needs action. + +- The default column and table names are now `snake_case` in every port (C#, Java, Kotlin's + runtime and Python defaulted to the field names). A database whose columns are named like + its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or + regenerated code queries columns that do not exist. The per-port naming flags and generator + 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). - **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` (`dotnet meta eject`, `metaobjects eject`, `mvn metaobjects:eject`) reports each copy that differs from the reference; diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index 1eafb2d1f..99f8489e0 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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 c0e94907e..0d9c36f86 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -34,7 +34,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 101 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 12 projection + 27 report + 8 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (HttpListener + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | +| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 105 (31 core + 10 tph + 9 m2m + 3 jsonb + 2 write-through + 12 projection + 27 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (HttpListener + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | | [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) | | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | @@ -347,9 +347,9 @@ REAL, a MySQL `DECIMAL` keeps its scale, a MySQL `DATETIME` is the UTC wall cloc `report-relative-date` carries a per-engine seed (`seed-data-engine`). `op: relate` and the write ops stay Postgres-only. See [features/reporting.md](features/reporting.md#what-the-corpus-gates). -### `fixtures/api-contract-conformance/` (101) +### `fixtures/api-contract-conformance/` (105) -All 101 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port +All 105 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD routes answer identically over HTTP — list / get / create / patch+put / delete, plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1` @@ -363,11 +363,11 @@ URL grammar `?filter[][]=` (FR-009). On top of the 31 core scenarios the corpus carries seven sub-corpora — `tph/` (10, single-table inheritance; a subtype decimal filters and refuses extra digits), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination each corpus alone could not reach, and 1 pinning the collection-URL spelling), -`jsonb/` (2, typed value-object columns), +`jsonb/` (3, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string), `write-through/` (2, table-write + view-read entities), `projection/` (12, a read-only view answers reads and refuses writes with 405; a projection with no declared identity has no item route; a projection keyed on a field not named `id` is addressed by -it, whether its identity names `@fields` or derives them; decimal and float fields filter), `decimal/` (8, ADR-0019 Amendment 1: a decimal is one +it, whether its identity names `@fields` or derives them; decimal and float fields filter), `decimal/` (11, ADR-0019 Amendment 1: a decimal, array element and undeclared-precision column included, is one canonical string, read exactly, filtered exactly and refused past its declared digits) and `report/` (27, FR-044: a view-backed `object.report` is listed, filtered, sorted and paged on its derived fields, answers `POST` with 405 and mounts no `/{id}`; a `@spine` report keeps @@ -554,7 +554,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) The fixtures in the nine corpora mapped above (metamodel 410 + yaml 16 + verify 37 -+ render 16 + persistence 59 + api-contract 101 + source-resolution 25 + scope 10 + ++ render 16 + persistence 59 + api-contract 105 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc, and so do the two requirement corpora and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/RELEASING.md b/docs/RELEASING.md index eca9b640f..a7fb9eb50 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -176,7 +176,7 @@ Publish in tier order so a dependent never lands before its dependency. **`forge lands, the page would publish the previous release's versions. 7. **`v` is cut LAST, and it is not just a marker — the website deploys from it.** - metaobjects.dev's Pages workflow resolves the newest `v0.x` tag, clones that tree, and + metaobjects.dev's Pages workflow resolves the newest `vN.N.N` release tag (version-agnostic), clones that tree, and injects its `examples/showcase/site-payload.json` into the pages. So the tag decides what the site states about the release, **including all five version coordinates.** @@ -203,6 +203,10 @@ Publish in tier order so a dependent never lands before its dependency. **`forge release did not ship on any line that makes a registry claim**, or a tag the deploy's own filter would not resolve. + **The tag does not trigger the site.** After `finish-release` has pushed the tag, dispatch + the website repository's **Deploy to GitHub Pages** workflow by hand (the previous releases + did the same); until you do, the site keeps stating the previous release. + A registry that genuinely **sat out** the release under publish-what-changed is declared (`--sat-out pypi,nuget`) rather than guessed — guessing "sat out" ships the stale number, and guessing "published" blocks a correct lagging registry. **npm can never sit out:** @@ -557,10 +561,14 @@ are its check. **Before `bun publish`: run the FULL local CI on the release commit and confirm it is green.** Its `ts-slow` section carries the real-Postgres migrate gate. Publishing is irreversible on all four registries, and the `v*` tag is pushed *after* `bun publish`, so -even if Actions were on, a tag-triggered `integration-tests` run could never be the -pre-publish gate. This is the last gate that can precede the irreversible step — and with -Actions disabled it is the only one, so the flagless script (all five ports, the reactor, -the docker matrix) is what you run, not `--quick`. +even though Actions is on (since 2026-09-20), a tag-triggered `integration-tests` run could +never be the pre-publish gate. This is the last gate that can precede the irreversible step, +and it is the only one that runs before it, so the flagless script (all five ports, the +reactor, the docker matrix) is what you run, not `--quick`. Run it on the commit you will +tag, after every fix for this release has merged: the `python-v`, `csharp-v` and `java-v` +tags must sit on that same commit, so cut the npm release commit from `main` only once the +last fix is in, and tag all three ports on it (a Python tag on an earlier commit publishes +without the later fixes). ```bash scripts/ci-local.sh --strict-toolchains # must end "LOCAL CI PASSED" @@ -839,6 +847,11 @@ versioned on its own major line — npm major + 7, so `7.x` while npm was `0.x` > ``` > If all are `200`, the release is out — the error was just the response parse. Bump the plugin to > ≥`0.7.0` to stop the crash on the next release. +> +> **A polling timeout can also report red after the deployment published.** `java-v8.0.11` +> failed with `Polling for … timed out before the deployment completed`, yet 8.0.11 is on +> Central (HTTP 200). Check Central with the loop above before any re-run: a re-run of a +> published version fails as a duplicate. **The `minor.patch` is CONVERGENT across languages, not lockstepped** (standing policy since 0.25.0): npm, PyPI and NuGet share one `major.minor.patch` and Maven Central takes the same diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 7159100c1..de11075b7 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -322,7 +322,7 @@ 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; 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` | 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.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`**. | diff --git a/docs/features/migrations/docs-are-generators.md b/docs/features/migrations/docs-are-generators.md index 3646b411e..ef3ff7f10 100644 --- a/docs/features/migrations/docs-are-generators.md +++ b/docs/features/migrations/docs-are-generators.md @@ -25,20 +25,20 @@ before writing anything, so a build never passes while silently writing no docum **What you see.** `meta docs` fails (exit 2) with: ``` -`meta docs` was removed in 1.1.0. `meta gen` writes documentation now: declare each docs generator in `generators` in metaobjects.config.ts. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". +`meta docs` was removed in 1.1.0. `meta gen` writes documentation now: declare each docs generator in `generators` in metaobjects.config.ts. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` `meta verify --docs` fails (exit 2) with: ``` -`meta verify --docs` was removed in 1.1.0. `meta verify --codegen` checks documentation now, with everything else `generators` declares. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". +`meta verify --docs` was removed in 1.1.0. `meta verify --codegen` checks documentation now, with everything else `generators` declares. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` A config that still has a `docs` block fails `meta gen` and `meta verify --codegen` before anything is written: ``` -meta gen: found 1.0 docs configuration (the docs block in metaobjects.config.ts). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in `generators` in metaobjects.config.ts. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". +meta gen: found 1.0 docs configuration (the docs block in metaobjects.config.ts). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in `generators` in metaobjects.config.ts. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` **Before (1.0).** `meta docs` wrote four sets of pages nobody listed (the model pages, the @@ -218,7 +218,7 @@ all and wrote the model pages and the requirement ledger. In 1.1.0 nothing is ge until a config declares it, and `meta gen` with no config fails with: ``` -meta gen: no metaobjects.config.ts. Nothing is generated until a config declares it, documentation included; `meta init` writes one. A 1.0 project that ran `meta docs` with no config: see https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". +meta gen: no metaobjects.config.ts. Nothing is generated until a config declares it, documentation included; `meta init` writes one. A 1.0 project that ran `meta docs` with no config: see https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` Write a `metaobjects.config.ts` at the project root with a `docs` target and `docsFile()` @@ -245,14 +245,14 @@ node, so drop it if yours declares none. Then run `meta gen`. **What you see.** `dotnet meta docs` prints this line and exits 2: ``` -dotnet meta docs was removed in 1.1.0. `dotnet meta gen` writes documentation now: declare each docs generator in codegen/Generators.cs. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta docs was removed in 1.1.0. `dotnet meta gen` writes documentation now: declare each docs generator in codegen/Generators.cs. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "C#". ``` `dotnet meta gen` (and `dotnet meta verify --codegen`) in a project with no `codegen/Generators.cs` prints this and exits 2, writing nothing: ``` -dotnet meta gen: no codegen/Generators.cs. The generator list lives there in 1.1.0, documentation included; `dotnet meta init` writes it. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta gen: no codegen/Generators.cs. The generator list lives there in 1.1.0, documentation included; `dotnet meta init` writes it. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "C#". ``` `dotnet meta gen` given `--project` or `--model-base-url` fails before writing anything, @@ -328,7 +328,7 @@ and replace `Program.cs` with the two lines above. `dotnet meta init` writes (`dotnet meta verify --codegen` in place of `dotnet meta gen` for the gate): ``` -dotnet meta gen: codegen/Program.cs does not run Generators.All, so it would ignore the generator list in codegen/Generators.cs. Replace it with the two-line entry `using MetaObjects.Codegen;` and `return CodegenCli.Run(args, Generators.All);`. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta gen: codegen/Program.cs does not run Generators.All, so it would ignore the generator list in codegen/Generators.cs. Replace it with the two-line entry `using MetaObjects.Codegen;` and `return CodegenCli.Run(args, Generators.All);`. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "C#". ``` `init` and `eject` say the same, and warn when `codegen/Codegen.csproj` still pins an older @@ -341,7 +341,7 @@ Neither is read in 1.1.0. A run that finds either, with no spec entry in the lis before writing anything: ``` -dotnet meta gen: found 1.0 template-spec configuration (template-spec.json). Nothing reads it any more, so this run would not render its templates. Declare it in codegen/Generators.cs as `new MetaObjects.Codegen.TemplateCodegen.TemplateSpecGenerator { Spec = "template-spec.json" }`. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta gen: found 1.0 template-spec configuration (template-spec.json). Nothing reads it any more, so this run would not render its templates. Declare it in codegen/Generators.cs as `new MetaObjects.Codegen.TemplateCodegen.TemplateSpecGenerator { Spec = "template-spec.json" }`. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "C#". ``` (`--template-spec` in place of `template-spec.json` when the flag was given; @@ -417,7 +417,7 @@ move the value to `naming` in `.metaobjects/config.json` ([physical-naming.md](p **What you see.** `metaobjects docs` prints this line and exits 2: ``` -metaobjects docs was removed in 1.1.0. `metaobjects gen` writes documentation now: declare each docs generator in a target's `generators` in metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects docs was removed in 1.1.0. `metaobjects gen` writes documentation now: declare each docs generator in a target's `generators` in metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python". ``` `metaobjects gen` (and `metaobjects verify --codegen`) with no `metaobjects.config.yaml` @@ -425,20 +425,20 @@ prints this and exits 2, writing nothing. That includes the 1.0 form `metaobjects gen --out --generators `: ``` -metaobjects gen: no metaobjects.config.yaml. The generator list lives there in 1.1.0, documentation included. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects gen: no metaobjects.config.yaml. The generator list lives there in 1.1.0, documentation included. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python". ``` A config that still has a top-level `docs` block fails `gen` before it writes anything: ``` -metaobjects gen: found 1.0 docs configuration (the docs block in metaobjects.config.yaml). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in a target's `generators` in metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects gen: found 1.0 docs configuration (the docs block in metaobjects.config.yaml). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in a target's `generators` in metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python". ``` A target with no `generators` key fails `gen` (exit 2) before anything is written; list its generators, or write `generators: []` for a target that writes nothing: ``` -metaobjects gen: target(s) 'api' declare no `generators` key. In 1.1.0 a target runs only the generators it lists: list them, or write `generators: []` for a target that writes nothing. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects gen: target(s) 'api' declare no `generators` key. In 1.1.0 a target runs only the generators it lists: list them, or write `generators: []` for a target that writes nothing. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python". ``` **Before (1.0).** Code from a config file, or from flags with no config, and the api @@ -530,7 +530,7 @@ metaobjects gen --target docs **What you see.** A pom that binds the `docs` goal fails with: ``` -metaobjects:docs was removed in 8.1.0. `mvn metaobjects:generate` writes documentation now: declare each docs generator as a entry in the plugin's . See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Java and Kotlin". +metaobjects:docs was removed in 8.1.0. `mvn metaobjects:generate` writes documentation now: declare each docs generator as a entry in the plugin's . See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Java and Kotlin". ``` A pom that still sets the goal's parameters fails in `generate` and `verify` with a line diff --git a/docs/features/migrations/physical-naming.md b/docs/features/migrations/physical-naming.md index 76381b896..dbfbcd34a 100644 --- a/docs/features/migrations/physical-naming.md +++ b/docs/features/migrations/physical-naming.md @@ -30,6 +30,10 @@ every runtime `ObjectManager` and `verify` read it, so they always produce the s An unknown key or value fails when the file is read. Index and constraint names are unchanged. +**Upgrade every port of an estate together.** A 1.0.x TypeScript tool refuses the new `naming` key, +but a 1.0.x C#, Java or Python tool ignores an unknown top-level key silently and keeps the old +column names, so a mixed estate disagrees about the schema without any error. + **The default is now `snake_case` in every port.** It is what `meta migrate` has always created. C#, Java, Kotlin's runtime and Python used `literal` by default. **If your database's columns are named exactly like your fields** (`firstName`, not `first_name`), add one line to @@ -47,7 +51,7 @@ Every place the rule used to be set now fails with a message that names this set example: ``` ---column-naming was removed in 1.1.0. Column and table naming is one project setting now, read by every port: "naming" in .metaobjects/config.json, for example "naming": { "columns": "literal" }. Move it there and delete --column-naming. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md +--column-naming was removed in 1.1.0. Column and table naming is one project setting now, read by every port: "naming" in .metaobjects/config.json, for example "naming": { "columns": "literal" }. Move it there and delete --column-naming. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/physical-naming.md ``` It never falls back to the default, because the old value may be the one that matches your 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 44957ac42..e1b98e6e1 100644 --- a/docs/features/migrations/upgrading-1.0-to-1.1.md +++ b/docs/features/migrations/upgrading-1.0-to-1.1.md @@ -7,16 +7,18 @@ under `[1.1.0]`, and the longer guides it links are written for a coding agent t 1.1.0 is a MINOR: `metamodelVersion` moves from `1.0` to `1.1`, the vocabulary is additive (a model that uses none of the new names keeps its meaning), and the reporting feature is new. -Five changes still need action in an existing project, and three of them fail nothing when you +Six changes still need action in an existing project, and several of them fail nothing when you skip them, so read the whole list before you regenerate. ## Before you start -- **Upgrade every port together.** All five ports read `.metaobjects/config.json`, and a 1.0.x - reader refuses a key it does not know: once you add `naming` (step 3), a 1.0.x TypeScript, - C#, Java, Kotlin or Python tool in the same estate fails with "Unrecognized key(s) in object: - 'naming'". From 1.1.0 on, an unknown key there is a warning, not a failure, so the next key a - release adds is not the same trap. +- **Upgrade every port together, because the failure is silent.** All five ports read + `.metaobjects/config.json`. Once you add `naming` (step 2), a 1.0.x TypeScript tool refuses + it ("Unrecognized key(s) in object: 'naming'"), but a 1.0.x C#, Java or Python tool ignores + the unknown key without a word and keeps its old column names. A mixed estate therefore does + not fail: the old port reads and writes columns named like the fields while the new ones use + `snake_case`. From 1.1.0 on, an unknown key there is a warning in TypeScript, so the next key + a release adds is not the same trap. - **Mixed-version exports.** A 1.1 `meta export` file can hold a root-level reference written `::Name`, which 1.0.x refuses (`ERR_RELATIVE_REF_IN_CANONICAL`). Read 1.1 exports with 1.1 tools. @@ -29,18 +31,27 @@ skip them, so read the whole list before you regenerate. `metaobjects` `1.1.0` (PyPI), `MetaObjects*` `1.1.0` (NuGet), `com.metaobjects:*` `8.1.0` (Maven, including the `metaobjects-maven-plugin` version in each `pom.xml`). Reinstall cleanly and read the resolved versions back. -2. **`meta upgrade`**, then `meta upgrade --apply` if it reports anything. Then load the model - (`meta verify`): a bare cross-package `extends` now fails with `ERR_UNRESOLVED_SUPER`, and the - message names the spelling to write (see "References and `extends`"). -3. **Set the physical naming rule** if your database's columns are named like the fields (C#, - Java, the JVM runtime and Python; see "Column and table names"). This is the step that - fails nothing when skipped. -4. **Move documentation into the generator list** if you ran `meta docs`, `dotnet meta docs`, - `metaobjects docs` or the `metaobjects:docs` goal (see "Documentation is generated"). +2. **Set the physical naming rule** if your database's columns are named like the fields (C#, + Java, the JVM runtime and Python; see "Column and table names"). TypeScript: move + `columnNamingStrategy` to the same setting. This is the step that fails nothing when + skipped. +3. **Give every port a generator list, and move documentation into it.** TypeScript: remove + the `docs` block from `metaobjects.config.ts` and declare each docs generator. C#: run + `dotnet meta init` once to write `codegen/Generators.cs`; without it `dotnet meta gen` and + `verify --codegen` exit 2. Python: write the target's `generators` in + `metaobjects.config.yaml`; without that file `metaobjects gen` exits 2, and the `gen + --out` form is gone. Java and Kotlin: the `` list in the pom, and the + `metaobjects:docs` goal is removed. Do this whether or not you ever ran a docs command (see + "Documentation is generated"). +4. **`meta upgrade`**, then `meta upgrade --apply` if it reports anything. Then load the model + (`meta verify`), which refuses a leftover `columnNamingStrategy` or `docs` block until steps + 2 and 3 are done: a bare cross-package `extends` now fails with `ERR_UNRESOLVED_SUPER`, and + the message names the spelling to write (see "References and `extends`"). 5. **Java and Kotlin: let `mvn metaobjects:generate` write the hash manifest, then commit `.metaobjects/.gen-state/.hashes.json`** in every module (see "JVM hash manifest"). -6. **Resync the generators you own.** `meta eject --list` (TypeScript; `metaobjects eject - --list`, `dotnet meta gen --list`, `mvn metaobjects:eject -Dlist=true` in the other ports) +6. **Resync the generators you own.** `meta eject --list` (TypeScript; `metaobjects gen --list` + in Python, which has no `eject --list`; `dotnet meta gen --list`, + `mvn metaobjects:eject -Dlist=true` in the other ports) reports each owned copy, and every file it owns beside it (in TypeScript the renderers under `codegen/templates/`), as identical to the 1.1.0 reference or not. An owned copy keeps the logic it was copied with: it gets no report output and none of the 1.1 fixes until you resync it. For a copy you never edited, @@ -132,9 +143,10 @@ had. `meta upgrade` does not make this edit: choosing the package needs the whol 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. A client that reads a decimal from those ports +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, and a value with more fraction +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 @@ -186,8 +198,21 @@ whole number written `1.0` still loads, as `1`. ## CLI behaviour that changed +- `meta gen` on a model that does not load exits 1 (1.0.13 exited 2). +- The removed docs commands exit 2; the Maven `docs` goal fails the build, so `mvn` exits 1. - `meta init` in a project that is already initialised is a no-op that exits 0 (1.0 exited 1, "already exists; use --force"). `--force` still re-scaffolds. +- TypeScript `ObjectManager` now enforces numeric bounds, array bounds, uri/inet format and an + assigned primary key; `RequirementTestArgs` and `RequirementView` gained required fields (a + compile break for code that builds them by hand). +- Java: every regenerated class has a one-time header diff; `entity` no longer writes + `Extractor`; a decimal filter operand is a `BigDecimal`, not a `String`, so a + hand-written repository that casts `(String) p.value()` breaks. +- Kotlin also emits `MetaPortableColumnTypes.kt` and `Names`; `portableUuid` binds text + on MySQL and SQLite, which differs from an Exposed-created `BINARY(16)` column. +- Python refuses a project module that shadows the standard library, and answers 400, not 422, + for a malformed body. +- The `mermaid-er` generator is removed. ## New in 1.1, nothing to do diff --git a/docs/llms/llms-full.txt b/docs/llms/llms-full.txt index b44a74315..b01d3f697 100644 --- a/docs/llms/llms-full.txt +++ b/docs/llms/llms-full.txt @@ -29,6 +29,8 @@ The metamodel is the **durable spine**; generated code is the **disposable artif If you are helping someone decide whether to adopt MetaObjects in an existing project (it has no MetaObjects files yet), fetch and follow it against the repository. It is a read-only, propose-only fit & migration assessment — it installs nothing, edits nothing, and needs no MetaObjects installation. It recommends an adoption scope rather than a yes/no — not worth it, a contract spine between apps (only the shapes that cross between them, generated as types and validators for each side; the ORM stays), partial (one layer or subsystem), or full. Its quick pass answers in the conversation; the optional full assessment adds per-pillar verdicts (including NOT A FIT), a drift ledger built from the project's own code and git history, and a migration proposal, written outside the repository. The "adopting" section below is the next step after a yes verdict. +**Upgrading a 1.0.x project to 1.1.0:** read before the first `gen`. It is the one page for every port: the steps in order and every change that needs action (the physical-naming default, documentation as a generator, the canonical decimal string, integer-only validator bounds). Several of them fail nothing when skipped. + ## For AI assistants adopting MetaObjects If you are an AI assistant helping someone adopt MetaObjects, do this **first** — the scaffolded local context is richer and version-matched, and it loads automatically into the project's agent context: diff --git a/docs/llms/llms.txt b/docs/llms/llms.txt index c948ab48f..f858089ec 100644 --- a/docs/llms/llms.txt +++ b/docs/llms/llms.txt @@ -30,6 +30,8 @@ If you are an AI assistant helping someone adopt MetaObjects in their project, d Re-run `meta init --refresh-docs` to update the scaffolded agent docs after a CLI upgrade. (The Node `meta` CLI is the single agent-context scaffolder for every backend, by design — ADR-0033; the JVM/Python/C# CLIs deliberately redirect to it. Run `meta init` regardless of backend language, or `npx meta agent-docs --server ` to scaffold just the agent context.) +**Upgrading a 1.0.x project to 1.1.0:** read before the first `gen`. It is the one page for every port: the steps in order and every change that needs action (the physical-naming default, documentation as a generator, the canonical decimal string, integer-only validator bounds). Several of them fail nothing when skipped. + 3. **After scaffolding, load `.metaobjects/AGENTS.md` and the `.claude/skills/metaobjects-*` skills.** They teach metadata authoring, codegen, runtime/UI, prompt construction, and `verify` — for this project's exact stack and version. This `llms.txt` is the short index; the deep, version-matched how-to is the scaffolded local context. diff --git a/examples/advanced-modeling/codegen/runtime/decimal-wire.ts b/examples/advanced-modeling/codegen/runtime/decimal-wire.ts index c7afadf45..8b4f4f349 100644 --- a/examples/advanced-modeling/codegen/runtime/decimal-wire.ts +++ b/examples/advanced-modeling/codegen/runtime/decimal-wire.ts @@ -71,10 +71,23 @@ export function decimalFromDouble15(value: number): string | undefined { */ export function parseDecimalInput(value: unknown): string | undefined { if (typeof value === "string") return canonicalDecimal(value); - if (typeof value === "number") return decimalFromDouble15(value); + if (typeof value === "number") return decimalFromJsonNumber(value); return undefined; } +/** + * W2 for a JSON number on input. `JSON.parse` has already rounded the client's digits to a double, + * so only a number that IS its own 15-digit form (or a safe integer, taken as `String(n)`) can be + * read exactly: anything else would be silently rounded, so it is refused (`undefined`) and the + * client is told to send a string. The test is `Number(n.toPrecision(15)) === n`: a number whose + * shortest round-trip form carries more than 15 significant digits fails it. + */ +export function decimalFromJsonNumber(value: number): string | undefined { + if (!Number.isFinite(value)) return undefined; + if (!Number.isSafeInteger(value) && Number(value.toPrecision(15)) !== value) return undefined; + return canonicalDecimal(String(value)); +} + /** W2 for a query-string filter operand: the same grammar as a body string, compared exactly. */ export function parseDecimalOperand(text: string): string | undefined { return canonicalDecimal(text); diff --git a/examples/advanced-modeling/codegen/templates/decimal-wire-helper.ts b/examples/advanced-modeling/codegen/templates/decimal-wire-helper.ts index c2b3a8422..161042ed2 100644 --- a/examples/advanced-modeling/codegen/templates/decimal-wire-helper.ts +++ b/examples/advanced-modeling/codegen/templates/decimal-wire-helper.ts @@ -17,7 +17,11 @@ export const DECIMAL_WRITE_HELPER_DECL = [ " * number, rewritten to its canonical string (plain notation, no trailing fraction zeros, zero as \"0\").", " * Garbage, or a value the column cannot hold without rounding, is a validation issue. Generated. */", `function ${DECIMAL_WRITE_HELPER}(v: string | number, ctx: z.RefinementCtx, precision?: number, scale = 0): string {`, - ' const text = typeof v === "number" ? (Number.isFinite(v) ? v.toPrecision(15) : "") : v;', + ' if (typeof v === "number" && Number.isFinite(v) && !Number.isSafeInteger(v) && Number(v.toPrecision(15)) !== v) {', + ' ctx.addIssue({ code: "custom", message: "Decimal number cannot be read exactly as a JSON number; send it as a string" });', + " return z.NEVER;", + " }", + ' const text = typeof v === "number" ? (Number.isFinite(v) ? String(v) : "") : v;', " const m = /^(-?)(0|[1-9][0-9]*)(?:\\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(text);", " const e = m === null || m[4] === undefined ? 0 : Number(m[4]);", " if (m === null || !Number.isSafeInteger(e) || Math.abs(e) > 1000) {", diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index 643429fac..5f8d45a2d 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -777,9 +777,10 @@ function zodFieldExpr( case FIELD_SUBTYPE_DECIMAL: { // ADR-0019 Amendment 1 (W2, W3): a WRITE shape takes a decimal as a string in the // JSON-number grammar or a JSON number, stores its canonical string, and refuses what the - // column cannot hold. The TPH read shape parses rows the database already holds, and a value - // object's member is JSON that stores whatever it is given, so both keep a bare string. - if (readShape || owner?.subType === OBJECT_SUBTYPE_VALUE) { + // column cannot hold. A decimal inside a jsonb value object follows the same rule (captain + // ruling C1: its @precision / @scale bound it, the stored JSON holds the canonical string). + // Only the TPH read shape, which parses rows the database already holds, keeps a bare string. + if (readShape) { baseStr = "z.string()"; break; } diff --git a/examples/showcase/codegen/runtime/decimal-wire.ts b/examples/showcase/codegen/runtime/decimal-wire.ts index c7afadf45..8b4f4f349 100644 --- a/examples/showcase/codegen/runtime/decimal-wire.ts +++ b/examples/showcase/codegen/runtime/decimal-wire.ts @@ -71,10 +71,23 @@ export function decimalFromDouble15(value: number): string | undefined { */ export function parseDecimalInput(value: unknown): string | undefined { if (typeof value === "string") return canonicalDecimal(value); - if (typeof value === "number") return decimalFromDouble15(value); + if (typeof value === "number") return decimalFromJsonNumber(value); return undefined; } +/** + * W2 for a JSON number on input. `JSON.parse` has already rounded the client's digits to a double, + * so only a number that IS its own 15-digit form (or a safe integer, taken as `String(n)`) can be + * read exactly: anything else would be silently rounded, so it is refused (`undefined`) and the + * client is told to send a string. The test is `Number(n.toPrecision(15)) === n`: a number whose + * shortest round-trip form carries more than 15 significant digits fails it. + */ +export function decimalFromJsonNumber(value: number): string | undefined { + if (!Number.isFinite(value)) return undefined; + if (!Number.isSafeInteger(value) && Number(value.toPrecision(15)) !== value) return undefined; + return canonicalDecimal(String(value)); +} + /** W2 for a query-string filter operand: the same grammar as a body string, compared exactly. */ export function parseDecimalOperand(text: string): string | undefined { return canonicalDecimal(text); diff --git a/examples/showcase/codegen/templates/decimal-wire-helper.ts b/examples/showcase/codegen/templates/decimal-wire-helper.ts index c2b3a8422..161042ed2 100644 --- a/examples/showcase/codegen/templates/decimal-wire-helper.ts +++ b/examples/showcase/codegen/templates/decimal-wire-helper.ts @@ -17,7 +17,11 @@ export const DECIMAL_WRITE_HELPER_DECL = [ " * number, rewritten to its canonical string (plain notation, no trailing fraction zeros, zero as \"0\").", " * Garbage, or a value the column cannot hold without rounding, is a validation issue. Generated. */", `function ${DECIMAL_WRITE_HELPER}(v: string | number, ctx: z.RefinementCtx, precision?: number, scale = 0): string {`, - ' const text = typeof v === "number" ? (Number.isFinite(v) ? v.toPrecision(15) : "") : v;', + ' if (typeof v === "number" && Number.isFinite(v) && !Number.isSafeInteger(v) && Number(v.toPrecision(15)) !== v) {', + ' ctx.addIssue({ code: "custom", message: "Decimal number cannot be read exactly as a JSON number; send it as a string" });', + " return z.NEVER;", + " }", + ' const text = typeof v === "number" ? (Number.isFinite(v) ? String(v) : "") : v;', " const m = /^(-?)(0|[1-9][0-9]*)(?:\\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(text);", " const e = m === null || m[4] === undefined ? 0 : Number(m[4]);", " if (m === null || !Number.isSafeInteger(e) || Math.abs(e) > 1000) {", diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index 643429fac..5f8d45a2d 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -777,9 +777,10 @@ function zodFieldExpr( case FIELD_SUBTYPE_DECIMAL: { // ADR-0019 Amendment 1 (W2, W3): a WRITE shape takes a decimal as a string in the // JSON-number grammar or a JSON number, stores its canonical string, and refuses what the - // column cannot hold. The TPH read shape parses rows the database already holds, and a value - // object's member is JSON that stores whatever it is given, so both keep a bare string. - if (readShape || owner?.subType === OBJECT_SUBTYPE_VALUE) { + // column cannot hold. A decimal inside a jsonb value object follows the same rule (captain + // ruling C1: its @precision / @scale bound it, the stored JSON holds the canonical string). + // Only the TPH read shape, which parses rows the database already holds, keeps a bare string. + if (readShape) { baseStr = "z.string()"; break; } diff --git a/examples/showcase/generated/csharp/SubscriberRoutes.g.cs b/examples/showcase/generated/csharp/SubscriberRoutes.g.cs index c517abb9c..8381645a7 100644 --- a/examples/showcase/generated/csharp/SubscriberRoutes.g.cs +++ b/examples/showcase/generated/csharp/SubscriberRoutes.g.cs @@ -85,7 +85,10 @@ await db.Subscribers.FindAsync(id) is { } found var jsonOpts = ((Microsoft.Extensions.Options.IOptions) http.RequestServices.GetService(typeof(Microsoft.Extensions.Options.IOptions))!) .Value.SerializerOptions; - using var __body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body); + System.Text.Json.JsonDocument __body; + try { __body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body); } + catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "validation" }); } + using var __bodyScope = __body; if (__body.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return Results.BadRequest(new { error = "validation" }); var __present = new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase); @@ -119,7 +122,10 @@ async System.Threading.Tasks.Task UpdateSubscriber(long id, HttpContext { var existing = await db.Subscribers.FindAsync(id); if (existing is null) return Results.NotFound(new { error = "not_found" }); - using var body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body); + System.Text.Json.JsonDocument body; + try { body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body); } + catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "validation" }); } + using var bodyScope = body; // Deserialize each value with the app's CONFIGURED JSON options, not defaults — // a field.timestamp binds to DateTimeOffset via a custom converter registered on // ConfigureHttpJsonOptions, and default options cannot read the wire format, throwing 500. 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 f67f1978a..4332d7fc8 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 @@ -49,16 +49,19 @@ exact config, generator names and commands for this project; read it. The other ## Upgrading a project from 1.0 to 1.1 -Read both guides before the first `gen` of an upgrade, for every port the project uses; the -build can stay green while the result is wrong. - -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: the default column and table names are now `snake_case` in every - port (C#, Java, Kotlin's runtime and Python defaulted to the field names). A database whose - columns are named like its fields needs `"naming": { "columns": "literal" }` in - `.metaobjects/config.json`, or regenerated code queries columns that do not exist. The - per-port naming flags and generator args are removed. -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: documentation is a generator in the project's list, and the docs - commands are removed. +Read the one upgrade guide, https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md, before the first `gen`, for every +port the project uses; the build can stay green while the result is wrong. It lists the steps +in order and every change that needs action. + +- The default column and table names are now `snake_case` in every port (C#, Java, Kotlin's + runtime and Python defaulted to the field names). A database whose columns are named like + its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or + regenerated code queries columns that do not exist. The per-port naming flags and generator + 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). - **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` (`dotnet meta eject`, `metaobjects eject`, `mvn metaobjects:eject`) reports each copy that differs from the reference; 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 970a7b7ec..676ab0431 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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 970a7b7ec..676ab0431 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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-codegen/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md index f67f1978a..4332d7fc8 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 @@ -49,16 +49,19 @@ exact config, generator names and commands for this project; read it. The other ## Upgrading a project from 1.0 to 1.1 -Read both guides before the first `gen` of an upgrade, for every port the project uses; the -build can stay green while the result is wrong. - -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: the default column and table names are now `snake_case` in every - port (C#, Java, Kotlin's runtime and Python defaulted to the field names). A database whose - columns are named like its fields needs `"naming": { "columns": "literal" }` in - `.metaobjects/config.json`, or regenerated code queries columns that do not exist. The - per-port naming flags and generator args are removed. -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: documentation is a generator in the project's list, and the docs - commands are removed. +Read the one upgrade guide, https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md, before the first `gen`, for every +port the project uses; the build can stay green while the result is wrong. It lists the steps +in order and every change that needs action. + +- The default column and table names are now `snake_case` in every port (C#, Java, Kotlin's + runtime and Python defaulted to the field names). A database whose columns are named like + its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or + regenerated code queries columns that do not exist. The per-port naming flags and generator + 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). - **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` (`dotnet meta eject`, `metaobjects eject`, `mvn metaobjects:eject`) reports each copy that differs from the reference; 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 ddac81bba..6a582934c 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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 ddac81bba..6a582934c 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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-codegen/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md index f67f1978a..4332d7fc8 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 @@ -49,16 +49,19 @@ exact config, generator names and commands for this project; read it. The other ## Upgrading a project from 1.0 to 1.1 -Read both guides before the first `gen` of an upgrade, for every port the project uses; the -build can stay green while the result is wrong. - -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: the default column and table names are now `snake_case` in every - port (C#, Java, Kotlin's runtime and Python defaulted to the field names). A database whose - columns are named like its fields needs `"naming": { "columns": "literal" }` in - `.metaobjects/config.json`, or regenerated code queries columns that do not exist. The - per-port naming flags and generator args are removed. -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: documentation is a generator in the project's list, and the docs - commands are removed. +Read the one upgrade guide, https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md, before the first `gen`, for every +port the project uses; the build can stay green while the result is wrong. It lists the steps +in order and every change that needs action. + +- The default column and table names are now `snake_case` in every port (C#, Java, Kotlin's + runtime and Python defaulted to the field names). A database whose columns are named like + its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or + regenerated code queries columns that do not exist. The per-port naming flags and generator + 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). - **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` (`dotnet meta eject`, `metaobjects eject`, `mvn metaobjects:eject`) reports each copy that differs from the reference; diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index a15894058..128af00ae 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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 a15894058..128af00ae 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index f67f1978a..4332d7fc8 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 @@ -49,16 +49,19 @@ exact config, generator names and commands for this project; read it. The other ## Upgrading a project from 1.0 to 1.1 -Read both guides before the first `gen` of an upgrade, for every port the project uses; the -build can stay green while the result is wrong. - -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: the default column and table names are now `snake_case` in every - port (C#, Java, Kotlin's runtime and Python defaulted to the field names). A database whose - columns are named like its fields needs `"naming": { "columns": "literal" }` in - `.metaobjects/config.json`, or regenerated code queries columns that do not exist. The - per-port naming flags and generator args are removed. -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: documentation is a generator in the project's list, and the docs - commands are removed. +Read the one upgrade guide, https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md, before the first `gen`, for every +port the project uses; the build can stay green while the result is wrong. It lists the steps +in order and every change that needs action. + +- The default column and table names are now `snake_case` in every port (C#, Java, Kotlin's + runtime and Python defaulted to the field names). A database whose columns are named like + its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or + regenerated code queries columns that do not exist. The per-port naming flags and generator + 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). - **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` (`dotnet meta eject`, `metaobjects eject`, `mvn metaobjects:eject`) reports each copy that differs from the reference; 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 7e9604803..6d591a47e 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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 7e9604803..6d591a47e 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md index f67f1978a..4332d7fc8 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 @@ -49,16 +49,19 @@ exact config, generator names and commands for this project; read it. The other ## Upgrading a project from 1.0 to 1.1 -Read both guides before the first `gen` of an upgrade, for every port the project uses; the -build can stay green while the result is wrong. - -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: the default column and table names are now `snake_case` in every - port (C#, Java, Kotlin's runtime and Python defaulted to the field names). A database whose - columns are named like its fields needs `"naming": { "columns": "literal" }` in - `.metaobjects/config.json`, or regenerated code queries columns that do not exist. The - per-port naming flags and generator args are removed. -- https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: documentation is a generator in the project's list, and the docs - commands are removed. +Read the one upgrade guide, https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/upgrading-1.0-to-1.1.md, before the first `gen`, for every +port the project uses; the build can stay green while the result is wrong. It lists the steps +in order and every change that needs action. + +- The default column and table names are now `snake_case` in every port (C#, Java, Kotlin's + runtime and Python defaulted to the field names). A database whose columns are named like + its fields needs `"naming": { "columns": "literal" }` in `.metaobjects/config.json`, or + regenerated code queries columns that do not exist. The per-port naming flags and generator + 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). - **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` (`dotnet meta eject`, `metaobjects eject`, `mvn metaobjects:eject`) reports each copy that differs from the reference; 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 e8e7c0c6e..72993110a 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## 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 e8e7c0c6e..72993110a 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 both guides before you run `gen`, because the build can stay green while the result is wrong. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md: 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. https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md: 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; 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. ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the diff --git a/fixtures/api-contract-conformance/README.md b/fixtures/api-contract-conformance/README.md index 1f068ae3a..261035fb3 100644 --- a/fixtures/api-contract-conformance/README.md +++ b/fixtures/api-contract-conformance/README.md @@ -53,18 +53,18 @@ fixtures/api-contract-conformance/ ``` Beside the 31 core scenarios above sit seven **sub-corpora**, each a directory with its -own `meta.json`, `seed.json`, `scenarios/` and `README.md` (70 scenarios in all, 101 with the +own `meta.json`, `seed.json`, `scenarios/` and `README.md` (74 scenarios in all, 105 with the core): | Directory | Scenarios | Gates | Lanes | |---|---|---|---| | `tph/` | 10 | single-table inheritance | both | | `m2m/` | 9 | M:N traversal, and TPH x M:N together | both | -| `jsonb/` | 2 | typed value-object columns | both | +| `jsonb/` | 3 | typed value-object columns, and the numbers inside them (a double stays a JSON number; a decimal is a canonical string, option A of the 1.1.0 review) | both | | `write-through/` | 2 | an entity that writes a table and reads a view | generated only | | `projection/` | 12 | a read-only `object.projection`: reads served, every write `405`; no declared identity means no `/{id}` route; a key not named `id` addresses the row; decimal and float filters. Its `docs-routes.json` pins the routes each port documents | generated only | | `report/` | 27 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default; a dimension over an array or a map is neither filterable nor sortable. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | -| `decimal/` | 8 | `field.decimal` is an exact canonical string on the wire (ADR-0019 Amendment 1): read, filter, sort, write and a report over decimals. A scenario's `engines:` key is exact; the `-15` twins run on SQLite and D1 as well. Also carries `schema.postgres.sql` / `schema.sqlite.sql` / `schema.mysql.sql` | generated only | +| `decimal/` | 11 | `field.decimal` is an exact canonical string on the wire (ADR-0019 Amendment 1): read, filter, sort, write and a report over decimals. A scenario's `engines:` key is exact; the `-15` twins run on SQLite and D1 as well. Also carries `schema.postgres.sql` / `schema.sqlite.sql` / `schema.mysql.sql` | generated only | `meta.json` declares a single canonical `Author` entity in the `acme::blog` package: diff --git a/fixtures/api-contract-conformance/decimal/README.md b/fixtures/api-contract-conformance/decimal/README.md index 5b0cae49c..b4659ea6f 100644 --- a/fixtures/api-contract-conformance/decimal/README.md +++ b/fixtures/api-contract-conformance/decimal/README.md @@ -1,7 +1,10 @@ # `api-contract-conformance/decimal/` — a decimal is exact on the wire (ADR-0019 Amendment 1) -Cross-port REST contract for `field.decimal`, over one writable entity (`Entry`, a -`decimal(28,10)` `amount`) and one served report (`EntryTotals`) read from it. +Cross-port REST contract for `field.decimal`, over two writable entities and one served report: `Entry` (a +`decimal(28,10)` `amount`) with the report `EntryTotals` read from it, and `Sample` (table `samples`, +served at `/api/samples`), which carries the two shapes `Entry` does not: `readings`, a +`field.decimal isArray` with `@precision 9 @scale 4`, and `free`, a `field.decimal` with NO +`@precision` and NO `@scale`. ## The rule (W1 to W4) @@ -21,6 +24,14 @@ Cross-port REST contract for `field.decimal`, over one writable entity (`Entry`, `"0.3"`. They do not promise `12345678901234567.89`, and equality against a computed double (a view's sum) is not promised either, so the `-15` scenarios use ranges there. +- **W5, array.** Each element of a decimal array follows W1 to W3 on its own; an element that + fails makes the whole write `400 validation` and persists nothing. A null element is outside + the contract. +- **W6, undeclared precision.** A decimal with no `@precision` has no range check and no implied + default (no `decimal(19,4)`): `1.234567` and a 20-integer-digit value are stored and read back + exactly and canonical, never rounded, never refused for size. Only a non-number is `400`. + The schemas: Postgres `NUMERIC` / `NUMERIC[]`, MySQL `DECIMAL(65,30)` / `JSON`, SQLite `NUMERIC` / `TEXT`. + ## Scenarios `engines:` is exact (see the persistence corpus README): a scenario with it runs only on the @@ -37,14 +48,17 @@ whose values fit 15 significant digits (buckets `A`, `D`, `E`, `F`), which every | `decimal-write-exact-15` | all | the 15-digit subset | | `decimal-report-exact` | postgres, mysql | the `EntryTotals` rows are exactly `seed.json` `reports`; a decimal measure filters exactly; `entries` (a count) is a JSON number | | `decimal-report-exact-15` | all | buckets A, D, E, F of the report | +| `decimal-array-exact` | postgres | `Sample.readings`: POST `["100.5000", 2.5, "1.5e1"]` answers and reads `["100.5","2.5","15"]`; a 5th fraction digit, a 6th integer digit and a non-number element are `400 validation` and persist nothing; PATCH replaces (also `[]`, and `null`) | +| `decimal-unbounded-exact` | postgres, mysql | `Sample.free`: `"1.234567"` and `"12345678901234567890.123456789"` are stored and read exactly; a JSON number is canonical; PATCH; `"12abc"` is `400` | +| `decimal-unbounded-exact-15` | all | the 15-digit subset (`1.234567`, `123456789012.123`, `0.0000123`) | ## Files ``` decimal/ ├── README.md -├── meta.json # Entry + the EntryTotals report (served at /api/entry_totals) -├── seed.json # `entries` (eleven rows) and `reports` (what v_entry_totals returns) +├── meta.json # Entry + the EntryTotals report (served at /api/entry_totals) + Sample (/api/samples) +├── seed.json # `entries` (eleven rows), `samples` (empty, so the first POST is id 1) and `reports` (what v_entry_totals returns) ├── schema.postgres.sql # TypeScript-produced: the table and the view ├── schema.sqlite.sql # the same, for SQLite and D1 ├── schema.mysql.sql # MySQL 8.4: the hand-written table plus the view buildReportViews lowers diff --git a/fixtures/api-contract-conformance/decimal/meta.json b/fixtures/api-contract-conformance/decimal/meta.json index 9b35e3286..81eb09064 100644 --- a/fixtures/api-contract-conformance/decimal/meta.json +++ b/fixtures/api-contract-conformance/decimal/meta.json @@ -18,6 +18,13 @@ { "measure.ratio": { "name": "flaggedShare", "@numerator": "flaggedEntries", "@denominator": "entries" } }, { "measure.ratio": { "name": "perFlagged", "@numerator": "entries", "@denominator": "flaggedEntries" } } ] } }, + { "object.entity": { "name": "Sample", "children": [ + { "source.rdb": { "@table": "samples" } }, + { "field.long": { "name": "id", "@filterable": true, "@sortable": true } }, + { "field.decimal": { "name": "readings", "isArray": true, "@precision": 9, "@scale": 4 } }, + { "field.decimal": { "name": "free" } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } } + ] } }, { "object.report": { "name": "EntryTotals", "@from": "Entry", "@dimensions": ["bucket"], "@measures": ["entries", "total", "totalOrZero", "mean", "top", "flaggedShare", "perFlagged"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_entry_totals" } } ] } } diff --git a/fixtures/api-contract-conformance/decimal/scenarios/decimal-array-exact.yaml b/fixtures/api-contract-conformance/decimal/scenarios/decimal-array-exact.yaml new file mode 100644 index 000000000..a91408584 --- /dev/null +++ b/fixtures/api-contract-conformance/decimal/scenarios/decimal-array-exact.yaml @@ -0,0 +1,106 @@ +name: decimal-array-exact +description: > + A `field.decimal isArray` (Sample.readings, @precision 9, @scale 4) follows the W1 to W3 rule per + element. Each element comes in as a string in the JSON-number grammar or a JSON number; + it is stored and answered canonical (`"100.5000"` is "100.5", `"1.5e1"` is "15", the number 2.5 is + "2.5"). (A null element is not part of the contract.) An element past @scale (a 5th fraction digit) or + past @precision - @scale integer digits (a 6th), or one that is not a number, makes the whole write + `400 {"error": "validation"}` and persists nothing. PATCH replaces the whole array; `[]` is an empty + array, distinct from null. Table `samples` starts empty (seed `samples: []`), so the first POST is id 1. + Postgres only: SQLite and MySQL have no native array column, and a port's runtime need not + support an array there. +engines: [postgres] +requests: + - id: r1 + method: POST + path: /api/samples + body: { readings: ["100.5000", 2.5, "1.5e1"] } + expect: + status: 201 + body: + hasId: true + row: + readings: ["100.5", "2.5", "15"] + - id: r2 + method: GET + path: /api/samples/1 + expect: + status: 200 + body: + row: + id: 1 + readings: ["100.5", "2.5", "15"] + - id: r3 + method: POST + path: /api/samples + body: { readings: ["1.12345"] } + expect: + status: 400 + body: + error: validation + - id: r4 + method: POST + path: /api/samples + body: { readings: ["1.5", "123456.5"] } + expect: + status: 400 + body: + error: validation + - id: r5 + method: POST + path: /api/samples + body: { readings: ["1.5", "12abc"] } + expect: + status: 400 + body: + error: validation + - id: r6 + method: GET + path: /api/samples + expect: + status: 200 + body: + length: 1 + - id: r7 + method: PATCH + path: /api/samples/1 + body: { readings: [0.5, "-3.2500", "0.0000"] } + expect: + status: 200 + body: + row: + readings: ["0.5", "-3.25", "0"] + - id: r8 + method: PATCH + path: /api/samples/1 + body: { readings: ["0.00001"] } + expect: + status: 400 + body: + error: validation + - id: r9 + method: GET + path: /api/samples/1 + expect: + status: 200 + body: + row: + readings: ["0.5", "-3.25", "0"] + - id: r10 + method: PATCH + path: /api/samples/1 + body: { readings: [] } + expect: + status: 200 + body: + row: + readings: [] + - id: r11 + method: PATCH + path: /api/samples/1 + body: { readings: null } + expect: + status: 200 + body: + row: + readings: null diff --git a/fixtures/api-contract-conformance/decimal/scenarios/decimal-unbounded-exact-15.yaml b/fixtures/api-contract-conformance/decimal/scenarios/decimal-unbounded-exact-15.yaml new file mode 100644 index 000000000..251cdd266 --- /dev/null +++ b/fixtures/api-contract-conformance/decimal/scenarios/decimal-unbounded-exact-15.yaml @@ -0,0 +1,50 @@ +name: decimal-unbounded-exact-15 +description: > + The decimal-unbounded-exact cases whose values fit 15 significant digits, run on every engine + (W4). An undeclared-precision decimal is stored and read back canonical: 1.234567 is not rounded + to 1.2346 and not refused. The 20-integer-digit cases are Postgres and MySQL only. +requests: + - id: r1 + method: POST + path: /api/samples + body: { free: "1.234567" } + expect: + status: 201 + body: + hasId: true + row: + free: "1.234567" + - id: r2 + method: GET + path: /api/samples/1 + expect: + status: 200 + body: + row: + free: "1.234567" + - id: r3 + method: POST + path: /api/samples + body: { free: "123456789012.123" } + expect: + status: 201 + body: + row: + free: "123456789012.123" + - id: r4 + method: PATCH + path: /api/samples/1 + body: { free: 0.0000123 } + expect: + status: 200 + body: + row: + free: "0.0000123" + - id: r5 + method: POST + path: /api/samples + body: { free: "12abc" } + expect: + status: 400 + body: + error: validation diff --git a/fixtures/api-contract-conformance/decimal/scenarios/decimal-unbounded-exact.yaml b/fixtures/api-contract-conformance/decimal/scenarios/decimal-unbounded-exact.yaml new file mode 100644 index 000000000..30152a44d --- /dev/null +++ b/fixtures/api-contract-conformance/decimal/scenarios/decimal-unbounded-exact.yaml @@ -0,0 +1,88 @@ +name: decimal-unbounded-exact +description: > + A `field.decimal` with NO @precision and NO @scale (Sample.free) has no range limit of its own: + the schema owner emits plain NUMERIC. A port must not invent a default such as decimal(19,4): the + value is stored and read back exactly and canonical, never rounded and never `400 validation` for + its size. Here 7 fraction digits and a 20-integer-digit value. Only a string that is not a number + is refused. MySQL has no unbounded decimal, so its schema uses DECIMAL(65,30), which holds all of + these. Postgres and MySQL only (W4: SQLite and D1 keep a double, see decimal-unbounded-exact-15). +engines: [postgres, mysql] +requests: + - id: r1 + method: POST + path: /api/samples + body: { free: "1.234567" } + expect: + status: 201 + body: + hasId: true + row: + free: "1.234567" + - id: r2 + method: GET + path: /api/samples/1 + expect: + status: 200 + body: + row: + free: "1.234567" + - id: r3 + method: POST + path: /api/samples + body: { free: "12345678901234567890.123456789" } + expect: + status: 201 + body: + row: + free: "12345678901234567890.123456789" + - id: r4 + method: GET + path: /api/samples/2 + expect: + status: 200 + body: + row: + free: "12345678901234567890.123456789" + - id: r5 + method: POST + path: /api/samples + body: { free: "-98765432109876543210" } + expect: + status: 201 + body: + row: + free: "-98765432109876543210" + - id: r6 + method: POST + path: /api/samples + body: { free: 1.5 } + expect: + status: 201 + body: + row: + free: "1.5" + - id: r7 + method: PATCH + path: /api/samples/1 + body: { free: "2.718281828" } + expect: + status: 200 + body: + row: + free: "2.718281828" + - id: r8 + method: GET + path: /api/samples/1 + expect: + status: 200 + body: + row: + free: "2.718281828" + - id: r9 + method: POST + path: /api/samples + body: { free: "12abc" } + expect: + status: 400 + body: + error: validation diff --git a/fixtures/api-contract-conformance/decimal/schema.mysql.sql b/fixtures/api-contract-conformance/decimal/schema.mysql.sql index 064d6dd7b..f84da9ab5 100644 --- a/fixtures/api-contract-conformance/decimal/schema.mysql.sql +++ b/fixtures/api-contract-conformance/decimal/schema.mysql.sql @@ -9,6 +9,12 @@ CREATE TABLE `entries` ( `flagged` BOOLEAN NOT NULL ); +CREATE TABLE `samples` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `readings` JSON, + `free` DECIMAL(65,30) +); + CREATE VIEW `v_entry_totals` AS SELECT e.`bucket` AS `bucket`, diff --git a/fixtures/api-contract-conformance/decimal/schema.postgres.sql b/fixtures/api-contract-conformance/decimal/schema.postgres.sql index 85d48e3fd..a72a002f7 100644 --- a/fixtures/api-contract-conformance/decimal/schema.postgres.sql +++ b/fixtures/api-contract-conformance/decimal/schema.postgres.sql @@ -10,6 +10,13 @@ CREATE TABLE "entries" ( CONSTRAINT "entries_pkey" PRIMARY KEY ("id") ); +CREATE TABLE "samples" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "readings" NUMERIC[], + "free" NUMERIC, + CONSTRAINT "samples_pkey" PRIMARY KEY ("id") +); + CREATE VIEW "v_entry_totals" AS SELECT e."bucket" AS "bucket", diff --git a/fixtures/api-contract-conformance/decimal/schema.sqlite.sql b/fixtures/api-contract-conformance/decimal/schema.sqlite.sql index 7d4c72fb7..4b1e90cb2 100644 --- a/fixtures/api-contract-conformance/decimal/schema.sqlite.sql +++ b/fixtures/api-contract-conformance/decimal/schema.sqlite.sql @@ -9,6 +9,12 @@ CREATE TABLE "entries" ( "flagged" INTEGER NOT NULL ); +CREATE TABLE "samples" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "readings" TEXT, + "free" NUMERIC +); + CREATE VIEW "v_entry_totals" AS SELECT e."bucket" AS "bucket", diff --git a/fixtures/api-contract-conformance/decimal/seed.json b/fixtures/api-contract-conformance/decimal/seed.json index 3cd36f226..2827103d8 100644 --- a/fixtures/api-contract-conformance/decimal/seed.json +++ b/fixtures/api-contract-conformance/decimal/seed.json @@ -12,6 +12,7 @@ { "id": 10, "bucket": "E", "amount": null, "flagged": false }, { "id": 11, "bucket": "F", "amount": null, "flagged": false } ], + "samples": [], "reports": { "EntryTotals": [ { "bucket": "A", "entries": 2, "total": "0.3", "totalOrZero": "0.3", "mean": "0.15", "top": "0.2", "flaggedShare": "0.5", "perFlagged": "2" }, diff --git a/fixtures/api-contract-conformance/jsonb/README.md b/fixtures/api-contract-conformance/jsonb/README.md index 15ca2e3fc..7b15c11f3 100644 --- a/fixtures/api-contract-conformance/jsonb/README.md +++ b/fixtures/api-contract-conformance/jsonb/README.md @@ -37,6 +37,10 @@ settable fields) passed. `r2`/`r4` below POST without an `id` and expect `201`, and `jsonb-value-object-patch`'s nested-constraint `400` pins that the fix still cascades validation into the value objects. +Two more columns carry numbers (scenario `jsonb-value-object-numbers`): `measure` (single) and +`measures` (array) of the `Measure` value object `{ ratio: field.double; samples: field.double +isArray; amount: field.decimal @precision 12 @scale 4 }`. Both are nullable. + The `Marker` value object (`object.value`) is `{ label: field.string @required @maxLength 40; score: field.int }` — so a nested-constraint violation (`null` / `""` / label > 40 chars) is a 400. @@ -53,7 +57,8 @@ jsonb/ ├── seed.json # 2 seed Documents (ids 1, 2), jsonb object + VO columns └── scenarios/ ├── jsonb-open-bag-roundtrip.yaml # open-bag parsed-value contract - └── jsonb-value-object-patch.yaml # Program D — cross-port VO-column PATCH/POST + ├── jsonb-value-object-patch.yaml # Program D — cross-port VO-column PATCH/POST + └── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object ``` `seed.json` is applied fresh before the scenario; the runner advances the id @@ -140,3 +145,19 @@ booted over HTTP) are wired and green for: All five ports are now wired and green — both lanes each. The jsonb open-bag parsed-value contract is locked cross-port at the api-contract boundary. + +## Scenario — `jsonb-value-object-numbers.yaml` (1.1.0 review D4 / C1) + +A number inside a jsonb value object keeps its type through every port. The ruling (option A): a +`field.decimal` inside a value object follows the SAME rule as a top-level decimal (ADR-0019 +Amendment 1; see `../decimal/README.md`), per element for an array of value objects. + +| Requests | What it pins | +|---|---| +| `r1` GET | the seeded `measure` reads back: doubles as JSON numbers, `amount "1.5"` a string | +| `r2`/`r3` POST + GET | fractional doubles (and a double array, including `[]`) round-trip as JSON numbers; the decimal accepts `"1.5000"` or `2.25` or `"100"` and comes out canonical: `"1.5"`, `"2.25"`, `"100"` | +| `r4` POST | a decimal sent as a JSON number (`1.5`) is `"1.5"` | +| `r5`-`r8`, `r9` | a 5th fraction digit (single and in an array element), a 9th integer digit and `"12abc"` are `400 validation`; nothing is persisted (GET id 5 is 404) | +| `r10`/`r11` PATCH + GET | PATCH single `measure` (decimal as a number) is canonical, `measures` untouched | +| `r12`-`r15` | PATCH array element canonicalized (`"3.10"` gives `"3.1"`); excess scale in an array element or in `measure` is `400` and the stored value is unchanged | +| `r16`-`r18` PUT | PUT canonicalizes and persists; excess scale on PUT is `400` and nothing changes | diff --git a/fixtures/api-contract-conformance/jsonb/meta.json b/fixtures/api-contract-conformance/jsonb/meta.json index 11acad9f7..cc9e9c61d 100644 --- a/fixtures/api-contract-conformance/jsonb/meta.json +++ b/fixtures/api-contract-conformance/jsonb/meta.json @@ -9,6 +9,14 @@ { "field.int": { "name": "score" } } ] }}, + { "object.value": { + "name": "Measure", + "children": [ + { "field.double": { "name": "ratio" } }, + { "field.double": { "name": "samples", "isArray": true } }, + { "field.decimal": { "name": "amount", "@precision": 12, "@scale": 4 } } + ] + }}, { "object.entity": { "name": "Document", "children": [ @@ -19,6 +27,8 @@ { "field.object": { "name": "primaryMarker", "@objectRef": "Marker", "@storage": "jsonb", "@required": true } }, { "field.object": { "name": "optionalMarker", "@objectRef": "Marker", "@storage": "jsonb" } }, { "field.object": { "name": "markers", "@objectRef": "Marker", "@storage": "jsonb", "isArray": true } }, + { "field.object": { "name": "measure", "@objectRef": "Measure", "@storage": "jsonb" } }, + { "field.object": { "name": "measures", "@objectRef": "Measure", "@storage": "jsonb", "isArray": true } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] }} diff --git a/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-numbers.yaml b/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-numbers.yaml new file mode 100644 index 000000000..fb264186d --- /dev/null +++ b/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-numbers.yaml @@ -0,0 +1,208 @@ +name: jsonb-value-object-numbers +description: > + Numbers inside a jsonb value object (Measure: `ratio` field.double, `samples` field.double + isArray, `amount` field.decimal @precision 12 @scale 4), single (Document.measure) and + array-of-VO (Document.measures). + A double is a JSON number in and out, fractional values kept (0.25 stays 0.25, never "0.25", never + rounded to an integer, never a 500). A decimal inside a value object follows the SAME rule as a + top-level decimal (ADR-0019 Amendment 1, W1 to W3): it is accepted as a string in the JSON-number + grammar or as a JSON number, read exactly, and emitted as a CANONICAL STRING ("1.5000" in or 1.5 + in gives "1.5" out; "100" stays "100"). More fraction digits than @scale (5th) or more integer + digits than @precision - @scale (9th) is `400 {"error": "validation"}`, on POST, PATCH and PUT, and + persists nothing. What is stored is the canonical form, so the GET after a write returns the same + strings. Seed row 1 carries measure { ratio 0.25, samples [0.5, 1.75], amount "1.5" }; implicit-id + POSTs land at 3, 4, ... The title and primaryMarker are required on every write. +requests: + - id: r1 + method: GET + path: /api/documents/1 + expect: + status: 200 + body: + row: + measure: { ratio: 0.25, samples: [0.5, 1.75], amount: "1.5" } + - id: r2 + method: POST + path: /api/documents + body: + title: "num-a" + primaryMarker: { label: "a", score: 1 } + measure: { ratio: 0.125, samples: [0.1, 2.5, 3], amount: "1.5000" } + measures: + - { ratio: 1.5, samples: [], amount: 2.25 } + - { ratio: -0.75, samples: [0.001], amount: "100" } + expect: + status: 201 + body: + hasId: true + row: + measure: { ratio: 0.125, samples: [0.1, 2.5, 3], amount: "1.5" } + measures: + - { ratio: 1.5, samples: [], amount: "2.25" } + - { ratio: -0.75, samples: [0.001], amount: "100" } + - id: r3 + method: GET + path: /api/documents/3 + expect: + status: 200 + body: + row: + measure: { ratio: 0.125, samples: [0.1, 2.5, 3], amount: "1.5" } + measures: + - { ratio: 1.5, samples: [], amount: "2.25" } + - { ratio: -0.75, samples: [0.001], amount: "100" } + - id: r4 + method: POST + path: /api/documents + body: + title: "num-b" + primaryMarker: { label: "b", score: 1 } + measure: { amount: 1.5 } + expect: + status: 201 + body: + row: + measure: { amount: "1.5" } + - id: r5 + method: POST + path: /api/documents + body: + title: "num-bad-scale" + primaryMarker: { label: "c", score: 1 } + measure: { amount: "1.12345" } + expect: + status: 400 + body: + error: validation + - id: r6 + method: POST + path: /api/documents + body: + title: "num-bad-scale-array" + primaryMarker: { label: "c", score: 1 } + measures: [ { amount: "1.5" }, { amount: "0.00001" } ] + expect: + status: 400 + body: + error: validation + - id: r7 + method: POST + path: /api/documents + body: + title: "num-bad-int-digits" + primaryMarker: { label: "c", score: 1 } + measure: { amount: "123456789.5" } + expect: + status: 400 + body: + error: validation + - id: r8 + method: POST + path: /api/documents + body: + title: "num-bad-text" + primaryMarker: { label: "c", score: 1 } + measure: { amount: "12abc" } + expect: + status: 400 + body: + error: validation + - id: r9 + method: GET + path: /api/documents/5 + expect: + status: 404 + - id: r10 + method: PATCH + path: /api/documents/3 + body: + measure: { ratio: 0.5, samples: [9.25], amount: 12345678.1234 } + expect: + status: 200 + body: + row: + measure: { ratio: 0.5, samples: [9.25], amount: "12345678.1234" } + - id: r11 + method: GET + path: /api/documents/3 + expect: + status: 200 + body: + row: + measure: { ratio: 0.5, samples: [9.25], amount: "12345678.1234" } + measures: + - { ratio: 1.5, samples: [], amount: "2.25" } + - { ratio: -0.75, samples: [0.001], amount: "100" } + - id: r12 + method: PATCH + path: /api/documents/3 + body: + measures: [ { ratio: 0.2, amount: "3.10" } ] + expect: + status: 200 + body: + row: + measures: [ { ratio: 0.2, amount: "3.1" } ] + - id: r13 + method: PATCH + path: /api/documents/3 + body: + measures: [ { ratio: 0.2, amount: "3.123456" } ] + expect: + status: 400 + body: + error: validation + - id: r14 + method: PATCH + path: /api/documents/3 + body: + measure: { amount: "1.123456" } + expect: + status: 400 + body: + error: validation + - id: r15 + method: GET + path: /api/documents/3 + expect: + status: 200 + body: + row: + measure: { ratio: 0.5, samples: [9.25], amount: "12345678.1234" } + measures: [ { ratio: 0.2, amount: "3.1" } ] + - id: r16 + method: PUT + path: /api/documents/3 + body: + title: "num-a-put" + primaryMarker: { label: "a", score: 1 } + measure: { ratio: 0.75, samples: [1.5, 2.5], amount: "7.5000" } + measures: [ { ratio: 2.5, samples: [0.5], amount: 8 } ] + expect: + status: 200 + body: + row: + title: "num-a-put" + measure: { ratio: 0.75, samples: [1.5, 2.5], amount: "7.5" } + measures: [ { ratio: 2.5, samples: [0.5], amount: "8" } ] + - id: r17 + method: PUT + path: /api/documents/3 + body: + title: "num-a-put-bad" + primaryMarker: { label: "a", score: 1 } + measure: { ratio: 0.75, amount: "7.55555" } + expect: + status: 400 + body: + error: validation + - id: r18 + method: GET + path: /api/documents/3 + expect: + status: 200 + body: + row: + title: "num-a-put" + measure: { ratio: 0.75, samples: [1.5, 2.5], amount: "7.5" } + measures: [ { ratio: 2.5, samples: [0.5], amount: "8" } ] diff --git a/fixtures/api-contract-conformance/jsonb/seed.json b/fixtures/api-contract-conformance/jsonb/seed.json index b4ee681c1..32d483a18 100644 --- a/fixtures/api-contract-conformance/jsonb/seed.json +++ b/fixtures/api-contract-conformance/jsonb/seed.json @@ -3,7 +3,8 @@ { "id": 1, "title": "alpha", "payload": { "seeded": true, "n": 1 }, "primaryMarker": { "label": "seed-owner-a", "score": 1 }, "optionalMarker": { "label": "seed-opt-a", "score": 2 }, - "markers": [ { "label": "m1", "score": 10 }, { "label": "m2", "score": 20 } ] }, + "markers": [ { "label": "m1", "score": 10 }, { "label": "m2", "score": 20 } ], + "measure": { "ratio": 0.25, "samples": [0.5, 1.75], "amount": "1.5" } }, { "id": 2, "title": "beta", "payload": { "k": "v" }, "primaryMarker": { "label": "seed-owner-b", "score": 3 } } ] diff --git a/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs b/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs index 936aac17d..c6fd092aa 100644 --- a/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs @@ -165,7 +165,7 @@ public void The_docs_command_prints_the_removal_line_and_exits_2() Assert.False(Directory.Exists(DocsDir)); Assert.Equal( "dotnet meta docs was removed in 1.1.0. `dotnet meta gen` writes documentation now: declare each docs " + - "generator in codegen/Generators.cs. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", + "generator in codegen/Generators.cs. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"C#\".", CodegenCli.DocsCommandRemoved); } @@ -184,7 +184,7 @@ public void The_codegen_projects_docs_command_prints_the_same_line() private static readonly string LegacyLine = "dotnet meta gen: found 1.0 docs configuration (--project, --model-base-url). Nothing reads it any more, " + "so this run would write no documentation. Declare each docs generator in codegen/Generators.cs. See " + - "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"C#\"."; [Fact] public void Gen_given_1_0_docs_flags_prints_the_amendment_line_and_writes_nothing() @@ -220,7 +220,7 @@ public void Gen_in_a_project_with_no_generator_list_prints_the_amendment_line() Assert.Equal(2, exit); Assert.Equal( "dotnet meta gen: no codegen/Generators.cs. The generator list lives there in 1.1.0, documentation " + - "included; `dotnet meta init` writes it. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", + "included; `dotnet meta init` writes it. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"C#\".", stderr.Trim()); Assert.Equal(CodegenCli.NoGeneratorList("dotnet meta gen"), stderr.Trim()); Assert.Empty(FilesUnder(OutDir)); @@ -320,7 +320,7 @@ private void WriteLegacyCodegenProject(bool withGeneratorList) private static string LegacyProgramLine(string command) => $"{command}: codegen/Program.cs does not run Generators.All, so it would ignore the generator list in " + "codegen/Generators.cs. Replace it with the two-line entry `using MetaObjects.Codegen;` and " + - "`return CodegenCli.Run(args, Generators.All);`. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "`return CodegenCli.Run(args, Generators.All);`. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"C#\"."; [Fact] public void Gen_with_a_1_0_program_refuses_with_the_migration_line_and_writes_nothing() @@ -358,7 +358,7 @@ public void Init_beside_a_1_0_program_says_to_replace_it_instead_of_claiming_gen Assert.DoesNotContain(lines, l => l.Contains("is the generator list `dotnet meta gen` runs", StringComparison.Ordinal)); Assert.Contains(lines, l => l.StartsWith("codegen/Program.cs does not run Generators.All", StringComparison.Ordinal)); Assert.Contains(" return CodegenCli.Run(args, Generators.All);", lines); - Assert.Contains("See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", lines); + Assert.Contains("See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"C#\".", lines); Assert.Equal(LegacyProgramCs, File.ReadAllText(Path.Combine(_tmp, "codegen", "Program.cs"))); } @@ -374,7 +374,7 @@ public void Eject_beside_a_1_0_program_says_to_replace_it_instead_of_claiming_ge Assert.DoesNotContain(outcome.Out, l => l.Contains("run that list", StringComparison.Ordinal)); Assert.Contains(outcome.Out, l => l.StartsWith("codegen/Program.cs does not run Generators.All", StringComparison.Ordinal)); Assert.Contains(" return CodegenCli.Run(args, Generators.All);", outcome.Out); - Assert.Contains("See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", outcome.Out); + Assert.Contains("See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"C#\".", outcome.Out); Assert.Equal(LegacyProgramCs, File.ReadAllText(Path.Combine(_tmp, "codegen", "Program.cs"))); } diff --git a/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs index a6cff1b82..1c5ae9a9e 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DecimalWireTests.cs @@ -273,4 +273,135 @@ public void Every_generated_decimal_property_carries_its_converter_and_the_outpu var edge = JsonSerializer.Deserialize("{\"DecVal\":\"123456789012.123456\"}", allTypes)!; Assert.Equal(123456789012.123456m, allTypes.GetProperty("DecVal")!.GetValue(edge)); } + + // C1 (captain ruling A): a decimal inside a jsonb value object follows the top-level rule. + private const string ValueObjectModel = """ + { "metadata.root": { "package": "acme::shop", "children": [ + { "object.value": { "name": "Price", "children": [ + { "field.decimal": { "name": "net", "@precision": 12, "@scale": 2 } }, + { "field.decimal": { "name": "free" } } + ]}}, + { "object.entity": { "name": "Item", "children": [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "field.object": { "name": "prices", "@objectRef": "Price", "isArray": true, "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + [Fact] + public void A_decimal_inside_a_jsonb_value_object_reads_exactly_checks_its_declared_range_and_writes_canonical() + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(ValueObjectModel, id: "vo-decimal.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-vo-decimal"), 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("vo_decimal_" + 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 price = asm.GetTypes().Single(t => t.Name == "Price"); + var item = asm.GetTypes().Single(t => t.Name == "Item"); + + string Net(string json) => JsonDocument.Parse(JsonSerializer.Serialize(JsonSerializer.Deserialize(json, price), price)) + .RootElement.GetProperty("net").GetString()!; + + // A string or a JSON number, read exactly; written as the canonical string. + Assert.Equal("1.5", Net("{\"net\":\"1.5000\"}")); + Assert.Equal("1234567890.12", Net("{\"net\":1234567890.12}")); + Assert.Equal("0", Net("{\"net\":\"0.00\"}")); + // Past the declared @precision/@scale is refused, never rounded. + foreach (var bad in new[] { "{\"net\":\"1.234\"}", "{\"net\":1.234}", "{\"net\":\"12345678901\"}", + "{\"net\":\"NaN\"}", "{\"net\":\"\"}" }) + Assert.Throws(() => JsonSerializer.Deserialize(bad, price)); + // A VO decimal with no @precision is bounded only by what decimal holds. + var free = JsonSerializer.Deserialize("{\"net\":\"1\",\"free\":\"1.2345678901234567890123\"}", price)!; + Assert.Equal(1.2345678901234567890123m, price.GetProperty("Free")!.GetValue(free)); + // The single and the array-of-VO members of the entity follow the same rule. + var back = JsonSerializer.Deserialize( + "{\"Price\":{\"net\":\"2.50\"},\"Prices\":[{\"net\":3},{\"net\":\"4.10\"}]}", item)!; + var outJson = JsonDocument.Parse(JsonSerializer.Serialize(back, item)).RootElement; + Assert.Equal("2.5", outJson.GetProperty("Price").GetProperty("net").GetString()); + Assert.Equal("3", outJson.GetProperty("Prices")[0].GetProperty("net").GetString()); + Assert.Equal("4.1", outJson.GetProperty("Prices")[1].GetProperty("net").GetString()); + Assert.Throws(() => JsonSerializer.Deserialize("{\"Prices\":[{\"net\":\"1.001\"}]}", item)); + } + + [Fact] + public void FitsDeclared_finds_the_declared_range_on_an_entity_carrying_another_namespaces_copy_of_the_helper() + { + // 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 + { + 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)); + } + + private const string ArrayShapeModel = """ + { "metadata.root": { "package": "acme::shop", "children": [ + { "object.value": { "name": "Labels", "children": [ + { "field.string": { "name": "tags", "isArray": true } } + ]}}, + { "object.entity": { "name": "BaseTagged", "abstract": true, "children": [ + { "field.string": { "name": "tags", "isArray": true } } + ]}}, + { "object.entity": { "name": "Post", "extends": "BaseTagged", "children": [ + { "source.rdb": { "@table": "posts" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "labels", "@objectRef": "Labels", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + [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 post = asm.GetTypes().Single(t => t.Name == "Post"); + var tags = Assert.IsAssignableFrom>(post.GetProperty("Tags")!.GetValue(Activator.CreateInstance(post))); + Assert.Empty(tags); + + var labels = asm.GetTypes().Single(t => t.Name == "Labels"); + Assert.Equal("{}", JsonSerializer.Serialize(Activator.CreateInstance(labels), labels)); + } } diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index c8eb15437..22ecef8d2 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -275,7 +275,7 @@ public static GenOutcome RunGen( /// DOCS_MIGRATION_GUIDE, Python's _MIGRATION_SECTION, Java's /// DocsMojo.MIGRATION); moving the document is one edit here too, not a six-site grep. public const string DocsMigrationGuide = - "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\""; + "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"C#\""; /// What dotnet meta docs prints, exit 2: the command was removed. public const string DocsCommandRemoved = @@ -482,7 +482,7 @@ private static int Unknown(string cmd) /// points at the same place. /// public const string PhysicalNamingMigrationGuide = - "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md"; + "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/physical-naming.md"; /// /// What --column-naming <value> prints, exit 2, on dotnet meta gen or diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index 8ba5e1567..68989d0e7 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -1253,8 +1253,19 @@ private static List JsonEnumConversions(MetaObject vo, GenContext ctx, s var lines = new List(); foreach (var nf in vo.Fields()) { - if (nf.SubType != FIELD_SUBTYPE_ENUM) continue; var prop = CSharpNaming.Pascal(nf.Name); + // A decimal member of a JSON document is stored as its CANONICAL STRING (the same text + // the wire carries, ADR-0019 Amendment 1), not as EF's default JSON number: an exact + // read-back, identical to every other port's stored form. Single members only. + if (nf.SubType == FIELD_SUBTYPE_DECIMAL && !nf.ResolvedIsArray() + && CSharpNaming.DbColumnTypeClrOverride(nf) is null) + { + lines.Add(CSharpNaming.IsRequired(vo, nf) + ? $" {builder}.Property(p => p.{prop}).HasConversion(v => v.ToString(\"0.############################\", System.Globalization.CultureInfo.InvariantCulture), s => decimal.Parse(s, System.Globalization.CultureInfo.InvariantCulture));" + : $" {builder}.Property(p => p.{prop}).HasConversion(v => v == null ? null : v.Value.ToString(\"0.############################\", System.Globalization.CultureInfo.InvariantCulture), s => s == null ? (decimal?)null : decimal.Parse(s, System.Globalization.CultureInfo.InvariantCulture));"); + continue; + } + if (nf.SubType != FIELD_SUBTYPE_ENUM) continue; // ADR-0039: resolving — array-ness is inheritable via extends. lines.Add(nf.ResolvedIsArray() ? $" {builder}.PrimitiveCollection(p => p.{prop}).ElementType().HasConversion();" diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index 87188a612..9a5172370 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -602,7 +602,12 @@ protected virtual string TphSubtypeScalarProperty( arr.AppendLine($" [Column({CSharpNaming.ColumnRef(entity, field, strategy, includeNames)})]"); AppendArrayValidatorAttributes(arr, field); AppendDecimalConverterAttribute(arr, field, nullable: false); - arr.Append($" public IList<{baseType}> {propName} {{ get; set; }} = new List<{baseType}>();"); + // 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)) + arr.Append($" public IList<{baseType}>? {propName} {{ get; set; }}"); + else + arr.Append($" public IList<{baseType}> {propName} {{ get; set; }} = new List<{baseType}>();"); return arr.ToString(); } @@ -830,7 +835,8 @@ protected virtual EmittedFile EmitValueObjectPoco(MetaObject vo, GenContext ctx) if (CSharpNaming.ScalarFor(field.SubType) is not null) // VO POCO members carry NO EF-mapping attrs but DO carry validation // DataAnnotations (the VO is validated in full on POST/PATCH; Program D). - member = ScalarProperty(vo, field, [], withAttributes: false, withValidationAttributes: true); + member = ScalarProperty(vo, field, [], withAttributes: false, withValidationAttributes: true, + valueObject: true); else if (field.SubType == FIELD_SUBTYPE_ENUM) // Same rule as the scalar arm: no [Column(...)]. A VO's column mapping is decided // by each OWNER (fluent, in the DbContext — .ToJson or the flattened per-member @@ -846,7 +852,8 @@ 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. - member = $" [JsonPropertyName(\"{field.Name}\")]\n" + member; + // A null member is omitted on the wire (the cross-port value-object contract). + member = $" [JsonPropertyName(\"{field.Name}\")]\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" + member; member = ApplyPropertyAttributes(member, vo, field, ctx); members.Add(XmlDocBuilder.Prepend(member, field, " ")); } @@ -1188,8 +1195,20 @@ public static bool Fits(decimal value, int precision, int scale) /// public static bool FitsDeclared(Type owner, string property, decimal value) { - var declared = owner.GetProperty(property)?.GetCustomAttributes(typeof(DeclaredAttribute), true); - return declared is not { Length: > 0 } || ((DeclaredAttribute)declared[0]).Accepts(value); + var attributes = owner.GetProperty(property)?.GetCustomAttributes(true); + if (attributes is null) return true; + foreach (var attribute in attributes) + { + // The attribute is matched by name, not by type identity: a route in another namespace + // 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; + 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; + } + return true; } private static decimal ReadOne(ref Utf8JsonReader reader, int? precision = null, int? scale = null) @@ -1441,9 +1460,10 @@ protected virtual string EnumProperty( // validation attrs follow withAttributes (mapped entities emit both, byte-identical // to before). // - // When the field is an array (isArray: true), emit List with an empty-list - // initializer instead of a scalar T property. Arrays are always non-nullable - // (the jsonb column holds the list; the list itself is never null in C#). + // 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. // // includeNames (C1) is only consulted when withAttributes is true — it is the // ctx.Config.IncludeNames run-level presence gate CSharpNaming.ColumnRef needs; @@ -1453,7 +1473,8 @@ protected virtual string ScalarProperty( MetaObject owner, MetaField field, IReadOnlyList pkFields, bool withAttributes, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal, bool includeNames = false, - string? baseTypeOverride = null, bool? withValidationAttributes = null) + string? baseTypeOverride = null, bool? withValidationAttributes = null, + bool valueObject = false) { var emitValidation = withValidationAttributes ?? withAttributes; // A @dbColumnType:jsonb open-bag shifts the CLR property to a parsed JSON value @@ -1479,7 +1500,13 @@ protected virtual string ScalarProperty( // 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); - arr.Append($" public IList<{baseType}> {propName} {{ get; set; }} = new List<{baseType}>();"); + // 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)) + arr.Append($" public IList<{baseType}>? {propName} {{ get; set; }}"); + else + arr.Append($" public IList<{baseType}> {propName} {{ get; set; }} = new List<{baseType}>();"); return arr.ToString(); } diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index cd0510717..c831b218a 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -292,7 +292,7 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte // body carries the derived (view-computed) fields (read-your-writes). AppendCreateHandler(sb, "/" + route, cls, dbSet, requiredKeys, "Results.Created(prefix + \"/" + route + "\", input)", voFields, mapVoFields, autoSetFields, - writeThrough ? (readDbSet, pkProp!, route) : null); + writeThrough ? (readDbSet, pkProp!, route) : null, parseBody: HasDecimalField(entity)); // PATCH + PUT share the same handler — TS reference exposes both verbs, and both // return the updated row (HTTP 200), matching the cross-port api-contract. @@ -315,7 +315,10 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte sb.AppendLine(" {"); sb.AppendLine(" var existing = await db." + dbSet + ".FindAsync(id);"); sb.AppendLine(" if (existing is null) return Results.NotFound(new { error = \"not_found\" });"); - sb.AppendLine(" using var body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body);"); + sb.AppendLine(" System.Text.Json.JsonDocument body;"); + sb.AppendLine(" try { body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body); }"); + sb.AppendLine(" catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = \"validation\" }); }"); + sb.AppendLine(" using var bodyScope = body;"); sb.AppendLine(" // Deserialize each value with the app's CONFIGURED JSON options, not defaults —"); sb.AppendLine(" // a field.timestamp binds to DateTimeOffset via a custom converter registered on"); sb.AppendLine(" // ConfigureHttpJsonOptions, and default options cannot read the wire format, throwing 500."); @@ -749,9 +752,13 @@ private static void AppendCreateHandler( IReadOnlyList requiredKeys, string createdExpr, IReadOnlyList voFields, IReadOnlyList mapVoFields, IReadOnlyList autoSetFields, - (string ReadDbSet, string PkProp, string Route)? reRead = null) + (string ReadDbSet, string PkProp, string Route)? reRead = null, + bool parseBody = false) { - if (requiredKeys.Count == 0) + // A decimal field can refuse its value while the body is bound (out of declared range, not a + // number). The framework's own binding answers that with a bare 400 and no body, so an entity + // carrying one parses the body itself and returns the cross-port {error:"validation"} envelope. + if (requiredKeys.Count == 0 && !parseBody) { sb.AppendLine($" app.MapPost(prefix + \"{mapPath}\", async ({cls} input, AppDbContext db) =>"); sb.AppendLine(" {"); @@ -766,7 +773,10 @@ private static void AppendCreateHandler( sb.AppendLine($" app.MapPost(prefix + \"{mapPath}\", async (HttpContext http, AppDbContext db) =>"); sb.AppendLine(" {"); AppendJsonOptsLocal(sb); - sb.AppendLine(" using var __body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body);"); + sb.AppendLine(" System.Text.Json.JsonDocument __body;"); + sb.AppendLine(" try { __body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body); }"); + sb.AppendLine(" catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = \"validation\" }); }"); + sb.AppendLine(" using var __bodyScope = __body;"); sb.AppendLine(" if (__body.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object)"); sb.AppendLine(" return Results.BadRequest(new { error = \"validation\" });"); // FR-036 #4 — @required-key presence: an omitted key OR an explicit null on a @required @@ -775,7 +785,7 @@ private static void AppendCreateHandler( sb.AppendLine(" var __present = new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase);"); sb.AppendLine(" foreach (var __p in __body.RootElement.EnumerateObject())"); sb.AppendLine(" if (__p.Value.ValueKind != System.Text.Json.JsonValueKind.Null) __present.Add(__p.Name);"); - sb.Append(" foreach (var __req in new[] { "); + sb.Append(requiredKeys.Count == 0 ? " foreach (var __req in new string[] { " : " foreach (var __req in new[] { "); sb.Append(string.Join(", ", requiredKeys.Select(k => "\"" + k + "\""))); sb.AppendLine(" })"); sb.AppendLine(" if (!__present.Contains(__req)) return Results.BadRequest(new { error = \"validation\" });"); @@ -922,8 +932,20 @@ private static void AppendCreateVoValidation( // (`mount`). The parameter keeps its name `app`, so a caller passing `app:` by name still compiles. // true iff the entity has a field that surfaces as a CLR decimal (and so a DecimalWire copy // is emitted into its namespace). - private static bool HasDecimalField(MetaObject entity) => - entity.Fields().Any(f => f.SubType == FIELD_SUBTYPE_DECIMAL && CSharpNaming.DbColumnTypeClrOverride(f) is null); + // A decimal reached through a value-object column counts too: its member is bound from the body + // with the same exact-read rule, so the same {error:"validation"} envelope must answer a refusal. + private static bool HasDecimalField(MetaObject entity, HashSet? seen = null) + { + seen ??= []; + if (!seen.Add(entity)) return false; + foreach (var f in entity.Fields()) + { + if (f.SubType == FIELD_SUBTYPE_DECIMAL && CSharpNaming.DbColumnTypeClrOverride(f) is null) return true; + if (f.SubType == FIELD_SUBTYPE_OBJECT && ValueObjectNames.ResolveFieldRef(f, entity) is { } vo + && HasDecimalField(vo, seen)) return true; + } + return false; + } private static void AppendDecimalRangeGroup(StringBuilder sb) { @@ -1167,7 +1189,8 @@ private static void AppendTphSubtypeRoutes( sb.AppendLine(); // VO columns on a TPH subtype are out of scope (Program D §6) — no typed arms. AppendCreateHandler(sb, "/" + subRoute, subCls, dbSet, requiredKeys, - "Results.Created(prefix + \"/" + subRoute + "/\" + input." + pkProp + ", input)", [], mapVoFields, autoSetFields); + "Results.Created(prefix + \"/" + subRoute + "/\" + input." + pkProp + ", input)", [], mapVoFields, autoSetFields, + parseBody: HasDecimalField(st.Entity)); // Per-subtype update (PATCH + PUT): partial merge of the JSON body onto the scoped // entity, NEVER touching the PK or the discriminator. Cross-subtype id → 404. @@ -1176,7 +1199,9 @@ private static void AppendTphSubtypeRoutes( sb.AppendLine(" {"); sb.AppendLine($" var existing = await db.{dbSet}.OfType<{subCls}>().FirstOrDefaultAsync(x => x.{pkProp} == id);"); sb.AppendLine(" if (existing is null) return Results.NotFound(new { error = \"not_found\" });"); - sb.AppendLine(" var body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body);"); + sb.AppendLine(" System.Text.Json.JsonDocument body;"); + sb.AppendLine(" try { body = await System.Text.Json.JsonDocument.ParseAsync(http.Request.Body); }"); + sb.AppendLine(" catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = \"validation\" }); }"); sb.AppendLine(" // Configured JSON options (not defaults) so a custom converter — e.g. the"); sb.AppendLine(" // field.timestamp DateTimeOffset converter — is honored; defaults throw 500 on it."); sb.AppendLine(" var jsonOpts = ((Microsoft.Extensions.Options.IOptions)"); diff --git a/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs b/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs index e7e373316..0387611a6 100644 --- a/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs +++ b/server/csharp/MetaObjects.Codegen/Runtime/DecimalWire.cs @@ -173,8 +173,20 @@ public static bool Fits(decimal value, int precision, int scale) /// public static bool FitsDeclared(Type owner, string property, decimal value) { - var declared = owner.GetProperty(property)?.GetCustomAttributes(typeof(DeclaredAttribute), true); - return declared is not { Length: > 0 } || ((DeclaredAttribute)declared[0]).Accepts(value); + var attributes = owner.GetProperty(property)?.GetCustomAttributes(true); + if (attributes is null) return true; + foreach (var attribute in attributes) + { + // The attribute is matched by name, not by type identity: a route in another namespace + // 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; + 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; + } + return true; } private static decimal ReadOne(ref Utf8JsonReader reader, int? precision = null, int? scale = null) diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/DecimalWireGeneratedApiTests.cs b/server/csharp/MetaObjects.IntegrationTests/Api/DecimalWireGeneratedApiTests.cs index 3f43800c4..f8da8b0fe 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/DecimalWireGeneratedApiTests.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/DecimalWireGeneratedApiTests.cs @@ -148,4 +148,22 @@ await ScenarioEngine.Postgres.ExecuteScriptAsync(pg.ConnectionString, Assert.Contains("decimal_out_of_range", bh); Assert.DoesNotContain(" at ", bh); } + + // A malformed or empty JSON body is the client's fault: 400 validation, never an unhandled + // JsonException (500) from the hand-parsed POST handler that decimal entities use. + [Fact] + public async Task Malformed_and_empty_body_are_400_not_500_on_sqlite() + { + using var db = new SqliteTempDatabase("mo-decimal-malformed-"); + await using var server = await Start(ScenarioEngine.Sqlite, $"Data Source={db.FilePath}"); + using var c = new HttpClient { BaseAddress = new Uri(server.BaseUrl) }; + + var (s1, b1) = await Send(c, "POST", "/api/ledgers", "{not json"); + Assert.Equal(400, s1); + Assert.Contains("validation", b1); + + var (s2, b2) = await Send(c, "POST", "/api/ledgers", ""); + Assert.Equal(400, s2); + Assert.Contains("validation", b2); + } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs index f4468cde7..26513b5b0 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs @@ -30,6 +30,8 @@ internal static class JsonbFixture "primaryMarker" jsonb NOT NULL, "optionalMarker" jsonb, "markers" jsonb, + "measure" jsonb, + "measures" jsonb, CONSTRAINT "documents_pkey" PRIMARY KEY ("id") ); """; @@ -70,8 +72,8 @@ public static async Task ApplySeedAsync(string connString, string seedPath) if (rowNode is not JsonObject row) continue; await using var ins = c.CreateCommand(); ins.CommandText = - "INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\") " + - "VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers)"; + "INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\") " + + "VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures)"; ins.Parameters.AddWithValue("@id", row["id"]!.GetValue()); ins.Parameters.AddWithValue("@title", row["title"]!.GetValue()); // payload + the VO columns are JSON — serialize each and bind as jsonb (an @@ -81,6 +83,8 @@ public static async Task ApplySeedAsync(string connString, string seedPath) AddJsonbParam(ins, "@primaryMarker", row["primaryMarker"]); AddJsonbParam(ins, "@optionalMarker", row["optionalMarker"]); AddJsonbParam(ins, "@markers", row["markers"]); + AddJsonbParam(ins, "@measure", row["measure"]); + AddJsonbParam(ins, "@measures", row["measures"]); await ins.ExecuteNonQueryAsync(); } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs index c7a9b3772..03cd25a9a 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs @@ -21,6 +21,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using MetaObjects.Codegen.Runtime; using MetaObjects.IntegrationTests.Runner; using Npgsql; using NpgsqlTypes; @@ -40,7 +41,7 @@ internal sealed class JsonbReferenceServer : IAsyncDisposable private const int MarkerLabelMaxLength = 40; // The jsonb columns carried in a document row's wire projection (id + title are scalar). - private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers" }; + private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures" }; private JsonbReferenceServer(PostgresContainer pg, HttpListener listener, string baseUrl) { @@ -151,6 +152,7 @@ private async Task CreateDocumentAsync(HttpListenerContext ctx) // Optional VO columns: when present + non-null, validate nested constraints. if (PresentNonNull(body, "optionalMarker") && !ValidMarker(body["optionalMarker"])) { await BadValidation(ctx); return; } if (PresentNonNull(body, "markers") && !ValidMarkerArray(body["markers"])) { await BadValidation(ctx); return; } + if (!CanonicalizeMeasures(body)) { await BadValidation(ctx); return; } long newId; await using var c = new NpgsqlConnection(_pg.ConnectionString); @@ -158,13 +160,15 @@ private async Task CreateDocumentAsync(HttpListenerContext ctx) await using (var ins = c.CreateCommand()) { ins.CommandText = - "INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\") " + - "VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers) RETURNING id"; + "INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\") " + + "VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures) RETURNING id"; ins.Parameters.AddWithValue("@title", body["title"]!.GetValue()); AddJsonbParam(ins, "@payload", body["payload"]); AddJsonbParam(ins, "@primaryMarker", body["primaryMarker"]); AddJsonbParam(ins, "@optionalMarker", body["optionalMarker"]); AddJsonbParam(ins, "@markers", body["markers"]); + AddJsonbParam(ins, "@measure", body["measure"]); + AddJsonbParam(ins, "@measures", body["measures"]); newId = Convert.ToInt64(await ins.ExecuteScalarAsync()); } await SendJsonAsync(ctx, 201, await SelectRowAsync(c, newId) ?? new Dictionary()); @@ -189,6 +193,7 @@ private async Task UpdateDocumentAsync(HttpListenerContext ctx, string idStr) } if (PresentNonNull(body, "optionalMarker") && !ValidMarker(body["optionalMarker"])) { await BadValidation(ctx); return; } if (PresentNonNull(body, "markers") && !ValidMarkerArray(body["markers"])) { await BadValidation(ctx); return; } + if (!CanonicalizeMeasures(body)) { await BadValidation(ctx); return; } await using var c = new NpgsqlConnection(_pg.ConnectionString); await c.OpenAsync(); @@ -242,6 +247,53 @@ private static bool ValidMarkerArray(JsonNode? node) return true; } + // Measure { ratio: double; samples: double[]; amount: decimal(12, 4) }. A present Measure is + // canonicalized in place: the decimal `amount` is read exactly (string-or-number), range-checked + // against its declared precision/scale and stored as its canonical string; null members are dropped. + private const int MeasurePrecision = 12, MeasureScale = 4; + + private static JsonObject? CanonicalMeasure(JsonNode? node) + { + if (node is not JsonObject obj) return null; + var outObj = new JsonObject(); + foreach (var kvp in obj) + { + if (kvp.Value is null || kvp.Value.GetValueKind() == JsonValueKind.Null) continue; + if (kvp.Key == "amount") + { + var el = JsonSerializer.SerializeToElement(kvp.Value); + if (!DecimalWire.TryParseElement(el, out var d) || !DecimalWire.Fits(d, MeasurePrecision, MeasureScale)) + return null; + outObj["amount"] = DecimalWire.Format(d); + } + else outObj[kvp.Key] = kvp.Value.DeepClone(); + } + return outObj; + } + + private static bool CanonicalizeMeasures(JsonObject body) + { + if (PresentNonNull(body, "measure")) + { + var canon = CanonicalMeasure(body["measure"]); + if (canon is null) return false; + body["measure"] = canon; + } + if (PresentNonNull(body, "measures")) + { + if (body["measures"] is not JsonArray arr) return false; + var list = new JsonArray(); + foreach (var el in arr) + { + var canon = CanonicalMeasure(el); + if (canon is null) return false; + list.Add(canon); + } + body["measures"] = list; + } + return true; + } + private static bool PresentNonNull(JsonObject body, string key) => body.TryGetPropertyValue(key, out var node) && node is not null && node.GetValueKind() != JsonValueKind.Null; diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs index bcc543fb9..d8b5f0edc 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/DecimalWire.g.cs @@ -173,8 +173,20 @@ public static bool Fits(decimal value, int precision, int scale) /// public static bool FitsDeclared(Type owner, string property, decimal value) { - var declared = owner.GetProperty(property)?.GetCustomAttributes(typeof(DeclaredAttribute), true); - return declared is not { Length: > 0 } || ((DeclaredAttribute)declared[0]).Accepts(value); + var attributes = owner.GetProperty(property)?.GetCustomAttributes(true); + if (attributes is null) return true; + foreach (var attribute in attributes) + { + // The attribute is matched by name, not by type identity: a route in another namespace + // 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; + 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; + } + return true; } private static decimal ReadOne(ref Utf8JsonReader reader, int? precision = null, int? scale = null) diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/Label.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/Label.g.cs index 62abd8c6d..403c6b284 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/Label.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/Label.g.cs @@ -12,7 +12,9 @@ namespace MetaObjects.IntegrationTests.Generated; public class Label { [JsonPropertyName("key")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Key { get; set; } [JsonPropertyName("weight")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Weight { get; set; } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs index b99f02c82..2a291fd74 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs @@ -12,12 +12,15 @@ namespace MetaObjects.IntegrationTests.Generated; public class ProgramBrief { [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [Required(AllowEmptyStrings = true)] [MaxLength(200)] [MinLength(1)] public string Title { get; set; } = default!; [JsonPropertyName("weekCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? WeekCount { get; set; } [JsonPropertyName("weekLabels")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ICollection? WeekLabels { get; set; } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs index 6e52e6d5b..2c4829c00 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs @@ -13,8 +13,10 @@ public class ProgramVerdict { public enum ProgramVerdictVerdict { GOOD, OK, POOR } [JsonPropertyName("verdict")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ProgramVerdictVerdict Verdict { get; set; } [JsonPropertyName("rationale")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [Required(AllowEmptyStrings = true)] [MaxLength(480)] [MinLength(1)] diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/Settings.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/Settings.g.cs index d71d17ad8..892e67927 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/Settings.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/Settings.g.cs @@ -12,9 +12,12 @@ namespace MetaObjects.IntegrationTests.Generated; public class Settings { [JsonPropertyName("theme")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Theme { get; set; } [JsonPropertyName("notify")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Notify { get; set; } [JsonPropertyName("retries")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Retries { get; set; } } diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs index 13626fb5e..ede7456b1 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs @@ -12,6 +12,7 @@ namespace MetaObjects.IntegrationTests.Generated; public class WeekLabel { [JsonPropertyName("label")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [Required(AllowEmptyStrings = true)] [MaxLength(80)] [MinLength(1)] diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 54792f4a1..a49fbe06e 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -2033,7 +2033,10 @@ private static void ValidateAttrSchemaNode( { errors.Add(new MetaError( $"{NodeLabel(node)} attribute '@{attrName}' must be of type " + - $"'{effectiveValueType}' but got {RuntimeTypeName(value)}", + $"'{effectiveValueType}' but got {RuntimeTypeName(value)}" + + (effectiveValueType is ATTR_SUBTYPE_INT or ATTR_SUBTYPE_LONG && value is double or float + ? "; this attribute is integral, so write an integer (for a validator @min/@max, use an integer bound)" + : ""), ErrorCode.ERR_BAD_ATTR_VALUE, Envelope: node.Source)); // Skip allowedValues check when type is already wrong. 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 d4b756546..53fa4fc7b 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 @@ -34,9 +34,12 @@ object KotlinDecimalWireEmitter { |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 @@ -135,19 +138,40 @@ object KotlinDecimalWireEmitter { | } | } | - | /** Reads a decimal from a string or a number, exactly; anything else is a 400-shaped mapping error. */ - | class Deserializer : StdDeserializer(BigDecimal::class.java) { + | /** + | * 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 { - | if (token == JsonToken.VALUE_STRING) return parse(p.text) - | if (token == JsonToken.VALUE_NUMBER_INT || token == JsonToken.VALUE_NUMBER_FLOAT) { - | return fromJsonNumber(p.decimalValue) + | 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? | } - | return ctxt.handleUnexpectedToken(BigDecimal::class.java, p) as BigDecimal? | } | } |} 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 50bce3558..5283ca0b2 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 @@ -222,6 +222,17 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { val typeBuilder = TypeSpec.classBuilder(shortName) .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) { + addAnnotation( + AnnotationSpec.builder(ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) + .addMember("%T.Include.NON_NULL", ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) + .build(), + ) + } + } // FR-017 TPH: a discriminator base's data class is the UNION of subtype columns and serves // as the partial-PATCH-friendly wire body — every property is nullable + defaulted + carries @@ -290,7 +301,8 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { propBuilder.addAnnotation(constraint(VALID)) } } - decimalWireAnnotations(field, pkg).forEach { propBuilder.addAnnotation(it) } + decimalWireAnnotations(field, pkg, declaredRange = obj.subType == MetaObject.SUBTYPE_VALUE) + .forEach { propBuilder.addAnnotation(it) } typeBuilder.addProperty(propBuilder.build()) } @@ -623,18 +635,29 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { * constructor parameter, the two places Jackson's Kotlin module looks. `contentUsing` for an * `@isArray` property (the elements are the decimals). */ - private fun decimalWireAnnotations(field: MetaField<*>, pkg: String): List { + private fun decimalWireAnnotations(field: MetaField<*>, pkg: String, declaredRange: Boolean = false): List { if (field !is DecimalField) return emptyList() decimalWirePackages.add(pkg) val serializer = ClassName(pkg, KotlinDecimalWireEmitter.FILE_NAME, "Serializer") val deserializer = ClassName(pkg, KotlinDecimalWireEmitter.FILE_NAME, "Deserializer") val member = if (field.isArrayType) "contentUsing" else "using" - return listOf( + 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(), ) + // 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. + if (declaredRange && !KotlinTypeMapper.isUnboundedDecimal(field)) { + annotations += AnnotationSpec.builder(ClassName(pkg, KotlinDecimalWireEmitter.FILE_NAME, "Declared")) + .useSiteTarget(AnnotationSpec.UseSiteTarget.PARAM) + .addMember("precision = %L", KotlinTypeMapper.decimalPrecision(field)) + .addMember("scale = %L", KotlinTypeMapper.decimalScale(field)) + .build() + } + return annotations } private fun isStrictNetField(field: MetaField<*>): Boolean = 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 b68d67360..9007f57da 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 @@ -652,7 +652,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase))" + } else { + " @Suppress(\"UNCHECKED_CAST\")\n" + + " stmt.setArray(index, \"numeric\", value as Array)" + } + return """ |private fun isPostgres(): Boolean = currentDialect.name.equals("postgresql", ignoreCase = true) | |private fun isSqlite(): Boolean = currentDialect.name.equals("sqlite", ignoreCase = true) @@ -1753,7 +1766,61 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase`. */ |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<*>) { + |$decimalArrayBind + | } 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()) |""".trimMargin() + } /** * File name (sans `.kt`) of the per-package support file emitted for a `field.string @@ -1777,8 +1844,8 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase, value: String) { if (field !is DecimalField) return + // No @precision: the column is unbounded NUMERIC, so there is no declared range to check + // (Java, C#, TypeScript and Python check only a declared precision). + if (KotlinTypeMapper.isUnboundedDecimal(field)) return val outOfRange = if (field.isArrayType) { "$value?.any { !${decimalFitsExpr(field, "it")} } == true" } else { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt index 51313046d..21ccdeb38 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt @@ -189,6 +189,13 @@ object KotlinTypeMapper { */ const val EXPOSED_PORTABLE_DECIMAL_FN = "portableDecimal" + /** + * Builder extension for an `isArray` `field.decimal` column: a `Column>` that is a + * native `NUMERIC[]` on Postgres and a JSON text array of canonical strings on MySQL and SQLite, + * which have no array type. Same support file as [EXPOSED_PORTABLE_DECIMAL_FN]. + */ + const val EXPOSED_PORTABLE_DECIMAL_ARRAY_FN = "portableDecimalArray" + /** * Compute the generated Kotlin enum-class name for an [EnumField] hung off [entity]. * @@ -512,9 +519,15 @@ object KotlinTypeMapper { // DOUBLE PRECISION (float8). Keeps field.float distinct on the wire. See R6. is FloatField -> "float($colExpr)" // field.decimal → Exposed `decimal(name, precision, scale)` (Postgres NUMERIC(p,s)). - // Exposed requires both precision and scale; read the declared @precision/@scale, - // falling back to 19,4 (matching the TS column-mapper default) when absent. - is DecimalField -> "$EXPOSED_PORTABLE_DECIMAL_FN($colExpr, ${decimalPrecision(field)}, ${decimalScale(field)})" + // A declared @precision gives `portableDecimal(name, p, s)`; with none the column is + // unbounded (plain NUMERIC, nothing rounded on read), as the schema owner emits it. + // + // An `isArray` decimal is a native `NUMERIC[]` on Postgres (the schema owner declares no bound on + // the elements; the wire layer has range-checked each against @precision/@scale), read unrounded. + is DecimalField -> + if (isArrayResolved(field)) "$EXPOSED_PORTABLE_DECIMAL_ARRAY_FN($colExpr)" + else if (isUnboundedDecimal(field)) "$EXPOSED_PORTABLE_DECIMAL_FN($colExpr)" + else "$EXPOSED_PORTABLE_DECIMAL_FN($colExpr, ${decimalPrecision(field)}, ${decimalScale(field)})" is BooleanField -> "bool($colExpr)" is DateField -> "date($colExpr)" // field.time → Exposed `time(name)` (Postgres TIME, java.time.LocalTime). @@ -668,7 +681,7 @@ object KotlinTypeMapper { * ([EXPOSED_PORTABLE_DECIMAL_FN]). Mirrors [exposedColumnSpec]'s two branches. */ fun usesPortableHelper(field: MetaField<*>): Boolean = - (field is UuidField || field is DecimalField) && !isArrayResolved(field) + field is DecimalField || (field is UuidField && !isArrayResolved(field)) fun usesUuidStringHelper(field: MetaField<*>): Boolean = field is StringField && !isArrayResolved(field) && dbColumnType(field) == DB_COLUMN_TYPE_UUID @@ -710,6 +723,13 @@ object KotlinTypeMapper { /** Default NUMERIC scale when a [DecimalField] declares no `@scale`. See [DECIMAL_DEFAULT_PRECISION]. */ private const val DECIMAL_DEFAULT_SCALE = 4 + /** + * True when a [DecimalField] declares no `@precision`: its column is plain unbounded NUMERIC + * (what the schema owner emits), so it is neither rounded on read nor range-checked on write. + */ + fun isUnboundedDecimal(field: DecimalField): Boolean = + intAttr(field, DecimalField.ATTR_PRECISION) == null + /** Resolve @precision on a DecimalField; default [DECIMAL_DEFAULT_PRECISION]. */ fun decimalPrecision(field: DecimalField): Int = intAttr(field, DecimalField.ATTR_PRECISION) ?: DECIMAL_DEFAULT_PRECISION 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 f1b759667..4febaabd1 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 @@ -253,6 +253,84 @@ class KotlinDecimalWireTest { assertEquals(false, call("fits", java.math.BigDecimal("100.1"), 4, 2)) } + private val unsizedModel = model.replace("\"@precision\": 12, \"@scale\": 2, ", "") + + @Test + fun `the unbounded decimal column compiles and reads a value exactly, never rounding to a scale`() { + val files = emit(unsizedModel, listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator())) + val sources = files.filterKeys { it.endsWith(".kt") }.map { (path, src) -> + com.tschuchort.compiletesting.SourceFile.kotlin(path.substringAfterLast('/'), src) + } + val result = com.tschuchort.compiletesting.KotlinCompilation().apply { + this.sources = sources + inheritClassPath = true + messageOutputStream = System.out + }.compile() + assertEquals(com.tschuchort.compiletesting.KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val type = result.classLoader.loadClass("acme.shop.MetaUnboundedDecimalColumnType") + .getDeclaredConstructor().apply { isAccessible = true }.newInstance() as org.jetbrains.exposed.sql.ColumnType<*> + assertEquals("NUMERIC", type.sqlType()) + @Suppress("UNCHECKED_CAST") + val t = type as org.jetbrains.exposed.sql.ColumnType + assertEquals(java.math.BigDecimal("1.234567"), t.valueFromDB(java.math.BigDecimal("1.234567"))) + assertEquals(java.math.BigDecimal("12345678901234567890.123456789"), t.valueFromDB("12345678901234567890.123456789")) + assertEquals("1.234567", t.nonNullValueToString(java.math.BigDecimal("1.234567"))) + } + + private val voModel = """{ + "metadata.root": { "package": "acme::shop", "children": [ + { "object.value": { "name": "Price", "children": [ + { "field.decimal": { "name": "net", "@precision": 12, "@scale": 2 } }, + { "field.decimal": { "name": "free" } } + ] } }, + { "object.entity": { "name": "Item", "children": [ + { "field.long": { "name": "id" } }, + { "field.object": { "name": "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "field.object": { "name": "prices", "@objectRef": "Price", "isArray": true, "@storage": "jsonb" } }, + { "source.rdb": { "@table": "items" } }, + { "identity.primary": { "@fields": ["id"], "@generation": "increment" } } + ] } } + ] } + }""".trimIndent() + + @Test + fun `a decimal inside a jsonb value object reads a string or number exactly, checks its declared range and writes canonical`() { + val files = emit(voModel, listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator())) + val sources = files.filterKeys { it.endsWith(".kt") }.map { (path, src) -> + com.tschuchort.compiletesting.SourceFile.kotlin(path.substringAfterLast('/'), src) + } + val result = com.tschuchort.compiletesting.KotlinCompilation().apply { + this.sources = sources + 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()) + val price = result.classLoader.loadClass("acme.shop.Price") + val item = result.classLoader.loadClass("acme.shop.Item") + fun net(json: String): String? = mapper.valueToTree(mapper.readValue(json, price)).get("net").asText() + // A string or a JSON number, read exactly; written as the canonical string. + assertEquals("1.5", net("""{"net":"1.5000"}""")) + assertEquals("1234567890.12", net("""{"net":1234567890.12}""")) + assertEquals("0", net("""{"net":"0.00"}""")) + // Past the declared @precision/@scale is refused, never rounded. + for (bad in listOf("""{"net":"1.234"}""", """{"net":1.234}""", """{"net":"12345678901"}""", """{"net":"NaN"}""", """{"net":""}""")) { + val e = runCatching { mapper.readValue(bad, price) }.exceptionOrNull() + assertTrue(e is com.fasterxml.jackson.core.JsonProcessingException, "must be refused: $bad but got $e") + } + // A VO decimal with no @precision is bounded only by the engine-wide limits. + val free = mapper.valueToTree(mapper.readValue("""{"net":"1","free":"1.23456789012345678901"}""", price)) + assertEquals("1.23456789012345678901", free.get("free").asText()) + // The single and the array-of-VO members of the entity follow the same rule. + val out = mapper.valueToTree( + mapper.readValue("""{"id":1,"price":{"net":"2.50"},"prices":[{"net":3},{"net":"4.10"}]}""", item)) + assertEquals("2.5", out.get("price").get("net").asText()) + assertEquals("3", out.get("prices").get(0).get("net").asText()) + assertEquals("4.1", out.get("prices").get(1).get("net").asText()) + val e = runCatching { mapper.readValue("""{"id":1,"prices":[{"net":"1.001"}]}""", item) }.exceptionOrNull() + assertTrue(e is com.fasterxml.jackson.core.JsonProcessingException, "an excess-scale element must be refused: $e") + } + @Test fun `a precision with no scale declares scale 0, as the Java port reads it`() { val json = model.replace("\"@precision\": 12, \"@scale\": 2,", "\"@precision\": 12,") 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 4a21e7ec8..793670d22 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 @@ -4,9 +4,12 @@ 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 @@ -105,19 +108,40 @@ internal object MetaDecimalWire { } } - /** Reads a decimal from a string or a number, exactly; anything else is a 400-shaped mapping error. */ - class Deserializer : StdDeserializer(BigDecimal::class.java) { + /** + * 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 { - if (token == JsonToken.VALUE_STRING) return parse(p.text) - if (token == JsonToken.VALUE_NUMBER_INT || token == JsonToken.VALUE_NUMBER_FLOAT) { - return fromJsonNumber(p.decimalValue) + 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? } - return ctxt.handleUnexpectedToken(BigDecimal::class.java, p) as BigDecimal? } } } 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 203e5cdd5..4b1d1eacd 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 @@ -4,9 +4,12 @@ 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 @@ -105,19 +108,40 @@ internal object MetaDecimalWire { } } - /** Reads a decimal from a string or a number, exactly; anything else is a 400-shaped mapping error. */ - class Deserializer : StdDeserializer(BigDecimal::class.java) { + /** + * 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 { - if (token == JsonToken.VALUE_STRING) return parse(p.text) - if (token == JsonToken.VALUE_NUMBER_INT || token == JsonToken.VALUE_NUMBER_FLOAT) { - return fromJsonNumber(p.decimalValue) + 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? } - return ctxt.handleUnexpectedToken(BigDecimal::class.java, p) as BigDecimal? } } } diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaPortableColumnTypes.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaPortableColumnTypes.kt index 2be4edf2a..8eb013dce 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaPortableColumnTypes.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-decimal-float-filter/acme/catalog/MetaPortableColumnTypes.kt @@ -6,6 +6,7 @@ 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 @@ -49,3 +50,57 @@ internal class MetaPortableDecimalColumnType(precision: Int, scale: Int) : Colum 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-storage/acme/commerce/Address.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/Address.kt index c7701487a..218258ede 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,5 +1,6 @@ package acme.commerce +import com.fasterxml.jackson.`annotation`.JsonInclude import jakarta.validation.constraints.Size import kotlin.String import kotlin.jvm.JvmStatic @@ -7,6 +8,7 @@ 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 8eee5c5c1..1d1b8024d 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,11 +1,13 @@ 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 9de23dd48..9d17a177f 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,11 +1,13 @@ 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/MetaDecimalWire.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaDecimalWire.kt index b2d598725..3fab045d4 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 @@ -4,9 +4,12 @@ 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 @@ -105,19 +108,40 @@ internal object MetaDecimalWire { } } - /** Reads a decimal from a string or a number, exactly; anything else is a 400-shaped mapping error. */ - class Deserializer : StdDeserializer(BigDecimal::class.java) { + /** + * 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 { - if (token == JsonToken.VALUE_STRING) return parse(p.text) - if (token == JsonToken.VALUE_NUMBER_INT || token == JsonToken.VALUE_NUMBER_FLOAT) { - return fromJsonNumber(p.decimalValue) + 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? } - return ctxt.handleUnexpectedToken(BigDecimal::class.java, p) as BigDecimal? } } } diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaPortableColumnTypes.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaPortableColumnTypes.kt index 8e118e02a..eda958203 100644 --- a/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaPortableColumnTypes.kt +++ b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-tph/acme/auth/MetaPortableColumnTypes.kt @@ -6,6 +6,7 @@ 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 @@ -49,3 +50,57 @@ internal class MetaPortableDecimalColumnType(precision: Int, scale: Int) : Colum 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-view/acme/report/MetaDecimalWire.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-view/acme/report/MetaDecimalWire.kt index b25b9c55a..4a63baeae 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 @@ -4,9 +4,12 @@ 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 @@ -105,19 +108,40 @@ internal object MetaDecimalWire { } } - /** Reads a decimal from a string or a number, exactly; anything else is a 400-shaped mapping error. */ - class Deserializer : StdDeserializer(BigDecimal::class.java) { + /** + * 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 { - if (token == JsonToken.VALUE_STRING) return parse(p.text) - if (token == JsonToken.VALUE_NUMBER_INT || token == JsonToken.VALUE_NUMBER_FLOAT) { - return fromJsonNumber(p.decimalValue) + 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? } - return ctxt.handleUnexpectedToken(BigDecimal::class.java, p) as BigDecimal? } } } 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 2d36df6d3..51b47de84 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,5 +1,6 @@ package acme.ai +import com.fasterxml.jackson.`annotation`.JsonInclude import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size import kotlin.String @@ -8,6 +9,7 @@ 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 3e337a313..ccee6f615 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,11 +1,13 @@ 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 7bb4ef857..190b77dca 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,5 +1,6 @@ package acme.ai +import com.fasterxml.jackson.`annotation`.JsonInclude import kotlin.Long import kotlin.String import kotlin.jvm.JvmStatic @@ -7,6 +8,7 @@ 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/SpringValueObjectGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java index f90414fb1..039850f46 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 @@ -161,11 +161,11 @@ private void emit(MetaObject vo, Path outRoot) { if (usesValidation) { src.append("import jakarta.validation.constraints.*;\n"); } - if (usesDecimal || usesValid || usesValidation) { - src.append('\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"); src.append("/** GENERATED — value object ").append(recordName) .append(". Do not hand-edit; regenerated from metadata. */\n"); + 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++) { 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 46fcd67a8..690ac5630 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 @@ -52,6 +52,8 @@ public class SpringDecimalWireCompileRunTest { { "field.decimal": { "name": "amount", "@precision": 20, "@scale": 10, "@filterable": true } }, { "field.string": { "name": "label", "@filterable": true } }, { "field.decimal": { "name": "tiers", "isArray": true, "@precision": 12, "@scale": 2 } }, + { "field.object": { "name": "price", "@objectRef": "Price", "@storage": "jsonb" } }, + { "field.object": { "name": "prices", "@objectRef": "Price", "isArray": true, "@storage": "jsonb" } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ]}} ]}} @@ -132,6 +134,44 @@ public void generatedDtoPatchAndAllowlistFollowTheDecimalWireRule() throws Excep Object p = MAPPER.readValue("{\"net\":\"9.50\"}", price); assertEquals("9.5", MAPPER.valueToTree(p).get("net").asText()); + // C1: a decimal inside a jsonb value object follows the top-level rule: a string or a + // JSON number read exactly, canonical string out, range-checked against the VO field's + // own @precision/@scale with nothing rounded. + Object num = MAPPER.readValue("{\"net\":1234567890.12}", price); + assertEquals("1234567890.12", MAPPER.valueToTree(num).get("net").asText()); + Object canon = MAPPER.readValue("{\"net\":\"1.5000\"}", price); + assertEquals("1.5", MAPPER.valueToTree(canon).get("net").asText()); + for (String bad : new String[]{"{\"net\":\"1.234\"}", "{\"net\":1.234}", + "{\"net\":\"12345678901\"}", "{\"net\":\"NaN\"}", "{\"net\":\"\"}"}) { + try { + MAPPER.readValue(bad, price); + fail("a value outside the VO field's declared range must be refused: " + bad); + } catch (com.fasterxml.jackson.core.JsonProcessingException expected) { + // Spring maps this to 400 validation + } + } + // The same through the entity's single and array-of-VO jsonb members. + Object withVo = MAPPER.readValue( + "{\"price\":{\"net\":\"2.50\"},\"prices\":[{\"net\":3},{\"net\":\"4.10\"}]}", dto); + JsonNode voOut = MAPPER.valueToTree(withVo); + assertEquals("2.5", voOut.get("price").get("net").asText()); + assertEquals("3", voOut.get("prices").get(0).get("net").asText()); + assertEquals("4.1", voOut.get("prices").get(1).get("net").asText()); + try { + MAPPER.readValue("{\"prices\":[{\"net\":\"1.001\"}]}", dto); + fail("an excess-scale decimal inside an array-of-VO element must be refused"); + } catch (com.fasterxml.jackson.core.JsonProcessingException expected) { + // Spring maps this to 400 validation + } + // The PATCH path binds the VO through the same record. + try { + patchClass.getMethod("fromJson", JsonNode.class, ObjectMapper.class) + .invoke(null, MAPPER.readTree("{\"price\":{\"net\":\"1.001\"}}"), MAPPER); + fail("a PATCH VO with an excess-scale decimal must be refused"); + } catch (java.lang.reflect.InvocationTargetException e) { + assertEquals("PatchValidationException", e.getCause().getClass().getSimpleName()); + } + // The allowlist names its decimal fields. Class allow = cl.loadClass(PKG + ".EntryFilterAllowlist"); assertEquals(java.util.Set.of("amount"), allow.getField("DECIMAL_FIELDS").get(null)); 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 6b095b6f3..c6de89854 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,8 +1,10 @@ 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/api/decimal/DecimalGeneratedApiContractConformanceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/decimal/DecimalGeneratedApiContractConformanceTest.kt index 7c712619f..282382a1f 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/decimal/DecimalGeneratedApiContractConformanceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/decimal/DecimalGeneratedApiContractConformanceTest.kt @@ -65,13 +65,13 @@ internal class DecimalGeneratedApiContractConformanceTest { } @Test - fun `the corpus has its eight scenarios`() { + fun `the corpus has its eleven scenarios`() { // A lane that silently ran fewer would still be green. - assertEquals(8, scenarios().count()) + assertEquals(11, scenarios().count()) } @Test - fun `the entity and the report both get a controller`() { + fun `the entities and the report all get a controller`() { for (name in GeneratedReportControllerHarness.DECIMAL_SERVED) { assertTrue("acme/ledger/${name}Controller.kt" in HARNESS.emittedFiles, "$name was not generated") } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt index e2089d0a2..89c66217a 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt @@ -38,7 +38,7 @@ import java.sql.DriverManager class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { private val db: Database = Database.connect(pg.jdbcUrl, user = pg.username, password = pg.password) - private val mapper = ObjectMapper() + private val mapper = ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) private val httpServer: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) val baseUrl: String @@ -80,6 +80,8 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { it[primaryMarker] = toJsonElement(r["primaryMarker"])!! it[optionalMarker] = toJsonElement(r["optionalMarker"]) it[markers] = toJsonElement(r["markers"]) + it[measure] = toJsonElement(r["measure"]) + it[measures] = toJsonElement(r["measures"]) } } } @@ -141,6 +143,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { val markersVal = body["markers"] if (markersVal != null && !(markersVal is List<*> && markersVal.all { isValidMarker(it) })) return sendJson(exchange, 400, mapOf("error" to "validation")) + val canon = canonicalizeMeasures(body) ?: return sendJson(exchange, 400, mapOf("error" to "validation")) val newId = transaction(db) { DocumentTable.insert { it[title] = body["title"] as String @@ -148,6 +151,8 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { it[primaryMarker] = toJsonElement(primary)!! it[optionalMarker] = toJsonElement(optional) it[markers] = toJsonElement(markersVal) + it[measure] = toJsonElement(canon["measure"]) + it[measures] = toJsonElement(canon["measures"]) }[DocumentTable.id] } val row = transaction(db) { @@ -188,7 +193,8 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { return sendJson(exchange, 400, mapOf("error" to "validation")) } // Apply only the present keys (absent = untouched). Guard the empty-SET case (Exposed throws). - val settableKeys = listOf("title", "payload", "primaryMarker", "optionalMarker", "markers") + val canon = canonicalizeMeasures(body) ?: return sendJson(exchange, 400, mapOf("error" to "validation")) + val settableKeys = listOf("title", "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures") if (settableKeys.any { body.containsKey(it) }) { transaction(db) { DocumentTable.update({ DocumentTable.id eq id }) { @@ -197,6 +203,8 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { if (body.containsKey("primaryMarker")) it[primaryMarker] = toJsonElement(body["primaryMarker"])!! if (body.containsKey("optionalMarker")) it[optionalMarker] = toJsonElement(body["optionalMarker"]) if (body.containsKey("markers")) it[markers] = toJsonElement(body["markers"]) + if (body.containsKey("measure")) it[measure] = toJsonElement(canon["measure"]) + if (body.containsKey("measures")) it[measures] = toJsonElement(canon["measures"]) } } } @@ -217,6 +225,43 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { return label.isNotEmpty() && label.length <= 40 } + /** + * Validate and canonicalise the present `Measure { ratio; samples; amount: decimal(12, 4) }` columns: + * returns a map holding the canonical `measure` / `measures` values, or null on a violation. + */ + private fun canonicalizeMeasures(body: Map): Map? { + val out = LinkedHashMap() + val single = body["measure"] + if (single != null) out["measure"] = canonicalMeasure(single) ?: return null + val many = body["measures"] + if (many != null) { + if (many !is List<*>) return null + out["measures"] = many.map { canonicalMeasure(it) ?: return null } + } + return out + } + + /** The Measure with its decimal amount range-checked and written as its canonical string, or null. */ + private fun canonicalMeasure(value: Any?): Map? { + val map = value as? Map<*, *> ?: return null + val out = LinkedHashMap() + for ((k, v) in map) out[k as String] = v + val amount = out["amount"] + if (amount != null) { + val d = when (amount) { + is String -> if (Regex("-?\\d+(\\.\\d+)?").matches(amount)) java.math.BigDecimal(amount) else return null + is Number -> java.math.BigDecimal(amount.toString()) + else -> return null + } + val stripped = if (d.signum() == 0) java.math.BigDecimal.ZERO else d.stripTrailingZeros() + val scale = maxOf(stripped.scale(), 0) + val intDigits = stripped.precision() - stripped.scale() + if (scale > 4 || intDigits > 12 - 4) return null + out["amount"] = stripped.toPlainString() + } + return out + } + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- @@ -235,6 +280,8 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { "primaryMarker" to fromJsonElement(row[DocumentTable.primaryMarker]), "optionalMarker" to fromJsonElement(row[DocumentTable.optionalMarker]), "markers" to fromJsonElement(row[DocumentTable.markers]), + "measure" to fromJsonElement(row[DocumentTable.measure]), + "measures" to fromJsonElement(row[DocumentTable.measures]), ) /** Jackson value (Map/List/scalar/null) → kotlinx `JsonElement` (the column type). */ @@ -277,6 +324,8 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { val primaryMarker = jsonb("primary_marker", { it.toString() }, { Json.parseToJsonElement(it) }) val optionalMarker = jsonb("optional_marker", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() val markers = jsonb("markers", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() + val measure = jsonb("measure", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() + val measures = jsonb("measures", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() override val primaryKey = PrimaryKey(id) } 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 03fda24cf..6c2b917c8 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 @@ -172,7 +172,7 @@ class GeneratedDocumentControllerHarness( // Program D: the seed rows carry the required primaryMarker (+ optional single/array // VO on row 1). Pass every present VO column through the generated controller's OWN // create path — so the create MUST handle value objects for seeding to succeed. - for (k in listOf("primaryMarker", "optionalMarker", "markers")) { + for (k in listOf("primaryMarker", "optionalMarker", "markers", "measure", "measures")) { if (r.containsKey(k)) body[k] = r[k] } val res = exchange("POST", "/api/documents", body) 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 da174ca39..ade72c81c 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 @@ -222,7 +222,7 @@ class GeneratedReportControllerHarness( * one time dimension at two grains; `SalesByCategory` reaches a dimension through two references. */ /** The `decimal/` sub-corpus: one writable entity and the report read from it. */ - val DECIMAL_SERVED = listOf("Entry", "EntryTotals") + val DECIMAL_SERVED = listOf("Entry", "Sample", "EntryTotals") val SERVED = listOf( "InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals", "InvoiceCalendar", "InvoiceDisputes", diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DecimalOmdbApiContractLane.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DecimalOmdbApiContractLane.java index 0c21882ea..65c472435 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DecimalOmdbApiContractLane.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DecimalOmdbApiContractLane.java @@ -67,6 +67,7 @@ void setUp() throws Exception { ObjectClassRegistry reg = new ObjectClassRegistry(); Map> bindings = new HashMap<>(); bindings.put("acme::ledger::Entry", ValueObject.class); + bindings.put("acme::ledger::Sample", ValueObject.class); reg.register(() -> bindings); ObjectClassRegistry.setGlobal(reg); @@ -141,6 +142,13 @@ private void reseed() throws SQLException { long next = rows.stream().mapToLong(r -> ((Number) r.get("id")).longValue()).max().orElse(0) + 1; try (Connection c = connection(); Statement s = c.createStatement()) { s.execute((engine == ScenarioEngine.SQLITE ? "DELETE FROM " : "TRUNCATE TABLE ") + q + "entries" + q); + // Sample (the array and unbounded decimals) starts empty: the seed holds `samples: []`. + s.execute((engine == ScenarioEngine.SQLITE ? "DELETE FROM " : "TRUNCATE TABLE ") + q + "samples" + q); + switch (engine) { + case POSTGRES -> s.execute("ALTER TABLE \"samples\" ALTER COLUMN \"id\" RESTART WITH 1"); + case MYSQL -> s.execute("ALTER TABLE `samples` AUTO_INCREMENT = 1"); + case SQLITE -> s.execute("DELETE FROM sqlite_sequence WHERE name = 'samples'"); + } for (Map row : rows) { String cols = row.keySet().stream().map(k -> q + k + q).collect(Collectors.joining(", ")); String vals = row.values().stream().map(DecimalOmdbApiContractLane::literal).collect(Collectors.joining(", ")); diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java index 7a00a85fe..32c2ef6eb 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java @@ -47,7 +47,8 @@ final class JsonbReferenceServer implements AutoCloseable { private final PostgresContainer pg; - private final ObjectMapper mapper = new ObjectMapper(); + private final ObjectMapper mapper = new ObjectMapper() + .enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); private final HttpServer httpServer; private final String baseUrl; @@ -78,15 +79,17 @@ private void createSchema() throws SQLException { + "payload JSONB, " + "primary_marker JSONB NOT NULL, " // required single VO (Program D) + "optional_marker JSONB, " // nullable single VO - + "markers JSONB)"); // nullable array-of-VO + + "markers JSONB, " // nullable array-of-VO + + "measure JSONB, " // nullable single Measure VO (decimal amount) + + "measures JSONB)"); // nullable array-of-Measure VO } } private void applySeed(List> seedRows) throws SQLException { try (Connection c = connect()) { try (PreparedStatement ps = c.prepareStatement( - "INSERT INTO \"documents\" (id, title, payload, primary_marker, optional_marker, markers) " - + "VALUES (?, ?, ?, ?, ?, ?)")) { + "INSERT INTO \"documents\" (id, title, payload, primary_marker, optional_marker, markers, measure, measures) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { for (Map r : seedRows) { ps.setLong(1, ((Number) r.get("id")).longValue()); ps.setString(2, String.valueOf(r.get("title"))); @@ -94,6 +97,8 @@ private void applySeed(List> seedRows) throws SQLException { bindJsonb(ps, 4, r.get("primaryMarker")); bindJsonb(ps, 5, r.get("optionalMarker")); bindJsonb(ps, 6, r.get("markers")); + bindJsonb(ps, 7, r.get("measure")); + bindJsonb(ps, 8, r.get("measures")); ps.executeUpdate(); } } @@ -137,7 +142,7 @@ private void doHandle(HttpExchange exchange) throws IOException, SQLException { Map body = readBody(exchange); // Program D: nested value-object validation on CREATE (required primaryMarker present + // valid; a present optional/array VO valid) → 400 before persisting. - if (!voValidForCreate(body)) { sendJson(exchange, 400, Map.of("error", "validation")); return; } + if (!voValidForCreate(body) || !canonicalizeMeasures(body)) { sendJson(exchange, 400, Map.of("error", "validation")); return; } sendJson(exchange, 201, create(body)); return; } @@ -146,7 +151,7 @@ private void doHandle(HttpExchange exchange) throws IOException, SQLException { Map body = readBody(exchange); // FR-035 tristate + Program D nested VO validation: present-null on the @required // primaryMarker → 400; a present value must be a valid Marker (single or array) → 400. - if (!voValidForPatch(body)) { sendJson(exchange, 400, Map.of("error", "validation")); return; } + if (!voValidForPatch(body) || !canonicalizeMeasures(body)) { sendJson(exchange, 400, Map.of("error", "validation")); return; } Map row = patch(id, body); if (row == null) { sendJson(exchange, 404, Map.of("error", "not_found")); return; } sendJson(exchange, 200, row); @@ -158,7 +163,7 @@ private void doHandle(HttpExchange exchange) throws IOException, SQLException { private Map findById(long id) throws SQLException { try (Connection c = connect(); PreparedStatement ps = c.prepareStatement( - "SELECT id, title, payload, primary_marker, optional_marker, markers " + "SELECT id, title, payload, primary_marker, optional_marker, markers, measure, measures " + "FROM \"documents\" WHERE id = ?")) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { @@ -170,14 +175,16 @@ private Map findById(long id) throws SQLException { private Map create(Map body) throws SQLException { try (Connection c = connect(); PreparedStatement ps = c.prepareStatement( - "INSERT INTO \"documents\" (title, payload, primary_marker, optional_marker, markers) " - + "VALUES (?, ?, ?, ?, ?) " - + "RETURNING id, title, payload, primary_marker, optional_marker, markers")) { + "INSERT INTO \"documents\" (title, payload, primary_marker, optional_marker, markers, measure, measures) " + + "VALUES (?, ?, ?, ?, ?, ?, ?) " + + "RETURNING id, title, payload, primary_marker, optional_marker, markers, measure, measures")) { ps.setString(1, String.valueOf(body.get("title"))); bindJsonb(ps, 2, body.get("payload")); bindJsonb(ps, 3, body.get("primaryMarker")); bindJsonb(ps, 4, body.get("optionalMarker")); bindJsonb(ps, 5, body.get("markers")); + bindJsonb(ps, 6, body.get("measure")); + bindJsonb(ps, 7, body.get("measures")); try (ResultSet rs = ps.executeQuery()) { rs.next(); return rowFrom(rs); @@ -200,6 +207,8 @@ private Map patch(long id, Map body) throws SQLE addAssignment(body, "primaryMarker", "primary_marker", true, setClauses, values, jsonb); addAssignment(body, "optionalMarker", "optional_marker", true, setClauses, values, jsonb); addAssignment(body, "markers", "markers", true, setClauses, values, jsonb); + addAssignment(body, "measure", "measure", true, setClauses, values, jsonb); + addAssignment(body, "measures", "measures", true, setClauses, values, jsonb); if (!setClauses.isEmpty()) { String sql = "UPDATE \"documents\" SET " + String.join(", ", setClauses) + " WHERE id = ?"; try (Connection c = connect(); PreparedStatement ps = c.prepareStatement(sql)) { @@ -233,6 +242,8 @@ private Map rowFrom(ResultSet rs) throws SQLException { row.put("primaryMarker", parseJson(rs.getString("primary_marker"))); row.put("optionalMarker", parseJson(rs.getString("optional_marker"))); row.put("markers", parseJson(rs.getString("markers"))); + row.put("measure", parseJson(rs.getString("measure"))); + row.put("measures", parseJson(rs.getString("measures"))); return row; } @@ -300,6 +311,59 @@ private static boolean validMarkerArray(Object v) { return true; } + // --- Measure { ratio: double; samples: double[]; amount: decimal(12, 4) } --------------------- + + private static final int MEASURE_PRECISION = 12; + private static final int MEASURE_SCALE = 4; + + /** Validate and canonicalise the present Measure columns in place; false on a violation. */ + @SuppressWarnings("unchecked") + private static boolean canonicalizeMeasures(Map body) { + Object single = body.get("measure"); + if (body.containsKey("measure") && single != null) { + Map canon = canonicalMeasure(single); + if (canon == null) return false; + body.put("measure", canon); + } + Object many = body.get("measures"); + if (body.containsKey("measures") && many != null) { + if (!(many instanceof List list)) return false; + List out = new ArrayList<>(); + for (Object m : list) { + Map canon = canonicalMeasure(m); + if (canon == null) return false; + out.add(canon); + } + body.put("measures", out); + } + return true; + } + + /** A Measure with its decimal amount range-checked and written as its canonical string, or null. */ + @SuppressWarnings("unchecked") + private static Map canonicalMeasure(Object measure) { + if (!(measure instanceof Map m)) return null; + Map out = new LinkedHashMap<>((Map) m); + Object amount = out.get("amount"); + if (amount != null) { + java.math.BigDecimal d; + if (amount instanceof String str) { + if (!str.matches("-?\\d+(\\.\\d+)?")) return null; + d = new java.math.BigDecimal(str); + } else if (amount instanceof Number n && !(amount instanceof Double dd && (dd.isNaN() || dd.isInfinite()))) { + d = new java.math.BigDecimal(n.toString()); + } else { + return null; + } + java.math.BigDecimal stripped = d.signum() == 0 ? java.math.BigDecimal.ZERO : d.stripTrailingZeros(); + int scale = Math.max(stripped.scale(), 0); + int intDigits = stripped.precision() - stripped.scale(); + if (scale > MEASURE_SCALE || intDigits > MEASURE_PRECISION - MEASURE_SCALE) return null; + out.put("amount", stripped.toPlainString()); + } + return out; + } + // ----------------------------------------------------------------------- // helpers // ----------------------------------------------------------------------- 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 dfa474a14..a311898a9 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 @@ -57,6 +57,8 @@ public final class GeneratedDecimalControllerHarness implements AutoCloseable { private final Constructor entryControllerCtor; private final Constructor totalsRepoCtor; private final Constructor totalsControllerCtor; + private final Constructor sampleControllerCtor; + private final Constructor sampleRepoCtor; private TomcatHost host; @@ -77,12 +79,13 @@ public GeneratedDecimalControllerHarness(Path corpusRoot, Path genDir) throws Ex runGenerator(new SpringFilterAllowlistGenerator(), loader, srcDir); Path pkgDir = srcDir.resolve(PKG.replace('.', '/')); - for (String generated : List.of("EntryController", "EntryTotalsController")) { + for (String generated : List.of("EntryController", "SampleController", "EntryTotalsController")) { if (!Files.exists(pkgDir.resolve(generated + ".java"))) { throw new IllegalStateException("codegen did not emit " + generated + " under " + pkgDir); } } Files.writeString(pkgDir.resolve("OmdbEntryRepository.java"), OmdbEntryRepositorySource.SOURCE); + Files.writeString(pkgDir.resolve("OmdbSampleRepository.java"), OmdbEntryRepositorySource.sourceFor("Sample")); Files.writeString(pkgDir.resolve(OmdbReportRepositorySource.simpleName("EntryTotals") + ".java"), OmdbReportRepositorySource.source(PKG, "EntryTotals")); @@ -94,6 +97,11 @@ public GeneratedDecimalControllerHarness(Path corpusRoot, Path genDir) throws Ex .getDeclaredConstructor(entryRepo, ObjectMapper.class, Validator.class); this.entryRepoCtor = classLoader.loadClass(OmdbEntryRepositorySource.FQCN) .getDeclaredConstructor(ObjectManagerDB.class, MetaObject.class); + Class sampleRepo = classLoader.loadClass(PKG + ".SampleRepository"); + this.sampleControllerCtor = classLoader.loadClass(PKG + ".SampleController") + .getDeclaredConstructor(sampleRepo, ObjectMapper.class, Validator.class); + this.sampleRepoCtor = classLoader.loadClass(OmdbEntryRepositorySource.fqcnFor("Sample")) + .getDeclaredConstructor(ObjectManagerDB.class, MetaObject.class); Class totalsRepo = classLoader.loadClass(PKG + ".EntryTotalsRepository"); this.totalsControllerCtor = classLoader.loadClass(PKG + ".EntryTotalsController") .getDeclaredConstructor(totalsRepo); @@ -106,9 +114,11 @@ public void start(ObjectManagerDB omdb) throws Exception { MetaObject entry = find("Entry"); MetaObject totals = find("EntryTotals"); Object entryController = entryControllerCtor.newInstance(entryRepoCtor.newInstance(omdb, entry), mapper, validator); + Object sampleController = sampleControllerCtor.newInstance( + sampleRepoCtor.newInstance(omdb, find("Sample")), mapper, validator); Object totalsController = totalsControllerCtor.newInstance(totalsRepoCtor.newInstance(omdb, totals)); if (host != null) host.close(); - this.host = TomcatHost.start(mapper, entryController, totalsController); + this.host = TomcatHost.start(mapper, entryController, sampleController, totalsController); } private MetaObject find(String name) { diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java index af84b060e..6b5577b67 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java @@ -80,7 +80,7 @@ public Optional findById(Long id) { @Override public DocumentDto create(DocumentDto dto) { DocumentDto saved = new DocumentDto(nextId.getAndIncrement(), dto.title(), dto.payload(), - dto.primaryMarker(), dto.optionalMarker(), dto.markers()); + dto.primaryMarker(), dto.optionalMarker(), dto.markers(), dto.measure(), dto.measures()); rows.add(saved); return saved; } @@ -96,7 +96,9 @@ public Optional update(Long id, DocumentDto dto) { dto.payload() != null ? dto.payload() : cur.payload(), dto.primaryMarker() != null ? dto.primaryMarker() : cur.primaryMarker(), dto.optionalMarker() != null ? dto.optionalMarker() : cur.optionalMarker(), - dto.markers() != null ? dto.markers() : cur.markers()); + dto.markers() != null ? dto.markers() : cur.markers(), + dto.measure() != null ? dto.measure() : cur.measure(), + dto.measures() != null ? dto.measures() : cur.measures()); rows.set(i, merged); return Optional.of(merged); } @@ -117,7 +119,9 @@ public Optional patch(Long id, DocumentPatch patch) { patch.hasPayload() ? patch.payload() : cur.payload(), patch.hasPrimaryMarker() ? patch.primaryMarker() : cur.primaryMarker(), patch.hasOptionalMarker() ? patch.optionalMarker() : cur.optionalMarker(), - patch.hasMarkers() ? patch.markers() : cur.markers()); + patch.hasMarkers() ? patch.markers() : cur.markers(), + patch.hasMeasure() ? patch.measure() : cur.measure(), + patch.hasMeasures() ? patch.measures() : cur.measures()); rows.set(i, merged); return Optional.of(merged); } diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/OmdbEntryRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/OmdbEntryRepositorySource.java index b6c812483..da858d17e 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/OmdbEntryRepositorySource.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/OmdbEntryRepositorySource.java @@ -212,4 +212,30 @@ private EntryDto toDto(ValueObject vo) { } } """; + + /** The same seam for another entity of the corpus (same shape; only the names differ). */ + static String sourceFor(String entity) { + String source = SOURCE; + if (entity.equals("Sample")) { + // Sample carries `readings` (a decimal array) and `free` (a decimal with no @precision). + source = source + .replace("if (dto.bucket() != null) vo.setString(\"bucket\", dto.bucket());", "vo.setObject(\"readings\", dto.readings());") + .replace("if (dto.flagged() != null) vo.setBoolean(\"flagged\", dto.flagged());", "") + .replace("vo.setString(\"bucket\", dto.bucket());", "vo.setObject(\"readings\", dto.readings());") + .replace("vo.setObject(\"amount\", dto.amount());", "vo.setObject(\"free\", dto.free());") + .replace("vo.setBoolean(\"flagged\", Boolean.TRUE.equals(dto.flagged()));", "") + .replace("if (patch.hasBucket()) vo.setString(\"bucket\", patch.bucket());", "if (patch.hasReadings()) vo.setObject(\"readings\", patch.readings());") + .replace("if (patch.hasAmount()) vo.setObject(\"amount\", patch.amount());", "if (patch.hasFree()) vo.setObject(\"free\", patch.free());") + .replace("if (patch.hasFlagged()) vo.setBoolean(\"flagged\", Boolean.TRUE.equals(patch.flagged()));", ""); + } + return source + .replace("OmdbEntryRepository", "Omdb" + entity + "Repository") + .replace("EntryRepository", entity + "Repository") + .replace("EntryDto", entity + "Dto") + .replace("EntryPatch", entity + "Patch"); + } + + static String fqcnFor(String entity) { + return PKG + ".Omdb" + entity + "Repository"; + } } diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java index 0571efca6..da407743d 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java @@ -21,7 +21,7 @@ public class DocsMojo extends AbstractMojo { /** Where the change to a 1.0 pom is written down, before and after. */ static final String MIGRATION = - "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"Java and Kotlin\""; + "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"Java and Kotlin\""; /** What this goal prints. ADR-0034 Amendment 9 fixes the sentence for every port. */ static final String REMOVED = diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java index 405018216..3fd3c8a1d 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java @@ -27,7 +27,7 @@ public class RemovedDocsGoalTest { private static final String MIGRATION = - "See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"Java and Kotlin\"."; + "See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section \"Java and Kotlin\"."; @Rule public MojoRule rule = new MojoRule(); diff --git a/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java b/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java index ffbebdc60..0726e5bc0 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java +++ b/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java @@ -54,7 +54,7 @@ private ColumnNaming() {} * at the same page. */ public static final String PHYSICAL_NAMING_MIGRATION_GUIDE = - "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md"; + "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/physical-naming.md"; /** * The exact refusal text for a configuration source ADR-0058 removed — a pom generator diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/GenericSQLDriver.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/GenericSQLDriver.java index e15def1cd..d73080ecc 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/GenericSQLDriver.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/GenericSQLDriver.java @@ -280,6 +280,7 @@ protected void bindNativeArrayParameter(PreparedStatement s, int index, String e java.util.List list = (value instanceof java.util.List l) ? l : java.util.List.of(value); boolean uuid = "uuid".equalsIgnoreCase(elementType); + boolean numeric = "numeric".equalsIgnoreCase(elementType); Object[] elements = new Object[list.size()]; for (int i = 0; i < list.size(); i++) { Object e = list.get(i); @@ -287,6 +288,8 @@ protected void bindNativeArrayParameter(PreparedStatement s, int index, String e elements[i] = null; } else if (uuid) { elements[i] = (e instanceof java.util.UUID u) ? u : java.util.UUID.fromString(e.toString()); + } else if (numeric) { + elements[i] = (e instanceof java.math.BigDecimal d) ? d : new java.math.BigDecimal(e.toString()); } else { elements[i] = e.toString(); } @@ -311,10 +314,13 @@ protected void readNativeArrayInto(Object o, MetaField f, String elementType, Re } Object raw = sqlArr.getArray(); boolean uuid = "uuid".equalsIgnoreCase(elementType); + boolean numeric = "numeric".equalsIgnoreCase(elementType); java.util.List out = new ArrayList<>(); if (raw instanceof Object[] arr) { for (Object e : arr) { if (e == null) { out.add(null); continue; } + // numeric[] elements stay exact BigDecimals (never text, never a double). + if (numeric) { out.add(e instanceof java.math.BigDecimal d ? d : new java.math.BigDecimal(e.toString())); continue; } out.add(uuid ? e.toString().toLowerCase(java.util.Locale.ROOT) : e.toString()); } } diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/PostgresDriver.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/PostgresDriver.java index 45daeda22..56cf730fd 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/PostgresDriver.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/PostgresDriver.java @@ -209,6 +209,8 @@ protected int jsonbNullSqlType() { protected String nativeArrayElementType(com.metaobjects.field.MetaField f) { if (!f.isArrayType()) return null; if (com.metaobjects.field.UuidField.SUBTYPE_UUID.equals(f.getSubType())) return "uuid"; + // field.decimal isArray is a native NUMERIC[] (no bound on the elements; the wire layer range-checks them). + if (com.metaobjects.field.DecimalField.SUBTYPE_DECIMAL.equals(f.getSubType())) return "numeric"; if (com.metaobjects.field.StringField.SUBTYPE_STRING.equals(f.getSubType()) && !isOpenJsonbField(f)) { return "text"; diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 46f73fde7..ebbd7ba28 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -176,7 +176,7 @@ def __init__(self) -> None: #: The migration section every 1.1.0 refusal below points at (ADR-0034 Amendment 9). -_MIGRATION_SECTION = 'See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python".' +_MIGRATION_SECTION = 'See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python".' #: What ``metaobjects docs`` prints, exit 2: the command was removed in 1.1.0. DOCS_COMMAND_REMOVED = ( diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index 7e73e29ad..72987e644 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -39,6 +39,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from pathlib import Path from metaobjects.apidocs.naming import route_path as _route_path @@ -51,6 +52,7 @@ from metaobjects.codegen.instance_artifacts import has_item_route from metaobjects.source_resolution import primary_rdb_source from metaobjects.codegen.type_map import PyType, field_is_array, py_type_for +from metaobjects.codegen.value_objects import object_ref_target from metaobjects.meta.core.field import field_constants as fc from metaobjects.loader.validate_field_mutability import ( is_read_only_mutability, @@ -162,7 +164,7 @@ def _scalar_fields(entity: MetaObject) -> list[MetaField]: " if dto is None: # the body was not a JSON object: the cross-port 400, not FastAPI's 422", ' return JSONResponse(status_code=400, content={"error": "validation"})', " try:", - " dto = coerce_decimal_fields(dto, _DECIMAL_FIELDS, _DECIMAL_LIMITS)", + " dto = coerce_decimal_fields(dto, _DECIMAL_FIELDS, _DECIMAL_LIMITS, _NESTED_DECIMALS)", " except ValueError:", ' return JSONResponse(status_code=400, content={"error": "validation"})', ] @@ -202,17 +204,65 @@ def _decimal_limits(entities: list[MetaObject]) -> dict[str, tuple[int, int]]: for f in entity.fields(): if f.sub_type != fc.FIELD_SUBTYPE_DECIMAL or f.name in out: continue - precision = f.attrs().get(fc.FIELD_ATTR_PRECISION) - scale = f.attrs().get(fc.FIELD_ATTR_SCALE, 0) - if isinstance(precision, int) and not isinstance(precision, bool) and isinstance(scale, int): - out[f.name] = (precision, scale) + limit = _decimal_limit(f) + if limit is not None: + out[f.name] = limit + return out + + +def _decimal_limit(f: MetaField) -> tuple[int, int] | None: + precision = f.attrs().get(fc.FIELD_ATTR_PRECISION) + scale = f.attrs().get(fc.FIELD_ATTR_SCALE, 0) + if isinstance(precision, int) and not isinstance(precision, bool) and isinstance(scale, int): + return (precision, scale) + return None + + +def _nested_decimal_spec(vo: MetaObject, seen: frozenset[str]) -> dict[str, Any] | None: + """The decimal fields inside the value object *vo* (and the value objects it embeds), as + ``{"d": {name: (precision, scale) | None}, "o": {name: spec}}``, or ``None`` when it holds + none (ADR-0019 Amendment 1: a decimal inside a jsonb value object follows the top-level rule).""" + if vo.name in seen: + return None + seen = seen | {vo.name} + spec: dict[str, Any] = {"d": {}, "o": {}} + for f in vo.fields(): + if f.sub_type == fc.FIELD_SUBTYPE_DECIMAL: + spec["d"][f.name] = _decimal_limit(f) + elif f.sub_type == fc.FIELD_SUBTYPE_OBJECT: + target = object_ref_target(f) + inner = _nested_decimal_spec(target, seen) if target is not None else None + if inner is not None: + spec["o"][f.name] = inner + return spec if spec["d"] or spec["o"] else None + + +def _nested_decimals(entities: list[MetaObject]) -> dict[str, Any]: + """``{object field name: spec}`` for each jsonb value-object field of *entities* that + reaches a decimal (the first declaration of a name wins).""" + out: dict[str, Any] = {} + for entity in entities: + for f in entity.fields(): + if f.name in out or f.sub_type != fc.FIELD_SUBTYPE_OBJECT: + continue + target = object_ref_target(f) + spec = _nested_decimal_spec(target, frozenset()) if target is not None else None + if spec is not None: + out[f.name] = spec return out 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()) + "}" - return ["", "", f"_DECIMAL_LIMITS: dict[str, tuple[int, int]] = {body}"] + return [ + "", + "", + f"_DECIMAL_LIMITS: dict[str, tuple[int, int]] = {body}", + "", + "", + f"_NESTED_DECIMALS: dict[str, Any] = {_nested_decimals(entities)!r}", + ] def _required_field_names(entity: MetaObject) -> list[str]: diff --git a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py index b9d55c71e..d6b52a070 100644 --- a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py +++ b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py @@ -19,7 +19,7 @@ import dataclasses import json import re -from decimal import Decimal, InvalidOperation +from decimal import Context, Decimal, InvalidOperation from typing import Any, Iterable #: A string body value and a filter operand: the JSON-number grammar. An exponent is allowed on @@ -31,13 +31,22 @@ MAX_DIGITS = 1000 +_EXACT = Context(prec=MAX_DIGITS * 2 + 64) + + +def _normalize(value: Decimal) -> Decimal: + """``Decimal.normalize`` rounds to the context precision (28 digits); an unbounded decimal + must not lose digits, so normalize under a context wide enough for any in-range value.""" + return value.normalize(_EXACT) + + def format_decimal(value: Decimal) -> str: """W1: the canonical wire string of *value*.""" if not value.is_finite(): raise ValueError(f"{value} is not a finite decimal") if value == 0: return "0" - normalized = value.normalize() + normalized = _normalize(value) if abs(normalized.adjusted()) > MAX_DIGITS: raise ValueError("decimal out of range") return format(normalized, "f") @@ -113,7 +122,7 @@ def check_decimal_range(value: Decimal, precision: int, scale: int) -> None: """W3: raise ``ValueError`` when *value* cannot be held by ``DECIMAL(precision, scale)`` -- more fraction digits than *scale* or more integer digits than ``precision - scale``. Trailing fraction zeros do not count (``100.2500`` is ``100.25``); nothing is rounded.""" - d = value.normalize() + d = _normalize(value) exponent = d.as_tuple().exponent if not isinstance(exponent, int): raise ValueError("not a finite number") @@ -128,12 +137,24 @@ def coerce_decimal_fields( dto: dict[str, Any], decimal_fields: frozenset[str], limits: dict[str, tuple[int, int]] | None = None, + nested: dict[str, Any] | None = None, ) -> dict[str, Any]: """A copy of *dto* with each present decimal field parsed under W2 (``null`` stays ``None``) and held to its column's range under W3 (*limits*: name to (precision, scale)). A list (a decimal declared ``isArray``) is parsed and range-checked element by element. Raises ``ValueError`` naming the field when one is malformed or out of range.""" - out = dict(dto) + # parse_json_object reads EVERY non-integer number as a Decimal; only a declared decimal + # field keeps it. A double/float field, or a number inside a jsonb value object, goes back + # to the float a JSON number is for them (json.dumps cannot serialize a Decimal). + nested = nested or {} + out: dict[str, Any] = {} + for k, v in dto.items(): + if k in decimal_fields: + out[k] = v + elif k in nested: + out[k] = _nested(v, nested[k], k) + else: + out[k] = _floats(v) for name in decimal_fields: if name in out and out[name] is not None: limit = limits.get(name) if limits else None @@ -149,6 +170,45 @@ def coerce_decimal_fields( return out +def _nested(value: Any, spec: dict[str, Any], path: str) -> Any: + """A jsonb value-object field (*value* a dict, or a list of them) under the top-level + decimal rule: each declared decimal inside is parsed under W2, held to its range under W3 + (*spec*: ``{"d": {name: (precision, scale) | None}, "o": {name: spec}}``) and stored as its + canonical string; every other number is a float. Raises ``ValueError`` naming the path.""" + if isinstance(value, list): + return [_nested(v, spec, path) for v in value] + if not isinstance(value, dict): + return _floats(value) + out: dict[str, Any] = {} + for k, v in value.items(): + if k in spec["d"] and v is not None: + limit = spec["d"][k] + try: + out[k] = [None if e is None else _canonical(e, limit) for e in v] if isinstance(v, list) else _canonical(v, limit) + except ValueError as exc: + raise ValueError(f"{path}.{k}: {exc}") from None + elif k in spec["o"]: + out[k] = _nested(v, spec["o"][k], f"{path}.{k}") + else: + out[k] = _floats(v) + return out + + +def _canonical(value: Any, limit: tuple[int, int] | None) -> str: + return format_decimal(_coerce_decimal(value, limit)) + + +def _floats(value: Any) -> Any: + """*value* with every ``Decimal`` (at any depth of a dict or list) as a ``float``.""" + if isinstance(value, Decimal): + return float(value) + if isinstance(value, dict): + return {k: _floats(v) for k, v in value.items()} + if isinstance(value, list): + return [_floats(v) for v in value] + return value + + def _coerce_decimal(value: Any, limit: tuple[int, int] | None) -> Decimal: parsed = parse_decimal(value) if limit is not None: diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index 421bc6c4b..47c02628b 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -550,7 +550,12 @@ def _validate_attr_schema( errors.append( MetaError( f"{_node_label(node)} attribute '@{attr_node.name}' has value " - f"{raw_value!r} which does not match expected type '{effective_value_type}'", + f"{raw_value!r} which does not match expected type '{effective_value_type}'" + + ( + "; this attribute is integral, so write an integer (for a validator @min/@max, use an integer bound)" + if effective_value_type in ("int", "long") and isinstance(raw_value, float) + else "" + ), ErrorCode.ERR_BAD_ATTR_VALUE, envelope=node.source, ) diff --git a/server/python/src/metaobjects/naming.py b/server/python/src/metaobjects/naming.py index 12711b358..3ff359909 100644 --- a/server/python/src/metaobjects/naming.py +++ b/server/python/src/metaobjects/naming.py @@ -60,7 +60,7 @@ #: ADR-0058 Decision 4 — the migration guide every removed naming source points at. #: One named constant: every refusal site below quotes the SAME url. PHYSICAL_NAMING_MIGRATION_GUIDE_URL = ( - "https://github.com/metaobjectsdev/metaobjects/blob/main/" + "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/" "docs/features/migrations/physical-naming.md" ) diff --git a/server/python/src/metaobjects/runtime/object_manager.py b/server/python/src/metaobjects/runtime/object_manager.py index ba61da963..9e10f32d1 100644 --- a/server/python/src/metaobjects/runtime/object_manager.py +++ b/server/python/src/metaobjects/runtime/object_manager.py @@ -1028,6 +1028,8 @@ def _coerce_write_value(field: MetaField, value: Any) -> Any: # decimal / currency: a decimal authored as a string → Decimal (exact; never # via float). currency is integer minor units — already an int, pass through. if sub == fc.FIELD_SUBTYPE_DECIMAL: + if _field_is_array(field) and isinstance(value, list): + return [None if v is None else v if isinstance(v, _decimal.Decimal) else _decimal.Decimal(str(v)) for v in value] return value if isinstance(value, _decimal.Decimal) else _decimal.Decimal(str(value)) # uuid (logical field.uuid, or a string field pinned to a uuid column): diff --git a/server/python/tests/codegen/test_one_generator_list.py b/server/python/tests/codegen/test_one_generator_list.py index 9fe4cdde3..324f7a147 100644 --- a/server/python/tests/codegen/test_one_generator_list.py +++ b/server/python/tests/codegen/test_one_generator_list.py @@ -117,7 +117,7 @@ def test_the_removal_line_is_the_amendment_s() -> None: assert DOCS_COMMAND_REMOVED == ( "metaobjects docs was removed in 1.1.0. `metaobjects gen` writes documentation now: " "declare each docs generator in a target's `generators` in metaobjects.config.yaml. " - 'See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python".' + 'See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python".' ) @@ -148,7 +148,7 @@ def test_the_1_0_shape_line_is_the_amendment_s() -> None: "metaobjects gen: found 1.0 docs configuration (the docs block in " "metaobjects.config.yaml). Nothing reads it any more, so this run would write no " "documentation. Declare each docs generator in a target's `generators` in " - "metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, " + "metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, " 'section "Python".' ) @@ -176,7 +176,7 @@ def test_no_config_fails_and_writes_nothing( def test_the_no_list_line_is_the_amendment_s() -> None: assert no_generator_list_message("metaobjects gen") == ( "metaobjects gen: no metaobjects.config.yaml. The generator list lives there in 1.1.0, " - 'documentation included. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python".' + 'documentation included. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python".' ) @@ -391,7 +391,7 @@ def test_a_target_with_no_generators_key_is_refused_with_the_migration_line( assert capsys.readouterr().err.strip() == ( f"{name}: target(s) 'api' declare no `generators` key. In 1.1.0 a target runs only the " "generators it lists: list them, or write `generators: []` for a target that writes " - 'nothing. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python".' + 'nothing. See https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/docs-are-generators.md, section "Python".' ) assert not (tmp_path / "gen").exists() diff --git a/server/python/tests/integration/api_contract_jsonb_server.py b/server/python/tests/integration/api_contract_jsonb_server.py index 0d58a0a12..754e73b44 100644 --- a/server/python/tests/integration/api_contract_jsonb_server.py +++ b/server/python/tests/integration/api_contract_jsonb_server.py @@ -28,6 +28,8 @@ from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse +from metaobjects.codegen.runtime.decimal_wire import check_decimal_range, format_decimal, parse_decimal + from .postgres_container import PostgresInfo @@ -44,6 +46,10 @@ # Value-object columns, split by cardinality (drives present-value validation). _VO_SINGLE_FIELDS: tuple[str, ...] = ("primaryMarker", "optionalMarker") _VO_ARRAY_FIELDS: tuple[str, ...] = ("markers",) +# Measure { ratio: double; samples: double[]; amount: decimal(12, 4) } columns. +_MEASURE_SINGLE = "measure" +_MEASURE_ARRAY = "measures" +_MEASURE_PRECISION, _MEASURE_SCALE = 12, 4 # Wire name → physical (snake_case) column. Every non-title column is jsonb. _COLUMN: dict[str, str] = { @@ -52,8 +58,12 @@ "primaryMarker": "primary_marker", "optionalMarker": "optional_marker", "markers": "markers", + "measure": "measure", + "measures": "measures", } -_JSONB_FIELDS: frozenset[str] = frozenset({"payload", "primaryMarker", "optionalMarker", "markers"}) +_JSONB_FIELDS: frozenset[str] = frozenset( + {"payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures"} +) class DocumentRepository: @@ -70,7 +80,9 @@ def create_schema(self) -> None: " payload JSONB," " primary_marker JSONB NOT NULL," " optional_marker JSONB," - " markers JSONB" + " markers JSONB," + " measure JSONB," + " measures JSONB" ")" ) @@ -88,8 +100,8 @@ def apply_seed(self, rows: list[dict[str, Any]]) -> None: for r in rows: cur.execute( 'INSERT INTO "documents" ' - "(id, title, payload, primary_marker, optional_marker, markers) " - "VALUES (%s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb)", + "(id, title, payload, primary_marker, optional_marker, markers, measure, measures) " + "VALUES (%s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb)", ( int(r["id"]), r["title"], @@ -97,6 +109,8 @@ def apply_seed(self, rows: list[dict[str, Any]]) -> None: _dump_payload(r.get("primaryMarker")), _dump_payload(r.get("optionalMarker")), _dump_payload(r.get("markers")), + _dump_payload(r.get("measure")), + _dump_payload(r.get("measures")), ), ) cur.execute( @@ -112,7 +126,7 @@ def find_by_id(self, ident: int) -> dict[str, Any] | None: cur = conn.cursor() try: cur.execute( - "SELECT id, title, payload, primary_marker, optional_marker, markers " + "SELECT id, title, payload, primary_marker, optional_marker, markers, measure, measures " 'FROM "documents" WHERE id = %s', (int(ident),), ) @@ -127,14 +141,16 @@ def create(self, dto: dict[str, Any]) -> dict[str, Any]: try: cur.execute( 'INSERT INTO "documents" ' - "(title, payload, primary_marker, optional_marker, markers) " - "VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb) RETURNING id", + "(title, payload, primary_marker, optional_marker, markers, measure, measures) " + "VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb) RETURNING id", ( dto.get("title"), _dump_payload(dto.get("payload")), _dump_payload(dto.get("primaryMarker")), _dump_payload(dto.get("optionalMarker")), _dump_payload(dto.get("markers")), + _dump_payload(dto.get("measure")), + _dump_payload(dto.get("measures")), ), ) new_id = int(cur.fetchone()[0]) @@ -217,7 +233,7 @@ def create_document(dto: dict[str, Any]) -> Any: if dto.get(name) is None: return _validation_400() # Present values validated with the create rules (title @maxLength + nested VO). - if _title_violation(dto) or _vo_present_violation(dto): + if _title_violation(dto) or _vo_present_violation(dto) or not _canonicalize_measures(dto): return _validation_400() return repo.create(dto) @@ -231,7 +247,7 @@ def update_document(document_id: int, dto: dict[str, Any]) -> Any: if name in dto and dto[name] is None: return _validation_400() # Validate PRESENT, non-null values (nested VO recurses into each element). - if _title_violation(dto) or _vo_present_violation(dto): + if _title_violation(dto) or _vo_present_violation(dto) or not _canonicalize_measures(dto): return _validation_400() saved = repo.update(document_id, dto) if saved is None: @@ -283,6 +299,42 @@ def _vo_present_violation(dto: dict[str, Any]) -> bool: return False +def _canonical_measure(measure: Any) -> dict[str, Any] | None: + """A ``Measure`` with its decimal ``amount`` canonicalised (W1 to W3), or ``None`` when the + value is not a valid Measure. What is stored is the canonical string.""" + if not isinstance(measure, dict): + return None + out = dict(measure) + amount = out.get("amount") + if amount is not None: + try: + d = parse_decimal(amount) + check_decimal_range(d, _MEASURE_PRECISION, _MEASURE_SCALE) + out["amount"] = format_decimal(d) + except (ValueError, ArithmeticError): + return None + return out + + +def _canonicalize_measures(dto: dict[str, Any]) -> bool: + """Validate and canonicalise the present Measure columns in place; False on a violation.""" + single = dto.get(_MEASURE_SINGLE) + if _MEASURE_SINGLE in dto and single is not None: + canon = _canonical_measure(single) + if canon is None: + return False + dto[_MEASURE_SINGLE] = canon + many = dto.get(_MEASURE_ARRAY) + if _MEASURE_ARRAY in dto and many is not None: + if not isinstance(many, list): + return False + canon_list = [_canonical_measure(m) for m in many] + if any(c is None for c in canon_list): + return False + dto[_MEASURE_ARRAY] = canon_list + return True + + def _dump_payload(value: Any) -> Any: """jsonb bind: a dict/list is ``json.dumps``-serialized for the ``::jsonb`` cast; ``None`` stays ``None`` (SQL NULL); a pre-serialized string passes @@ -306,7 +358,7 @@ def _decode_jsonb(value: Any) -> Any: def _row_to_dict(row: Any) -> dict[str, Any]: - ident, title, payload, primary_marker, optional_marker, markers = row + ident, title, payload, primary_marker, optional_marker, markers, measure, measures = row return { "id": int(ident), "title": title, @@ -314,4 +366,6 @@ def _row_to_dict(row: Any) -> dict[str, Any]: "primaryMarker": _decode_jsonb(primary_marker), "optionalMarker": _decode_jsonb(optional_marker), "markers": _decode_jsonb(markers), + "measure": _decode_jsonb(measure), + "measures": _decode_jsonb(measures), } diff --git a/server/python/tests/integration/report_lane.py b/server/python/tests/integration/report_lane.py index c02a7cab5..3fc4f672f 100644 --- a/server/python/tests/integration/report_lane.py +++ b/server/python/tests/integration/report_lane.py @@ -61,11 +61,26 @@ def reseed(self, *, empty: bool) -> None: # An explicit id does not advance an identity column: continue after the seed # (or restart at 1 when the table was left empty), as the other engines do. for table, seed_rows in self.base_tables.items(): - if _has_id(seed_rows): + if not seed_rows: + # An empty seed (the decimal corpus' ``samples``) restarts at 1 so the + # first POST is id 1; only if the table has a generated ``id``. cur.execute( - f"SELECT setval(pg_get_serial_sequence('{d.quote(table)}', 'id'), " - f"COALESCE((SELECT MAX({d.quote('id')}) FROM {d.quote(table)}), 0) + 1, false)" + f"SELECT 1 FROM information_schema.columns WHERE table_name = '{table}' " + "AND column_name = 'id' AND (is_identity = 'YES' OR column_default LIKE 'nextval%')" ) + if cur.fetchone() is None: + continue + elif not _has_id(seed_rows): + continue + cur.execute( + f"SELECT setval(pg_get_serial_sequence('{d.quote(table)}', 'id'), " + f"COALESCE((SELECT MAX({d.quote('id')}) FROM {d.quote(table)}), 0) + 1, false)" + ) + elif self.engine == "mysql": + # DELETE leaves AUTO_INCREMENT where it was: restart an empty-seeded table at 1. + for table, seed_rows in self.base_tables.items(): + if not seed_rows: + cur.execute(f"ALTER TABLE {d.quote(table)} AUTO_INCREMENT = 1") self.conn.commit() finally: cur.close() diff --git a/server/python/tests/integration/test_api_contract_decimal.py b/server/python/tests/integration/test_api_contract_decimal.py index 163cc2b4f..a9f8aa4df 100644 --- a/server/python/tests/integration/test_api_contract_decimal.py +++ b/server/python/tests/integration/test_api_contract_decimal.py @@ -34,7 +34,7 @@ for p in [Path(__file__).resolve(), *Path(__file__).resolve().parents] if (p / "fixtures" / "api-contract-conformance" / "decimal").is_dir() ) -_MODEL = LaneModel(reports=(("EntryTotals", "entry_totals"),), entities=(("Entry", "entry"),)) +_MODEL = LaneModel(reports=(("EntryTotals", "entry_totals"),), entities=(("Entry", "entry"), ("Sample", "sample"))) _KNOWN_ENGINES = {"postgres", "mysql", "sqlite", "d1"} #: The fields the model declares decimal: Entry.amount and the EntryTotals measures that #: sum, average, take a maximum or divide a decimal. diff --git a/server/python/tests/integration/test_generated_decimal_wire.py b/server/python/tests/integration/test_generated_decimal_wire.py index a90f3abed..98702e134 100644 --- a/server/python/tests/integration/test_generated_decimal_wire.py +++ b/server/python/tests/integration/test_generated_decimal_wire.py @@ -190,3 +190,119 @@ def test_a_value_beyond_the_declared_range_is_a_400_and_nothing_is_rounded(clien resp = _post(client, '{"amount": ' + bad + "}") assert resp.status_code == 400 and resp.json() == {"error": "validation"} assert client.get("/api/entries").json() == [] + + +_FLOAT_META = { + "metadata.root": { + "package": "acme::fleet", + "children": [ + {"object.value": {"name": "Fix", "children": [ + {"field.double": {"name": "latitude", "@required": True}}, + {"field.double": {"name": "readings", "isArray": True}}, + {"field.decimal": {"name": "amount", "@precision": 12, "@scale": 4}}, + ]}}, + {"object.entity": {"name": "Sample", "children": [ + {"source.rdb": {"@table": "samples"}}, + {"field.long": {"name": "id"}}, + {"field.double": {"name": "score"}}, + {"field.decimal": {"name": "series", "isArray": True, "@precision": 9, "@scale": 4}}, + {"field.object": {"name": "fix", "@objectRef": "Fix", "@storage": "jsonb"}}, + {"field.object": {"name": "fixes", "@objectRef": "Fix", "@storage": "jsonb", "isArray": True}}, + {"identity.primary": {"name": "pk", "@fields": ["id"], "@generation": "increment"}}, + ]}}, + ], + } +} + + +@pytest.fixture() +def float_client() -> TestClient: + root = load_string(json.dumps(_FLOAT_META)).root + objs = {c.name.split("::")[-1]: c for c in root.children()} + pkg = f"genflt_{uuid.uuid4().hex[:8]}" + tmp = Path(tempfile.mkdtemp(prefix="genflt-")) + pkg_dir = tmp / pkg + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + (pkg_dir / "Fix.py").write_text(render_entity_model(objs["Fix"])) + 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)) + db = sqlite3.connect(":memory:", check_same_thread=False) + db.execute("CREATE TABLE samples (id INTEGER PRIMARY KEY AUTOINCREMENT, score REAL, series TEXT, fix TEXT, fixes TEXT)") + om = ObjectManager(root, SqliteDriver(db)) # type: ignore[arg-type] + + class Repo(_Repo): + def list(self, limit: int, offset: int, sort: Any, filters: list[FilterPredicate]) -> list[Any]: + return self.om.find_many("Sample", None, sort=[("id", "asc")], limit=limit, offset=offset) + + def find_by_id(self, id: int) -> Any: + return self.om.find_by_id("Sample", id) + + def create(self, dto: Any) -> Any: + return self.om.create("Sample", dto) + + def update(self, id: int, dto: Any) -> Any: + return self.om.update("Sample", id, dto) + + app = FastAPI() + app.include_router(mod.router) + app.dependency_overrides[mod.get_repository] = lambda: Repo(om) + return TestClient(app) + + +def test_a_double_and_a_jsonb_value_object_take_fractional_numbers_on_every_write(float_client: TestClient) -> None: + # parse_json_object reads every fractional number as a Decimal; only a declared decimal + # keeps it. A double, or a number inside a jsonb value object / array, is a float again -- + # json.dumps of the jsonb column used to fail with a 500. + body = '{"score": 1.5, "fix": {"latitude": 51.25, "readings": [0.5, 2]}, "fixes": [{"latitude": 1.75}]}' + hdr = {"content-type": "application/json"} + created = float_client.post("/api/samples", content=body, headers=hdr) + assert created.status_code == 201, created.text + assert created.json()["score"] == 1.5 + assert created.json()["fix"]["latitude"] == 51.25 + for send in (float_client.patch, float_client.put): + resp = send("/api/samples/1", content='{"score": 2.5, "fix": {"latitude": 3.125}}', headers=hdr) + assert resp.status_code == 200, resp.text + assert resp.json()["score"] == 2.5 and resp.json()["fix"]["latitude"] == 3.125 + + +def test_a_decimal_inside_a_jsonb_value_object_follows_the_top_level_rule(float_client: TestClient) -> None: + # C1: a string or a JSON number in, read exactly, stored and emitted as the canonical string. + hdr = {"content-type": "application/json"} + ok = float_client.post( + "/api/samples", + content='{"fix": {"latitude": 1.5, "amount": "100.5000"}, "fixes": [{"latitude": 2.5, "amount": 2.50}, {"latitude": 3, "amount": null}]}', + headers=hdr, + ) + assert ok.status_code == 201, ok.text + body = ok.json() + assert body["fix"] == {"latitude": 1.5, "amount": "100.5", "readings": None} or body["fix"]["amount"] == "100.5" + assert [f["amount"] for f in body["fixes"]] == ["2.5", None] + assert float_client.get("/api/samples/1").json()["fix"]["amount"] == "100.5" + for bad in ('"0.12345"', '"abc"', '"123456789.5"'): + resp = float_client.post("/api/samples", content='{"fix": {"latitude": 1, "amount": ' + bad + "}}", headers=hdr) + assert resp.status_code == 400 and resp.json() == {"error": "validation"}, (bad, resp.text) + assert len(float_client.get("/api/samples").json()) == 1 + + +def test_a_decimal_array_is_range_checked_element_wise_and_coerced_for_the_driver(float_client: TestClient) -> None: + # SQLite has no array column, so the write itself is covered on Postgres by the shared + # api-contract decimal corpus; here: the route refuses an excess-scale element (nothing + # persisted) and the runtime codec turns a list into a list of Decimals (it used to + # Decimal(str(list)) and raise InvalidOperation -> 500). + hdr = {"content-type": "application/json"} + bad = float_client.post("/api/samples", content='{"series": ["1", "0.12345"]}', headers=hdr) + assert bad.status_code == 400 and bad.json() == {"error": "validation"} + root = load_string(json.dumps(_FLOAT_META)).root + sample = next(c for c in root.children() if c.name.endswith("Sample")) + series = next(f for f in sample.fields() if f.name == "series") + 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] diff --git a/server/python/tests/runtime/test_decimal_wire.py b/server/python/tests/runtime/test_decimal_wire.py index 2dd52e024..f7cfcd1b9 100644 --- a/server/python/tests/runtime/test_decimal_wire.py +++ b/server/python/tests/runtime/test_decimal_wire.py @@ -143,3 +143,17 @@ def test_a_decimal_array_is_coerced_and_range_checked_element_by_element() -> No for bad in (["1", "0.12345"], ["100000"], ["1", "12abc"], [True]): with pytest.raises(ValueError, match="readings"): coerce_decimal_fields({"readings": bad}, fields, limits) + + +def test_a_non_decimal_number_in_a_body_is_a_float_not_a_decimal() -> None: + # parse_json_object reads every non-integer number as a Decimal; only a declared decimal + # field keeps it. A double field and a number nested in a jsonb value object (or an array + # of them) must come back as floats, or json.dumps of the jsonb column fails. + body = parse_json_object( + '{"amount": 1.50, "weight": 2.5, "telemetry": {"latitude": 51.5, "tags": [1.25, 3]}}' + ) + coerced = coerce_decimal_fields(body, frozenset({"amount"})) + assert coerced["amount"] == Decimal("1.50") + assert coerced["weight"] == 2.5 and isinstance(coerced["weight"], float) + assert coerced["telemetry"] == {"latitude": 51.5, "tags": [1.25, 3]} + json.dumps(coerced["telemetry"]) diff --git a/server/python/tests/unit/test_column_naming_strategy.py b/server/python/tests/unit/test_column_naming_strategy.py index 3d2494802..63bb83daf 100644 --- a/server/python/tests/unit/test_column_naming_strategy.py +++ b/server/python/tests/unit/test_column_naming_strategy.py @@ -120,5 +120,5 @@ def test_object_manager_column_naming_argument_is_refused() -> None: 'project setting now, read by every port: "naming" in .metaobjects/config.json, for ' 'example "naming": { "columns": "literal" }. Move it there and delete ' "ObjectManager(column_naming=...). See " - "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/physical-naming.md" + "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/physical-naming.md" ) diff --git a/server/typescript/packages/cli/README.md b/server/typescript/packages/cli/README.md index 232d4358e..e75834cce 100644 --- a/server/typescript/packages/cli/README.md +++ b/server/typescript/packages/cli/README.md @@ -4,6 +4,8 @@ The MetaObjects CLI — scaffolds `metaobjects/` + `.metaobjects/`, runs codegen **Status:** v0.3. TS reference implementation with Projects D–G shipped end-to-end. +**Upgrading from 1.0.x:** see the [1.0 to 1.1 upgrade guide](https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/upgrading-1.0-to-1.1.md) before you regenerate. It lists every step and change that needs action, per port. + ## Install ```bash diff --git a/server/typescript/packages/codegen-ts/README.md b/server/typescript/packages/codegen-ts/README.md index 92959bd81..5cd9c8896 100644 --- a/server/typescript/packages/codegen-ts/README.md +++ b/server/typescript/packages/codegen-ts/README.md @@ -3,6 +3,8 @@ TypeScript codegen for the metaobjects metamodel — emits Drizzle schema + inferred types + Zod validators + typed CRUD queries from a loaded `MetaData`. +**Upgrading from 1.0.x:** see the [1.0 to 1.1 upgrade guide](https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/upgrading-1.0-to-1.1.md) before you regenerate. It lists every step and change that needs action, per port. + ## Install ```bash diff --git a/server/typescript/packages/codegen-ts/src/templates/decimal-wire-helper.ts b/server/typescript/packages/codegen-ts/src/templates/decimal-wire-helper.ts index c2b3a8422..161042ed2 100644 --- a/server/typescript/packages/codegen-ts/src/templates/decimal-wire-helper.ts +++ b/server/typescript/packages/codegen-ts/src/templates/decimal-wire-helper.ts @@ -17,7 +17,11 @@ export const DECIMAL_WRITE_HELPER_DECL = [ " * number, rewritten to its canonical string (plain notation, no trailing fraction zeros, zero as \"0\").", " * Garbage, or a value the column cannot hold without rounding, is a validation issue. Generated. */", `function ${DECIMAL_WRITE_HELPER}(v: string | number, ctx: z.RefinementCtx, precision?: number, scale = 0): string {`, - ' const text = typeof v === "number" ? (Number.isFinite(v) ? v.toPrecision(15) : "") : v;', + ' if (typeof v === "number" && Number.isFinite(v) && !Number.isSafeInteger(v) && Number(v.toPrecision(15)) !== v) {', + ' ctx.addIssue({ code: "custom", message: "Decimal number cannot be read exactly as a JSON number; send it as a string" });', + " return z.NEVER;", + " }", + ' const text = typeof v === "number" ? (Number.isFinite(v) ? String(v) : "") : v;', " const m = /^(-?)(0|[1-9][0-9]*)(?:\\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(text);", " const e = m === null || m[4] === undefined ? 0 : Number(m[4]);", " if (m === null || !Number.isSafeInteger(e) || Math.abs(e) > 1000) {", diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index c8fc21241..246aac296 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -777,9 +777,10 @@ function zodFieldExpr( case FIELD_SUBTYPE_DECIMAL: { // ADR-0019 Amendment 1 (W2, W3): a WRITE shape takes a decimal as a string in the // JSON-number grammar or a JSON number, stores its canonical string, and refuses what the - // column cannot hold. The TPH read shape parses rows the database already holds, and a value - // object's member is JSON that stores whatever it is given, so both keep a bare string. - if (readShape || owner?.subType === OBJECT_SUBTYPE_VALUE) { + // column cannot hold. A decimal inside a jsonb value object follows the same rule (captain + // ruling C1: its @precision / @scale bound it, the stored JSON holds the canonical string). + // Only the TPH read shape, which parses rows the database already holds, keeps a bare string. + if (readShape) { baseStr = "z.string()"; break; } diff --git a/server/typescript/packages/codegen-ts/test/decimal-vo-e2e.test.ts b/server/typescript/packages/codegen-ts/test/decimal-vo-e2e.test.ts new file mode 100644 index 000000000..74ddb6de2 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/decimal-vo-e2e.test.ts @@ -0,0 +1,101 @@ +// A field.decimal inside a jsonb value object follows the top-level decimal rule (captain ruling +// C1, ADR-0019 Amendment 1): a string or an exact JSON number in, checked against the VO field's +// @precision / @scale, stored and answered as the canonical string. Generated code executed. + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { 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 } from "drizzle-orm/sqlite-core"; +import { MetaDataLoader, InMemoryStringSource, type MetaObject } 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"; + +const MODEL = { + "metadata.root": { package: "ledger", children: [ + { "object.value": { name: "Money", children: [ + { "field.string": { name: "label" } }, + { "field.decimal": { name: "amount", "@precision": 9, "@scale": 4, "@required": true } }, + { "field.decimal": { name: "free" } }, + ]}}, + { "object.entity": { name: "Invoice", children: [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { name: "id" } }, + { "field.object": { name: "total", "@objectRef": "Money", "@storage": "jsonb" } }, + { "field.object": { name: "lines", "@objectRef": "Money", isArray: true, "@storage": "jsonb" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ]}}, + ]}, +}; + +const invoices = sqliteTable("invoices", { + id: integer("id").primaryKey({ autoIncrement: true }), + total: text("total", { mode: "json" }), + lines: text("lines", { mode: "json" }), +}); + +const dir = mkdtempSync(join(import.meta.dir, "tmp-decimal-vo-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +async function execute(name: string, source: string): Promise> { + const file = join(dir, `${name}.ts`); + writeFileSync(file, source); + return import(pathToFileURL(file).href); +} + +let app: Hono; +beforeAll(async () => { + const { root, errors } = await new MetaDataLoader({ strict: true }).load([ + new InMemoryStringSource(JSON.stringify(MODEL), { id: "ledger.json" }), + ]); + expect(errors).toEqual([]); + const money = root.objects().find((o) => o.name === "Money") as MetaObject; + const invoice = root.objects().find((o) => o.name === "Invoice") as MetaObject; + const moneyText = renderInsertSchemaOnly(money).toString(); + expect(moneyText).toContain("function decimalWrite"); + await execute("Money", moneyText); + const schemas = await execute("schemas", renderZodValidators(invoice).toString()); + const allowlists = await execute("allowlist", renderFilterAllowlist(invoice).toString()); + const sqlite = new Database(":memory:"); + sqlite.run("CREATE TABLE invoices (id INTEGER PRIMARY KEY AUTOINCREMENT, total TEXT, lines TEXT)"); + app = new Hono(); + mountCrudRoutes({ + app, path: "/invoices", db: drizzle(sqlite), table: invoices, + insertSchema: schemas["InvoiceInsertSchema"] as never, + updateSchema: schemas["InvoiceUpdateSchema"] as never, + filterAllowlist: allowlists["InvoiceFilterAllowlist"] as never, + sortAllowlist: {}, dialect: "sqlite", + }); +}); + +const post = (body: unknown) => + app.request("/invoices", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + +describe("a decimal inside a jsonb value object", () => { + test("a string and an exact number are stored and answered as the canonical string", async () => { + const res = await post({ total: { amount: 100.25, free: "1.50" }, lines: [{ amount: "2.5000" }, { amount: 0.3 }] }); + expect(res.status).toBe(201); + const body = (await res.json()) as { total: unknown; lines: unknown }; + expect(body.total).toEqual({ amount: "100.25", free: "1.5" }); + expect(body.lines).toEqual([{ amount: "2.5" }, { amount: "0.3" }]); + }); + + test("a value the member's @precision / @scale cannot hold is a 400", async () => { + for (const amount of ["0.12345", "100000", "12abc", true]) { + expect((await post({ total: { amount } })).status).toBe(400); + expect((await post({ lines: [{ amount }] })).status).toBe(400); + } + }); + + test("a number a double cannot hold exactly is refused, not rounded", async () => { + const res = await post({ total: { amount: 1, free: 1234567890.123456789 } }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe("validation"); + // the same digits as a string stay exact + expect((await post({ total: { amount: 1, free: "1234567890.123456789" } })).status).toBe(201); + }); +}); 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 9f711b33c..2111adb62 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 @@ -113,6 +113,16 @@ describe("generated decimal write schema and route, SQLite", () => { } }); + test("a JSON number a double cannot hold exactly is a 400 telling the client to send a string, never rounded", async () => { + // JSON.stringify of the double, so the wire carries the rounded digits a real client's body would + for (const amount of [0.1 + 0.2, 1234.56789012345678]) { + const res = await post({ bucket: "inexact", amount, weight: "1" }); + expect(res.status).toBe(400); + expect(JSON.stringify(await res.json())).toContain("send it as a string"); + } + expect((await post({ bucket: "inexact", weight: 9007199254740991 })).status).toBe(201); + }); + test("a decimal list answers strings, never numbers, and filters by number", async () => { const rows = (await (await app.request("/entries?filter[bucket][eq]=a")).json()) as Array<{ amount: unknown }>; expect(rows.map((r) => r.amount)).toEqual(["0.1", "0.3", "100.25", "15", "0"]); diff --git a/server/typescript/packages/codegen-ts/test/decimal-write-schema.test.ts b/server/typescript/packages/codegen-ts/test/decimal-write-schema.test.ts index 13338d490..d1630af1c 100644 --- a/server/typescript/packages/codegen-ts/test/decimal-write-schema.test.ts +++ b/server/typescript/packages/codegen-ts/test/decimal-write-schema.test.ts @@ -50,12 +50,29 @@ describe("generated decimal write schema", () => { else expect(r.success ? r.data : r.error).toBe(v.value as string); }); } + // double15 is the READ rule (a SQLite double rendered at 15 digits). On INPUT a JSON number is + // read exactly or refused (D3): the vectors whose shortest form carries more than 15 significant + // digits (17, 16 and 17 here, counted by hand) fail. + const refusedAsInput = new Set(["0.30000000000000004", "0.3333333333333333", "123456789012345680"]); for (const v of vectors.double15) { - test(`double15 ${v.double}`, () => { - expect(open.parse(Number(v.double))).toBe(v.out); + const n = Number(v.double); + const exact = !refusedAsInput.has(v.double); + test(`double15 ${v.double} as input is ${exact ? "read exactly" : "refused"}`, () => { + if (exact) expect(open.parse(n)).toBe(v.out); + else expect(open.safeParse(n).success).toBe(false); }); } + test("D3: a JSON number a double cannot hold exactly is refused with a message that says to send a string", () => { + const r = open.safeParse(JSON.parse("1234567890.123456789")); + expect(r.success).toBe(false); + expect(JSON.stringify(r.success ? "" : r.error.issues)).toContain("send it as a string"); + expect(open.safeParse(0.1 + 0.2).success).toBe(false); + expect(open.parse(9007199254740991)).toBe("9007199254740991"); + expect(open.parse(1234567890123456)).toBe("1234567890123456"); + expect(open.parse("1234567890.123456789")).toBe("1234567890.123456789"); + }); + test("W3: a value the declared column cannot hold is refused, never rounded", () => { const col = schemaFor(28, 10); expect(col.parse("12345678901234567.89")).toBe("12345678901234567.89"); diff --git a/server/typescript/packages/integration-tests/src/api-contract-decimal-schema.ts b/server/typescript/packages/integration-tests/src/api-contract-decimal-schema.ts index 29a522a13..d14cc57f3 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-decimal-schema.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-decimal-schema.ts @@ -27,6 +27,12 @@ const MYSQL_TABLES = [ \`bucket\` VARCHAR(8) NOT NULL, \`amount\` DECIMAL(28,10), \`flagged\` BOOLEAN NOT NULL +)`, + // MySQL has no array column and no unbounded decimal: readings is JSON (strings), free is the widest DECIMAL. + `CREATE TABLE \`samples\` ( + \`id\` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`readings\` JSON, + \`free\` DECIMAL(65,30) )`, ]; diff --git a/server/typescript/packages/integration-tests/src/api-contract-decimal-server.ts b/server/typescript/packages/integration-tests/src/api-contract-decimal-server.ts index ca0f445a0..50d42e322 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-decimal-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-decimal-server.ts @@ -32,7 +32,10 @@ import { splitStatements } from "./sql-script.ts"; export type DecimalEngine = "postgres" | "mysql" | "sqlite" | "d1"; /** The served objects: the emitted routes file and the route the corpus calls. */ -const SERVED = ["Entry", "EntryTotals"] as const; +const SERVED = ["Entry", "EntryTotals", "Sample"] as const; + +/** The tables the seed fills (and resets), in the order they are emptied. */ +const TABLES = ["entries", "samples"] as const; export interface DecimalServerHandle { baseUrl: string; @@ -103,7 +106,9 @@ export async function startDecimalServer( return { baseUrl, applySeed: async (seed: ReportSeed) => { - await client.run(engine === "postgres" ? `TRUNCATE TABLE "entries" RESTART IDENTITY` : engine === "mysql" ? "TRUNCATE TABLE `entries`" : `DELETE FROM "entries"`); + for (const table of TABLES) { + await client.run(engine === "postgres" ? `TRUNCATE TABLE "${table}" RESTART IDENTITY` : engine === "mysql" ? `TRUNCATE TABLE \`${table}\`` : `DELETE FROM "${table}"`); + } for (const r of seedInserts(root, seed)) { const q = engine === "mysql" ? "`" : '"'; await client.run( @@ -142,7 +147,10 @@ export const db = drizzle(pool); await admin.query(sql.replace(/\?/g, () => `$${++n}`), params); }, afterSeed: async () => { - await admin.query(`SELECT setval(pg_get_serial_sequence('"entries"', 'id'), (SELECT COALESCE(MAX("id"), 1) FROM "entries"))`); + // An empty table must hand out id 1 next (is_called false); a seeded one continues after its highest id. + for (const table of TABLES) { + await admin.query(`SELECT setval(pg_get_serial_sequence('"${table}"', 'id'), (SELECT COALESCE(MAX("id"), 1) FROM "${table}"), (SELECT COUNT(*) > 0 FROM "${table}"))`); + } }, close: async () => { await mod.pool.end(); await admin.end(); }, }; @@ -160,6 +168,7 @@ export const db = drizzle(pool); // A previous server on this database may have left the objects behind. await admin.query("DROP VIEW IF EXISTS `v_entry_totals`"); await admin.query("DROP TABLE IF EXISTS `entries`"); + await admin.query("DROP TABLE IF EXISTS `samples`"); return { run: async (sql, params = []) => { await admin.query(sql, params); }, afterSeed: async () => {}, @@ -167,6 +176,7 @@ export const db = drizzle(pool); await new Promise((done) => mod.pool.end(done)); await admin.query("DROP VIEW IF EXISTS `v_entry_totals`"); await admin.query("DROP TABLE IF EXISTS `entries`"); + await admin.query("DROP TABLE IF EXISTS `samples`"); await admin.end(); }, }; diff --git a/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts index 27199022d..9ef06aed5 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts @@ -87,7 +87,9 @@ export const db = drizzle(pool); "payload" jsonb, "primary_marker" jsonb NOT NULL, "optional_marker" jsonb, - "markers" jsonb + "markers" jsonb, + "measure" jsonb, + "measures" jsonb )`, ); @@ -113,11 +115,11 @@ export const db = drizzle(pool); v === undefined || v === null ? "NULL" : `${sqlStr(JSON.stringify(v))}::jsonb`; await executeSql( connectionUri, - `INSERT INTO "documents" ("id", "title", "payload", "primary_marker", "optional_marker", "markers") VALUES (${ + `INSERT INTO "documents" ("id", "title", "payload", "primary_marker", "optional_marker", "markers", "measure", "measures") VALUES (${ r.id ?? "DEFAULT" }, ${sqlStr(r.title)}, ${jsonbSql(r.payload)}, ${jsonbSql(r.primaryMarker)}, ${jsonbSql( r.optionalMarker, - )}, ${jsonbSql(r.markers)})`, + )}, ${jsonbSql(r.markers)}, ${jsonbSql(r.measure)}, ${jsonbSql(r.measures)})`, ); } await executeSql( diff --git a/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts b/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts index 6f9ac921c..ed67a0edf 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts @@ -29,6 +29,12 @@ export interface Marker { score?: number; } +export interface Measure { + ratio?: number; + samples?: number[]; + amount?: string | number; +} + export interface DocumentRow { id?: number; title: string; @@ -36,6 +42,8 @@ export interface DocumentRow { primaryMarker?: Marker; optionalMarker?: Marker | null; markers?: Marker[] | null; + measure?: Measure | null; + measures?: Measure[] | null; } export interface JsonbServerHandle { @@ -58,7 +66,7 @@ export async function startJsonbServer(connectionUri: string, root: MetaRoot): P // Literal column naming (the corpus pins physical name = field name), so a // small CREATE TABLE matches the entity. `payload` is a bare jsonb column — - // the open bag holds any JSON value. The three Marker value-object columns + // the open bag holds any JSON value. The Marker and Measure value-object columns // are jsonb too (single / single-nullable / array); `primaryMarker` is // @required (NOT NULL). Program D exercises VO-column POST + PATCH here. await kysely.schema @@ -69,6 +77,8 @@ export async function startJsonbServer(connectionUri: string, root: MetaRoot): P .addColumn("primaryMarker", "jsonb", (c) => c.notNull()) .addColumn("optionalMarker", "jsonb") .addColumn("markers", "jsonb") + .addColumn("measure", "jsonb") + .addColumn("measures", "jsonb") .execute(); const driver = kyselyDriver({ db: kysely as never, dialect: "postgres" }); diff --git a/server/typescript/packages/metadata/README.md b/server/typescript/packages/metadata/README.md index e7a90792e..a4ff50aca 100644 --- a/server/typescript/packages/metadata/README.md +++ b/server/typescript/packages/metadata/README.md @@ -2,6 +2,8 @@ The metamodel loader, typed views, and constants for the MetaObjects standard. This is the foundation package every other `@metaobjectsdev/*` package builds on — it parses `metaobjects/*.json` files into a typed object model, resolves `extends` and overlay merging, and exposes the 11-type vocabulary as named constants. +**Upgrading from 1.0.x:** see the [1.0 to 1.1 upgrade guide](https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/upgrading-1.0-to-1.1.md) before you regenerate. It lists every step and change that needs action, per port. + ## Install ```bash diff --git a/server/typescript/packages/metadata/src/core/attr/meta-attr.ts b/server/typescript/packages/metadata/src/core/attr/meta-attr.ts index 530dddac6..37835a12b 100644 --- a/server/typescript/packages/metadata/src/core/attr/meta-attr.ts +++ b/server/typescript/packages/metadata/src/core/attr/meta-attr.ts @@ -116,7 +116,7 @@ export class MetaAttr extends MetaData implements DataTypeAware { // Rule A: an integral number (4, 4.0, 1e2) is an int; a fraction is not. return Number.isInteger(value) ? [] - : [{ message: `attribute '@${this.name}' must be of type '${this.subType}' but got the fraction ${value}` }]; + : [{ message: `attribute '@${this.name}' must be of type '${this.subType}' but got the fraction ${value}; this attribute is integral, so write an integer (for a validator @min/@max, use an integer bound)` }]; } if (dt === DATA_TYPE_DOUBLE) { return typeof value === "number" ? [] : [this._typeError("number", value)]; diff --git a/server/typescript/packages/runtime-ts/src/decimal-wire.ts b/server/typescript/packages/runtime-ts/src/decimal-wire.ts index c7afadf45..8b4f4f349 100644 --- a/server/typescript/packages/runtime-ts/src/decimal-wire.ts +++ b/server/typescript/packages/runtime-ts/src/decimal-wire.ts @@ -71,10 +71,23 @@ export function decimalFromDouble15(value: number): string | undefined { */ export function parseDecimalInput(value: unknown): string | undefined { if (typeof value === "string") return canonicalDecimal(value); - if (typeof value === "number") return decimalFromDouble15(value); + if (typeof value === "number") return decimalFromJsonNumber(value); return undefined; } +/** + * W2 for a JSON number on input. `JSON.parse` has already rounded the client's digits to a double, + * so only a number that IS its own 15-digit form (or a safe integer, taken as `String(n)`) can be + * read exactly: anything else would be silently rounded, so it is refused (`undefined`) and the + * client is told to send a string. The test is `Number(n.toPrecision(15)) === n`: a number whose + * shortest round-trip form carries more than 15 significant digits fails it. + */ +export function decimalFromJsonNumber(value: number): string | undefined { + if (!Number.isFinite(value)) return undefined; + if (!Number.isSafeInteger(value) && Number(value.toPrecision(15)) !== value) return undefined; + return canonicalDecimal(String(value)); +} + /** W2 for a query-string filter operand: the same grammar as a body string, compared exactly. */ export function parseDecimalOperand(text: string): string | undefined { return canonicalDecimal(text); diff --git a/server/typescript/packages/runtime-ts/src/type-coercer.ts b/server/typescript/packages/runtime-ts/src/type-coercer.ts index caa77dad2..cee6b6876 100644 --- a/server/typescript/packages/runtime-ts/src/type-coercer.ts +++ b/server/typescript/packages/runtime-ts/src/type-coercer.ts @@ -18,7 +18,7 @@ // strings for temporal columns directly, and the temporal read parsers pin the // wire form. -import type { MetaData } from "@metaobjectsdev/metadata"; +import { refTarget, type MetaData } from "@metaobjectsdev/metadata"; import { TYPE_FIELD, FIELD_SUBTYPE_BOOLEAN, @@ -27,6 +27,8 @@ import { FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_STORAGE, + FIELD_ATTR_OBJECT_REF, + OBJECT_SUBTYPE_VALUE, FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_INT_VALUE_MAP, FIELD_ATTR_PRECISION, @@ -290,45 +292,76 @@ function parseJsonTextArrays(entity: MetaData, row: Row): Row { return out ?? row; } -/** Read side: every `field.decimal` the row carries becomes its canonical string. */ -function canonicalizeDecimals(entity: MetaData, row: Row): Row { +/** The `object.value` a jsonb `field.object` references, or undefined (not a value object, or unresolved). */ +function valueObjectOf(field: MetaData): MetaData | undefined { + if (field.type !== TYPE_FIELD || field.subType !== FIELD_SUBTYPE_OBJECT) return undefined; + // ADR-0059 R7: the object the loader bound @objectRef to. + const target = refTarget(field, FIELD_ATTR_OBJECT_REF); + return target?.subType === OBJECT_SUBTYPE_VALUE ? target : undefined; +} + +/** + * Apply `convert` to every `field.decimal` of `owner` present in `row`, and recurse into a + * `field.object` whose value object declares decimals (captain ruling C1: a decimal inside a + * jsonb value object follows the top-level rule). `convert` answers the new value; a row, an + * object or an array is copied only when something changed. + */ +function mapDecimals( + owner: MetaData, + row: Row, + convert: (field: MetaData, value: unknown) => unknown, +): Row { let out: Row | null = null; // Effective children (ADR-0039): a decimal may be inherited via extends, and a report's // derived ratio / average / sum is a decimal field of the report itself. - for (const child of entity.children()) { - if (child.type !== TYPE_FIELD || child.subType !== FIELD_SUBTYPE_DECIMAL) continue; + for (const child of owner.children()) { + if (child.type !== TYPE_FIELD) continue; if (!(child.name in row)) continue; const v = row[child.name]; if (v === null || v === undefined) continue; - const wire = decimalToWire(v); - if (wire === v) continue; + let next: unknown = v; + if (child.subType === FIELD_SUBTYPE_DECIMAL) { + next = convert(child, v); + } else { + const vo = valueObjectOf(child); + if (vo === undefined) continue; + const one = (el: unknown): unknown => + el !== null && typeof el === "object" && !Array.isArray(el) ? mapDecimals(vo, el as Row, convert) : el; + if (Array.isArray(v)) { + const mapped = v.map(one); + if (mapped.some((el, i) => el !== v[i])) next = mapped; + } else { + next = one(v); + } + } + if (next === v) continue; out ??= { ...row }; - out[child.name] = wire; + out[child.name] = next; } return out ?? row; } +/** Read side: every `field.decimal` the row carries becomes its canonical string. */ +function canonicalizeDecimals(entity: MetaData, row: Row): Row { + return mapDecimals(entity, row, (_field, v) => decimalToWire(v)); +} + /** * Write side: a `field.decimal` value is bound as its exact canonical string, and a decimal - * declared `isArray` as an array of them. A string in the JSON-number grammar or a number is - * accepted; anything else, or a value the declared `@precision` / `@scale` cannot hold (W3), is - * refused here rather than reaching a column that would round it, reject it with a driver error, - * or store garbage. + * declared `isArray` as an array of them. A string in the JSON-number grammar or a number a double + * holds exactly is accepted; anything else, or a value the declared `@precision` / `@scale` cannot + * hold (W3), is refused here rather than reaching a column that would round it, reject it with a + * driver error, or store garbage. A decimal inside a jsonb value object is bound the same way. */ function bindDecimals(entity: MetaData, row: Row): Row { - let out: Row | null = null; - // ADR-0039: resolving — a decimal, its isArray and its @precision / @scale may be inherited. - for (const child of entity.children()) { - if (child.type !== TYPE_FIELD || child.subType !== FIELD_SUBTYPE_DECIMAL) continue; - if (!(child.name in row)) continue; - const v = row[child.name]; - if (v === null || v === undefined) continue; + return mapDecimals(entity, row, (child, v) => { + // ADR-0039: resolving — a decimal, its isArray and its @precision / @scale may be inherited. const precision = child.attr(FIELD_ATTR_PRECISION); const scale = child.attr(FIELD_ATTR_SCALE); const bind = (value: unknown): string => { const canonical = parseDecimalInput(value); if (canonical === undefined) { - throw new Error(`field.decimal '${child.name}' cannot be written: ${JSON.stringify(value)} is not a decimal number (a string in the JSON-number grammar, or a number)`); + throw new Error(`field.decimal '${child.name}' cannot be written: ${JSON.stringify(value)} is not a decimal number (a string in the JSON-number grammar, or a number a double holds exactly: send a string for more than 15 significant digits)`); } if (!decimalFits(canonical, typeof precision === "number" ? precision : undefined, typeof scale === "number" ? scale : undefined)) { throw new Error(`field.decimal '${child.name}' cannot be written: ${canonical} does not fit @precision ${String(precision)} / @scale ${String(scale ?? 0)}`); @@ -339,14 +372,8 @@ function bindDecimals(entity: MetaData, row: Row): Row { if (!Array.isArray(v)) { throw new Error(`field.decimal '${child.name}' cannot be written: ${JSON.stringify(v)} is not an array of decimal numbers`); } - out ??= { ...row }; - out[child.name] = v.map((element) => (element === null ? null : bind(element))); - continue; + return v.map((element) => (element === null ? null : bind(element))); } - const canonical = bind(v); - if (canonical === v) continue; - out ??= { ...row }; - out[child.name] = canonical; - } - return out ?? row; + return bind(v); + }); } diff --git a/server/typescript/packages/runtime-ts/src/validator-runner.ts b/server/typescript/packages/runtime-ts/src/validator-runner.ts index 71037de4a..5b55b5f7c 100644 --- a/server/typescript/packages/runtime-ts/src/validator-runner.ts +++ b/server/typescript/packages/runtime-ts/src/validator-runner.ts @@ -7,7 +7,8 @@ import { VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_CURRENCY, - FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_OBJECT, + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_DECIMAL, + FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_ATTR_REQUIRED, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_DEFAULT, FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, FIELD_ATTR_OBJECT_REF, OBJECT_SUBTYPE_VALUE, @@ -17,6 +18,7 @@ import { IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, GENERATION_INCREMENT, GENERATION_UUID, } from "@metaobjectsdev/metadata"; import type { ValidationFailure } from "./errors.js"; +import { decimalFits, parseDecimalInput } from "./decimal-wire.js"; import { isAbsoluteUri, isInetLiteral } from "./net-format.js"; export type ValidationResult = @@ -300,6 +302,33 @@ function checkType(subType: string, value: unknown): string | null { return null; } +/** + * ADR-0019 Amendment 1 (W2, W3) on a write: a decimal is a string in the JSON-number grammar or a + * JSON number a double holds exactly, and must fit the declared `@precision` / `@scale`. Refused + * here, as a validation failure, so the write answers 400 rather than failing later in the coercer. + */ +function decimalErrors(field: MetaData, value: unknown, label: string): ValidationFailure[] { + const canonical = parseDecimalInput(value); + if (canonical === undefined) { + return [{ + field: label, rule: "type", + message: `'${label}' must be a decimal number (a string in the JSON-number grammar, or a number a double holds exactly: send a string for more than 15 significant digits)`, + expected: FIELD_SUBTYPE_DECIMAL, received: typeof value, + }]; + } + // ADR-0039: effective attrs — @precision / @scale may be inherited via extends. + const precision = field.attr(FIELD_ATTR_PRECISION); + const scale = field.attr(FIELD_ATTR_SCALE); + if (!decimalFits(canonical, typeof precision === "number" ? precision : undefined, typeof scale === "number" ? scale : undefined)) { + return [{ + field: label, rule: "range", + message: `'${label}' (${canonical}) does not fit @precision ${String(precision)} / @scale ${String(scale ?? 0)}`, + expected: { precision, scale }, received: canonical, + }]; + } + return []; +} + /** * The type, length and regex errors for one scalar value of `field` — the whole field * value, or one element of a scalar array (then `required` is false: element presence is not @@ -307,6 +336,7 @@ function checkType(subType: string, value: unknown): string | null { */ function scalarErrors(field: MetaData, value: unknown, required: boolean, label: string): ValidationFailure[] { const errors: ValidationFailure[] = []; + if (field.subType === FIELD_SUBTYPE_DECIMAL) return decimalErrors(field, value, label); const typeError = checkType(field.subType, value); if (typeError !== null) { errors.push({ 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 19f953979..604964137 100644 --- a/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts +++ b/server/typescript/packages/runtime-ts/test/decimal-wire.test.ts @@ -107,3 +107,24 @@ describe("read-only mounts send a named decimal as a string", () => { expect(byKind(JSON.parse(res.body) as Array<{ kind: string; n: number; share: number }>)).toEqual([{ kind: "a", n: 2, share: 0.5 }, { kind: "b", n: 1, share: 0 }]); }); }); + +describe("D3: a JSON number is read exactly or refused, never rounded", async () => { + const { parseDecimalInput } = await import("../src/decimal-wire.js"); + test("exact numbers are accepted", () => { + expect(parseDecimalInput(0.3)).toBe("0.3"); + expect(parseDecimalInput(100.25)).toBe("100.25"); + expect(parseDecimalInput(1e-7)).toBe("0.0000001"); + expect(parseDecimalInput(1e21)).toBe("1000000000000000000000"); + expect(parseDecimalInput(Number.MAX_SAFE_INTEGER)).toBe("9007199254740991"); + expect(parseDecimalInput(1234567890123456)).toBe("1234567890123456"); + }); + test("a number carrying more than 15 significant digits is refused", () => { + expect(parseDecimalInput(JSON.parse("1234567890.123456789"))).toBeUndefined(); + expect(parseDecimalInput(0.1 + 0.2)).toBeUndefined(); + expect(parseDecimalInput(2 ** 53 + 2)).toBeUndefined(); + expect(parseDecimalInput(Number.NaN)).toBeUndefined(); + }); + test("the same digits as a string stay exact", () => { + expect(parseDecimalInput("1234567890.123456789")).toBe("1234567890.123456789"); + }); +}); diff --git a/server/typescript/packages/runtime-ts/test/type-coercer.test.ts b/server/typescript/packages/runtime-ts/test/type-coercer.test.ts index 871af0ade..0b8de1edb 100644 --- a/server/typescript/packages/runtime-ts/test/type-coercer.test.ts +++ b/server/typescript/packages/runtime-ts/test/type-coercer.test.ts @@ -128,3 +128,68 @@ describe("field.decimal isArray", () => { expect(() => coerceRowOnWrite(e, { amount: "0.12345" }, "postgres")).toThrow("does not fit"); }); }); + +describe("a field.decimal inside a jsonb value object (captain ruling C1)", () => { + async function invoice(): Promise { + const model = { "metadata.root": { package: "ledger", children: [ + { "object.value": { name: "Money", children: [ + { "field.decimal": { name: "amount", "@precision": 9, "@scale": 4 } }, + ]}}, + { "object.entity": { name: "Invoice", children: [ + { "field.long": { name: "id" } }, + { "field.object": { name: "total", "@objectRef": "Money", "@storage": "jsonb" } }, + { "field.object": { name: "lines", "@objectRef": "Money", isArray: true, "@storage": "jsonb" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ]}}, + ]}}; + const { root, errors } = await new MetaDataLoader({ strict: true }).load([ + new InMemoryStringSource(JSON.stringify(model), { id: "ledger.json" }), + ]); + expect(errors).toEqual([]); + return root.objects().find((o) => o.name === "Invoice") as MetaData; + } + + test("write: a string or exact number is bound as the canonical string, single and array", async () => { + const e = await invoice(); + const out = coerceRowOnWrite(e, { id: 1, total: { amount: 100.25 }, lines: [{ amount: "2.5000" }, { amount: 0.3 }] }, "postgres"); + expect(JSON.parse(out.total as string)).toEqual({ amount: "100.25" }); + expect(JSON.parse(out.lines as string)).toEqual([{ amount: "2.5" }, { amount: "0.3" }]); + }); + + test("write: excess scale, a non-number, and an inexact JSON number are refused", async () => { + const e = await invoice(); + for (const amount of ["0.12345", "100000", "12abc", 1234567890.123456789]) { + expect(() => coerceRowOnWrite(e, { id: 1, total: { amount } }, "postgres")).toThrow(/field\.decimal 'amount' cannot be written/); + expect(() => coerceRowOnWrite(e, { id: 1, lines: [{ amount }] }, "postgres")).toThrow(/field\.decimal 'amount' cannot be written/); + } + }); + + test("read: a stored uncanonical string, or a number, is answered canonical", async () => { + const e = await invoice(); + const out = coerceRowOnRead(e, { id: 1, total: '{"amount":"1.5000"}', lines: [{ amount: "2.50" }, { amount: 0.1 }] }, "postgres"); + expect(out.total).toEqual({ amount: "1.5" }); + expect(out.lines).toEqual([{ amount: "2.5" }, { amount: "0.1" }]); + }); +}); + +describe("runValidators on a decimal (ADR-0019 Amendment 1)", () => { + test("a non-number, an inexact JSON number and an excess scale are validation failures, not thrown errors", async () => { + const { runValidators } = await import("../src/validator-runner.js"); + const { root, errors } = await new MetaDataLoader({ strict: true }).load([ + new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: "p", children: [ + { "object.entity": { name: "E", children: [ + { "field.long": { name: "id" } }, + { "field.decimal": { name: "amt", "@precision": 9, "@scale": 4 } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ]}}, + ]}}), { id: "p.json" }), + ]); + expect(errors).toEqual([]); + const e = root.objects().find((o) => o.name === "E") as MetaData; + expect(runValidators(e, { id: 1, amt: "1.5" }).ok).toBe(true); + expect(runValidators(e, { id: 1, amt: 0.25 }).ok).toBe(true); + for (const amt of ["12abc", "1.12345", "123456", 1234567890.123456789, true]) { + expect(runValidators(e, { id: 1, amt }).ok).toBe(false); + } + }); +}); diff --git a/server/typescript/packages/test-generators/src/templates/decimal-wire-helper.ts b/server/typescript/packages/test-generators/src/templates/decimal-wire-helper.ts index c2b3a8422..161042ed2 100644 --- a/server/typescript/packages/test-generators/src/templates/decimal-wire-helper.ts +++ b/server/typescript/packages/test-generators/src/templates/decimal-wire-helper.ts @@ -17,7 +17,11 @@ export const DECIMAL_WRITE_HELPER_DECL = [ " * number, rewritten to its canonical string (plain notation, no trailing fraction zeros, zero as \"0\").", " * Garbage, or a value the column cannot hold without rounding, is a validation issue. Generated. */", `function ${DECIMAL_WRITE_HELPER}(v: string | number, ctx: z.RefinementCtx, precision?: number, scale = 0): string {`, - ' const text = typeof v === "number" ? (Number.isFinite(v) ? v.toPrecision(15) : "") : v;', + ' if (typeof v === "number" && Number.isFinite(v) && !Number.isSafeInteger(v) && Number(v.toPrecision(15)) !== v) {', + ' ctx.addIssue({ code: "custom", message: "Decimal number cannot be read exactly as a JSON number; send it as a string" });', + " return z.NEVER;", + " }", + ' const text = typeof v === "number" ? (Number.isFinite(v) ? String(v) : "") : v;', " const m = /^(-?)(0|[1-9][0-9]*)(?:\\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(text);", " const e = m === null || m[4] === undefined ? 0 : Number(m[4]);", " if (m === null || !Number.isSafeInteger(e) || Math.abs(e) > 1000) {", diff --git a/server/typescript/packages/test-generators/src/templates/zod-validators.ts b/server/typescript/packages/test-generators/src/templates/zod-validators.ts index 643429fac..5f8d45a2d 100644 --- a/server/typescript/packages/test-generators/src/templates/zod-validators.ts +++ b/server/typescript/packages/test-generators/src/templates/zod-validators.ts @@ -777,9 +777,10 @@ function zodFieldExpr( case FIELD_SUBTYPE_DECIMAL: { // ADR-0019 Amendment 1 (W2, W3): a WRITE shape takes a decimal as a string in the // JSON-number grammar or a JSON number, stores its canonical string, and refuses what the - // column cannot hold. The TPH read shape parses rows the database already holds, and a value - // object's member is JSON that stores whatever it is given, so both keep a bare string. - if (readShape || owner?.subType === OBJECT_SUBTYPE_VALUE) { + // column cannot hold. A decimal inside a jsonb value object follows the same rule (captain + // ruling C1: its @precision / @scale bound it, the stored JSON holds the canonical string). + // Only the TPH read shape, which parses rows the database already holds, keeps a bare string. + if (readShape) { baseStr = "z.string()"; break; }