diff --git a/CHANGELOG.md b/CHANGELOG.md index 514b4743a..ca94ccbb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ number. The first landed on 1.0.x: 439 lines of handler code retired. The second and it is a SQL reduction only: in an adopter, hand-written SQL went from 75 lines to 15 (-80%). Total hand-written lines did not fall, so do not read it as a total line reduction. -**Two diffs to expect when you upgrade.** +**Three diffs to expect when you upgrade.** - **An ejected generator needs a resync (G11).** An owned copy keeps the gate and the options it was copied with, so it does not receive the new report behaviour until you resync it. The @@ -53,6 +53,27 @@ Total hand-written lines did not fall, so do not read it as a total line reducti surface of a keyless projection (no `/:id` route, no by-id function, and no write verbs in the API docs), and `meta verify --docs` reports the affected pages as stale until they are regenerated. Run `meta gen` and review the diff; three-way merge preserves hand edits. +- **A multi-hop enum field references the shared enum.** A field that reaches a + package-level abstract enum through another field, with no `@values` or `@intValueMap` + redeclared on the way, used to get an enum type of its own; it now references the shared one, + in every port. In Kotlin its former `` enum file is no longer written, and code + that imported that type imports the shared enum instead. Java has two more diffs: a + value-object field that reaches a shared or `@provided` enum, even in one hop, references that + type instead of nesting a copy (`TicketPayload.Priority` becomes the top-level `Priority`), + and an inline enum that redeclares `@values` or `@intValueMap` under a shared enum moves from + the shared enum's name to `` (`TaskDto.Priority` becomes `TaskDto.TaskPhase`). + Every other inline enum, in every port, keeps its previous name and bytes. Two build + settings follow from it. Java: `SpringDtoGenerator` owns the shared enum files, so a pom + that runs the value-object or output-parser generator WITHOUT it, on a model where one of + their objects reaches a shared enum, must set `emitSharedEnums=true` on one of them. Kotlin: + every generator that types an enum now needs `providedEnumNamespace` / + `providedEnumPackages` for a model with a `@provided` enum; set them in the plugin's + ``. An owned (ejected) Kotlin generator keeps compiling and keeps its previous + `@provided` answer until you re-eject it. An owned Java `value-object` or `output-parser` + generator must be re-ejected, or merged with its new reference, when a response value object + reaches a shared or `@provided` enum: an old copy of one beside the packaged other generates + code that does not compile. The **Fixed** entry lists each port's before and after, and the + re-eject commands. **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 @@ -68,6 +89,48 @@ generator catalog and the Atlas evaluation are 1.2 work. generated-share figure must carry the net hand-written line change (a figure to report, not the test) and is never a target. Documentation only; no code or generator changed. +- **Templates: a `-last` list accessor, so a separator needs no payload flag (every port).** + `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c`. Every object element of a + collection now carries a derived boolean `-last`; the name starts with `-`, which no field name + can, and it is an ordinary Mustache tag name, so a template that uses it is still valid + Mustache. An authored member of the same name wins, a nested list keeps its own position, and + a template that does not use it renders byte-for-byte as before. It works whatever shape the + payload arrives in, and so does `has`: a map or plain object, a TypeScript class + instance, a C# object with or without `[JsonPropertyName]`, a Java record or getter-based + bean, a Kotlin data class, a Pydantic model. The same data renders the same bytes. Which + typed payloads are overlaid depends on the shape. A TypeScript class instance, a C# object + with no `[JsonPropertyName]` member, a Java getter-based bean and a Kotlin data class are + overlaid, never copied: the object's own members (methods and fields included) resolve as + they always did and only when the template names them, its string form is its own, and the + derived names are added on top. A Java record, a C# object with a `[JsonPropertyName]` + member and a Pydantic model are viewed as the map of their fields, exactly as before this + change: every field is read up front, and the derived names are added to that map. A method + on a Java record that is not a component accessor does not resolve. + `meta verify --templates` accepts `-last` only when the innermost enclosing section over a + list iterates objects, and reports it as `ERR_VAR_NOT_ON_PAYLOAD` anywhere else: at the top + level, inside a list of scalars, and inside a list of scalars nested in a list of objects, + where the lookup would bind to the outer element and separate the inner list wrongly. A + scalar conditional or a single nested object inside a list is transparent, so `-last` there + is the enclosing element's. To decide that, the verify field tree now records which fields + are lists; a generated render helper for a payload with a list field is rewritten by `meta + gen` to carry that flag, and a helper with none is unchanged. Gated by the + `render-list-separator` render fixture and six `verify-positional-accessor-*` verify + fixtures, run by TypeScript, C#, Java, Kotlin and Python, plus a typed-payload render test in + each port. An adopter's payloads carried hand-computed `last` and `has` flags: `last` + is what this removes, and the emptiness half was already derived (`{{#hasTags}}` / + `{{^hasTags}}`, see `docs/features/templates-and-payloads.md`, "List separators and + emptiness"). +- **TypeScript: a value object gets a field-rules constant.** An entity has long emitted + `..rules`; a value object emitted only its interface and Zod schema, so a caller + reading a limit such as `maxLength` went through the schema's getters. An `object.value` + module now also exports `const = { $entity, : { name, label, view, + rules, ... } }`, the same per-field shape without `$table` and `$path`, since a value object + has no source and no route. Only the value subtype takes it: an abstract object, a TPH + subtype, a sourceless entity, a sourceless projection and an entity emitted to a contract-only + target are unchanged. `meta gen` adds the constant to existing value-object modules; + three-way merge preserves hand edits. An ejected `entity` generator picks it up without a + resync, because the owned copy calls the package's value-object composer. + - **TypeScript: the `cube-model` reference generator (FR-044 Plan 4).** `cubeModel()` writes the reporting vocabulary as Cube data-model files, `model/cubes/.yml`: a cube for each table-backed entity that declares dimensions, measures or segments, a join for each to-one @@ -490,6 +553,126 @@ until you regenerate. ### Fixed +- **One shared-enum rule in all five ports: a field keeps the shared type through any number + of `extends` hops.** An enum field that extends another field which itself reaches a shared + (package-level abstract) enum was resolved only when it extended the abstract directly, so a + two-hop field got its own inlined enum beside the shared one, and each port decided the + question in more than one place. The rule is now one function per port, in the metadata + layer, that the loader and every generator call (ADR-0026 Amendment 1): walk the `extends` + chain from the field; the nearest package-level abstract enum is the field's type; a field + that declares its own `@values` or `@intValueMap` on the way starts a new vocabulary and + keeps a per-object inline enum, as does a chain that never reaches a shared declaration. The + hosting object does not matter: an entity, projection or value-object field all reference the + one shared type. The shared type's members and int map are read from the declaration, so what + is generated no longer depends on which consumer is visited first, and it is generated + whenever any consumer references it, including when the only consumer is a projection or a + value object. `ERR_ENUM_EXTENDS_VALUES_CONFLICT` is unchanged: it still fires for own + `@values` or `@intValueMap` directly under a shared declaration, and a redeclaration further + down the chain still loads. This supersedes the per-projection enum copy #259 introduced in + Kotlin. Gated by a decision table every port answers + (`fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json`). + + What regenerates differently, port by port. The first case is the same in all five: a field + that reaches a shared enum through two or more hops, with nothing redeclared on the way, now + references the shared type. What it generated before: + + | Port | Before, for such a field | + |---|---| + | TypeScript | an inline union and `Enum` constant in the object's own module, named for the field it extends | + | C# | a nested `` enum | + | Java | a nested enum in the DTO that reused the shared enum's name and shadowed it there | + | Kotlin | an `` enum file, which is no longer written | + | Python | an inline `Literal[...]` | + + Java has two more changes, and no other port has them: + + - A value-object field that reaches a shared or `@provided` enum, in one hop or many, now + references that type. The record used to nest its own copy, so one declaration became two + Java types that do not convert. Migration: `TicketPayload.Priority` becomes the top-level + `Priority`, and a `@provided` enum is referenced where `providedEnumNamespace` / + `providedEnumPackages` point, which the value-object and output-parser generators now read. + The file for a shared enum has ONE writer, `SpringDtoGenerator`, which writes every shared + enum the model uses, including one only a value object or an output parser reaches. So a + separate `outputDir` per generator still compiles: no second copy of the class is written. + A pom that runs the value-object or output-parser generator WITHOUT the DTO generator, on a + model where one of their objects reaches a shared enum, must set + `true` on one of them, or the type it references is not + generated. (Before this change such a value object nested its own copy, so nothing was + missing.) + - An inline enum on a chain that redeclares `@values` or `@intValueMap` under a shared enum + was nested under the shared enum's own name and shadowed it inside the DTO. It is now named + ``: `TaskDto.Priority` (with the redeclared members) becomes + `TaskDto.TaskPhase`. It stays inline, with its own members. + - **Upgrading an owned Java `value-object` or `output-parser` generator.** This applies if you + own (ejected) either generator and a response value object has a field that reaches a + shared or `@provided` enum. The two generators must emit the same shape, and a copy ejected + before this release emits the old one. One old copy beside one packaged generator produces + code that does not compile: + - An owned old `output-parser` with the packaged `value-object`: the parser emits + `..valueOf(s)` (`TicketPayload.Priority.valueOf(s)`) for an enum the record + no longer nests. + - An owned old `value-object` with the packaged `output-parser`: the record still nests + `TicketPayload.Priority`, and the parser passes the top-level `Priority` to the record's + constructor. + + Bring every copy you own of the two to the new shape in one change, so the pair agrees. + `mvn metaobjects:eject -Dlist` shows where each copy stands: it prints + `[owned — identical]` or `[owned — DIFFERS: N behind, M of your own]` beside the generator. + `behind` counts reference lines your copy lacks. `of your own` counts lines of your copy the + reference does not have, which includes lines this release changed, so a copy you never + edited also reads `DIFFERS` after the upgrade. Whether you edited a copy is in your own + history (`git log -- codegen/`). For a copy you did not edit, re-eject it: + `mvn metaobjects:eject -Dnames=value-object,output-parser -Dport=java -Dforce`, naming only + the generators you own. `-Dport=java` is there because `output-parser` is also a Kotlin + generator. `-Dforce` replaces the copy and rewrites `codegen/pom.xml`, so review both diffs. + For an edited copy, extract the new reference from the `metaobjects-codegen-spring` jar + (`META-INF/metaobjects/reference/java/SpringValueObjectGenerator.java` and + `SpringOutputParserGenerator.java`), diff it against your copy ignoring the package line, + and apply the hunks by hand: the procedure is "Pulling an upstream fix into your copy" in + `docs/features/own-your-codegen.md`. An owned `dto` copy needs nothing for this: it asks + the packaged `Fr019SharedEnum` helper for each enum's type, so it follows the new rule + without a change. + + Kotlin has one more change, to configuration: + + - Every Kotlin generator that types an enum now takes `providedEnumNamespace` / + `providedEnumPackages`: the entity, Exposed table, Spring controller, relations and + output-parser generators, and the API pages `mvn metaobjects:docs` writes. Only the entity + generator read them before, so for a `@provided` enum the data class named your class while + the Exposed table and the controller named a class in the declaration's package that nothing + generates, and the output did not compile. They now all resolve it through one function. A + generator configured without the args fails at generate time for a model with a `@provided` + enum, naming the enum, its package and both args. Set them once in the plugin's `` + and every generator, and the docs goal, reads the same values. + - **Upgrading an owned Kotlin generator.** A copy ejected before this release keeps compiling + unchanged. The helper signatures it calls remain as deprecated forms: + `KotlinTypeMapper.enumTypeName(field, entity)`, `KotlinEnumEmitter.emitEnumFile` without a + config, `KotlinM2mSupport.resolve` and `tphQueryFnNames`, both + `KotlinGenUtil.reportEnumClasses` forms, `KotlinExtractSchemaEmitter.emitMirrorFiles` and + `mirrorSource`, `Fr019SharedEnum.ProvidedEnumConfig.of`, + `Fr019SharedEnum.providedClassName(field, config)` and the no-argument + `KotlinApiModelBuilder()`. They apply the same enum rule, so an owned copy also types a + multi-hop field by the shared enum. What the forms that take no config cannot do is read the + two args: for a `@provided` enum they give the answer they gave before, the class in the + declaration's own package, and they do not fail. So an owned entity generator still names + your configured class (it reads the args itself), and an owned Exposed table, Spring + controller, relations or output-parser generator still names the declaration-package class. + To get the configured class from every generator, re-eject the five that type an enum: + `mvn metaobjects:eject -Dnames=entity,exposed-table,routes,relations,output-parser + -Dport=kotlin -Dforce`. `-Dforce` overwrites the copy, so use it only on one you have not + edited (`mvn metaobjects:eject -Dlist` shows how far each copy is from its reference). For an edited copy, + extract the new reference from the `metaobjects-codegen-kotlin` jar + (`META-INF/metaobjects/reference/kotlin/.kt`), diff it against your copy + ignoring the package line, and apply the hunks by hand: the procedure is "Pulling an + upstream fix into your copy" in `docs/features/own-your-codegen.md`. + + Nothing else moves. Every other inline enum, in every port, keeps its previous name and bytes: + in Java an inline enum that extends another field is still named for its top-most super + (`ShipmentSummaryDto.Status`), which a golden test pins against the previous generator's + output. A model regenerates unchanged unless it has a field that reaches a shared enum through + another field, or, in Java only, a value-object field that reaches a shared or `@provided` + enum or a redeclaration under a shared enum. + - **`meta verify --db` no longer reports a matching view as drift, and says what differs when a view does not.** An adopter on 1.1.0-rc.2 rebuilt a SQLite database from the committed chain: every report and projection view's stored SQL was byte-identical to the metadata's, yet diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index c12553bec..9081b3e82 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -28,9 +28,9 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor |---|---|---|---|---|---|---| | [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 374 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | -| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ | +| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | -| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 15 | ✓ | ✓ | inherits via Java | ✓ | ✓ | +| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 16 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 52 (46 query + 6 migration) | all 52 (the 19 `report-*` also on SQLite, MySQL and D1) | 46 query (migrations TS-only, ADR-0015); the 19 `report-*` also on MySQL | 46 query (via Exposed); the 19 `report-*` also on SQLite and MySQL | 46 query; the 19 `report-*` also on SQLite and MySQL | 46 query (Postgres only: pg8000) | @@ -38,7 +38,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ | -| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 4 | ✓ | ✓ | ✓ | ✓ | ✓ | +| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 5 shared inputs; `shared-provided-enum` also carries the enum-type decision table every port answers | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/template-codegen-conformance/`](../fixtures/template-codegen-conformance/) | 3 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/template-output-render-conformance/`](../fixtures/template-output-render-conformance/) | 5 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/generator-registry-conformance/`](../fixtures/generator-registry-conformance/) | 1 canonical manifest | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -284,19 +284,21 @@ before its base in one file, etc.) and 7 `error-yaml-*` fixtures that pin the YAML 1.1 coercion guards (bool / null / hex in string contexts; numeric in enum contexts; reserved-as-attr). -### `fixtures/render-conformance/` (15) +### `fixtures/render-conformance/` (16) -All 15 fixtures → [features/templates-and-payloads.md](features/templates-and-payloads.md) +All 16 fixtures → [features/templates-and-payloads.md](features/templates-and-payloads.md) (render engine output section). 4 are end-to-end shape examples (prompt / email / -spreadsheet / CSV-injection escape); 10 pin Mustache-engine semantics — dotted-path +spreadsheet / CSV-injection escape); 11 pin Mustache-engine semantics — dotted-path lookup, parent-context fallthrough, falsy/empty-array section behavior, inverted sections, nested partials, standalone-tag whitespace stripping, raw-HTML bypass, -trailing-newline preservation, and unicode multibyte handling. +trailing-newline preservation, unicode multibyte handling, and the `-last` list-separator accessor. -### `fixtures/verify-conformance/` (31) +### `fixtures/verify-conformance/` (37) -All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md) -(template drift section — `Renderer.verify`). +All 37 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md) +(template drift section — `Renderer.verify`). A node of a fixture's `payload.json` field tree +may carry `"isArray": true`; with `fields` it is a list of objects, without it a list of +scalars. The six `verify-positional-accessor-*` cases pin where `-last` is available. ### `fixtures/persistence-conformance/` (52 — 46 query + 6 migration) @@ -522,8 +524,8 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 374 + yaml 16 + verify 31 -+ render 15 + persistence 52 + api-contract 88 + source-resolution 25 + scope 10 + +The fixtures in the nine corpora mapped above (metamodel 374 + yaml 16 + verify 37 ++ render 16 + persistence 52 + api-contract 88 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc, and so do the two requirement corpora and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/features/field-types.md b/docs/features/field-types.md index 10dac1d74..19570f511 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -222,8 +222,9 @@ Two rules follow from int-backing being a **persistence-layer codec**: Reuse a constraint set across entities by declaring one **abstract** `field.enum` (with the `@values`) and having each concrete field `extends` it — including **across packages** (declare the shared enum in a common package and reference it -by FQN). Every field that extends the same abstract enum collapses onto one -generated enum type, so the members stay in exactly one place: +by FQN). Every field that reaches the same abstract enum, directly or through other +fields, collapses onto one generated enum type, so the members stay in exactly one +place: ```jsonc // meta.common.json — package acme::common @@ -243,11 +244,21 @@ Two rules the loader/codegen enforce: inherit the shared set. If you need a **different** member set, declare a separate `field.enum` with its own `@values` rather than extending the shared one — extending is for *reuse* of one member set, not for overriding it. -- **Inherited members resolve through any number of `extends` hops.** A projection - field that extends an entity field which itself extends a shared abstract enum - gets the shared members transitively — the projection still materializes its own - per-projection enum type, populated from the inherited set (a projection is - self-contained: its enum lives in the projection's own package). +- **A field keeps the shared type through any number of `extends` hops.** A field + that extends another field which itself reaches the shared abstract enum has the + SAME generated type as a field that extends it directly, whatever object hosts it + (entity, projection, value object): + + ```jsonc + { "field.enum": { "name": "status", "extends": "acme::common::RecordStatus" } } // on Order + { "field.enum": { "name": "current", "extends": "acme::orders::Order.status" } } // on OrderView: RecordStatus too + ``` + + The walk stops at the first field that declares its own `@values` or + `@intValueMap`: that field starts a new vocabulary, so it and everything extending + it get a per-object inline enum instead. A chain that never reaches a package-level + abstract enum is inline as well. Declaring the vocabulary at package level is how + you ask for one type. ## Embedded value objects — `field.object` + `@storage` diff --git a/docs/features/migrations-and-drift.md b/docs/features/migrations-and-drift.md index c4fdbe164..53c9cffb0 100644 --- a/docs/features/migrations-and-drift.md +++ b/docs/features/migrations-and-drift.md @@ -474,12 +474,13 @@ The following conformance fixtures gate this feature's behavior across ports: **Template drift (`fixtures/verify-conformance/`)** — the `Renderer.verify` engine asserts that every variable, section, partial, and required-tag in a template -resolves against its declared payload. 31 fixtures, grouped: +resolves against its declared payload. 37 fixtures, grouped: - *Variables*: `verify-var-known-clean`, `verify-var-unknown`, `verify-var-implicit-iterator-clean`, `verify-var-unescaped-and-triple-unknown` - *Dotted paths*: `verify-dotted-path-clean`, `verify-dotted-path-head-nonfield`, `verify-dotted-path-tail-nonfield` - *Sections*: `verify-section-over-nonfield`, `verify-section-pushes-element-clean`, `verify-section-element-nonfield`, `verify-nested-sections-clean`, `verify-nested-section-nonfield`, `verify-parent-context-in-section-clean`, `verify-scalar-section-conditional-clean`, `verify-scalar-section-conditional-nonfield` - *Inverted sections*: `verify-inverted-section-clean`, `verify-inverted-section-over-nonfield` +- *Positional accessor (`-last`)*: `verify-positional-accessor-clean`, `verify-positional-accessor-transparent-sections-clean`, `verify-positional-accessor-outside-element`, `verify-positional-accessor-object-section`, `verify-positional-accessor-scalar-list`, `verify-positional-accessor-nested-scalar-list` - *Partials*: `verify-partial-no-provider`, `verify-partial-unresolved`, `verify-partial-resolved-clean`, `verify-partial-resolved-drift`, `verify-partial-in-section-context` - *Required slots / tags*: `verify-required-slot-used-clean`, `verify-required-slot-unused`, `verify-required-slot-via-section`, `verify-required-tags-present`, `verify-required-tag-in-partial`, `verify-required-tag-self-closing`, `verify-required-tag-missing-open`, `verify-required-tag-missing-close`, `verify-required-tag-prefix-no-overmatch` diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 2938e9472..0e2a6e335 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -194,6 +194,16 @@ The selection that actually hits this today is `entity` together with `value-obj both emit a Java type for an `object.value` at the same path — a POJO class and a record, which are not two spellings of one thing. Pick the one your tier needs. +**A separate `outputDir` per generator is safe for shared enums, because one generator owns +them.** A package-level abstract enum that fields extend is one Java type, written once as +`/.java`. `SpringDtoGenerator` (`dto`) owns those files: it writes every shared +enum the model uses, including one only a value object or an output parser references. The +value-object and output-parser generators reference the type and write none, so two output +directories never hold the same class. If your `` run `value-object` or +`output-parser` WITHOUT `dto`, on a model where one of their objects reaches a shared enum, +give exactly one of them `true` and it writes the files +into its own `outputDir`. + **Ownership still wins over the collision error.** The marker question is asked first, so a path you hand-own is refused before it is ever claimed: two generators colliding on a file you took ownership of produce two warnings and a green build, not a failed reactor. diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index 01214c397..c3f6f03b9 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -209,6 +209,42 @@ For the `lobby/welcome` template: ``` +## List separators and emptiness + +A template needs a separator between list items and a way to ask "is there anything here", and +neither needs a payload field the caller computes. + +- **Separator — `-last`.** Every object element of a collection carries a derived boolean, so + `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c` with no `last` flag in + the payload. The name starts with `-`, which no field name can, and it is an ordinary Mustache + tag name, so the template is still valid Mustache and any Mustache tool parses it. An authored + member of the same name wins. A nested list keeps its own position, independent of the list + around it. +- **Emptiness — `has`.** `{{#hasTags}}…{{/hasTags}}` / `{{^hasTags}}…{{/hasTags}}` is + derived from the field `tags` (string: non-blank, collection: non-empty, object: present). + `{{#tags}}` over an empty list already renders nothing; `hasTags` is for the block that + surrounds the loop, a heading or a wrapper element. + +Both work the same whatever shape the payload arrives in: a map or plain object, or the port's +typed payload (a TypeScript class instance, a C# object with or without `[JsonPropertyName]`, a +Java record or getter-based bean, a Kotlin data class, a Pydantic model). The same data renders +the same bytes. + +**Where `-last` is available.** `meta verify --templates` accepts `-last` only when the +innermost enclosing section over a LIST iterates objects. Anything else is reported as +`ERR_VAR_NOT_ON_PAYLOAD`: + +| Where `-last` appears | Verdict | +|---|---| +| Inside `{{#posts}}`, a list of objects | accepted | +| Inside a scalar conditional or a single nested object within that list (`{{#posts}}{{#note}}…{{/note}}{{/posts}}`) | accepted: those sections do not iterate, so the post's position is still the one in reach | +| Inside `{{#labels}}`, a list of scalars | reported: a scalar element has nowhere to carry a key. Give the list an object element instead | +| Inside a list of scalars nested in a list of objects (`{{#posts}}{{#labels}}…{{/labels}}{{/posts}}`) | reported: the lookup would fall through to the POST's position and separate the labels wrongly | +| At the top level, or inside a section over a single object that no list encloses | reported | + +A template that verify accepts renders correctly; that is the rule the table follows. Output of a +template that does not use `-last` is byte-for-byte what it was. + ## What each port generates ### TypeScript @@ -680,6 +716,7 @@ for payload typing per #270) - [`fixtures/render-conformance/render-empty-array-falsiness/`](../../fixtures/render-conformance/render-empty-array-falsiness/) — `{{#xs}}…{{/xs}}` over an empty array renders nothing (vs. iterates) - [`fixtures/render-conformance/render-falsy-values/`](../../fixtures/render-conformance/render-falsy-values/) — `false`, `null`, empty string, `0` — which are truthy for `{{#x}}` sections (per Mustache spec, not JS truthiness) - [`fixtures/render-conformance/render-inverted-section/`](../../fixtures/render-conformance/render-inverted-section/) — `{{^x}}…{{/x}}` renders when `x` is falsy/absent +- [`fixtures/render-conformance/render-list-separator/`](../../fixtures/render-conformance/render-list-separator/) — the `-last` positional accessor: `{{^-last}}, {{/-last}}` separators, nested lists, single-element and empty lists, a scalar conditional and a single nested object inside a list, an authored key - [`fixtures/render-conformance/render-nested-partials/`](../../fixtures/render-conformance/render-nested-partials/) — `{{>partial}}` resolves through the provider, supports nesting - [`fixtures/render-conformance/render-standalone-tag-stripping/`](../../fixtures/render-conformance/render-standalone-tag-stripping/) — a line containing only a section/partial tag is removed (whitespace + newline) - [`fixtures/render-conformance/render-raw-html-bypass/`](../../fixtures/render-conformance/render-raw-html-bypass/) — `{{{x}}}` (or `{{&x}}`) emits raw, unescaped under `@format=html` diff --git a/docs/ports/java.md b/docs/ports/java.md index d03257fb9..8af1efadb 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -300,6 +300,61 @@ object it emits — so its output ships whenever `entity` is in your `` entry for it. The other four ports expose `extractor` as a standalone generator; Java fuses it, which is why it carries a stable name here at all. +### Shared and `@provided` enums + +A package-level abstract `field.enum` that fields extend is one top-level Java `enum`, +`/.java`, referenced by every DTO, value-object record and output parser that +reaches it (directly or through another field). `SpringDtoGenerator` owns those files and +writes all of them. `SpringValueObjectGenerator` and `SpringOutputParserGenerator` write none +unless asked, which is what keeps a separate `outputDir` per generator from compiling two +copies of one class: + +```xml + + com.metaobjects.generator.spring.SpringValueObjectGenerator + + ${project.build.directory}/generated-sources/java + + true + + +``` + +A `@provided` enum is never generated. All three generators reference it where +`providedEnumNamespace` (one namespace for every provided enum) or `providedEnumPackages` +(`=` entries separated by `;`) point, and fail at generate time, +naming the enum, when neither resolves it. Set them once for every generator in the plugin's +``. + +**If you own `value-object` or `output-parser`.** A copy ejected before 1.1.0 nests a shared or +`@provided` enum inside the value-object record (`TicketPayload.Priority`) and parses into that +nested type. The packaged generators now reference the top-level enum. The two generators must +agree, so an old copy of one beside the packaged other generates code that does not compile +whenever a response value object has a field that reaches such an enum: + +| You own (ejected before 1.1.0) | Packaged | What fails to compile | +|---|---|---| +| `output-parser` | `value-object` | The parser emits `..valueOf(s)` for an enum the record no longer nests. | +| `value-object` | `output-parser` | The record still nests the enum, and the parser passes the top-level shared type to the record's constructor. | + +Bring every copy you own of the two to the new shape in one change. `mvn metaobjects:eject -Dlist` +prints `[owned — identical]` or `[owned — DIFFERS: N behind, M of your own]` beside each +generator. `behind` counts reference lines your copy lacks; `of your own` counts lines of your +copy the reference does not have, which includes lines a release changed, so an unedited old +copy also reads `DIFFERS`. For a copy you did not edit, re-eject it, naming only the generators +you own: + +```bash +mvn metaobjects:eject -Dnames=value-object,output-parser -Dport=java -Dforce +``` + +`-Dport=java` is there because `output-parser` is also a Kotlin generator. `-Dforce` replaces +the copy and rewrites `codegen/pom.xml`, so review both diffs. For an edited copy, diff the new +reference against it and apply the hunks by hand, as in +[Pulling an upstream fix into your copy](../features/own-your-codegen.md#what-eject-hands-over-and-what-stays-core). +An owned `dto` copy needs nothing for this: it asks the packaged `Fr019SharedEnum` helper for +each enum's type, so it follows the new rule without a change. + ### Reports A concrete `object.report` whose read source is a `source.rdb` of `@kind: view` is served diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 304897e38..725ca7b78 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -254,6 +254,37 @@ Every generator above that emits Exposed code reads one shared `exposedApi` arg: not others emits a mix of `org.jetbrains.exposed.sql.*` and `.v1.*` imports that cannot compile together. +### `@provided` enums (`providedEnumNamespace` / `providedEnumPackages`) + +A `field.enum` declared `@provided` is your own enum class: MetaObjects never generates it, +and every generated reference points at the namespace you configure. Every Kotlin generator +that types an enum reads the same two args: `KotlinEntityGenerator`, +`KotlinExposedTableGenerator`, `KotlinSpringControllerGenerator`, `KotlinRelationsGenerator`, +`KotlinOutputParserGenerator`, and the API pages `mvn metaobjects:docs` writes. Set them once, +in the plugin's ``, so no generator can be left out: + +```xml + + + + com.acme.enums + + acme::shop=com.acme.shop.enums;acme::billing=com.acme.money + + +``` + +`providedEnumPackages` is consulted first and `providedEnumNamespace` is the fallback. A model +with a `@provided` enum and neither arg fails at generate time, in whichever generator reaches +the enum first, with an error naming the enum, its package and both args. It applies to a +field that reaches the provided enum through another field as well as one that extends it +directly. + +A generator you ejected before 1.1.0 keeps compiling: the helper signatures it calls remain as +deprecated forms. Those forms take no config, so they name a `@provided` enum by the class in +its declaration's own package, as they did before, and never fail. Re-eject the generator to +read the two args (the 1.1.0 CHANGELOG has the command). + **Why 1.x.** Exposed 0.x shares one `IdentifierManagerApi` per `Database` across all threads, and its identifier caches are plain `LinkedHashMap`s mutated on every query — under concurrent load they corrupt (`ClassCastException`, or a thread spinning forever diff --git a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json index edb060783..06ad48809 100644 --- a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json +++ b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json @@ -3,7 +3,7 @@ "src/generated/Author.queries.ts": "42cc1ab2fd442559e1e9c5c3f14978f306efcda5472f8b5370d3339753e5a36f", "src/generated/Author.routes.ts": "3b0412b4501d02ba8716dae634522202714d467c5f9d577d8ea583868b1187d5", "src/generated/Author.ts": "36f28e09c3633848bf3bdb45372e03ae8b036792dec05f503a6f0d3abc1bb1cc", - "src/generated/InstructorProfile.ts": "83aa3425bb0f2ba409865339fa0d3b6af3ae63a4cbf1accc41b0e6e5c1841ccc", + "src/generated/InstructorProfile.ts": "2643b88401edf303789461d7d05bafd7cb1979b0ff19c27958d156192265986a", "src/generated/Lesson.form.tsx": "10435d259837450502de4261ec6220fd6fdeecdf14fb22e377d8fd6d149c172e", "src/generated/Lesson.queries.ts": "bc8e9bff3cb70c2c47ecfd07619f90d3f26a3981aca8ce6828f75e2a80fb9273", "src/generated/Lesson.routes.ts": "ab910b2a1cfe1d38a49ce5d54de149bdbdfcd5adb077363b913ce6bebd01de13", @@ -23,6 +23,6 @@ "src/generated/Purchase.queries.ts": "ae7896ebdf3a57332e2c79dc9dd99fef9547054371547d6a469a752a7fc64ad6", "src/generated/Purchase.routes.ts": "b1d1df2277754b63f137d5059603abc812a36795656099e7b9df4cf67928549c", "src/generated/Purchase.ts": "e37c57f29eda8d7d4a13e8d58ab1695f0d07b8a804b6f5abadaba75adaa66484", - "src/generated/SyllabusSection.ts": "bade3037cfbab599b92ea4f0b29509edbdbd5014bf29467e392853b124cdd2e3", + "src/generated/SyllabusSection.ts": "655f1fdd3c2f104fe48e8fb9a4147c3977a820f82e2b0a6ce1db8e4bd1b3ca2c", "src/generated/index.ts": "b542ee705d68fe1695f930a14bd7fb54bf8e89f4f0688aaba58b7df2c2fb29db" } diff --git a/examples/advanced-modeling/src/generated/InstructorProfile.ts b/examples/advanced-modeling/src/generated/InstructorProfile.ts index 293317cc7..4ee0b9bb9 100644 --- a/examples/advanced-modeling/src/generated/InstructorProfile.ts +++ b/examples/advanced-modeling/src/generated/InstructorProfile.ts @@ -13,3 +13,38 @@ export const InstructorProfileInsertSchema = z.object({ bio: z.string().max(2000).optional(), credentialsSummary: z.string().max(300).optional(), }); +/** + * Metadata constants for the value object InstructorProfile: each field's name, label, view and the + * validation rules derived from its validator children (e.g. `InstructorProfile..rules.maxLength.value`). + */ +export const InstructorProfile = { + $entity: "InstructorProfile", + displayName: { + name: "displayName", + label: "Display Name", + view: "text", + htmlType: "text", + rules: { + required: "Display Name is required", + maxLength: { value: 120, message: "Must be 120 characters or fewer" }, + }, + }, + bio: { + name: "bio", + label: "Bio", + view: "text", + htmlType: "text", + rules: { + maxLength: { value: 2000, message: "Must be 2000 characters or fewer" }, + }, + }, + credentialsSummary: { + name: "credentialsSummary", + label: "Credentials Summary", + view: "text", + htmlType: "text", + rules: { + maxLength: { value: 300, message: "Must be 300 characters or fewer" }, + }, + }, +} as const; diff --git a/examples/advanced-modeling/src/generated/SyllabusSection.ts b/examples/advanced-modeling/src/generated/SyllabusSection.ts index 3540fbca0..ae049c1ad 100644 --- a/examples/advanced-modeling/src/generated/SyllabusSection.ts +++ b/examples/advanced-modeling/src/generated/SyllabusSection.ts @@ -13,3 +13,36 @@ export const SyllabusSectionInsertSchema = z.object({ summary: z.string().max(500).optional(), orderIndex: z.number().int(), }); +/** + * Metadata constants for the value object SyllabusSection: each field's name, label, view and the + * validation rules derived from its validator children (e.g. `SyllabusSection..rules.maxLength.value`). + */ +export const SyllabusSection = { + $entity: "SyllabusSection", + heading: { + name: "heading", + label: "Heading", + view: "text", + htmlType: "text", + rules: { + required: "Heading is required", + maxLength: { value: 120, message: "Must be 120 characters or fewer" }, + }, + }, + summary: { + name: "summary", + label: "Summary", + view: "text", + htmlType: "text", + rules: { + maxLength: { value: 500, message: "Must be 500 characters or fewer" }, + }, + }, + orderIndex: { + name: "orderIndex", + label: "Order Index", + view: "number", + htmlType: "number", + rules: { required: "Order Index is required" }, + }, +} as const; diff --git a/examples/showcase/.metaobjects/.gen-state/.hashes.json b/examples/showcase/.metaobjects/.gen-state/.hashes.json index a7c20cc3d..a35c3c239 100644 --- a/examples/showcase/.metaobjects/.gen-state/.hashes.json +++ b/examples/showcase/.metaobjects/.gen-state/.hashes.json @@ -14,7 +14,7 @@ "generated/ts/Subscriber.queries.ts": "9f1a459c57fafc98bf074b4443b506d4a1e7023ed188f293021ae89c298d4562", "generated/ts/Subscriber.routes.ts": "147d32ae658f985e024c3de6e8894ca7359f767eeaa765cec70756802e3bd300", "generated/ts/Subscriber.ts": "a78c17f915222ee108290c28468313d77f77b5cff438f485324409302e389a96", - "generated/ts/SubscriberBlurbPayload.ts": "9b5e9be314bc0a9feed5fe0ad911d2cd14c961e455e52b87a44b4905353ec19e", + "generated/ts/SubscriberBlurbPayload.ts": "83272130c428ad0cc68190cb63e32b418a0bcfd05345804def9bf22e79b55fcc", "generated/ts/index.ts": "2c915565fd8711503dbcd0b0139c657bb927c7f0f502d8d2a8b51e026b257694", "generated/ts/prompts.ts": "67365bc5504dbb19fafbf6c654afa822f7fe6af4e5f24fd246d69657698db606", "generated/ts/requirements/subscriberCanBePausedWithoutErasingHistory.field.enum.test.ts": "a5d2494ce5340673da43f2ee3ef13d7505ee349724e8d85f377a84eafeea50ae" diff --git a/examples/showcase/generated/ts/SubscriberBlurbPayload.ts b/examples/showcase/generated/ts/SubscriberBlurbPayload.ts index c2b66c0d9..e8c4f8182 100644 --- a/examples/showcase/generated/ts/SubscriberBlurbPayload.ts +++ b/examples/showcase/generated/ts/SubscriberBlurbPayload.ts @@ -13,3 +13,18 @@ export const SubscriberBlurbPayloadInsertSchema = z.object({ name: z.string().optional(), status: z.enum(["active", "paused", "cancelled"]), }); +/** + * Metadata constants for the value object SubscriberBlurbPayload: each field's name, label, view and the + * validation rules derived from its validator children (e.g. `SubscriberBlurbPayload..rules.maxLength.value`). + */ +export const SubscriberBlurbPayload = { + $entity: "SubscriberBlurbPayload", + name: { name: "name", label: "Name", view: "text", htmlType: "text" }, + status: { + name: "status", + label: "Status", + view: "dropdown", + rules: { required: "Status is required" }, + options: ["active", "paused", "cancelled"] as const, + }, +} as const; diff --git a/fixtures/codegen-conformance/README.md b/fixtures/codegen-conformance/README.md index a29f2803f..b86d565f5 100644 --- a/fixtures/codegen-conformance/README.md +++ b/fixtures/codegen-conformance/README.md @@ -37,6 +37,19 @@ closed the project's normal way: **adding new metamodel behavior ⇒ add a behav exercises it** (`spec/conformance-tests.md`). No standing codegen-manifest corpus is needed; add a persistence/api-contract fixture when one of those types/validators is touched. +## `shared-provided-enum/` is a shared input with a decision table, not an output corpus + +`shared-provided-enum/` holds one metadata input every port's FR-019 test loads, and +`expected-enum-types.json`, a port-neutral DECISION TABLE: for every object-hosted enum field +in that input, the shared declaration its type resolves to (or `null`), whether that declaration +is `@provided`, and the declarations that must be materialized. It records a RESOLUTION, never +generated bytes, so each port stays free to emit its own idiom. + +It meets the bar this README sets. The incident: a two-hop enum field (`A.x extends B.y`, where +`B.y extends` a shared abstract enum) generated a per-object copy of the enum in one port and +the shared type in the others, and every behavior corpus stayed green, because both shapes +store and validate the same members. The rule it gates is ADR-0026 Amendment 1. + ## Do not delete this README / do not re-propose FR-007 Keep this file so the gap-that-isn't stays documented. If you believe a codegen corpus is warranted, diff --git a/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json new file mode 100644 index 000000000..d1652b74b --- /dev/null +++ b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json @@ -0,0 +1,29 @@ +{ + "fields": { + "acme::shop::Ticket.priority": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Ticket.severity": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Ticket.tier": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Ticket.currency": { "shared": "acme::shop::Currency", "provided": true }, + "acme::shop::Order.priority": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Order.status": { "shared": null, "provided": false }, + "acme::shop::Task.urgency": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Task.importance": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Task.phase": { "shared": null, "provided": false }, + "acme::shop::Task.weight": { "shared": null, "provided": false }, + "acme::shop::Subtask.rank": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Subtask.phase": { "shared": null, "provided": false }, + "acme::shop::Subtask.weight": { "shared": null, "provided": false }, + "acme::shop::Invoice.money": { "shared": "acme::shop::Money", "provided": false }, + "acme::shop::Invoice.tier": { "shared": "acme::shop::Money", "provided": false }, + "acme::shop::Refund.unit": { "shared": "acme::shop::Money", "provided": false }, + "acme::shop::Refund.tender": { "shared": "acme::shop::Currency", "provided": true }, + "acme::shop::Refund.orderStatus": { "shared": null, "provided": false }, + "acme::shop::Refund.ticketTier": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::Refund.invoiceTier": { "shared": "acme::shop::Money", "provided": false }, + "acme::shop::TicketSummary.level": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::TicketBadge.badge": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::TicketLabel.grade": { "shared": "acme::shop::Priority", "provided": false }, + "acme::shop::TicketLabel.denomination": { "shared": "acme::shop::Currency", "provided": true } + }, + "materialized": ["Money", "Priority"] +} diff --git a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json index eb87f21f3..c3bf2f1a0 100644 --- a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json +++ b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json @@ -6,23 +6,67 @@ { "field.enum": { "name": "Currency", "abstract": true, "@provided": true, "@values": ["USD", "EUR", "GBP"] } }, { "field.enum": { "name": "Money", "abstract": true, "extends": "Currency" } }, { "object.entity": { "name": "Ticket", "children": [ - { "field.long": { "name": "id" } }, - { "field.enum": { "name": "priority", "extends": "Priority" } }, - { "field.enum": { "name": "currency", "extends": "Currency" } }, - { "source.rdb": { "@table": "tickets" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "priority", "extends": "Priority" } }, + { "field.enum": { "name": "severity", "extends": "Priority" } }, + { "field.enum": { "name": "tier", "extends": "Priority" } }, + { "field.enum": { "name": "currency", "extends": "Currency" } }, + { "source.rdb": { "@table": "tickets" } }, { "identity.primary": { "name": "pk", "@fields": ["id"] } } ] } }, { "object.entity": { "name": "Order", "children": [ - { "field.long": { "name": "id" } }, - { "field.enum": { "name": "priority", "extends": "Priority" } }, - { "source.rdb": { "@table": "orders" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "priority", "extends": "Priority" } }, + { "field.enum": { "name": "status", "@values": ["NEW", "PAID"] } }, + { "source.rdb": { "@table": "orders" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "Task", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "urgency", "extends": "acme::shop::Ticket.priority" } }, + { "field.enum": { "name": "importance", "extends": "acme::shop::Ticket.severity" } }, + { "field.enum": { "name": "phase", "extends": "acme::shop::Ticket.tier", "@values": ["OPEN", "DONE"] } }, + { "field.enum": { "name": "weight", "extends": "acme::shop::Ticket.severity", "@intValueMap": { "LOW": 1, "MEDIUM": 2, "HIGH": 3 } } }, + { "source.rdb": { "@table": "tasks" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "Subtask", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "rank", "extends": "acme::shop::Task.importance" } }, + { "field.enum": { "name": "phase", "extends": "acme::shop::Task.phase" } }, + { "field.enum": { "name": "weight", "extends": "acme::shop::Task.weight" } }, + { "source.rdb": { "@table": "subtasks" } }, { "identity.primary": { "name": "pk", "@fields": ["id"] } } ] } }, { "object.entity": { "name": "Invoice", "children": [ - { "field.long": { "name": "id" } }, - { "field.enum": { "name": "money", "extends": "Money" } }, - { "source.rdb": { "@table": "invoices" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "money", "extends": "Money" } }, + { "field.enum": { "name": "tier", "extends": "Money" } }, + { "source.rdb": { "@table": "invoices" } }, { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "Refund", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "unit", "extends": "acme::shop::Invoice.money" } }, + { "field.enum": { "name": "tender", "extends": "acme::shop::Ticket.currency" } }, + { "field.enum": { "name": "orderStatus", "extends": "acme::shop::Order.status" } }, + { "field.enum": { "name": "ticketTier", "extends": "acme::shop::Ticket.tier" } }, + { "field.enum": { "name": "invoiceTier", "extends": "acme::shop::Invoice.tier" } }, + { "source.rdb": { "@table": "refunds" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.projection": { "name": "TicketSummary", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_ticket_summary" } }, + { "field.long": { "name": "id", "extends": "acme::shop::Ticket.id" } }, + { "field.enum": { "name": "level", "extends": "acme::shop::Ticket.severity" } }, + { "identity.primary": { "name": "pk", "extends": "acme::shop::Ticket.pk" } } + ] } }, + { "object.value": { "name": "TicketBadge", "children": [ + { "field.enum": { "name": "badge", "extends": "acme::shop::Ticket.severity" } } + ] } }, + { "object.value": { "name": "TicketLabel", "children": [ + { "field.enum": { "name": "grade", "extends": "Priority" } }, + { "field.enum": { "name": "denomination", "extends": "Currency" } } ] } } ] } diff --git a/fixtures/render-conformance/render-list-separator/expected.txt b/fixtures/render-conformance/render-list-separator/expected.txt new file mode 100644 index 000000000..c786b4029 --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/expected.txt @@ -0,0 +1,7 @@ +items: a, b, c +solo: only +none: [] +nested: [x|y];[z] +conditional: a(x); bc(z) +object: ann, bob, cy +authored: pq diff --git a/fixtures/render-conformance/render-list-separator/meta.json b/fixtures/render-conformance/render-list-separator/meta.json new file mode 100644 index 000000000..0fdd6d37f --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/meta.json @@ -0,0 +1 @@ +{ "format": "text", "note": "Positional -last accessor on collection elements: separator via {{^-last}}, nested lists keep their own position, single-element and empty lists, a scalar conditional and a single nested object inside a list see the enclosing element's position, authored key wins" } diff --git a/fixtures/render-conformance/render-list-separator/payload.json b/fixtures/render-conformance/render-list-separator/payload.json new file mode 100644 index 000000000..4bf973660 --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/payload.json @@ -0,0 +1,15 @@ +{ + "items": [{ "name": "a" }, { "name": "b" }, { "name": "c" }], + "solo": [{ "name": "only" }], + "none": [], + "groups": [ + { "tags": [{ "label": "x" }, { "label": "y" }] }, + { "tags": [{ "label": "z" }] } + ], + "entries": [ + { "name": "a", "note": "x", "owner": { "nick": "ann" } }, + { "name": "b", "owner": { "nick": "bob" } }, + { "name": "c", "note": "z", "owner": { "nick": "cy" } } + ], + "authored": [{ "name": "p", "-last": true }, { "name": "q", "-last": true }] +} diff --git a/fixtures/render-conformance/render-list-separator/template.mustache b/fixtures/render-conformance/render-list-separator/template.mustache new file mode 100644 index 000000000..a7d11096f --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/template.mustache @@ -0,0 +1,7 @@ +items: {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}} +solo: {{#solo}}{{name}}{{^-last}}, {{/-last}}{{/solo}} +none: [{{#none}}{{name}}{{^-last}}, {{/-last}}{{/none}}] +nested: {{#groups}}[{{#tags}}{{label}}{{^-last}}|{{/-last}}{{/tags}}]{{^-last}};{{/-last}}{{/groups}} +conditional: {{#entries}}{{name}}{{#note}}({{note}}){{^-last}}; {{/-last}}{{/note}}{{/entries}} +object: {{#entries}}{{#owner}}{{nick}}{{^-last}}, {{/-last}}{{/owner}}{{/entries}} +authored: {{#authored}}{{name}}{{^-last}}, {{/-last}}{{/authored}} diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json new file mode 100644 index 000000000..ecf481bd5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json @@ -0,0 +1,12 @@ +[ + { "name": "displayName" }, + { "name": "posts", "isArray": true, "fields": [ + { "name": "title" }, + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } + ] }, + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } +] diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache new file mode 100644 index 000000000..ab48fe2d2 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache @@ -0,0 +1 @@ +{{#posts}}{{title}}{{^-last}}, {{/-last}}{{#tags}}{{name}}{{^-last}}|{{/-last}}{{/tags}}{{/posts}} diff --git a/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json new file mode 100644 index 000000000..b8eb25ba5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json @@ -0,0 +1 @@ +[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ] diff --git a/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json new file mode 100644 index 000000000..ecf481bd5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json @@ -0,0 +1,12 @@ +[ + { "name": "displayName" }, + { "name": "posts", "isArray": true, "fields": [ + { "name": "title" }, + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } + ] }, + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } +] diff --git a/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache new file mode 100644 index 000000000..631dc3b47 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache @@ -0,0 +1 @@ +{{#posts}}{{title}}: {{#labels}}{{.}}{{^-last}}, {{/-last}}{{/labels}}{{/posts}} diff --git a/fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json new file mode 100644 index 000000000..b8eb25ba5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json @@ -0,0 +1 @@ +[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ] diff --git a/fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json b/fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json new file mode 100644 index 000000000..ecf481bd5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json @@ -0,0 +1,12 @@ +[ + { "name": "displayName" }, + { "name": "posts", "isArray": true, "fields": [ + { "name": "title" }, + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } + ] }, + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } +] diff --git a/fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache new file mode 100644 index 000000000..2225bd4b1 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache @@ -0,0 +1 @@ +{{#owner}}{{name}}{{^-last}}, {{/-last}}{{/owner}} diff --git a/fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json new file mode 100644 index 000000000..b8eb25ba5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json @@ -0,0 +1 @@ +[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ] diff --git a/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json new file mode 100644 index 000000000..ecf481bd5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json @@ -0,0 +1,12 @@ +[ + { "name": "displayName" }, + { "name": "posts", "isArray": true, "fields": [ + { "name": "title" }, + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } + ] }, + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } +] diff --git a/fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache new file mode 100644 index 000000000..503a949dc --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache @@ -0,0 +1 @@ +{{displayName}}{{^-last}}, {{/-last}} diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json new file mode 100644 index 000000000..b8eb25ba5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json @@ -0,0 +1 @@ +[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ] diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json new file mode 100644 index 000000000..ecf481bd5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json @@ -0,0 +1,12 @@ +[ + { "name": "displayName" }, + { "name": "posts", "isArray": true, "fields": [ + { "name": "title" }, + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } + ] }, + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } +] diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache new file mode 100644 index 000000000..250f9e5f5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache @@ -0,0 +1 @@ +{{#labels}}{{.}}{{^-last}}, {{/-last}}{{/labels}} diff --git a/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json new file mode 100644 index 000000000..ecf481bd5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json @@ -0,0 +1,12 @@ +[ + { "name": "displayName" }, + { "name": "posts", "isArray": true, "fields": [ + { "name": "title" }, + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } + ] }, + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } +] diff --git a/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache new file mode 100644 index 000000000..38152320a --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache @@ -0,0 +1 @@ +{{#posts}}{{title}}{{#note}} ({{note}}){{^-last}}; {{/-last}}{{/note}}{{#author}} by {{name}}{{^-last}}, {{/-last}}{{/author}}{{/posts}} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs index d86ef9166..15a90c6ce 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs @@ -1,10 +1,13 @@ +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using MetaObjects.Codegen; using MetaObjects.Codegen.Generators; using MetaObjects.Loader; using MetaObjects.Meta; +using static MetaObjects.Core.Field.FieldConstants; using Xunit; namespace MetaObjects.Codegen.Tests; @@ -61,6 +64,24 @@ public void Shared_abstract_enum_is_materialized_once_and_referenced_by_both_ent } } + [Fact] + public void Multi_hop_enum_extends_still_resolves_to_the_shared_enum() + { + // Task.urgency extends Ticket.priority, which extends the shared abstract Priority + // (two hops); Task.importance extends Ticket.severity, same chain. Task ALSO carries + // Task.phase/Task.weight, which declare their own @values/@intValueMap and so keep + // their own per-object nested enum (rule (b)) — the fixture now exercises both in one + // object, so the assertion below is scoped to the SHARED type, not "any nested enum". + var files = new EntityGenerator().Generate(Ctx(LoadFixture(), providedNs: "Acme.External")).ToList(); + var task = Assert.Single(files, f => f.Path == "Task.g.cs"); + Assert.DoesNotContain("public enum Priority", task.Content); + Assert.Contains("public Priority? Urgency", task.Content); + Assert.Contains("public Priority? Importance", task.Content); + // The negatives: own @values / own @intValueMap stop resolution, so these stay inline. + Assert.Contains("public enum TaskPhase", task.Content); + Assert.Contains("public enum TaskWeight", task.Content); + } + [Fact] public void Provided_enum_is_not_materialized_and_referenced_at_the_configured_namespace() { @@ -80,4 +101,131 @@ public void Provided_enum_with_no_namespace_config_is_a_codegen_error_naming_the new EntityGenerator().Generate(Ctx(LoadFixture(), providedNs: null)).ToList()); Assert.Contains("Currency", ex.Message); } + + // ── Decision table (#246/FR-019 chain rule) ── + // + // For every object-hosted field.enum in the fixture, compute the SAME decision the + // generators use — Fr019SharedEnum.ResolveSharedEnumDecl (the resolved declaration, or + // null for inline) + Fr019SharedEnum.SharedEnumForField (its @provided-ness) — keyed + // `::.`, and assert exact equality (no missing key, no extra + // key) against the shared fixture's expected-enum-types.json. The chain rule is a + // walk-order-sensitive decision (nearest shared declaration wins; an own @values/ + // @intValueMap partway up stops it), so a table beats one assertion per case. + + private static JsonDocument LoadExpectedEnumTypes() + { + var path = Path.Combine( + LocateRepoRoot(), "fixtures", "codegen-conformance", "shared-provided-enum", "expected-enum-types.json"); + return JsonDocument.Parse(File.ReadAllText(path)); + } + + private static (string? Shared, bool Provided) Decide(MetaField field) + { + var decl = Fr019SharedEnum.ResolveSharedEnumDecl(field); + var provided = decl is not null && (Fr019SharedEnum.SharedEnumForField(field)?.Provided ?? false); + return (decl?.ResolutionKey(), provided); + } + + [Fact] + public void Decision_table_matches_the_shared_fixture_exactly() + { + var root = LoadFixture(); + using var expected = LoadExpectedEnumTypes(); + var expectedFields = expected.RootElement.GetProperty("fields"); + + var actual = new Dictionary(System.StringComparer.Ordinal); + foreach (var obj in root.Objects()) + foreach (var field in obj.Fields()) + { + if (field.SubType != FIELD_SUBTYPE_ENUM) continue; + actual[$"{obj.ResolutionKey()}.{field.Name}"] = Decide(field); + } + + var expectedKeys = new HashSet( + expectedFields.EnumerateObject().Select(p => p.Name), System.StringComparer.Ordinal); + var missing = expectedKeys.Except(actual.Keys).ToList(); + var extra = actual.Keys.Except(expectedKeys).ToList(); + Assert.Empty(missing); + Assert.Empty(extra); + + foreach (var prop in expectedFields.EnumerateObject()) + { + var sharedEl = prop.Value.GetProperty("shared"); + string? expectedShared = sharedEl.ValueKind == JsonValueKind.Null ? null : sharedEl.GetString(); + var expectedProvided = prop.Value.GetProperty("provided").GetBoolean(); + var (actualShared, actualProvided) = actual[prop.Name]; + Assert.Equal(expectedShared, actualShared); + Assert.Equal(expectedProvided, actualProvided); + } + + var expectedMaterialized = expected.RootElement.GetProperty("materialized") + .EnumerateArray().Select(e => e.GetString()!).ToList(); + var actualMaterialized = Fr019SharedEnum.MaterializedSharedEnums(root) + .Select(e => e.Name).OrderBy(n => n, System.StringComparer.Ordinal).ToList(); + Assert.Equal(expectedMaterialized, actualMaterialized); + } + + [Fact] + public void Generated_output_covers_every_multi_hop_and_negative_case_in_the_decision_table() + { + var files = new EntityGenerator().Generate(Ctx(LoadFixture(), providedNs: "Acme.External")).ToList(); + + // Priority (and its sibling chained declaration Money) are each emitted exactly + // once, no matter how many fields across how many hops consume them. + foreach (var name in new[] { "Priority", "Money" }) + { + var decls = files.Sum(f => Regex.Matches(f.Content, $@"public enum {name}\b").Count); + Assert.Equal(1, decls); + } + + // Multi-hop consumers reference the shared Priority type and nest nothing. + var task = Assert.Single(files, f => f.Path == "Task.g.cs"); + Assert.Contains("public Priority? Importance", task.Content); // Task.importance -> Ticket.severity -> Priority + + var subtask = Assert.Single(files, f => f.Path == "Subtask.g.cs"); + Assert.Contains("public Priority? Rank", subtask.Content); // Subtask.rank: three hops to Priority + Assert.DoesNotContain("public enum Priority", subtask.Content); + + var ticketSummary = Assert.Single(files, f => f.Path == "TicketSummary.g.cs"); // projection, view source + Assert.Contains("public Priority? Level", ticketSummary.Content); + Assert.DoesNotContain("public enum", ticketSummary.Content); + + var ticketBadge = Assert.Single(files, f => f.Path == "TicketBadge.g.cs"); // value object + Assert.Contains("public Priority? Badge", ticketBadge.Content); + Assert.DoesNotContain("public enum", ticketBadge.Content); + + // Refund: two fields whose immediate supers are same-named fields of different + // objects, resolving to two different shared declarations. + var refund = Assert.Single(files, f => f.Path == "Refund.g.cs"); + Assert.Contains("public Priority? TicketTier", refund.Content); // Refund.ticketTier -> Ticket.tier -> Priority + Assert.Contains("public Money? InvoiceTier", refund.Content); // Refund.invoiceTier -> Invoice.tier -> Money + Assert.Contains("public Money? Unit", refund.Content); // Refund.unit -> Invoice.money -> Money (materialized) + Assert.Contains("public Acme.External.Currency? Tender", refund.Content); // @provided, never materialized + Assert.DoesNotContain("public enum Currency", refund.Content); + + // Negatives: an own @values / own @intValueMap partway up the chain stops sharing, + // so these keep their per-object inline nested enum. + Assert.Contains("public enum TaskPhase", task.Content); + Assert.Contains("public enum TaskWeight", task.Content); + Assert.Contains("public enum SubtaskPhase", subtask.Content); + Assert.Contains("public enum SubtaskWeight", subtask.Content); + Assert.Contains("public enum RefundOrderStatus", refund.Content); + } + + [Fact] + public void A_run_narrowed_to_only_the_projection_still_materializes_the_shared_enum_it_consumes() + { + var root = LoadFixture(); + var narrowed = root.Objects().Where(o => o.Name == "TicketSummary").ToList(); + var ctx = new GenContext + { + Entities = narrowed, + Root = root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Gen", ProvidedEnumNamespace = "Acme.External" }, + }; + + var files = new EntityGenerator().Generate(ctx).ToList(); + var enums = Assert.Single(files, f => f.Path == "Enums.g.cs"); + Assert.Contains("public enum Priority { LOW, MEDIUM, HIGH }", enums.Content); + } } diff --git a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs index f320a939c..7325f6bce 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs @@ -22,6 +22,7 @@ // enum. Shared + @provided are additive branches gated on "root-level abstract // field.enum", so the inline default output is byte-identical (the drift gate). +using MetaObjects.Core.Field; using MetaObjects.Meta; using MetaObjects.Shared; using static MetaObjects.Core.Field.FieldConstants; @@ -42,28 +43,24 @@ public sealed record SharedEnum(string Name, IReadOnlyList Values, bool public static class Fr019SharedEnum { /// - /// The root-level abstract field.enum a concrete enum field resolves to via - /// extends, or when the field is an inline enum (no - /// root-abstract super). A super qualifies only when it is (a) an abstract field, AND - /// (b) a direct child of the metadata root (a package-level declaration) — NOT an - /// abstract field nested inside another object. + /// The shared enum declaration a concrete enum field resolves to via extends, or + /// when the field is an inline enum. Delegates the walk to + /// — the single + /// cross-port/loader authority (#246 / FR-019) — so naming and materialization can never + /// drift from what the loader's conflict check agrees is "shared". /// - public static MetaField? ResolveSharedEnumDecl(MetaField field) - { - if (field.SubType != FIELD_SUBTYPE_ENUM) return null; - if (field.ResolveSuper() is not { } sup) return null; - if (!sup.IsAbstract) return null; - // The declaration must sit at the package/root level (sibling of object.entity), - // i.e. its parent node is the metadata root. - if (sup.Parent is not { } parent || parent.Type != BaseTypes.TYPE_METADATA) return null; - return sup; - } + public static MetaField? ResolveSharedEnumDecl(MetaField field) => + SharedEnumDeclaration.ResolveSharedEnumDeclaration(field); /// The shared-enum descriptor a field resolves to, or null for inline enums. public static SharedEnum? SharedEnumForField(MetaField field) { if (ResolveSharedEnumDecl(field) is not { } decl) return null; - if (field.EffectiveEnumValues is not { Count: > 0 } values) return null; + // Read the member set from the DECLARATION, not the consuming field: every field + // sharing this declaration must agree on its materialized shape, so the read must be + // anchored at the one node the shape is owned by rather than wherever the walk + // happened to start — output cannot depend on which consumer codegen visits first. + if (decl.EffectiveEnumValues is not { Count: > 0 } values) return null; return new SharedEnum( Name: CSharpNaming.Pascal(decl.Name), Values: values, diff --git a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs index fc31a0e6e..07843efb1 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs @@ -286,6 +286,11 @@ private static IReadOnlyList DerivePayloadFieldTree( var fields = new List(); foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD)) { + // ADR-0039: ResolvedIsArray() — the effective array-ness — is the one fact Verify's + // positional-accessor rule needs to tell an array-of-objects/array-of-scalars + // section from a single-object/scalar one (both carry child Fields for the object + // case; only IsArray tells them apart). + var isArray = f.ResolvedIsArray(); if (f.SubType == FIELD_SUBTYPE_OBJECT && // ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr). f.Attr(FIELD_ATTR_OBJECT_REF) is string refName) @@ -300,11 +305,11 @@ private static IReadOnlyList DerivePayloadFieldTree( if (target is not null && target.SubType == OBJECT_SUBTYPE_VALUE) { var children = DerivePayloadFieldTree(root, target, new HashSet(seen, StringComparer.Ordinal)); - fields.Add(new PayloadField(f.Name, children)); + fields.Add(new PayloadField(f.Name, children, isArray)); continue; } } - fields.Add(new PayloadField(f.Name)); + fields.Add(new PayloadField(f.Name, IsArray: isArray)); } return fields; } @@ -329,7 +334,9 @@ private static IReadOnlyList DerivePayloadFieldTree( /// Emit a IReadOnlyList<PayloadField> as a deterministic C# array /// literal: new PayloadField[] { new PayloadField("name"), /// new PayloadField("nested", new PayloadField[] { ... }) }. An empty tree - /// emits new global::MetaObjects.Render.PayloadField[] { }. + /// emits new global::MetaObjects.Render.PayloadField[] { }. The array flag is + /// emitted ONLY when true (IsArray: true), so a helper with no array field in + /// its payload stays byte-identical to before this flag existed. /// private static string FieldTreeLiteral(IReadOnlyList fields) { @@ -338,10 +345,11 @@ private static string FieldTreeLiteral(IReadOnlyList fields) { if (i > 0) sb.Append(", "); var f = fields[i]; + var arrayArg = f.IsArray ? ", IsArray: true" : ""; if (f.Fields is not null) - sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)}, {FieldTreeLiteral(f.Fields)})"); + sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)}, {FieldTreeLiteral(f.Fields)}{arrayArg})"); else - sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)})"); + sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)}{arrayArg})"); } sb.Append(" }"); return sb.ToString(); diff --git a/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs b/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs index 4f7a99cf5..b0ebbcfe1 100644 --- a/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs +++ b/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs @@ -56,6 +56,11 @@ private static IReadOnlyList BuildTree(MetaData root, string voNam var fields = new List(); foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD)) { + // ADR-0039: ResolvedIsArray() — the EFFECTIVE (inherited-via-extends) array-ness, + // never the own flag — is what Verify's positional-accessor rule needs to tell an + // array-of-scalars (no `-last`) from a single scalar, and an array-of-objects from + // a single nested object (both carry child Fields; only IsArray disambiguates). + var isArray = f.ResolvedIsArray(); // ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr). if (f.SubType == FIELD_SUBTYPE_OBJECT && f.Attr(FIELD_ATTR_OBJECT_REF) is string refName) { @@ -65,10 +70,10 @@ private static IReadOnlyList BuildTree(MetaData root, string voNam var fieldPkg = f.Parent is not null ? global::MetaObjects.NamingRefs.EffectivePackage(f.Parent) : voPkg; - fields.Add(new PayloadField(f.Name, BuildTree(root, refName, fieldPkg, visiting))); + fields.Add(new PayloadField(f.Name, BuildTree(root, refName, fieldPkg, visiting), isArray)); } else - fields.Add(new PayloadField(f.Name)); + fields.Add(new PayloadField(f.Name, IsArray: isArray)); } visiting.Remove(vo.ResolutionKey()); return fields; diff --git a/server/csharp/MetaObjects.Conformance.Tests/EnumExtendsCycleTests.cs b/server/csharp/MetaObjects.Conformance.Tests/EnumExtendsCycleTests.cs new file mode 100644 index 000000000..e7aa15774 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/EnumExtendsCycleTests.cs @@ -0,0 +1,56 @@ +// A cyclic `extends` between two field.enum nodes that declare no @values must load to the +// loader's ordinary errors. Reading the effective @values of either node walks the chain, and +// the C# loader does not reject a cycle before the validation passes run, so an unguarded walk +// overflows the stack and takes the process down with no diagnostic. +// +// The expected codes are the TypeScript reference loader's answer for this same input: +// ERR_MISSING_REQUIRED_ATTR (neither node resolves a @values) and ERR_BAD_ATTR_VALUE (the +// @default is not a member of an empty set). + +using MetaObjects; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public class EnumExtendsCycleTests +{ + private const string CyclicEnums = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "A", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "x", "extends": "acme::B.y", "@default": "Q" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "B", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "y", "extends": "acme::A.x" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } } + ] } } + """; + + [Fact] + public void Cyclic_enum_extends_loads_to_ordinary_errors() + { + var result = MetaDataLoader.FromString(CyclicEnums, MetaDataFormat.Json); + + Assert.Contains(result.Errors, e => e.Code == ErrorCode.ERR_MISSING_REQUIRED_ATTR); + Assert.Contains(result.Errors, e => e.Code == ErrorCode.ERR_BAD_ATTR_VALUE); + } + + [Fact] + public void Effective_enum_values_of_a_cyclic_chain_is_null() + { + var result = MetaDataLoader.FromString(CyclicEnums, MetaDataFormat.Json); + + var cyclic = result.Root!.Objects() + .SelectMany(o => o.Children()) + .OfType() + .Where(f => f.Name is "x" or "y") + .ToList(); + Assert.Equal(2, cyclic.Count); + Assert.All(cyclic, f => Assert.Null(f.EffectiveEnumValues)); + } +} diff --git a/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs new file mode 100644 index 000000000..23a8cff81 --- /dev/null +++ b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs @@ -0,0 +1,217 @@ +using System.Text.Json.Serialization; +using Xunit; + +namespace MetaObjects.Render.Tests; + +/// +/// A typed payload must render byte-identically to the equal map payload: -last and +/// has<Field> come from one mechanism, whatever the payload's shape. The oracle is +/// the shared render-list-separator fixture, whose expected.txt is shared by every port. +/// +public class TypedPayloadTests +{ + // Generated-POCO shape: PascalCase members carrying the metadata field name as a wire name. + private sealed class WireItem { [JsonPropertyName("name")] public string Name { get; set; } = ""; } + private sealed class WireTag { [JsonPropertyName("label")] public string Label { get; set; } = ""; } + private sealed class WireGroup { [JsonPropertyName("tags")] public List Tags { get; set; } = new(); } + private sealed class WireOwner { [JsonPropertyName("nick")] public string Nick { get; set; } = ""; } + private sealed class WireEntry + { + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("note")] public string? Note { get; set; } + [JsonPropertyName("owner")] public WireOwner Owner { get; set; } = new(); + } + private sealed class WirePayload + { + [JsonPropertyName("items")] public List Items { get; set; } = new(); + [JsonPropertyName("solo")] public List Solo { get; set; } = new(); + [JsonPropertyName("none")] public List None { get; set; } = new(); + [JsonPropertyName("groups")] public List Groups { get; set; } = new(); + [JsonPropertyName("entries")] public List Entries { get; set; } = new(); + [JsonPropertyName("authored")] public List> Authored { get; set; } = new(); + } + + // Plain shape: members ARE the field names, no attributes. + private sealed record PlainItem(string name); + private sealed record PlainTag(string label); + private sealed record PlainGroup(List tags); + private sealed record PlainOwner(string nick); + private sealed record PlainEntry(string name, string? note, PlainOwner owner); + private sealed record PlainPayload( + List items, List solo, List none, + List groups, List entries, List> authored); + + private static string FixtureDir() + { + string root = AppContext.BaseDirectory; + while (!Directory.Exists(Path.Combine(root, "fixtures", "render-conformance"))) + root = Directory.GetParent(root)?.FullName ?? throw new InvalidOperationException("fixtures not found"); + return Path.Combine(root, "fixtures", "render-conformance", "render-list-separator"); + } + + private static string Render(string template, object? payload) => + Renderer.Render(new RenderRequest { Template = template, Payload = payload, Format = "text", Provider = new InMemoryProvider(new Dictionary()) }); + + private static Dictionary Authored(string name) => + new() { ["name"] = name, ["-last"] = true }; + + [Fact] + public void WireNamedPayloadRendersTheSharedFixtureByteIdentically() + { + var payload = new WirePayload + { + Items = { new() { Name = "a" }, new() { Name = "b" }, new() { Name = "c" } }, + Solo = { new() { Name = "only" } }, + Groups = + { + new() { Tags = { new() { Label = "x" }, new() { Label = "y" } } }, + new() { Tags = { new() { Label = "z" } } }, + }, + Entries = + { + new() { Name = "a", Note = "x", Owner = new() { Nick = "ann" } }, + new() { Name = "b", Owner = new() { Nick = "bob" } }, + new() { Name = "c", Note = "z", Owner = new() { Nick = "cy" } }, + }, + Authored = { Authored("p"), Authored("q") }, + }; + var template = File.ReadAllText(Path.Combine(FixtureDir(), "template.mustache")); + var expected = File.ReadAllText(Path.Combine(FixtureDir(), "expected.txt")); + Assert.Equal(expected, Render(template, payload)); + } + + [Fact] + public void PlainMemberPayloadRendersTheSharedFixtureByteIdentically() + { + var payload = new PlainPayload( + [new("a"), new("b"), new("c")], [new("only")], [], + [new([new("x"), new("y")]), new([new("z")])], + [new("a", "x", new("ann")), new("b", null, new("bob")), new("c", "z", new("cy"))], + [Authored("p"), Authored("q")]); + var template = File.ReadAllText(Path.Combine(FixtureDir(), "template.mustache")); + var expected = File.ReadAllText(Path.Combine(FixtureDir(), "expected.txt")); + Assert.Equal(expected, Render(template, payload)); + } + + [Fact] + public void HasFieldGateWorksOnTypedElementsLikeOnMaps() + { + const string template = "{{#items}}{{name}}:{{#hasNote}}y{{/hasNote}}{{^hasNote}}n{{/hasNote}} {{/items}}"; + var typed = new List + { + new() { Name = "a", Note = "x" }, + new() { Name = "b", Note = " " }, + }; + var maps = new List> + { + new() { ["name"] = "a", ["note"] = "x" }, + new() { ["name"] = "b", ["note"] = " " }, + }; + var viaTyped = Render(template, new Dictionary { ["items"] = typed }); + var viaMaps = Render(template, new Dictionary { ["items"] = maps }); + Assert.Equal("a:y b:n ", viaMaps); + Assert.Equal(viaMaps, viaTyped); + } + + // ---- The overlay adds names; it never takes any away ----------------------------------- + + private readonly record struct Money(long Cents, string Currency) + { + public override string ToString() => $"{Currency} {Cents}c"; + } + + /// Every read of , counted before it throws. + private static int s_explodingReads; + + private sealed class Line + { + public string Sku = ""; + public Money Price { get; init; } + public string Label() => "label:" + Sku; + public string Exploding + { + get + { + Interlocked.Increment(ref s_explodingReads); + throw new InvalidOperationException("read a member the template never names"); + } + } + } + + private sealed class Order + { + public string Id { get; init; } = ""; + public Money Total { get; init; } + public List Lines { get; init; } = new(); + public string Summary() => $"order {Id}"; + public override string ToString() => $"Order[{Id}]"; + } + + private static Order SampleOrder() => new() + { + Id = "o-1", + Total = new Money(1250, "USD"), + Lines = + { + new Line { Sku = "a", Price = new Money(500, "USD") }, + new Line { Sku = "b", Price = new Money(750, "USD") }, + }, + }; + + /// The engine with no MetaObjects view at all: what a typed payload rendered before. + private static string RenderWithBareEngine(string template, object payload) => + new Stubble.Core.Builders.StubbleBuilder().Build().Render(template, payload); + + [Fact] + public void MethodBackedAndFieldBackedMembersStillResolve() + { + const string template = "{{Summary}}|{{#Lines}}{{Sku}}={{Label}};{{/Lines}}"; + Assert.Equal("order o-1|a=label:a;b=label:b;", Render(template, SampleOrder())); + } + + [Fact] + public void AValueRendersThroughItsOwnStringForm() + { + const string template = "{{Total}}|{{#Lines}}{{Price}};{{/Lines}}|{{#Lines}}{{.}};{{/Lines}}"; + var order = SampleOrder(); + Assert.Equal( + $"USD 1250c|USD 500c;USD 750c;|{order.Lines[0]};{order.Lines[1]};", + Render(template, order)); + } + + [Fact] + public void AMemberTheTemplateNeverNamesIsNeverRead() + { + // Line.Exploding counts each read and then throws. The template does not name it. + // The count is what proves laziness: a view that read every member up front and + // swallowed the failure would still render "a,b". + const string template = "{{#Lines}}{{Sku}}{{^-last}},{{/-last}}{{/Lines}}"; + Interlocked.Exchange(ref s_explodingReads, 0); + Assert.Equal("a,b", Render(template, SampleOrder())); + Assert.Equal(0, Volatile.Read(ref s_explodingReads)); + } + + [Fact] + public void ATemplateWithoutDerivedNamesRendersExactlyAsTheBareEngineDoes() + { + const string template = + "{{Id}} {{Summary}} {{Total}} {{Total.Cents}} {{ToString}}" + + "{{#Lines}} [{{Sku}} {{Label}} {{Price}} {{Price.Currency}} {{Id}}]{{/Lines}}" + + "{{#Total}} {{Currency}}{{/Total}}{{^Missing}} none{{/Missing}}"; + var order = SampleOrder(); + string expected = RenderWithBareEngine(template, order); + Assert.Equal( + "o-1 order o-1 USD 1250c 1250 Order[o-1] [a label:a USD 500c USD o-1] [b label:b USD 750c USD o-1] USD none", + expected); + Assert.Equal(expected, Render(template, order)); + } + + [Fact] + public void DerivedNamesSitOnTopOfATypedObjectsOwnMembers() + { + const string template = + "{{#hasLines}}{{#Lines}}{{Label}}{{^-last}} + {{/-last}}{{/Lines}}{{/hasLines}}" + + "{{#hasId}} has-id{{/hasId}}{{^hasMissing}} no-missing{{/hasMissing}}"; + Assert.Equal("label:a + label:b has-id no-missing", Render(template, SampleOrder())); + } +} diff --git a/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs b/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs index 2c03fcd62..22a0f04d2 100644 --- a/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs +++ b/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs @@ -68,9 +68,10 @@ private static IReadOnlyList ParseFields(JsonElement array) => private static PayloadField ParseField(JsonElement el) { var name = el.GetProperty("name").GetString()!; + var isArray = el.TryGetProperty("isArray", out var ia) && ia.ValueKind == JsonValueKind.True; return el.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array - ? new PayloadField(name, ParseFields(fields)) - : new PayloadField(name); + ? new PayloadField(name, ParseFields(fields), isArray) + : new PayloadField(name, IsArray: isArray); } // (code, path) ordinal order, so the comparison is a sorted multiset independent of diff --git a/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs b/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs index 94ac87023..fbef95046 100644 --- a/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs +++ b/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs @@ -18,8 +18,8 @@ public class VerifyTests new PayloadField("posts", [ new PayloadField("title"), - new PayloadField("tags", [new PayloadField("name")]), - ]), + new PayloadField("tags", [new PayloadField("name")], IsArray: true), + ], IsArray: true), ]; private static List Codes(string text, IReadOnlyList fields, VerifyOptions? o = null) => diff --git a/server/csharp/MetaObjects.Render/PayloadAccessors.cs b/server/csharp/MetaObjects.Render/PayloadAccessors.cs index c1c56b3f6..088afcd9e 100644 --- a/server/csharp/MetaObjects.Render/PayloadAccessors.cs +++ b/server/csharp/MetaObjects.Render/PayloadAccessors.cs @@ -33,6 +33,20 @@ public static class PayloadAccessors /// public static string HasAccessorName(string fieldName) => HasPrefix + Capitalize(fieldName); + /// + /// The positional accessor injected into every map-shaped ELEMENT of a collection, so a + /// list separator needs no hand-computed last flag: + /// {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}. The leading - cannot + /// collide with a field name and is still an ordinary Mustache tag name, so the template + /// stays valid Mustache. A scalar element has nowhere to hold a key and carries none; an + /// authored key of the same name wins, as for has<Field>. + /// + public const string LastAccessor = "-last"; + + /// True when is -last and the scope is a collection element. + public static bool IsPositionalAccessor(bool inElement, string name) => + inElement && name == LastAccessor; + /// Capitalize the first character, leaving an already-uppercase one untouched. public static string Capitalize(string s) { @@ -89,12 +103,18 @@ public static bool IsBooleanAccessor(List> stack, st } /// - /// A view over carrying its derived has<Field> - /// accessors, recursively. NON-MUTATING — a render must not change the object it was - /// handed. An AUTHORED key always wins. Recursion follows Mustache's own scoping, so + /// A view over carrying its derived accessors, recursively: + /// has<Field> on every object, and -last on every object that is an + /// element of a collection. NON-MUTATING — a render must not change the object it was + /// handed. An AUTHORED member always wins. Recursion follows Mustache's own scoping, so /// every nested object and every collection ELEMENT becomes a context in its own right. + /// A typed object with no wire names comes back as an overlay only + /// 's engine resolves ( / ). /// - public static object? WithDerivedAccessors(object? payload, int depth = 0) + public static object? WithDerivedAccessors(object? payload, int depth = 0) => Derive(payload, depth, null); + + /// is set only for an element of a collection: its position. + private static object? Derive(object? payload, int depth, bool? last) { if (depth > 32 || payload is null) return payload; // pathological graph if (payload is string) return payload; @@ -105,7 +125,7 @@ public static bool IsBooleanAccessor(List> stack, st foreach (System.Collections.DictionaryEntry e in dict) { if (e.Key is not string k) continue; - outMap[k] = WithDerivedAccessors(e.Value, depth + 1); + outMap[k] = Derive(e.Value, depth + 1, null); } foreach (System.Collections.DictionaryEntry e in dict) { @@ -115,30 +135,34 @@ public static bool IsBooleanAccessor(List> stack, st bool? derived = AccessorValue(e.Value); if (derived is not null) outMap[name] = derived; } + if (last is bool position) outMap.TryAdd(LastAccessor, position); return outMap; } if (payload is System.Collections.IEnumerable seq) { - var outList = new List(); - foreach (var item in seq) outList.Add(WithDerivedAccessors(item, depth + 1)); + var items = seq.Cast().ToList(); + var outList = new List(items.Count); + for (int i = 0; i < items.Count; i++) outList.Add(Derive(items[i], depth + 1, i == items.Count - 1)); return outList; } // ADR-0056 — a generated value-object POCO. Its members are PascalCase C# properties // that carry the metadata field name in [JsonPropertyName], while a template names the // FIELD (`{{bio}}`). So the POCO is viewed as the map of its wire names, which also gives - // it the derived has accessors a map payload gets. Only a type that declares wire - // names is viewed this way: a record or anonymous object whose members ARE the field - // names keeps resolving by reflection, exactly as before. + // it the derived accessors a map payload gets. if (WireNamedProperties(payload.GetType()) is { } props) { var asMap = new Dictionary(StringComparer.Ordinal); foreach (var (name, prop) in props) asMap[name] = prop.GetValue(payload); - return WithDerivedAccessors(asMap, depth); + return Derive(asMap, depth, last); } - return payload; + // A value the engine treats specially by its TYPE — a scalar, a formattable value, a + // lambda, a dynamic object — is handed over untouched. Any other object is overlaid. + if (payload is IConvertible or IFormattable or Delegate or System.Dynamic.IDynamicMetaObjectProvider) + return payload; + return new TypedView(payload, depth, last); } private static readonly System.Collections.Concurrent.ConcurrentDictionary @@ -147,7 +171,7 @@ public static bool IsBooleanAccessor(List> stack, st /// /// The readable public properties of , each keyed by its /// [JsonPropertyName] (or its own name when it has none) — or null when no property - /// declares a wire name, which leaves the type to Mustache's reflection lookup. + /// declares a wire name, which leaves the type to the overlay. /// private static (string, System.Reflection.PropertyInfo)[]? WireNamedProperties(Type type) => WireNameCache.GetOrAdd(type, static t => @@ -161,4 +185,57 @@ private static (string, System.Reflection.PropertyInfo)[]? WireNamedProperties(T if (!props.Any(p => WireName(p) is not null)) return null; return props.Select(p => (WireName(p) ?? p.Name, p)).ToArray(); }); + + /// + /// The derived-accessor overlay of a typed object. The object stays the thing members are + /// resolved on, and only when the template asks for one; the overlay adds the derived names + /// on top, and its string form is the object's own. + /// + private sealed class TypedView(object target, int depth, bool? last) + { + internal readonly object Target = target; + internal readonly int Depth = depth; + internal readonly bool? Last = last; + + public override string? ToString() => Target.ToString(); + } + + /// The overlay type is registered for. + internal static Type ViewType => typeof(TypedView); + + // The engine's own reflective member lookup, so a typed object resolves exactly the + // members it always did: properties, fields and parameterless methods. + private static readonly Stubble.Core.Settings.RendererSettingsDefaults.ValueGetterDelegate OwnMember = + Stubble.Core.Settings.RendererSettingsDefaults.DefaultValueGetters()[typeof(object)]; + + private const System.Reflection.BindingFlags PublicInstance = + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance; + + /// + /// The engine's value getter for an overlay: the object's own member when it has one of that + /// name, else the derived -last / has<Field>. + /// + internal static object? ViewLookup(object value, string key, bool ignoreCase) + { + var view = (TypedView)value; + if (OwnMember(view.Target, key, ignoreCase) is { } own) return Derive(own, view.Depth + 1, null); + var flags = ignoreCase ? PublicInstance | System.Reflection.BindingFlags.IgnoreCase : PublicInstance; + Type type = view.Target.GetType(); + if (type.GetMember(key, flags).Length > 0) return null; // authored wins, even when null + if (key == LastAccessor) return view.Last; + if (!key.StartsWith(HasPrefix, StringComparison.Ordinal) || key.Length == HasPrefix.Length) return null; + string rest = key.Substring(HasPrefix.Length); + foreach (string field in new[] { char.ToLowerInvariant(rest[0]) + rest.Substring(1), rest }) + { + if (HasAccessorName(field) != key) continue; + object? read; + if (type.GetProperty(field, PublicInstance) is { CanRead: true } prop && prop.GetIndexParameters().Length == 0) + read = prop.GetValue(view.Target); + else if (type.GetField(field, PublicInstance) is { } fld) + read = fld.GetValue(view.Target); + else continue; + if (AccessorValue(read) is { } derived) return derived; + } + return null; + } } diff --git a/server/csharp/MetaObjects.Render/Renderer.cs b/server/csharp/MetaObjects.Render/Renderer.cs index 37b332822..42d4bf0b7 100644 --- a/server/csharp/MetaObjects.Render/Renderer.cs +++ b/server/csharp/MetaObjects.Render/Renderer.cs @@ -97,7 +97,9 @@ public static string Render(RenderRequest request) var escaper = Escapers.For(request.Format); var stubble = new StubbleBuilder() - .Configure(settings => settings.SetEncodingFunction(v => escaper(v))) + .Configure(settings => settings + .SetEncodingFunction(v => escaper(v)) + .AddValueGetter(PayloadAccessors.ViewType, PayloadAccessors.ViewLookup)) .Build(); // Derived `has` accessors are part of the payload contract, not of the diff --git a/server/csharp/MetaObjects.Render/Verify.cs b/server/csharp/MetaObjects.Render/Verify.cs index 3856c5358..ce225b24d 100644 --- a/server/csharp/MetaObjects.Render/Verify.cs +++ b/server/csharp/MetaObjects.Render/Verify.cs @@ -17,8 +17,11 @@ namespace MetaObjects.Render; /// A plain field-tree node mirroring an object.value view-object's field /// walk. present → a context-pushing field (object / /// array-of-object); null → a scalar (string/number/boolean/scalar-array). +/// is the field's EFFECTIVE (resolving) array-ness — the one fact +/// Verify needs to tell a single nested object's conditional {{#x}} apart +/// from an array-of-objects' iterating {{#x}}, since both carry . /// -public sealed record PayloadField(string Name, IReadOnlyList? Fields = null); +public sealed record PayloadField(string Name, IReadOnlyList? Fields = null, bool IsArray = false); /// A single drift finding: a code + the offending var path / partial ref / slot. public sealed record VerifyError(string Code, string Path); @@ -153,7 +156,7 @@ public static IReadOnlyList Check( // (no second resolution pass). var staticTexts = new List { templateText }; - void Walk(IReadOnlyList tokens, List> stack, List seen) + void Walk(IReadOnlyList tokens, List> stack, List seen, bool inElement = false) { bool atRoot = stack.Count == 1 && ReferenceEquals(stack[0], root); foreach (var tok in tokens) @@ -164,22 +167,30 @@ void Walk(IReadOnlyList tokens, List> stack, Li if (v.Value == ".") break; // implicit iterator — always valid if (atRoot) referencedAtRoot.Add(v.Value.Split('.')[0]); if (Resolve(stack, v.Value) is null - && !PayloadAccessors.IsBooleanAccessor(stack, v.Value)) + && !PayloadAccessors.IsBooleanAccessor(stack, v.Value) + && !PayloadAccessors.IsPositionalAccessor(inElement, v.Value)) errors.Add(new VerifyError(ERR_VAR_NOT_ON_PAYLOAD, v.Value)); break; case SectionTok s: - if (s.Value == ".") { Walk(s.Children, stack, seen); break; } + if (s.Value == ".") { Walk(s.Children, stack, seen, inElement); break; } if (atRoot) referencedAtRoot.Add(s.Value.Split('.')[0]); var field = Resolve(stack, s.Value); if (field is null) { + // A positional `-last` gate is a BOOLEAN on the current + // collection element: it pushes nothing, so the body walks in the SAME scope. + if (PayloadAccessors.IsPositionalAccessor(inElement, s.Value)) + { + Walk(s.Children, stack, seen, inElement); + break; + } // A derived `has` gate is a BOOLEAN over the current // context: it resolves nothing and pushes nothing, so walk the // body in the SAME scope — what `{{#hasX}}{{#x}}…` depends on. if (PayloadAccessors.IsBooleanAccessor(stack, s.Value)) { - Walk(s.Children, stack, seen); + Walk(s.Children, stack, seen, inElement); break; } // Unresolved section head is itself drift; skip the body @@ -190,12 +201,21 @@ void Walk(IReadOnlyList tokens, List> stack, Li // `#` over a container pushes its element fields; `^` (and `#` // over a scalar, used as a conditional) keep the current context. bool push = !s.Inverted && field.Fields is not null; + // The ONE positional-accessor rule (#304-class hardening): `-last` is + // available in the body only when the INNERMOST enclosing ARRAY section + // iterates OBJECT elements. A non-inverted section over an array field + // sets the flag fresh — true for array-of-objects (Fields present), false + // for array-of-scalars (no per-element Fields, so no `-last` even though + // an outer object array still encloses it). Any other section (scalar + // conditional, single nested object, `^`-inverted) is not an array + // iteration at all, so it inherits the current scope's flag unchanged. + bool childInElement = !s.Inverted && field.IsArray ? field.Fields is not null : inElement; if (push) { var next = new List>(stack) { field.Fields! }; - Walk(s.Children, next, seen); + Walk(s.Children, next, seen, childInElement); } - else Walk(s.Children, stack, seen); + else Walk(s.Children, stack, seen, childInElement); break; case PartialTok p: @@ -208,7 +228,7 @@ void Walk(IReadOnlyList tokens, List> stack, Li break; } staticTexts.Add(text); - Walk(Tokenize(text), stack, new List(seen) { p.Value }); + Walk(Tokenize(text), stack, new List(seen) { p.Value }, inElement); break; } } diff --git a/server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs b/server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs new file mode 100644 index 000000000..e9f873d40 --- /dev/null +++ b/server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs @@ -0,0 +1,66 @@ +// Shared-enum resolution — the ONE authority for "what field.enum declaration does this +// field's vocabulary come from?" (#246 / FR-019). Lives in the core metadata assembly so +// both the loader's #246 validator and every codegen port's enum naming/materialization +// call the SAME predicate and the SAME walk; neither may keep its own copy. +// +// THE RULE. A "shared enum declaration" is a field.enum that is abstract AND a direct +// child of the metadata root. To resolve a field: start at the field itself and walk its +// extends chain; at each node N, in this order: +// (a) N is a shared enum declaration -> the answer is N (nearest wins: a chained root +// abstract such as `Money extends Currency` answers Money, not Currency). +// (b) N declares its OWN @values or its OWN @intValueMap -> the answer is NONE (N starts +// a new vocabulary; the field keeps its per-object inline enum type). +// (c) otherwise continue to N's super. +// A non-enum node, the end of the chain, or a cycle answers NONE. There is no condition on +// the hosting object's subtype (entity, projection, value object all qualify) and no hop +// limit. + +using MetaObjects.Meta; +using static MetaObjects.Core.Field.FieldConstants; + +namespace MetaObjects.Core.Field; + +/// +/// The single cross-port authority for shared (#246 / FR-019) field.enum resolution. +/// The loader's ERR_ENUM_EXTENDS_VALUES_CONFLICT validator and every codegen port's enum +/// naming/materialization call THIS predicate/walk — never a local re-derivation. +/// +public static class SharedEnumDeclaration +{ + /// + /// True when is a shared enum DECLARATION: an abstract + /// field.enum that is a direct child of the metadata root (a package-level + /// declaration, not one nested under an object). Used both for the #246 validator's + /// immediate-super check and as the stopping condition inside + /// 's chain walk. + /// + public static bool IsSharedEnumDeclaration(MetaField node) => + node.SubType == FIELD_SUBTYPE_ENUM + && node.IsAbstract + && node.Parent is { } parent + && parent.Type == TYPE_METADATA; + + /// + /// Resolves to the shared enum declaration its vocabulary + /// comes from, or when it has none (an inline enum: no reachable + /// shared declaration, or a node along the way starts its own vocabulary — see rule (b) + /// on the type). Walks the extends chain starting at the field itself. + /// + public static MetaField? ResolveSharedEnumDeclaration(MetaField field) + { + if (field.SubType != FIELD_SUBTYPE_ENUM) return null; + + var seen = new HashSet(ReferenceEqualityComparer.Instance); + MetaField? node = field; + while (node is not null && node.SubType == FIELD_SUBTYPE_ENUM && seen.Add(node)) + { + if (IsSharedEnumDeclaration(node)) return node; + // Rule (b): THIS node owning @values or @intValueMap starts a new vocabulary — + // own-only reads, matching the #246 validator's own-attrs-only content rules. + if (node.OwnAttr(FIELD_ATTR_VALUES) is not null) return null; + if (node.OwnAttr(FIELD_ATTR_INT_VALUE_MAP) is not null) return null; + node = node.ResolveSuper(); + } + return null; + } +} diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 032427cae..08f636d74 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using MetaObjects.Core.Attr; +using MetaObjects.Core.Field; using MetaObjects.Core.Relationship; using MetaObjects.Core.Requirement; using MetaObjects.Meta; @@ -2559,21 +2560,19 @@ public static IReadOnlyList ValidateEnumValues(MetaData root) /// /// The shared-enum super of , or null when it has none. - /// "Shared" (FR-019 / #246) means the immediate super is abstract AND declared at - /// metadata-root — a metadata.root child, not one nested under an object. Such a - /// declaration is materialized ONCE per port as a single named type, so anything a - /// consuming field re-declares that belongs to the shared TYPE's contract (its - /// @values member set, or the @intValueMap integer backing of that set) is a conflict. - /// Immediate-super-only, matching codegen's Fr019SharedEnum.ResolveSharedEnumDecl so - /// the validator and the shared-enum collapse agree on what "shared" means. + /// "Shared" (FR-019 / #246) means the IMMEDIATE super is a shared enum declaration per + /// — the single cross-port + /// authority also used by every codegen port's naming/materialization walk + /// (), so this check and + /// that walk can never disagree about what "shared" means. Such a declaration is + /// materialized ONCE per port as a single named type, so anything a consuming field + /// re-declares that belongs to the shared TYPE's contract (its @values member set, or + /// the @intValueMap integer backing of that set) is a conflict — checked immediate-super-only + /// because that is the one hop a field can own a conflicting redeclaration against; a + /// mid-chain redeclaration further up the chain is a different field's own concern. /// - private static MetaData? SharedEnumSuper(MetaField field) - { - var sup = field.SuperData; - return sup is not null && sup.IsAbstract && sup.Parent is { } p && p.Type == TYPE_METADATA - ? sup - : null; - } + private static MetaField? SharedEnumSuper(MetaField field) => + field.ResolveSuper() is { } sup && SharedEnumDeclaration.IsSharedEnumDeclaration(sup) ? sup : null; private static void WalkEnumValues(MetaData node, List errors) { diff --git a/server/csharp/MetaObjects/Meta/MetaField.cs b/server/csharp/MetaObjects/Meta/MetaField.cs index 9426fd383..7c819e65b 100644 --- a/server/csharp/MetaObjects/Meta/MetaField.cs +++ b/server/csharp/MetaObjects/Meta/MetaField.cs @@ -202,10 +202,21 @@ public long? Scale /// /// Effective enum member symbols: own @values first, then the /// inherited (super) values. Returns when neither - /// this field nor any super carries @values. + /// this field nor any super carries @values, which is also the answer for a + /// cyclic extends chain (the loader does not reject one). /// - public IReadOnlyList? EffectiveEnumValues => - EnumValues ?? (ResolveSuper()?.EnumValues); + public IReadOnlyList? EffectiveEnumValues + { + get + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + for (MetaField? node = this; node is not null && visited.Add(node); node = node.ResolveSuper()) + { + if (node.EnumValues is { } own) return own; + } + return null; + } + } /// /// True if the field is required (NOT NULL). diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java index 2e4e38543..690068363 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java @@ -189,16 +189,19 @@ private static List derivePayloadFieldTree( seen.add(vo.getName()); List fields = new ArrayList<>(); for (MetaField field : vo.getMetaFields()) { + // ADR-0039: isArrayType() is the EFFECTIVE (inherited-via-extends) array-ness; the + // own-only isArray() flag would read false for a field that inherits @isArray. + boolean isArray = field.isArrayType(); if (field instanceof ObjectField of) { MetaObject target = resolveNestedObjectRef(loader, of); if (target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) { List children = derivePayloadFieldTree(loader, target, new LinkedHashSet<>(seen)); - fields.add(PayloadField.object(field.getName(), children)); + fields.add(PayloadField.object(field.getName(), children, isArray)); continue; } } - fields.add(PayloadField.scalar(field.getName())); + fields.add(PayloadField.scalar(field.getName(), isArray)); } return fields; } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt index 6e098b657..3fdedca55 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt @@ -1,10 +1,8 @@ package com.metaobjects.generator.kotlin -import com.metaobjects.MetaRoot import com.metaobjects.field.EnumField import com.metaobjects.field.MetaField import com.metaobjects.generator.GeneratorException -import com.metaobjects.generator.util.GeneratorUtil import com.squareup.kotlinpoet.ClassName /** @@ -13,17 +11,59 @@ import com.squareup.kotlinpoet.ClassName * * Kotlin already materializes a package-level abstract `field.enum` ONCE as a standalone * `enum class` ([KotlinEnumEmitter] + [KotlinTypeMapper.enumTypeName], deduped by FQN), so the - * shared-materialize half of FR-019 is already satisfied. This object adds the `@provided` arm: - * detecting a `@provided` shared enum (skip materialization) and resolving its reference to a - * per-port-configured namespace (ADR-0001 — never an FQN in metadata). The namespace binds to the - * enum's declaring metadata package via a package→namespace map + a single fallback, both - * generator args. + * shared-materialize half of FR-019 is already satisfied — [KotlinTypeMapper.enumTypeName] and + * [resolveSharedEnumDecl] below both delegate to the SAME cross-generator authority, + * `EnumField.resolveSharedEnumDeclaration` (metadata module), so the two can never disagree. This + * object holds the `@provided` arm [KotlinTypeMapper.enumTypeName] applies: detecting a + * `@provided` shared enum (skip materialization) and resolving its reference to a + * per-port-configured namespace (ADR-0001 — never an FQN in metadata). The namespace binds to + * the enum's declaring metadata package via a package→namespace map + a single fallback, both + * generator args every generator that types an enum reads through [ProvidedEnumConfig.fromArgs]. */ object Fr019SharedEnum { - /** Per-port config for resolving a `@provided` enum's Kotlin namespace. Generator args only. */ - data class ProvidedEnumConfig(val fallbackNamespace: String?, val packageNamespaces: Map) { + /** Generator arg: the single fallback namespace every `@provided` enum is referenced from. */ + const val ARG_PROVIDED_ENUM_NAMESPACE: String = "providedEnumNamespace" + + /** Generator arg: the `;`-delimited `=` map (see [parsePackageMap]). */ + const val ARG_PROVIDED_ENUM_PACKAGES: String = "providedEnumPackages" + + /** + * Per-port config for resolving a `@provided` enum's Kotlin namespace. Generator args only. + * + * [providedInDeclarationPackage] is the pre-config answer, held only by [DECLARATION_PACKAGE]: + * `@provided` is not applied, so the enum is named, and emitted, as the class in its + * declaration's own package and the two namespaces are never read. + */ + data class ProvidedEnumConfig( + val fallbackNamespace: String?, + val packageNamespaces: Map, + internal val providedInDeclarationPackage: Boolean = false, + ) { companion object { + /** + * The config every deprecated helper form that takes none delegates with, so a + * generator ejected before [fromArgs] existed keeps the answer it had then. + */ + internal val DECLARATION_PACKAGE: ProvidedEnumConfig = + ProvidedEnumConfig(null, emptyMap(), providedInDeclarationPackage = true) + + /** + * The config a generator's args declare — the ONE place the two arg names are read, so + * every generator that types an enum resolves a `@provided` one from the same input. + * Absent args give a config under which a `@provided` enum is a [GeneratorException]. + */ + @JvmStatic + fun fromArgs(args: Map?): ProvidedEnumConfig = + ProvidedEnumConfig( + args?.get(ARG_PROVIDED_ENUM_NAMESPACE)?.trim()?.takeIf { it.isNotEmpty() }, + parsePackageMap(args?.get(ARG_PROVIDED_ENUM_PACKAGES)), + ) + + @Deprecated( + "Read both generator args in one place with ProvidedEnumConfig.fromArgs(args), and pass " + + "the result to the helpers that type an enum.", + ) fun of(fallback: String?, packageMapArg: String?): ProvidedEnumConfig = ProvidedEnumConfig(fallback?.trim()?.takeIf { it.isNotEmpty() }, parsePackageMap(packageMapArg)) } @@ -48,18 +88,16 @@ object Fr019SharedEnum { } /** - * The package-level abstract `field.enum` a concrete enum field resolves to via `extends`, or - * `null` for an inline enum. Qualifies only when the immediate super is (a) an [EnumField], - * (b) abstract, AND (c) a direct child of the metadata root ([MetaRoot]) — a sibling of - * `object.entity`, not an abstract field nested inside an object. + * The shared-enum declaration [field] resolves to via `extends`, or `null` when the field is + * an inline enum. Delegates entirely to the single cross-generator authority, + * `EnumField.resolveSharedEnumDeclaration` (metadata module) — the SAME function + * [KotlinTypeMapper.enumTypeName], the Java/Spring port, and the loader's #246 validator call, + * so no two of them can disagree about what "shared" means. See that method's Javadoc for the + * full rule (nearest-declaration-wins, an own-`@values`/`@intValueMap` node stops the walk, no + * hop limit, no hosting-object-subtype condition). */ - fun resolveSharedEnumDecl(field: MetaField<*>): EnumField? { - if (field !is EnumField) return null - val sup = field.superField as? EnumField ?: return null - if (!GeneratorUtil.isAbstract(sup)) return null - if (sup.parent !is MetaRoot) return null - return sup - } + fun resolveSharedEnumDecl(field: MetaField<*>): EnumField? = + EnumField.resolveSharedEnumDeclaration(field) as? EnumField /** * Read `@provided` on an enum declaration. ADR-0039: `@provided` is a @@ -82,30 +120,44 @@ object Fr019SharedEnum { } /** - * The [ClassName] a consuming property emits for a `@provided` shared enum — `.`, with - * `ns` resolved from the package map (keyed by the enum's declaring metadata package) then the - * single fallback. Throws a codegen-time [GeneratorException] naming the enum + package when - * unresolved. Returns `null` when [field] is NOT a `@provided` shared enum, so the caller falls - * back to the normal materialized [KotlinTypeMapper.enumTypeName]. + * The [ClassName] of the `@provided` shared enum [decl] — `.`, with `ns` resolved + * from the package map (keyed by the enum's declaring metadata package) then the single + * fallback. Throws a codegen-time [GeneratorException] naming the enum, its package and the + * two args when neither resolves, so no generator can emit a reference to a class nothing + * writes. Under [ProvidedEnumConfig.DECLARATION_PACKAGE] the answer is the class in the + * declaration's own package instead, and nothing throws. */ - fun providedClassName(field: MetaField<*>, config: ProvidedEnumConfig): ClassName? { - val decl = resolveSharedEnumDecl(field) ?: return null - if (!isProvided(decl)) return null - val (_, shortRaw) = PackageMapping.splitFqn(decl.name) + internal fun providedClassName(decl: EnumField, config: ProvidedEnumConfig): ClassName { + val (declPackage, shortRaw) = PackageMapping.splitFqn(decl.name) val simple = shortRaw.replaceFirstChar { it.uppercase() } + if (config.providedInDeclarationPackage) return ClassName(declPackage, simple) val metaPackage = metaPackageOf(decl.name) val ns = config.packageNamespaces[metaPackage]?.takeIf { it.isNotBlank() } ?: config.fallbackNamespace if (ns.isNullOrBlank()) { throw GeneratorException( "provided enum \"$simple\" (declared in package \"$metaPackage\") is marked @provided " + "but no Kotlin namespace is configured to reference it from. Map its package via the " + - "providedEnumPackages generator arg (\"$metaPackage=your.app.enums\") or set the single " + - "providedEnumNamespace fallback, so the generated code can reference \"$simple\"." + "$ARG_PROVIDED_ENUM_PACKAGES generator arg (\"$metaPackage=your.app.enums\") or set the single " + + "$ARG_PROVIDED_ENUM_NAMESPACE fallback, so the generated code can reference \"$simple\"." ) } return ClassName(ns, simple) } + /** + * The configured class of the `@provided` shared enum [field] resolves to, or `null` when + * [field] does not resolve to one. Throws as [KotlinTypeMapper.enumTypeName] does when the + * declaration's namespace is not configured. + */ + @Deprecated( + "KotlinTypeMapper.enumTypeName(field, entity, provided) answers the configured class of a " + + "@provided enum itself; call it instead of checking for one first.", + ) + fun providedClassName(field: MetaField<*>, config: ProvidedEnumConfig): ClassName? { + val decl = resolveSharedEnumDecl(field) ?: return null + return if (isProvided(decl)) providedClassName(decl, config) else null + } + /** The declaration's metadata package in `::` form, stripped of the trailing `::Name`. */ private fun metaPackageOf(fqn: String): String { val idx = fqn.lastIndexOf("::") 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 708873be5..04a1f2470 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 @@ -87,8 +87,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { override fun execute(loader: MetaDataLoader) { parseArgs() val outRoot = Paths.get(outDir.absolutePath) - fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( - getArg("providedEnumNamespace"), getArg("providedEnumPackages")) + fr019Config = Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) // emitAbstractShapes (default OFF): when ON, an abstract entity is emitted as a Kotlin // `interface` shape (read-only properties) instead of being suppressed. It is NEVER // emitted as an instantiable data class — abstracts are scaffolding. @@ -162,7 +161,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { // the class already emitted for the entity it reads from, the same class the // report's Exposed table types the column by, so the generated row mapper compiles. // Empty for every other object. - reportEnumClasses = KotlinGenUtil.reportEnumClasses(obj, loader) + reportEnumClasses = KotlinGenUtil.reportEnumClasses(obj, loader, fr019Config) try { emitDataClass(obj, outRoot, loader, emittedEnumFqns) } finally { @@ -193,17 +192,14 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { for (field in obj.metaFields) { // A report's enum field references an entity's class; nothing is materialized. if (reportRow) break - // FR-019: a @provided shared enum is referenced externally, never materialized — skip it. - if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) - KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) + if (field is EnumField) KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns, fr019Config) } // FR-017 TPH: the base union folds in subtype-only fields (below). A folded field.enum // resolves to a typed enum ClassName, so its enum file must be emitted here (owner = base, // matching the fold's type resolution) — the declaring subtype is skipped in execute(), so // no other pass emits it. No-op for a non-TPH entity (collectSubtypeFields is empty). for (field in KotlinTphPlan.collectSubtypeFields(obj, loader)) { - if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) - KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) + if (field is EnumField) KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns, fr019Config) } val (pkg, shortName) = PackageMapping.splitFqn(obj.name) @@ -379,9 +375,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { */ protected open fun emitAbstractShape(obj: MetaObject, outRoot: Path, loader: MetaDataLoader, emittedEnumFqns: MutableSet) { for (field in obj.metaFields) { - // FR-019: a @provided shared enum is referenced externally, never materialized — skip it. - if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) - KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) + if (field is EnumField) KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns, fr019Config) } val (pkg, shortName) = PackageMapping.splitFqn(obj.name) @@ -426,13 +420,11 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { /** The Kotlin TypeName for a single (non-array) element of [field]. */ protected open fun resolveElementType(field: MetaField<*>, owner: MetaObject, loader: MetaDataLoader): TypeName { - // FR-019: a @provided shared enum is referenced at its configured external namespace - // (.E) instead of the materialized in-package class; an unresolved namespace throws. - Fr019SharedEnum.providedClassName(field, fr019Config)?.let { return it } // FR-044: a report's derived enum field → the class of the entity field it reads. if (owner is ReportReadModel) reportEnumClasses[field.name]?.let { return it } - // field.enum → typed enum class generated alongside this entity. - KotlinTypeMapper.enumTypeName(field, owner)?.let { return it } + // field.enum → the typed enum class generated alongside this entity, the shared one it + // reaches, or (FR-019) the configured external class of a @provided one. + KotlinTypeMapper.enumTypeName(field, owner, fr019Config)?.let { return it } if (field is ObjectField) { val ref = readObjectRef(field) val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt index ec21d2520..cc488b3c5 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt @@ -37,10 +37,18 @@ object KotlinEnumEmitter { field: EnumField, outRoot: Path, emittedFqns: MutableSet, + provided: Fr019SharedEnum.ProvidedEnumConfig, ): Boolean { - val enumClassName = KotlinTypeMapper.enumTypeName(field, owner) ?: return false + // FR-019: a @provided shared enum is referenced externally, never materialized. + if (!provided.providedInDeclarationPackage && Fr019SharedEnum.isProvidedEnumField(field)) return false + val enumClassName = KotlinTypeMapper.enumTypeName(field, owner, provided) ?: return false if (!emittedFqns.add(enumClassName.canonicalName)) return false // dedupe by enum FQN - val members = readEnumValues(field) + // Read members from the DECLARATION (FR-019: EnumField.resolveSharedEnumDeclaration — the + // same authority enumTypeName just resolved), never from whichever consuming field is + // visited first, so output cannot depend on visit order. For an inline field (no reachable + // declaration) the field itself IS the declaration-equivalent — its own resolving read. + val declaration = EnumField.resolveSharedEnumDeclaration(field) as? EnumField ?: field + val members = readEnumValues(declaration) if (members.isNullOrEmpty()) { // #259: a field.enum must resolve to a NON-EMPTY @values set — the loader's ValidationPhase // guarantees it (a root enum owns @values or fails ERR_MISSING_REQUIRED_ATTR; a field with a @@ -74,6 +82,21 @@ object KotlinEnumEmitter { return true } + /** + * [emitEnumFile] with no provided-enum config. It does not skip a `@provided` enum: as before + * the config existed, the caller decides that with [Fr019SharedEnum.isProvidedEnumField]. + */ + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a fifth argument; that form " + + "skips a @provided enum itself.", + ) + fun emitEnumFile( + owner: MetaObject, + field: EnumField, + outRoot: Path, + emittedFqns: MutableSet, + ): Boolean = emitEnumFile(owner, field, outRoot, emittedFqns, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + /** * Read the `@values` string-array attr, INCLUDING inherited values — a field that * `extends` an abstract enum super (e.g. `currentPriority extends Priority`) carries no own 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 0d66b4a90..527e67332 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 @@ -6,6 +6,7 @@ package com.metaobjects.generator.kotlin // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.generator.kotlin.ExposedApi import com.metaobjects.generator.kotlin.ExposedImports +import com.metaobjects.generator.kotlin.Fr019SharedEnum import com.metaobjects.generator.kotlin.KotlinGenUtil import com.metaobjects.generator.kotlin.KotlinNaming import com.metaobjects.generator.kotlin.KotlinTphPlan @@ -112,6 +113,13 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase = MetaObject::class.java override fun execute(loader: MetaDataLoader) { @@ -398,7 +406,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase, entity: MetaObject): ClassName? = - reportPlans[entity]?.enumClasses?.get(field.name) ?: KotlinTypeMapper.enumTypeName(field, entity) + reportPlans[entity]?.enumClasses?.get(field.name) ?: KotlinTypeMapper.enumTypeName(field, entity, providedEnums) /** * The Exposed column spec of a non-enum scalar [field] of [entity]: the type mapper's, @@ -927,7 +935,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase) { + fun emitMirrorFiles( + rootVo: MetaObject, + outRoot: Path, + emitted: MutableSet, + provided: Fr019SharedEnum.ProvidedEnumConfig, + ) { if (!emitted.add(rootVo.name)) return val (pkg, _) = PackageMapping.splitFqn(rootVo.name) val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("${mirrorName(rootVo)}.kt") - GeneratedFileWriter.write(outFile, mirrorSource(rootVo)) + GeneratedFileWriter.write(outFile, mirrorSource(rootVo, provided)) for (field in rootVo.metaFields) { val nested = objectRefValueObject(field) ?: continue KotlinNaming.requireReferenceable(pkg, nested, "value object '${rootVo.name}'") - emitMirrorFiles(nested, outRoot, emitted) + emitMirrorFiles(nested, outRoot, emitted, provided) } } + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a fourth argument, so a " + + "@provided enum is typed by its configured class. Without it a @provided enum is named by the " + + "class in its declaration's own package.", + ) + fun emitMirrorFiles(rootVo: MetaObject, outRoot: Path, emitted: MutableSet) = + emitMirrorFiles(rootVo, outRoot, emitted, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a second argument, so a " + + "@provided enum is typed by its configured class. Without it a @provided enum is named by the " + + "class in its declaration's own package.", + ) + fun mirrorSource(vo: MetaObject): String = + mirrorSource(vo, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + /** The full Kotlin source of [vo]'s mirror file. */ - fun mirrorSource(vo: MetaObject): String { + fun mirrorSource(vo: MetaObject, provided: Fr019SharedEnum.ProvidedEnumConfig): String { val (pkg, short) = PackageMapping.splitFqn(vo.name) val mirror = mirrorName(vo) val fields = vo.metaFields.toList() @@ -98,7 +119,7 @@ object KotlinExtractSchemaEmitter { append(" /** The strict [").append(short).append("]. Call only once the extract report shows no lost or malformed required field. */\n") append(" fun toStrict(): ").append(short).append(" = ").append(short).append("(\n") for (field in fields) { - append(" ").append(field.name).append(" = ").append(strictArg(field, vo)).append(",\n") + append(" ").append(field.name).append(" = ").append(strictArg(field, vo, provided)).append(",\n") } append(" )\n\n") append(" companion object {\n\n") @@ -195,7 +216,7 @@ object KotlinExtractSchemaEmitter { * property is non-null ([KotlinGenUtil.isNullableShapeProperty] — the same predicate the entity * generator uses, so the two cannot disagree). */ - private fun strictArg(field: MetaField<*>, owner: MetaObject): String { + private fun strictArg(field: MetaField<*>, owner: MetaObject, provided: Fr019SharedEnum.ProvidedEnumConfig): String { val name = field.name val nullable = KotlinGenUtil.isNullableShapeProperty(field) // `!!` where the strict property is non-null; a safe call where it may be null. @@ -211,7 +232,7 @@ object KotlinExtractSchemaEmitter { // String, the strict property is the generated enum class. `valueOf` is safe because the // loader validated @values against the enum constants. if (field is EnumField) { - val enumType = KotlinTypeMapper.enumTypeName(field, owner) + val enumType = KotlinTypeMapper.enumTypeName(field, owner, provided) if (enumType != null) { val enumFqn = enumType.canonicalName return if (field.isArrayType()) { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt index 945aa3968..98dfac3f2 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt @@ -696,7 +696,10 @@ public object KotlinGenUtil { * cannot reach this; it guards a tree built in code, where typing the column by a * guessed class would compile against the wrong enum. */ - fun reportEnumClasses(shape: ReportShape): Map { + fun reportEnumClasses( + shape: ReportShape, + provided: Fr019SharedEnum.ProvidedEnumConfig, + ): Map { val report = shape.report() val out = LinkedHashMap() for (f in shape.fields()) { @@ -710,7 +713,7 @@ public object KotlinGenUtil { "entity that reference names does not resolve, so the generated column has no enum " + "class to be typed by." ) - out[f.name] = KotlinTypeMapper.enumTypeName(f.typeSource, owner) + out[f.name] = KotlinTypeMapper.enumTypeName(f.typeSource, owner, provided) ?: throw GeneratorException( "report \"${report.shortName}\": its $item is an enum with no generated enum class." ) @@ -718,10 +721,30 @@ public object KotlinGenUtil { return out } + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a second argument, so a " + + "@provided enum is typed by its configured class. Without it a @provided enum is named by the " + + "class in its declaration's own package.", + ) + fun reportEnumClasses(shape: ReportShape): Map = + reportEnumClasses(shape, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a third argument, so a " + + "@provided enum is typed by its configured class. Without it a @provided enum is named by the " + + "class in its declaration's own package.", + ) + fun reportEnumClasses(obj: MetaObject, loader: MetaDataLoader): Map = + reportEnumClasses(obj, loader, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + /** [reportEnumClasses] for a report's read model; empty for any other object. */ - fun reportEnumClasses(obj: MetaObject, loader: MetaDataLoader): Map { + fun reportEnumClasses( + obj: MetaObject, + loader: MetaDataLoader, + provided: Fr019SharedEnum.ProvidedEnumConfig, + ): Map { val report = (obj as? ReportReadModel)?.report() ?: return emptyMap() - return reportEnumClasses(ReportShape.of(report, loader.root)) + return reportEnumClasses(ReportShape.of(report, loader.root), provided) } /** diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt index f35208a69..a8e08986f 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt @@ -96,7 +96,7 @@ object KotlinM2mSupport { * @param loader the loader (for the model root, to find junction + target entities) * @return the resolved M:N navigations, in declaration order */ - fun resolve(entity: MetaObject, loader: MetaDataLoader): List { + fun resolve(entity: MetaObject, loader: MetaDataLoader, provided: Fr019SharedEnum.ProvidedEnumConfig): List { val root = loader.root val out = ArrayList() for (rel in entity.relationships) { @@ -152,8 +152,7 @@ object KotlinM2mSupport { "resolve to a field on storage object \"${target.name}\" — target narrowing " + "cannot be derived." ) - val enumSimple = runCatching { KotlinTypeMapper.enumTypeName(discMetaField, target)?.simpleName } - .getOrNull() + val enumSimple = KotlinTypeMapper.enumTypeName(discMetaField, target, provided)?.simpleName ?: throw M2MFields.M2MDerivationException( "M:N relationship \"$relLabel\": discriminator field \"$discField\" on " + "\"${target.name}\" is not a materialized enum — target narrowing cannot " + @@ -221,7 +220,12 @@ object KotlinM2mSupport { * shape (structural equality) reuses that same helper rather than minting another — so two * subtypes shadowing a name identically still share one helper, matching the plain-name case. */ - fun tphQueryFnNames(base: MetaObject, plan: KotlinTphPlan.Plan, loader: MetaDataLoader): Map { + fun tphQueryFnNames( + base: MetaObject, + plan: KotlinTphPlan.Plan, + loader: MetaDataLoader, + provided: Fr019SharedEnum.ProvidedEnumConfig, + ): Map { val names = LinkedHashMap() val firstShapePerName = HashMap() fun visit(navs: List, suffixSource: String?) { @@ -237,11 +241,27 @@ object KotlinM2mSupport { } } } - visit(resolve(base, loader), null) - for (st in plan.subtypes) visit(resolve(st.entity, loader), st.value) + visit(resolve(base, loader, provided), null) + for (st in plan.subtypes) visit(resolve(st.entity, loader, provided), st.value) return names } + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a third argument, so a " + + "@provided discriminator enum is typed by its configured class. Without it a @provided enum is " + + "named by the class in its declaration's own package.", + ) + fun resolve(entity: MetaObject, loader: MetaDataLoader): List = + resolve(entity, loader, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a fourth argument, so a " + + "@provided discriminator enum is typed by its configured class. Without it a @provided enum is " + + "named by the class in its declaration's own package.", + ) + fun tphQueryFnNames(base: MetaObject, plan: KotlinTphPlan.Plan, loader: MetaDataLoader): Map = + tphQueryFnNames(base, plan, loader, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + /** The single primary-key field name of an entity (defaults to `id`). */ private fun primaryKeyField(entity: MetaObject): String { // ADR-0039: identities are inheritable — RESOLVE via getIdentities(true); diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt index 8bfef309f..beeb1b267 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt @@ -5,6 +5,7 @@ package com.metaobjects.generator.kotlin // `mvn metaobjects:eject` and its own package is renamed — an explicit import, not // same-package bare-name resolution, is what keeps the ejected copy compiling. import com.metaobjects.generator.kotlin.FindInbound +import com.metaobjects.generator.kotlin.Fr019SharedEnum import com.metaobjects.generator.kotlin.KotlinExtractSchemaEmitter import com.metaobjects.generator.kotlin.KotlinNaming import com.metaobjects.generator.kotlin.PackageMapping @@ -68,6 +69,13 @@ import com.metaobjects.generator.util.GeneratedFileWriter */ open class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase() { + /** + * FR-019: where a `@provided` enum is referenced from, read from the same two generator args + * every generator that types an enum takes ([Fr019SharedEnum.ProvidedEnumConfig.fromArgs]). + */ + private val providedEnums: Fr019SharedEnum.ProvidedEnumConfig + get() = Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) + override fun getFilterClass(): Class = MetaObject::class.java override fun execute(loader: MetaDataLoader) { @@ -122,7 +130,7 @@ open class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase() */ protected fun exposedApi(): ExposedApi = ExposedApi.parse(getArg(ExposedApi.ARG_EXPOSED_API)) + /** + * FR-019: where a `@provided` enum is referenced from, read from the same two generator args + * every generator that types an enum takes ([Fr019SharedEnum.ProvidedEnumConfig.fromArgs]). + */ + private val providedEnums: Fr019SharedEnum.ProvidedEnumConfig + get() = Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) + override fun getFilterClass(): Class = MetaObject::class.java override fun execute(loader: MetaDataLoader) { @@ -317,10 +325,10 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase() * name)`, ADR-0039) gets its OWN distinctly-named helper — see [KotlinM2mSupport.tphQueryFnNames]. */ private fun tphAugmentedM2mNavs(entity: MetaObject, loader: MetaDataLoader): List { - val ownNavs = KotlinM2mSupport.resolve(entity, loader) + val ownNavs = KotlinM2mSupport.resolve(entity, loader, providedEnums) val plan = KotlinTphPlan.planFor(entity, loader) ?: return ownNavs.map { KotlinM2mSupport.NamedM2mNav(it, KotlinM2mSupport.defaultQueryFnName(it.relationName)) } - return KotlinM2mSupport.tphQueryFnNames(entity, plan, loader) + return KotlinM2mSupport.tphQueryFnNames(entity, plan, loader, providedEnums) .map { (nav, fnName) -> KotlinM2mSupport.NamedM2mNav(nav, fnName) } } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt index 8445b9ba1..f0b9db157 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt @@ -252,6 +252,9 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase() for (field in vo.metaFields) { + // ADR-0039: isArrayType() is the EFFECTIVE (inherited-via-extends) array-ness; the + // own-only isArray flag would read false for a field that inherits @isArray. + val isArray = field.isArrayType if (field is ObjectField) { // Resolve the nested @objectRef by BARE short-name — the cross-port // render-helper consensus. Do NOT use the package-FOLDING resolver here; @@ -259,11 +262,11 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase = MetaObject::class.java override fun execute(loader: MetaDataLoader) { @@ -274,7 +282,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase traversal sub-resources. - val m2mNavs = KotlinM2mSupport.resolve(entity, loader) + val m2mNavs = KotlinM2mSupport.resolve(entity, loader, providedEnums) val source = buildString { if (pkg.isNotEmpty()) { append("package $pkg\n\n") @@ -515,7 +523,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase" else elem val fn = field.name @@ -653,7 +661,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase.` (an enum constant), so // every @discriminatorValue MUST be a valid Kotlin identifier — fail loud at generation with a @@ -723,15 +731,15 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase. - val elem = KotlinTypeMapper.enumTypeName(field, base) ?: KotlinTypeMapper.kotlinTypeName(field) + val elem = KotlinTypeMapper.enumTypeName(field, base, providedEnums) ?: KotlinTypeMapper.kotlinTypeName(field) val ktType = if (field.isArrayType) "kotlin.collections.List<$elem>" else "$elem" val fn = field.name val cap = capitalizeFirst(fn) @@ -1772,7 +1780,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase, owner: MetaObject): String? = - if (KotlinGenUtil.isIntBackedEnum(field)) KotlinTypeMapper.enumTypeName(field, owner)?.canonicalName else null + if (KotlinGenUtil.isIntBackedEnum(field)) KotlinTypeMapper.enumTypeName(field, owner, providedEnums)?.canonicalName else null /** * The element Kotlin type the generated `Table`'s column for [field] holds — @@ -1885,7 +1893,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase = entity.metaFields .filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) } .map { 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 ee22eecb2..f1ed1d008 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 @@ -177,63 +177,42 @@ object KotlinTypeMapper { * Compute the generated Kotlin enum-class name for an [EnumField] hung off [entity]. * * Returns {@code null} when [field] is not an {@link EnumField} (the caller should - * fall through to the generic [kotlinTypeName] mapping). Naming rule: - * {@code }, in the same Kotlin package as the + * fall through to the generic [kotlinTypeName] mapping). + * + * Shared-enum naming (FR-019 / ADR-0026): when [field]'s `extends` chain reaches a + * package-level ABSTRACT {@code field.enum} that is a direct child of the metadata root — a + * shared-enum DECLARATION, resolved by the single cross-generator authority + * [EnumField.resolveSharedEnumDeclaration] (metadata module; the SAME function the Java/Spring + * port and the loader's #246 validator use) — the type is named for THAT declaration and + * collapses every field reaching it onto ONE class, however many hops away (a projection or + * value-object field included: #259's per-object remedy is superseded — see that method's + * Javadoc for the full rule, including the own-{@code @values}/{@code @intValueMap} + * short-circuit). A declaration marked `@provided` is never generated: the answer is the + * class [provided] names for it ([Fr019SharedEnum.providedClassName]), and an unconfigured one + * is a `GeneratorException` here, in whichever generator asked. Every caller gets that answer + * from this one function. With no reachable declaration the field keeps its per-object + * {@code } naming, in the same Kotlin package as the * entity (derived from the entity's metadata FQN via [PackageMapping.splitFqn]). * - * Entity-prefixing prevents collisions across entities (e.g., {@code Player.status} → - * {@code PlayerStatus}; {@code Game.status} → {@code GameStatus}). When [entity] is - * null (e.g., bare-mapper unit tests) the enum class is rendered with no package - * prefix and no entity-name prefix — only the field name pascalised — so the helper - * still produces a useful ClassName for documentation / debug output. Generators that + * Entity-prefixing (the inline case) prevents collisions across entities (e.g., + * {@code Player.status} → {@code PlayerStatus}; {@code Game.status} → {@code GameStatus}). + * When [entity] is null (e.g., bare-mapper unit tests) the inline-case enum class is rendered + * with no package prefix and no entity-name prefix — only the field name pascalised — so the + * helper still produces a useful ClassName for documentation / debug output. Generators that * actually emit code always pass the owning entity. */ - fun enumTypeName(field: MetaField<*>, entity: MetaObject?): ClassName? { + fun enumTypeName( + field: MetaField<*>, + entity: MetaObject?, + provided: Fr019SharedEnum.ProvidedEnumConfig, + ): ClassName? { if (field !is EnumField) return null - // Shared-enum naming: when this field `extends` a package-level ABSTRACT enum super (e.g. - // two fields both `extends: Priority`), ALL such fields collapse onto ONE enum class named - // for the top-most super (`Priority`) so the generated type is shared (and deduped by FQN at - // emission). When there is no super, fall back to the per-entity `` - // naming. Mirrors the C#/TS/Python ports. - // - // The collapse decision keys on the DIRECT (immediate) super, NOT the top-most root: collapse - // onto a SHARED enum type ONLY when the field's IMMEDIATE `extends` target is a package-level - // abstract enum — identified by having NO declaring object. A field whose direct super is a - // CONCRETE entity/projection field (e.g. a read-model projection `extends`-ing `ActiveNpc.status`) - // does NOT collapse — even when that entity field itself `extends` a shared abstract enum (#259, - // the two-hop case): it falls through to the per-object naming below, so the projection gets its - // OWN `` enum in its OWN package (self-contained — no cross-package - // reference), populated with the values it inherits via `extends` ([KotlinEnumEmitter.readEnumValues] - // is inheritance-aware across ANY number of hops). Keying on the top-most root instead (an earlier - // bug) walked PAST the concrete entity super to the shared abstract root and wrongly collapsed the - // projection onto the shared type, so its per-projection enum was never emitted. Without any guard - // at all, a concrete-super field named the enum from the super's bare short name (`status`), which - // `splitFqn` collapsed to a root-package `Status` — colliding across every entity with a `status`. - val immediateSuper = field.superField - if (immediateSuper != null && runCatching { immediateSuper.declaringObject }.getOrNull() == null) { - // The DIRECT super is a package-level abstract enum → collapse onto the shared type, named - // for THAT declaration — the immediate super, NOT the top-most root of an abstract chain. - // - // Naming by the top-most root (the previous behaviour) contradicted this file's own FR-019 - // arm, which resolves the shared declaration from the IMMEDIATE super - // ([Fr019SharedEnum.resolveSharedEnumDecl]) exactly as TS/C#/Java/Python do. On a CHAINED - // declaration (root abstract `Money extends` root abstract `@provided Currency`) the two - // halves disagreed: the materialize-vs-reference decision saw `Money` (own @provided absent - // → materialize) while the NAME collapsed to `Currency`, so Kotlin emitted a local - // `Currency.kt` that collided with the `com.acme.ext.Currency` reference emitted for fields - // extending `Currency` directly. - // - // Per ADR-0026 §2 a materialized type is named for ITS OWN declaration, so a chain yields one - // type per declaration, each carrying the member set it inherits ([KotlinEnumEmitter - // .readEnumValues] is inheritance-aware across any number of hops). #246 guarantees a chained - // declaration can never MUTATE the vocabulary it inherits — it may rename it, nothing more. - // - // Non-chained output is byte-identical: with no further super the root walk returned the - // immediate super anyway. The #259 two-hop projection guard is the `declaringObject == null` - // condition above, which is evaluated FIRST and is unaffected. - val (superPkg, superShort) = PackageMapping.splitFqn(immediateSuper.name) - return ClassName(superPkg, superShort.replaceFirstChar { it.uppercase() }) + val declaration = Fr019SharedEnum.resolveSharedEnumDecl(field) + if (declaration != null) { + if (Fr019SharedEnum.isProvided(declaration)) return Fr019SharedEnum.providedClassName(declaration, provided) + val (declPkg, declShort) = PackageMapping.splitFqn(declaration.name) + return ClassName(declPkg, declShort.replaceFirstChar { it.uppercase() }) } val fieldPascal = field.name.replaceFirstChar { it.uppercase() } @@ -245,6 +224,18 @@ object KotlinTypeMapper { } } + /** + * [enumTypeName] with no provided-enum config: the same resolution, except that a `@provided` + * declaration is named by the class in its own package and nothing throws. + */ + @Deprecated( + "Pass the generator's Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args) as a third argument, so a " + + "@provided enum is typed by its configured class. Without it a @provided enum is named by the " + + "class in its declaration's own package.", + ) + fun enumTypeName(field: MetaField<*>, entity: MetaObject?): ClassName? = + enumTypeName(field, entity, Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) + /** Map a MetaField to its KotlinPoet data-class property TypeName. */ fun kotlinTypeName(field: MetaField<*>): TypeName = when (field) { // This is the SCALAR/element mapper — `isArray` List<…> wrapping is applied by the diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt index b1f3b3961..842cf9565 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt @@ -58,7 +58,16 @@ import com.metaobjects.template.TemplateConstants * record), so the wire shape is documented via the MODEL + VALIDATION symbols (matching what the * generators actually emit). */ -class KotlinApiModelBuilder { +class KotlinApiModelBuilder( + /** FR-019: where a `@provided` enum is referenced from — the same config every generator takes. */ + private val providedEnums: com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig, +) { + + @Deprecated( + "Pass Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args), so a @provided enum is documented by its " + + "configured class. Without it a @provided enum is named by the class in its declaration's own package.", + ) + constructor() : this(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.DECLARATION_PACKAGE) private companion object { /** Structured formats that additionally get an output-format prompt + a tolerant extractor. */ @@ -329,7 +338,7 @@ class KotlinApiModelBuilder { rest("PUT $base/{id}", "update (PUT alias)") rest("DELETE $base/{id}", "delete") // FR-018 M:N traversal — GET //{id}/. - for (nav in KotlinM2mSupport.resolve(obj, loader)) { + for (nav in KotlinM2mSupport.resolve(obj, loader, providedEnums)) { rest( "GET $base/{id}/${nav.relationName}", "M:N traversal — the related ${nav.targetShortName} rows", @@ -497,7 +506,7 @@ class KotlinApiModelBuilder { * entity-CRUD DTO) now agree. Keeps the documented type == the generated type on both surfaces. */ private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject, forPayload: Boolean = false): String { - KotlinTypeMapper.enumTypeName(field, owner)?.let { enumType -> + KotlinTypeMapper.enumTypeName(field, owner, providedEnums)?.let { enumType -> val simple = enumType.simpleName return if (field.isArrayType) "List<$simple>" else simple } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt index b1549e4a8..9f5eda4fd 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt @@ -80,7 +80,7 @@ class ApiDocsCrossPortConformanceKtTest { val metaJson = Files.readString(caseDir().resolve("input/meta.json")) val loader: MetaDataLoader = loadString("apiDocsCrossPort", metaJson) - val model = KotlinApiModelBuilder().build(loader, PROJECT) + val model = KotlinApiModelBuilder(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)).build(loader, PROJECT) // ---- coverage: the Kotlin builder documents the SAME unit set the manifest lists ---- val manifestNodes = sortedSetOf() diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt index b8e6f816b..8c1a9611b 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt @@ -143,7 +143,7 @@ class KotlinApiDocsAccuracyKtTest { @BeforeTest fun setUp() { loader = loadString("apidocs-accuracy", fixture) - model = KotlinApiModelBuilder().build(loader, PROJECT) + model = KotlinApiModelBuilder(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)).build(loader, PROJECT) outDir = Files.createTempDirectory("kapidocs-gen-") templateRoot = Files.createTempDirectory("kapidocs-tpl-") diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsSourceSelectionKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsSourceSelectionKtTest.kt index 52ea38277..9818a9f7a 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsSourceSelectionKtTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsSourceSelectionKtTest.kt @@ -120,7 +120,7 @@ class KotlinApiDocsSourceSelectionKtTest { @Test fun theDocumentedDataAccessSymbolIsTheOneTheGeneratorsEmit() { - val model = KotlinApiModelBuilder().build(loader, PROJECT) + val model = KotlinApiModelBuilder(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)).build(loader, PROJECT) val unit = model.units.single { it.node.endsWith("PhaseSummary") } val dataAccess = unit.symbols.filter { it.kind == ApiSymbolKind.DATA_ACCESS } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt index 098e3fd84..fd2b555d7 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt @@ -48,7 +48,7 @@ class ProjectionDocsRoutesKtTest { val expected = ObjectMapper().readTree(Files.readString(corpus.resolve("docs-routes.json"))).get("units") val loader = loadString("projectionDocs", Files.readString(corpus.resolve("meta.json"))) - val model = KotlinApiModelBuilder().build(loader, "projection-docs") + val model = KotlinApiModelBuilder(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)).build(loader, "projection-docs") for ((node, routes) in expected.fields().asSequence().map { it.key to it.value }) { val want = routes.map { it.asText() }.sorted() diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedAtBaseGeneratorsCompatTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedAtBaseGeneratorsCompatTest.kt new file mode 100644 index 000000000..f337894ca --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedAtBaseGeneratorsCompatTest.kt @@ -0,0 +1,181 @@ +package com.metaobjects.generator.kotlin + +import com.fasterxml.jackson.databind.ObjectMapper +import com.metaobjects.generator.GeneratorBase +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.metadata.ktx.loadString +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Upgrade proof for an OWNED generator: a Kotlin generator an adopter ejected BEFORE the + * helper signatures gained a `ProvidedEnumConfig` parameter still compiles, unchanged, against + * this module, and still generates. + * + * [EjectedGeneratorsCompileTest] cannot show this: it compiles the CURRENT sources, which are + * edited in the same commit as the helpers they call. An adopter's copy is not. + * + * The frozen copies live in `src/test/resources/ejected-at-base/.kt.txt` (the `.txt` + * suffix keeps the build from compiling them as test sources). Each is the verbatim output of + * `git show :` for the commit the provided-enum-config change was built on, with + * nothing added, so it is exactly what `mvn metaobjects:eject` wrote into an adopter's repo at + * that point. They cover every ejectable generator whose source that change touched; the rest + * are byte-identical to the current sources [EjectedGeneratorsCompileTest] compiles. + * + * Refresh them ONLY when a release note tells adopters to re-eject: until then, a copy that + * stops compiling here is an adopter's build that stops compiling on upgrade. + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class EjectedAtBaseGeneratorsCompatTest { + + private companion object { + const val OWNED_PACKAGE = "com.acme.owned" + const val PROVIDED_NAMESPACE = "com.acme.ext" + + val frozenSimpleNames = listOf( + "KotlinEntityGenerator", + "KotlinExposedTableGenerator", + "KotlinSpringControllerGenerator", + "KotlinRelationsGenerator", + "KotlinOutputParserGenerator", + "KotlinRenderHelperGenerator", + ) + + val packageLine = Regex("(?m)^package\\s+[\\w.]+\\s*$") + + /** The frozen copies, package-renamed exactly as eject does, compiled once for the class. */ + val compiledFrozen: KotlinCompilation.Result by lazy { + val dir = Path.of("src/test/resources/ejected-at-base") + val sources = frozenSimpleNames.map { simpleName -> + val frozen = dir.resolve("$simpleName.kt.txt") + assertTrue(Files.exists(frozen), "expected ${frozen.toAbsolutePath()} to exist") + val source = Files.readString(frozen) + val match = packageLine.find(source) ?: throw AssertionError("expected a package line in $frozen") + SourceFile.kotlin("$simpleName.kt", source.replaceRange(match.range, "package $OWNED_PACKAGE")) + } + KotlinCompilation().apply { + this.sources = sources + inheritClassPath = true + messageOutputStream = System.out + }.compile() + } + } + + private fun fixtureDir(): Path = + Path.of(System.getProperty("user.dir")).resolve("../../..").normalize() + .resolve("fixtures/codegen-conformance/shared-provided-enum") + + private fun loadFixture(): MetaDataLoader = + loadString("ejectedAtBaseCompat", Files.readString(fixtureDir().resolve("input/meta.json"))) + + /** Run the frozen copy of [simpleName] on the shared fixture, as an adopter's build would. */ + private fun generate(simpleName: String, args: Map): Path { + assertEquals(KotlinCompilation.ExitCode.OK, compiledFrozen.exitCode, compiledFrozen.messages) + val generator = compiledFrozen.classLoader.loadClass("$OWNED_PACKAGE.$simpleName") + .getDeclaredConstructor().newInstance() as GeneratorBase + val out = Files.createTempDirectory("kgen-ejected-at-base-") + generator.setArgs(args + ("outputDir" to out.toString())) + generator.execute(loadFixture()) + return out + } + + private fun sourcesUnder(dir: Path): List = + Files.walk(dir).use { walk -> walk.filter { it.isRegularFile() }.sorted().toList() } + + private fun compileGenerated(generated: List, vararg standIns: SourceFile): KotlinCompilation.Result { + val result = KotlinCompilation().apply { + sources = generated.mapIndexed { i, path -> SourceFile.kotlin("g${i}_${path.fileName}", path.readText()) } + + standIns + inheritClassPath = true + messageOutputStream = System.out + }.compile() + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + return result + } + + private fun providedCurrencyIn(pkg: String): SourceFile = + SourceFile.kotlin("Currency_${pkg.replace('.', '_')}.kt", "package $pkg\n\nenum class Currency { USD, EUR, GBP }\n") + + @Test + fun `every generator ejected before the provided-enum config still compiles unchanged`() { + assertEquals(KotlinCompilation.ExitCode.OK, compiledFrozen.exitCode, + "a generator ejected before this change no longer compiles against this module:\n${compiledFrozen.messages}") + } + + /** + * The oracle is the cross-port decision table `expected-enum-types.json`: which shared + * declaration every enum field of the fixture resolves to. An owned entity generator gets + * that answer through the helpers it calls, so a multi-hop field with no redeclaration is + * typed by the shared enum without the adopter touching the copy. A copy still on the + * one-hop rule would type `Task.importance` by a per-object `TaskImportance`. + */ + @Test + fun `the entity generator ejected at base types every field by the decision table`() { + val out = generate("KotlinEntityGenerator", mapOf("providedEnumNamespace" to PROVIDED_NAMESPACE)) + try { + val classes = compileGenerated(sourcesUnder(out), providedCurrencyIn(PROVIDED_NAMESPACE)).classLoader + val table = ObjectMapper().readTree(Files.readString(fixtureDir().resolve("expected-enum-types.json"))) + val sharedClassNames = setOf("acme.shop.Priority", "acme.shop.Money", "$PROVIDED_NAMESPACE.Currency") + var sharedFields = 0 + for ((key, expected) in table.get("fields").fields()) { + val (ownerFqn, field) = key.split(".").let { it[0] to it[1] } + val ownerClass = classes.loadClass(ownerFqn.replace("::", ".")) + val actual = ownerClass.getMethod("get" + field.replaceFirstChar { it.uppercase() }).returnType.name + val shared = expected.get("shared") + if (shared.isNull) { + assertFalse(actual in sharedClassNames, "$key is an inline enum; was typed $actual") + continue + } + val declarationShort = shared.asText().substringAfterLast("::") + val expectedClass = + if (expected.get("provided").asBoolean()) "$PROVIDED_NAMESPACE.$declarationShort" + else "acme.shop.$declarationShort" + assertEquals(expectedClass, actual, "$key must be typed by the shared declaration ${shared.asText()}") + sharedFields++ + } + assertTrue(sharedFields > 0, "the decision table named no shared field") + assertFalse(Files.exists(out.resolve("acme/shop/Currency.kt")), "a @provided enum must not be generated") + } finally { + out.toFile().deleteRecursively() + } + } + + /** + * A table generator ejected at base takes no provided-enum args and never did. Before the + * config existed it named a `@provided` enum by the class in the declaration's own package + * and generated without error; it must keep doing both, not start failing an adopter's build + * on upgrade. The stand-in `acme.shop.Currency` is that class, so the output compiling + * against it is the proof of where the reference points. + */ + @Test + fun `the Exposed table generator ejected at base still generates for a provided enum`() { + val entityOut = generate("KotlinEntityGenerator", mapOf("providedEnumNamespace" to PROVIDED_NAMESPACE)) + val tableOut = generate("KotlinExposedTableGenerator", emptyMap()) + try { + val classes = compileGenerated( + sourcesUnder(entityOut) + sourcesUnder(tableOut), + providedCurrencyIn(PROVIDED_NAMESPACE), + providedCurrencyIn("acme.shop"), + ).classLoader + for ((tableClass, column) in listOf("acme.shop.TicketTable" to "getCurrency", "acme.shop.RefundTable" to "getTender")) { + val columnType = classes.loadClass(tableClass).getMethod(column).genericReturnType.typeName + assertTrue("acme.shop.Currency" in columnType, + "$tableClass.$column must stay a column of the declaration-package Currency; was $columnType") + } + val importance = classes.loadClass("acme.shop.TaskTable").getMethod("getImportance").genericReturnType.typeName + assertTrue("acme.shop.Priority" in importance, + "TaskTable.importance (two hops) must be a column of the shared Priority; was $importance") + } finally { + entityOut.toFile().deleteRecursively() + tableOut.toFile().deleteRecursively() + } + } +} diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt index 9d7a36982..756686caa 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt @@ -1,12 +1,17 @@ package com.metaobjects.generator.kotlin +import com.fasterxml.jackson.databind.ObjectMapper +import com.metaobjects.field.EnumField +import com.metaobjects.field.MetaField import com.metaobjects.metadata.ktx.loadString import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.readText +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -119,4 +124,151 @@ class KotlinFr019SharedProvidedConformanceTest { assertTrue(ex.message?.contains("Currency") == true, "error must name the provided enum Currency; was: ${ex.message}") } + + // ========================================================================= + // Decision table — one shared-enum rule for BOTH JVM generators + // ========================================================================= + + private fun readExpectedEnumTypes(): com.fasterxml.jackson.databind.JsonNode { + val path = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize() + .resolve("fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json") + assertTrue(Files.exists(path), "expected-enum-types.json must exist at $path") + return ObjectMapper().readTree(Files.readString(path)) + } + + /** `::.` -> `{"shared": |null, "provided": bool}`, + * computed via [Fr019SharedEnum.resolveSharedEnumDecl] for every object-hosted `field.enum` + * on every concrete entity/projection/value object — no condition on the hosting object's + * subtype. The SAME function [KotlinTypeMapper.enumTypeName] calls for naming. */ + private fun computeDecisionTable(loader: MetaDataLoader): Map> { + val out = LinkedHashMap>() + for (owner in loader.metaObjects) { + val subType = owner.subType + if (subType != MetaObject.SUBTYPE_ENTITY && subType != MetaObject.SUBTYPE_PROJECTION + && subType != MetaObject.SUBTYPE_VALUE) continue + for (field in owner.metaFields) { + if (field !is EnumField) continue + val key = "${owner.name}.${field.name}" + val decl = Fr019SharedEnum.resolveSharedEnumDecl(field) + out[key] = if (decl == null) { + mapOf("shared" to null, "provided" to false) + } else { + mapOf("shared" to decl.name, "provided" to Fr019SharedEnum.isProvided(decl)) + } + } + } + return out + } + + @Test + fun `decision table matches shared fixture expectation`() { + val loader = loadFixture() + val actual = computeDecisionTable(loader) + val expected = readExpectedEnumTypes() + + val expectedFields = expected.get("fields") + val expectedKeys = expectedFields.fieldNames().asSequence().toList() + for (key in expectedKeys) { + assertTrue(actual.containsKey(key), "decision table missing key $key; actual keys: ${actual.keys}") + val expectedEntry = expectedFields.get(key) + val expectedShared = if (expectedEntry.get("shared").isNull) null else expectedEntry.get("shared").asText() + val actualEntry = actual.getValue(key) + assertEquals(expectedShared, actualEntry["shared"], "mismatched 'shared' for $key") + assertEquals(expectedEntry.get("provided").asBoolean(), actualEntry["provided"], "mismatched 'provided' for $key") + } + assertEquals(expectedKeys.size, actual.size, "decision table has extra keys beyond the fixture; actual: ${actual.keys}") + + val expectedMaterialized = expected.get("materialized").map { it.asText() }.sorted() + val out = Files.createTempDirectory("kgen-fr019-decision-") + try { + KotlinEntityGenerator().apply { setArgs(mapOf("outputDir" to out.toString(), "providedEnumNamespace" to "com.acme.ext")) } + .execute(loadFixture()) + val actualMaterialized = expectedMaterialized.filter { name -> + Files.exists(out.resolve("acme/shop/$name.kt")) + }.sorted() + assertEquals(expectedMaterialized, actualMaterialized, + "materialized shared enums must match the fixture") + // And nothing ELSE claiming to be a shared declaration got materialized either — + // the @provided Currency must never be written. + assertFalse(Files.exists(out.resolve("acme/shop/Currency.kt")), + "@provided Currency must never be materialized") + } finally { + out.toFile().deleteRecursively() + } + } + + // ========================================================================= + // Generated-output assertions — the multi-hop / provided / negative cases + // ========================================================================= + + @Test + fun `multi-hop projection and value-object fields reference the shared type`() { + val out = generate("com.acme.ext") + try { + // Multi-hop fields reference the ONE shared Priority — on an entity (Task.importance, + // two hops; Subtask.rank, three hops), a projection (TicketSummary.level) and a value + // object (TicketBadge.badge) alike — and no per-object enum is written for any of them. + val shop = out.resolve("acme/shop") + assertTrue(Files.exists(shop.resolve("Priority.kt")), "shared Priority.kt must be emitted") + for (stale in listOf("TaskImportance", "TaskUrgency", "SubtaskRank", "TicketSummaryLevel", "TicketBadgeBadge")) + assertFalse(Files.exists(shop.resolve("$stale.kt")), + "$stale.kt must NOT be materialized — the field references the shared type") + val task = shop.resolve("Task.kt").readText() + assertTrue("val importance: Priority?" in task, "Task.importance must be typed Priority; saw:\n$task") + val subtask = shop.resolve("Subtask.kt").readText() + assertTrue("val rank: Priority" in subtask, "Subtask.rank must be typed Priority; saw:\n$subtask") + val summary = shop.resolve("TicketSummary.kt").readText() + assertTrue("val level: Priority" in summary, + "TicketSummary.level (projection) must be typed Priority; saw:\n$summary") + val badge = shop.resolve("TicketBadge.kt").readText() + assertTrue("val badge: Priority" in badge, + "TicketBadge.badge (value object) must be typed Priority; saw:\n$badge") + } finally { + out.toFile().deleteRecursively() + } + } + + @Test + fun `refund fields resolve to distinct shared declarations on the same object`() { + val out = generate("com.acme.ext") + try { + val refund = out.resolve("acme/shop/Refund.kt").readText() + // Two fields whose immediate supers are same-named fields ("tier") of different + // objects resolve to DIFFERENT shared declarations. + assertTrue("val ticketTier: Priority" in refund, "Refund.ticketTier must be typed Priority; saw:\n$refund") + assertTrue("val invoiceTier: Money" in refund, "Refund.invoiceTier must be typed Money; saw:\n$refund") + assertTrue("val unit: Money" in refund, "Refund.unit must be typed Money; saw:\n$refund") + // Refund.tender -> Ticket.currency -> the @provided Currency at the configured namespace. + assertTrue("import com.acme.ext.Currency" in refund && "val tender: Currency" in refund, + "Refund.tender must be typed by the configured external Currency; saw:\n$refund") + assertTrue("val orderStatus: RefundOrderStatus" in refund, + "Refund.orderStatus must keep its inline per-object enum; saw:\n$refund") + } finally { + out.toFile().deleteRecursively() + } + } + + @Test + fun `own values or intValueMap stops the walk and keeps an inline per-object enum`() { + val out = generate("com.acme.ext") + try { + // An inline enum is its own per-object file (.kt) the data class types its + // property by, and no shared type stands in for it. + fun assertInline(owner: String, field: String, property: String) { + val type = owner + field.replaceFirstChar { it.uppercase() } + val file = out.resolve("acme/shop/$type.kt") + assertTrue(Files.exists(file), "$owner.$field must keep its inline enum file $type.kt") + assertTrue("enum class $type" in file.readText(), "$type.kt must declare enum class $type") + val src = out.resolve("acme/shop/$owner.kt").readText() + assertTrue("val $property: $type" in src, "$owner.$field must be typed $type; saw:\n$src") + } + assertInline("Task", "phase", "phase") + assertInline("Task", "weight", "weight") + assertInline("Subtask", "phase", "phase") + assertInline("Subtask", "weight", "weight") + assertInline("Refund", "orderStatus", "orderStatus") + } finally { + out.toFile().deleteRecursively() + } + } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilTest.kt index aefbd43fa..ee7db1479 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilTest.kt @@ -106,7 +106,7 @@ class KotlinGenUtilTest { val outDir: Path = Files.createTempDirectory("kgu-mirrors-") try { val digest = loader.getMetaObjectByName("acme::app::Digest") as MetaObject - KotlinExtractSchemaEmitter.emitMirrorFiles(digest, outDir, mutableSetOf()) + KotlinExtractSchemaEmitter.emitMirrorFiles(digest, outDir, mutableSetOf(), com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)) return Files.walk(outDir).filter { Files.isRegularFile(it) } .map { outDir.relativize(it).toString() }.toList().toSet() } finally { diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mCodegenTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mCodegenTest.kt index 4a3cca285..4893ed284 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mCodegenTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mCodegenTest.kt @@ -222,7 +222,7 @@ class KotlinM2mCodegenTest { listOf("acme::graph::Node", "acme::graph::NodeBase", "acme::graph::NodeLink"), loader.root.getChildren(MetaObject::class.java, false).map { it.name }, ) - val navs = KotlinM2mSupport.resolve(node, loader) + val navs = KotlinM2mSupport.resolve(node, loader, com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)) assertEquals(1, navs.size, "expected the inherited peers navigation; saw $navs") assertEquals("peers", navs[0].relationName) assertEquals("aId", navs[0].sourceField) diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupportAbstractMidLevelTargetTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupportAbstractMidLevelTargetTest.kt index 917804752..8e4747eb6 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupportAbstractMidLevelTargetTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupportAbstractMidLevelTargetTest.kt @@ -87,7 +87,7 @@ class KotlinM2mSupportAbstractMidLevelTargetTest { fun `abstract mid-level target resolves with no target discriminator`() { val loader = loader(abstractMidLevelFixture) val party = loader.getMetaObjectByName("acme::auth::Party") - val navs = KotlinM2mSupport.resolve(party, loader) + val navs = KotlinM2mSupport.resolve(party, loader, com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)) val nav = navs.single { it.relationName == "auths" } assertNull(nav.targetDiscriminator, "an abstract mid-level target has no single discriminator value; traversal stays unfiltered") @@ -98,7 +98,7 @@ class KotlinM2mSupportAbstractMidLevelTargetTest { val loader = loader(brokenConcreteDiscriminatorFixture) val party = loader.getMetaObjectByName("acme::auth::Party") assertFailsWith { - KotlinM2mSupport.resolve(party, loader) + KotlinM2mSupport.resolve(party, loader, com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)) } } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt index 8a42d6e6e..be1411d84 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt @@ -13,24 +13,18 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * #259 — a `field.enum` that inherits its `@values` through TWO `extends` hops - * (projection.status → entity.status → shared-abstract enum) must still generate the - * projection's OWN per-projection enum, populated with the transitively-inherited members. + * #259 / FR-019 — a `field.enum` that inherits its `@values` through TWO `extends` hops + * (projection.status → entity.status → shared-abstract enum) resolves to the shared + * declaration, the SAME cross-generator authority every other hop count resolves through + * ([EnumField.resolveSharedEnumDeclaration], metadata module). * - * Sibling of #246 (same shared-enum feature area). The one-hop case - * ([KotlinProjectionExtendsInheritanceTest]) already worked because the entity field carried - * its own inline `@values`. The two-hop case regressed: [KotlinTypeMapper.enumTypeName] - * decided "collapse onto the shared enum" from the TOP-MOST super (its private - * `resolveSuperRoot`), so a projection field walked PAST its concrete entity super to the root - * abstract enum (no declaring object) and wrongly collapsed to the shared type — the - * per-projection enum was never emitted, and every consumer of that column failed to resolve - * the (now-absent) per-projection type. Values themselves were never the problem: - * `getMetaAttr(@values)` is inheritance-aware across multiple hops. - * - * The fix keys the collapse decision on the IMMEDIATE super: only a field whose direct - * `extends` target is a package-level abstract enum (no declaring object) collapses onto the - * shared type. A field whose direct super is a CONCRETE entity/projection field gets its own - * `` enum, populated with the depth-2-inherited members. + * This SUPERSEDES the original #259 remedy, which gave the projection its OWN per-projection + * `ProgramViewStatus` enum so the two-hop case would not silently emit nothing. That worked + * around the real defect instead of fixing it: a projection reaching a shared declaration is + * exactly like an entity reaching one, and must reference the SAME materialized type — not a + * second, differently-populated copy. The one-hop case ([KotlinProjectionExtendsInheritanceTest]) + * already demonstrated this is unproblematic when the chain is shallow; the two-hop case must + * behave identically, just with one more hop walked. */ @OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) class KotlinProjectionTwoHopEnumTest { @@ -56,6 +50,20 @@ class KotlinProjectionTwoHopEnumTest { ] } }""".trimIndent() + // The reduced model for the "projection-only" must-generate check: NO entity at all — the + // projection's field.enum extends the shared declaration directly (one hop), so the shared + // type's ONLY possible generated consumer in the whole loaded model is the projection. + private val projectionOnlyFixture = """{ + "metadata.root": { "package": "acme::commerce", "children": [ + { "field.enum": { "name": "SharedStatus", "abstract": true, "@required": true, + "@values": ["DRAFT", "LIVE", "ARCHIVED"] } }, + { "object.projection": { "name": "ProgramView", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_program" } }, + { "field.enum": { "name": "status", "extends": "acme::commerce::SharedStatus", "@required": true } } + ] } } + ] } + }""".trimIndent() + private fun compile(outDir: Path): KotlinCompilation.Result { val sources = Files.walk(outDir).filter { it.isRegularFile() }.sorted().toList() .map { path -> SourceFile.kotlin(path.parent.relativize(path).toString().replace('/', '_'), path.readText()) } @@ -66,7 +74,7 @@ class KotlinProjectionTwoHopEnumTest { }.compile() } - @Test fun `two-hop projection enum materializes its own per-projection type`() { + @Test fun `two-hop projection enum references the shared type, not a per-projection copy`() { val outDir = Files.createTempDirectory("k2hop-enum-") try { val loader = loadString("two-hop-enum", fixture) @@ -75,25 +83,25 @@ class KotlinProjectionTwoHopEnumTest { gen.execute(loader) } - // The shared enum is emitted once (the entity field DOES collapse onto it). - assertTrue(Files.exists(outDir.resolve("acme/commerce/SharedStatus.kt")), + // The shared enum is emitted exactly once, carrying every member. + val sharedEnum = outDir.resolve("acme/commerce/SharedStatus.kt") + assertTrue(Files.exists(sharedEnum), "shared SharedStatus.kt must be emitted; files=${Files.walk(outDir).toList()}") - - // The projection gets its OWN enum, populated with the DEPTH-2-inherited values. - val viewEnum = outDir.resolve("acme/commerce/ProgramViewStatus.kt") - assertTrue(Files.exists(viewEnum), - "#259: projection enum ProgramViewStatus.kt must be materialized (values inherited " + - "through TWO extends hops); files=${Files.walk(outDir).toList()}") - val viewEnumSrc = viewEnum.readText() + val sharedSrc = sharedEnum.readText() for (m in listOf("DRAFT", "LIVE", "ARCHIVED")) - assertTrue(m in viewEnumSrc, "inherited member $m must be present; saw:\n$viewEnumSrc") + assertTrue(m in sharedSrc, "member $m must be present; saw:\n$sharedSrc") - // The projection table references its OWN enum, NOT the collapsed shared one. + // NO per-projection enum is written — the #259 remedy is superseded. + assertFalse(Files.exists(outDir.resolve("acme/commerce/ProgramViewStatus.kt")), + "a per-projection ProgramViewStatus.kt must NOT be materialized now that the " + + "projection references the shared type; files=${Files.walk(outDir).toList()}") + + // The projection table references the SHARED enum. val viewTable = outDir.resolve("acme/commerce/ProgramViewTable.kt").readText() - assertTrue("ProgramViewStatus::class" in viewTable, - "projection table must reference its own ProgramViewStatus enum; saw:\n$viewTable") - assertFalse("SharedStatus::class" in viewTable, - "#259: projection table must NOT collapse to the shared SharedStatus enum; saw:\n$viewTable") + assertTrue("SharedStatus::class" in viewTable, + "projection table must reference the shared SharedStatus enum; saw:\n$viewTable") + assertFalse("ProgramViewStatus::class" in viewTable, + "projection table must NOT reference a per-projection enum; saw:\n$viewTable") // The ENTITY table still collapses onto the shared enum (byte-identity guardrail). val programTable = outDir.resolve("acme/commerce/ProgramTable.kt").readText() @@ -107,4 +115,29 @@ class KotlinProjectionTwoHopEnumTest { outDir.toFile().deleteRecursively() } } + + @Test fun `shared enum materializes when a projection is the only generated consumer`() { + val outDir = Files.createTempDirectory("k-proj-only-enum-") + try { + val loader = loadString("projection-only-enum", projectionOnlyFixture) + for (gen in listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator())) { + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loader) + } + + val sharedEnum = outDir.resolve("acme/commerce/SharedStatus.kt") + assertTrue(Files.exists(sharedEnum), + "shared SharedStatus.kt must be materialized when a projection is the only " + + "consumer; files=${Files.walk(outDir).toList()}") + + val viewTable = outDir.resolve("acme/commerce/ProgramViewTable.kt").readText() + assertTrue("SharedStatus::class" in viewTable, + "projection table must reference the shared SharedStatus enum; saw:\n$viewTable") + + val result = compile(outDir) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + } finally { + outDir.toFile().deleteRecursively() + } + } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProvidedEnumAcrossGeneratorsTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProvidedEnumAcrossGeneratorsTest.kt new file mode 100644 index 000000000..4ad6b58d7 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProvidedEnumAcrossGeneratorsTest.kt @@ -0,0 +1,119 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.generator.GeneratorException +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.metadata.ktx.loadString +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * FR-019 — a `@provided` shared enum is ONE class, wherever a generator types it. + * + * The shared fixture declares `Currency` (`@provided`) and reaches it in one hop + * (`Ticket.currency`) and through another field (`Refund.tender extends Ticket.currency`). The + * provided class is whatever `providedEnumNamespace` / `providedEnumPackages` name; nothing + * generates one in the declaration's own package. So every generator must type both fields by + * the configured class — a data class typed `com.acme.ext.Currency` beside an Exposed table + * typed `acme.shop.Currency` does not compile, because the second class does not exist. + * + * The entity and Exposed table output are compiled together with a hand-written + * `com.acme.ext.Currency` standing in for the adopter's own enum. The Spring controller tier is + * NOT in the compile: its framework imports are not on this module's test classpath (the same + * boundary the codegen-compile gate draws), so its enum reference is asserted on the emitted + * source instead. + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class KotlinProvidedEnumAcrossGeneratorsTest { + + private val provided = mapOf("providedEnumNamespace" to "com.acme.ext") + + private fun loadFixture(): MetaDataLoader { + val fixture = Path.of(System.getProperty("user.dir")) + .resolve("../../..").normalize() + .resolve("fixtures/codegen-conformance/shared-provided-enum/input/meta.json") + return loadString("providedEnumAcrossGenerators", Files.readString(fixture)) + } + + private fun generate(generator: com.metaobjects.generator.GeneratorBase, args: Map): Path { + val out = Files.createTempDirectory("kgen-provided-") + generator.setArgs(args + ("outputDir" to out.toString())) + generator.execute(loadFixture()) + return out + } + + private fun sourcesUnder(dir: Path): List = + Files.walk(dir).use { walk -> walk.filter { it.isRegularFile() }.sorted().toList() } + + @Test + fun `one-hop and multi-hop provided enum fields are the configured class in the data class and the table`() { + val entityOut = generate(KotlinEntityGenerator(), provided) + val tableOut = generate(KotlinExposedTableGenerator(), provided) + try { + val generated = sourcesUnder(entityOut) + sourcesUnder(tableOut) + for (file in generated) { + assertFalse("acme.shop.Currency" in file.readText(), + "$file must not reference a Currency in the declaration's package, which nothing generates") + } + assertFalse(generated.any { it.fileName.toString() == "Currency.kt" }, + "a @provided enum must never be generated; files=$generated") + + val result = KotlinCompilation().apply { + sources = generated.mapIndexed { i, path -> SourceFile.kotlin("g${i}_${path.fileName}", path.readText()) } + + SourceFile.kotlin("ProvidedCurrency.kt", "package com.acme.ext\n\nenum class Currency { USD, EUR, GBP }\n") + inheritClassPath = true + messageOutputStream = System.out + }.compile() + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + + val loader = result.classLoader + assertEquals("com.acme.ext.Currency", + loader.loadClass("acme.shop.Ticket").getMethod("getCurrency").returnType.name) + assertEquals("com.acme.ext.Currency", + loader.loadClass("acme.shop.Refund").getMethod("getTender").returnType.name) + for ((table, column) in listOf("acme.shop.TicketTable" to "getCurrency", "acme.shop.RefundTable" to "getTender")) { + val columnType = loader.loadClass(table).getMethod(column).genericReturnType.typeName + assertTrue("com.acme.ext.Currency" in columnType, + "$table.$column must be a column of the configured provided enum; was $columnType") + } + } finally { + entityOut.toFile().deleteRecursively() + tableOut.toFile().deleteRecursively() + } + } + + @Test + fun `the Spring controller references the configured provided class`() { + val out = generate(KotlinSpringControllerGenerator(), provided) + try { + for (name in listOf("TicketController.kt", "RefundController.kt")) { + val src = out.resolve("acme/shop").resolve(name).readText() + assertTrue("com.acme.ext.Currency" in src, + "$name must type the provided enum by the configured class; saw:\n$src") + assertFalse("acme.shop.Currency" in src, + "$name must not reference a Currency in the declaration's package; saw:\n$src") + } + } finally { + out.toFile().deleteRecursively() + } + } + + @Test + fun `a generator with no provided config fails at generate time, naming the enum and the args`() { + for (generator in listOf(KotlinExposedTableGenerator(), KotlinSpringControllerGenerator(), KotlinEntityGenerator())) { + val ex = assertThrows("${generator::class.simpleName}") { generate(generator, emptyMap()) } + val message = ex.message.orEmpty() + for (expected in listOf("Currency", "acme::shop", "providedEnumNamespace", "providedEnumPackages")) { + assertTrue(expected in message, "${generator::class.simpleName}: the error must name $expected; was: $message") + } + } + } +} diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt index 4d139c61e..1579d4450 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportRestSurfaceTest.kt @@ -380,7 +380,7 @@ class KotlinReportRestSurfaceTest { @Test fun `api docs document a served report as a list and nothing else`() { - val model = KotlinApiModelBuilder().build(withModel(), "shop") + val model = KotlinApiModelBuilder(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)).build(withModel(), "shop") val unit = model.units.single { it.node == "StoreTotals" } assertEquals("report", unit.kind) assertEquals( diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt index 22b927e9b..c12399824 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt @@ -377,13 +377,13 @@ class KotlinTypeMapperTest { val entity = loader.metaObjects.first { it.name == "acme::demo::Player" } val field = entity.metaFields.first { it.name == "status" } as EnumField - val name = KotlinTypeMapper.enumTypeName(field, entity) + val name = KotlinTypeMapper.enumTypeName(field, entity, com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)) assertEquals("acme.demo", name?.packageName) assertEquals("PlayerStatus", name?.simpleName) // Non-enum fields → null (caller falls through to kotlinTypeName). val idField = entity.metaFields.first { it.name == "id" } - assertNull(KotlinTypeMapper.enumTypeName(idField, entity)) + assertNull(KotlinTypeMapper.enumTypeName(idField, entity, com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null))) } // === Long-text dispatch (varchar vs text) === diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt new file mode 100644 index 000000000..c0e31142d --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt @@ -0,0 +1,151 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.render.InMemoryProvider +import com.metaobjects.render.RenderRequest +import com.metaobjects.render.Renderer +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals + +/** + * A Kotlin data class payload — the shape a generated value object takes — goes through the JVM + * renderer and must render byte-identically to the equal map payload: `-last` and `has` + * come from one mechanism whatever the payload's shape. The oracle is the shared + * render-list-separator fixture, whose expected.txt every port renders identically. + */ +class KotlinTypedPayloadRenderTest { + + companion object { + /** Every read of [Line.exploding], counted before it throws. */ + val explodingReads = java.util.concurrent.atomic.AtomicInteger() + } + + data class Item(val name: String) + data class Tag(val label: String) + data class Group(val tags: List) + data class Owner(val nick: String) + data class Entry(val name: String, val note: String?, val owner: Owner) + data class ListSeparator( + val items: List, + val solo: List, + val none: List, + val groups: List, + val entries: List, + val authored: List>, + ) + + private val fixture: Path = Path.of(System.getProperty("user.dir")) + .resolve("../../..").normalize() + .resolve("fixtures/render-conformance/render-list-separator") + + private fun render(template: String, payload: Any): String = + Renderer().render(RenderRequest(template, null, payload, InMemoryProvider(emptyMap()), "text", null, null)) + + @Test + fun `a data class payload renders the shared fixture byte-identically`() { + val payload = ListSeparator( + items = listOf(Item("a"), Item("b"), Item("c")), + solo = listOf(Item("only")), + none = emptyList(), + groups = listOf(Group(listOf(Tag("x"), Tag("y"))), Group(listOf(Tag("z")))), + entries = listOf( + Entry("a", "x", Owner("ann")), + Entry("b", null, Owner("bob")), + Entry("c", "z", Owner("cy")), + ), + authored = listOf(mapOf("name" to "p", "-last" to true), mapOf("name" to "q", "-last" to true)), + ) + assertEquals( + Files.readString(fixture.resolve("expected.txt")), + render(Files.readString(fixture.resolve("template.mustache")), payload), + ) + } + + @Test + fun `has gate on a data class element matches the equal map payload`() { + val template = "{{#items}}{{name}}:{{#hasNote}}y{{/hasNote}}{{^hasNote}}n{{/hasNote}} {{/items}}" + val viaMaps = mapOf("items" to listOf(mapOf("name" to "a", "note" to "x"), mapOf("name" to "b", "note" to " "))) + val viaData = mapOf("items" to listOf(Entry("a", "x", Owner("o")), Entry("b", " ", Owner("o")))) + assertEquals("a:y b:n ", render(template, viaMaps)) + assertEquals(render(template, viaMaps), render(template, viaData)) + } + + // ---- The overlay adds names; it never takes any away ----------------------------------- + + class Money(val cents: Long, val currency: String) { + override fun toString(): String = "$currency ${cents}c" + } + + data class Line(val sku: String, val price: Money) { + fun label(): String = "label:$sku" + val exploding: String + get() { + explodingReads.incrementAndGet() + throw IllegalStateException("read a member the template never names") + } + } + + data class Order(val id: String, val total: Money, val lines: List) { + fun summary(): String = "order $id" + } + + private val order = Order("o-1", Money(1250, "USD"), listOf(Line("a", Money(500, "USD")), Line("b", Money(750, "USD")))) + + /** The engine with no MetaObjects view at all: what a typed payload rendered before. */ + private fun renderWithBareEngine(template: String, payload: Any): String { + val out = java.io.StringWriter() + com.github.mustachejava.DefaultMustacheFactory() + .compile(java.io.StringReader(template), "bare").execute(out, payload).flush() + return out.toString() + } + + @Test + fun `a function member and a value's own string form still render`() { + assertEquals( + "order o-1 USD 1250c|a=label:a USD 500c;b=label:b USD 750c;", + render("{{summary}} {{total}}|{{#lines}}{{sku}}={{label}} {{price}};{{/lines}}", order), + ) + } + + class Scoreboard(val scores: IntArray, val weights: DoubleArray) + + /** + * The oracle is the Mustache spec: a section over a list renders its body once per element + * and an inverted section renders for an empty list. `IntArray` and `DoubleArray` are JVM + * primitive arrays, which the engine iterates itself. + */ + @Test + fun `a primitive array property is iterated per element`() { + assertEquals( + "1,2,3,|0.5;", + render("{{#scores}}{{.}},{{/scores}}|{{#weights}}{{.}};{{/weights}}", Scoreboard(intArrayOf(1, 2, 3), doubleArrayOf(0.5))), + ) + assertEquals( + "none", + render("{{^scores}}none{{/scores}}{{#weights}}{{.}}{{/weights}}", Scoreboard(intArrayOf(), doubleArrayOf())), + ) + } + + @Test + fun `a property the template never names is never read`() { + // Line.exploding counts each read and then throws. The count is what proves laziness: a + // view that read every property up front and swallowed the failure would still render "a,b". + explodingReads.set(0) + assertEquals("a,b", render("{{#lines}}{{sku}}{{^-last}},{{/-last}}{{/lines}}", order)) + assertEquals(0, explodingReads.get()) + } + + @Test + fun `a template without derived names renders exactly as the bare engine does`() { + val template = "{{id}} {{summary}} {{total}} {{total.cents}}" + + "{{#lines}} [{{sku}} {{label}} {{price}} {{price.currency}} {{id}}]{{/lines}}" + + "{{#total}} {{currency}}{{/total}}{{^missing}} none{{/missing}}" + val expected = renderWithBareEngine(template, order) + assertEquals( + "o-1 order o-1 USD 1250c 1250 [a label:a USD 500c USD o-1] [b label:b USD 750c USD o-1] USD none", + expected, + ) + assertEquals(expected, render(template, order)) + } +} diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt index 7f72e3e90..fd1bfc4ad 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt @@ -190,7 +190,7 @@ class ReportingInertTest { * sourceless one has none. */ private fun apiDocs(variant: String): Map { - val model = KotlinApiModelBuilder().build(load(variant), "shop") + val model = KotlinApiModelBuilder(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)).build(load(variant), "shop") val renderer = KotlinApiDocsRenderer() val pages = TreeMap() for (unit in model.units) { diff --git a/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinEntityGenerator.kt.txt b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinEntityGenerator.kt.txt new file mode 100644 index 000000000..708873be5 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinEntityGenerator.kt.txt @@ -0,0 +1,895 @@ +package com.metaobjects.generator.kotlin + +// Eject note (ADR-0034, JVM eject design): these siblings stay in +// com.metaobjects.generator.kotlin when this generator is copied out via +// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not +// same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.generator.kotlin.Fr019SharedEnum +import com.metaobjects.generator.kotlin.KotlinEnumEmitter +import com.metaobjects.generator.kotlin.KotlinGenUtil +import com.metaobjects.generator.kotlin.KotlinNaming +import com.metaobjects.generator.kotlin.KotlinPoetFileWriter +import com.metaobjects.generator.kotlin.KotlinTphPlan +import com.metaobjects.generator.kotlin.KotlinTypeMapper +import com.metaobjects.generator.kotlin.PackageMapping + +import com.metaobjects.MetaData +import com.metaobjects.field.EnumField +import com.metaobjects.field.InetField +import com.metaobjects.field.MetaField +import com.metaobjects.field.MapField +import com.metaobjects.field.ObjectField +import com.metaobjects.field.StringField +import com.metaobjects.field.UriField +import com.metaobjects.generator.GeneratorIOWriter +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject +import com.metaobjects.validator.ArrayValidator +import com.metaobjects.validator.LengthValidator +import com.metaobjects.validator.MetaValidator +import com.metaobjects.validator.NumericValidator +import com.metaobjects.validator.RegexValidator +import com.metaobjects.validator.RequiredValidator +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LIST +import com.squareup.kotlinpoet.MAP +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import java.io.OutputStream +import java.io.PrintWriter +import java.nio.file.Path +import java.nio.file.Paths +import com.metaobjects.generator.util.GeneratedFileWriter +import com.metaobjects.generator.util.RestSurfaceGate +import com.metaobjects.reporting.ReportReadModel + +/** + * Generator: one plain Kotlin data class (Jackson-compatible) per `object.entity` and + * `object.value`. No kotlinx `@Serializable` — see [emit] for why. + * + *

Skips abstract objects and any subType other than `entity` or `value`. KotlinPoet emits + * whole files; the parent class's print-style writer machinery is bypassed in favour of a + * direct override of [execute]. + * + *

Nested objects: when a parent field is a `field.object` with `@objectRef`, the parent + * data class gets a typed property referencing the generated `object.value` data class + * (e.g., `val address: Address`). The `@storage` attr (`flattened` / `jsonb`) does not + * affect Kotlin type emission — it only affects the Exposed table's column shape. + * + *

Args: + *

    + *
  • {@code outputDir} (required): output directory root; package directories are + * created under it.
  • + *
+ */ +open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { + + override fun getFilterClass(): Class = MetaObject::class.java + + /** + * FR-019 per-port config for resolving `@provided` enum namespaces, parsed from the + * `providedEnumNamespace` (single fallback) + `providedEnumPackages` (package→namespace map) + * generator args. Empty by default; a referenced provided enum with no resolvable namespace is + * a codegen-time error. + */ + private var fr019Config = Fr019SharedEnum.ProvidedEnumConfig(null, emptyMap()) + + /** Real work happens here — sidesteps the parent's print-style writer machinery. */ + override fun execute(loader: MetaDataLoader) { + parseArgs() + val outRoot = Paths.get(outDir.absolutePath) + fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( + getArg("providedEnumNamespace"), getArg("providedEnumPackages")) + // emitAbstractShapes (default OFF): when ON, an abstract entity is emitted as a Kotlin + // `interface` shape (read-only properties) instead of being suppressed. It is NEVER + // emitted as an instantiable data class — abstracts are scaffolding. + val emitAbstractShapes = (getArg("emitAbstractShapes", "false") ?: "false").toBoolean() + // Run-level dedupe of emitted enum-class files by enum FQN: two fields sharing an + // abstract enum super (resolved by KotlinTypeMapper.enumTypeName) collapse onto one file. + val emittedEnumFqns = mutableSetOf() + // Emit a data class for entities AND value objects. Value objects (object.value) are + // referenced by field.object on entities; the value class must exist for the entity's + // typed property to resolve. + // Local-only abstract check (own attribute, not inherited) so concrete subtypes + // extending an abstract base still emit normally. + for (declared in loader.metaObjects) { + // FR-044: a served report is emitted from its read model (one field per derived + // column); any other report maps to null and emits nothing. + val obj = RestSurfaceGate.restShapeOf(declared) ?: continue + if (!emitsDataClass(obj)) continue + if (KotlinGenUtil.isAbstractEntity(obj)) { + if (emitAbstractShapes) emitAbstractShape(obj, outRoot, loader, emittedEnumFqns) + continue + } + // FR-017 TPH: a discriminator subtype folds into its base's union data class + + // discriminator enum. A per-subtype data class is dead and re-emits the inherited + // enum under a spurious name. Mirror the controller/table skip — but FR-036 still + // emits the subtype's annotated Validation shape (the base controller's per-subtype + // POST/PATCH validates present values against it; the union base is annotation-free). + if (KotlinTphPlan.isTphSubtype(obj)) { + emitTphSubtypeValidation(obj, outRoot, loader) + continue + } + emit(obj, outRoot, loader, emittedEnumFqns) + } + // #234: emit the strict field.uri / field.inet Jackson deserializers ONCE per package that + // has such a field (a same-package MetaNetJson the data-class properties bind through). + emitNetJsonSupport(loader, outRoot) + } + + /** + * #234: emit the per-package `MetaNetJson.kt` support object (the strict field.uri / field.inet + * Jackson deserializers a data-class property binds through via `@field:JsonDeserialize`), ONCE + * for each package with such a field. Dependency-free (java.net + Jackson), so the generated code + * still runs without any MetaObjects runtime. + */ + private fun emitNetJsonSupport(loader: MetaDataLoader, outRoot: Path) { + val packages = linkedSetOf() + for (obj in loader.metaObjects) { + // A report's row carries no deserializer (it is never bound from a request), so + // a report is not counted here. + if (obj.subType !in EMITTED_SUBTYPES) continue + if (KotlinGenUtil.isAbstractEntity(obj)) continue + if (KotlinTphPlan.isTphSubtype(obj)) continue + if (obj.metaFields.any { isStrictNetField(it) }) { + packages.add(PackageMapping.splitFqn(obj.name).first) + } + } + for (pkg in packages) writeNetJsonSupportFile(pkg, outRoot) + } + + /** Write `MetaNetJson.kt` into `pkg` — two `JsonDeserializer`s implementing the #234 Contract-A + * strict URI / IP-literal rules (verified against the shared validation-conformance corpus). The + * class body is fixed; only the package header varies. */ + private fun writeNetJsonSupportFile(pkg: String, outRoot: Path) { + val header = if (pkg.isEmpty()) "" else "package $pkg\n\n" + val body = header + NET_JSON_SUPPORT_BODY + val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("$NET_JSON_SUPPORT.kt") + GeneratedFileWriter.write(outFile, body) + } + + protected open fun emit(obj: MetaObject, outRoot: Path, loader: MetaDataLoader, emittedEnumFqns: MutableSet) { + // FR-044: a report's row gets no enum of its own. A derived enum field is typed by + // the class already emitted for the entity it reads from, the same class the + // report's Exposed table types the column by, so the generated row mapper compiles. + // Empty for every other object. + reportEnumClasses = KotlinGenUtil.reportEnumClasses(obj, loader) + try { + emitDataClass(obj, outRoot, loader, emittedEnumFqns) + } finally { + reportEnumClasses = emptyMap() + } + } + + /** + * The enum classes of the report row being emitted, by derived field name, for the + * duration of one [emit] call; empty outside it and for any object that is not a report. + * It reaches [resolveElementType] this way, not as a parameter, because that function is + * `protected open` and an adopter's subclass may override it. + */ + private var reportEnumClasses: Map = emptyMap() + + /** + * Whether [obj] gets a data class: an entity, value object or projection, and the read + * model of a served report (FR-044), whose row a query returns. A declared report node + * never reaches here: [execute] maps it to its read model or drops it. + */ + private fun emitsDataClass(obj: MetaObject): Boolean = + obj.subType in EMITTED_SUBTYPES || obj is ReportReadModel + + private fun emitDataClass(obj: MetaObject, outRoot: Path, loader: MetaDataLoader, emittedEnumFqns: MutableSet) { + val reportRow = obj is ReportReadModel + // Emit one Kotlin enum class file per `field.enum` child BEFORE the data class + // so the resolved property type (a ClassName) points at a real file. Deduped per run. + for (field in obj.metaFields) { + // A report's enum field references an entity's class; nothing is materialized. + if (reportRow) break + // FR-019: a @provided shared enum is referenced externally, never materialized — skip it. + if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) + KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) + } + // FR-017 TPH: the base union folds in subtype-only fields (below). A folded field.enum + // resolves to a typed enum ClassName, so its enum file must be emitted here (owner = base, + // matching the fold's type resolution) — the declaring subtype is skipped in execute(), so + // no other pass emits it. No-op for a non-TPH entity (collectSubtypeFields is empty). + for (field in KotlinTphPlan.collectSubtypeFields(obj, loader)) { + if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) + KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) + } + + val (pkg, shortName) = PackageMapping.splitFqn(obj.name) + + // NO kotlinx @Serializable: entity/value/projection data classes are plain + // (Jackson-compatible) data classes. The annotation was decorative — no build enables + // the kotlinx-serialization compiler plugin for these, and the moment one did, every + // class carrying a java.util.UUID / java.time.* / java.math.BigDecimal / java.net.* field + // would fail to compile (kotlinx has no built-in serializer for those). Jackson (the codec + // every consumer actually uses) round-trips them with zero per-type plumbing — including the + // response parser, which decodes a model's reply straight into these classes (ADR-0056). + // Only KotlinEnumEmitter's enums keep @Serializable, where it is harmless. + val typeBuilder = TypeSpec.classBuilder(shortName) + .addModifiers(KModifier.DATA) + .addKdoc("GENERATED — do not hand-edit. Regenerated from metadata.\n") + + // 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 + // NO validation (a per-subtype POST/PATCH supplies only its own columns; the DB column + // nullability is the real constraint). Non-TPH entities keep their per-field required rules. + val tphBase = KotlinTphPlan.isTphBase(obj, loader) + // #214 FR-024 §7: on a WRITE-THROUGH entity the single read data class is ALSO the create + // @RequestBody, so a DERIVED (origin.*) field — view-computed, never client-supplied — must + // be a NULLABLE, default-null ctor param (else a guaranteed-non-null aggregate / @required + // derived field is a non-null param with no default and a create body omitting the + // view-computed value fails jackson-module-kotlin deserialization → HTTP 400). Its validation + // annotations are suppressed too (no @field:NotNull to reject the omitted value). Mirrors the + // Java port dropping @NotNull for derived-on-write-through. A projection is read-only + // (isWriteThrough=false), so its derived fields keep their origin-aware non-null read type. + val writeThrough = obj.isWriteThrough + + val ctorBuilder = FunSpec.constructorBuilder() + for (field in obj.metaFields) { + val baseType = resolvePropertyType(field, obj, loader) + val derivedReadOnly = writeThrough && KotlinGenUtil.isDerivedField(field) + // #195: a field derived by origin.aggregate @agg:any|all|collect is COALESCE-guaranteed + // non-null in the synthesized read-model view (any→false, all→true, collect→[]), so its + // read type is non-null even when the field is NOT @required — matching the Exposed view + // column ([KotlinExposedTableGenerator]) so the read/write types stay consistent (the + // PR-#80 nullable-column-vs-non-null-read-type invariant). origin.first / origin.computed + // keep the conservative nullable default. #214 overrides this to nullable on a + // write-through entity (the create-body relaxation above; the view column stays non-null, + // which maps safely into a nullable property). + // An ASSIGNED primary key is create-REQUIRED whatever @required says — the + // caller is its only source (KotlinGenUtil.isAssignedPrimaryKeyField), so the + // property stays non-null and a create body omitting it cannot bind. + // A SERVER-OWNED-on-create field — the key an increment/uuid identity generates, or an + // @autoSet column the CRUD path stamps — is never the caller's to send, and this one data + // class is also the create @RequestBody. As a non-null param with no default it fails + // jackson-module-kotlin deserialization on a body that correctly omits it, so EVERY + // create answered 400 on an entity whose BaseEntity declares `id` @required — the shape + // real models have. (A missing `Long` silently binds 0, which is how an increment key + // hid it; a `UUID` cannot.) Same relaxation as a derived field on a write-through + // entity: nullable, default null, no @field:NotNull. Rows read back always carry it. + val serverOwned = KotlinGenUtil.isServerOwnedOnCreate(field, obj) + val nullable = tphBase || derivedReadOnly || serverOwned || KotlinGenUtil.isNullableShapeProperty(field) + val propType = if (nullable) baseType.copy(nullable = true) else baseType + val propName = field.name + val param = ParameterSpec.builder(propName, propType) + .apply { if (nullable) defaultValue("null") } + .build() + ctorBuilder.addParameter(param) + val propBuilder = PropertySpec.builder(propName, propType).initializer(propName) + // FR-044: a report's row carries no constraint either. Nothing binds or validates + // it (it is read from the view, never sent), and a `@Size(min = 1)` on a required + // dimension would claim a rule the view does not have: an empty string is a group. + if (!tphBase && !derivedReadOnly && !serverOwned && !reportRow) { + for (annotation in validationAnnotations(field)) { + propBuilder.addAnnotation(annotation) + } + // #234: a STRICT field.uri / field.inet property binds through the codegen-owned + // literal deserializer (absolute-scheme URI / IPv4-or-IPv6 literal, no DNS), so a + // malformed value is rejected at the wire tier — aligning with TS/Python strict. + netDeserializeAnnotation(field, pkg)?.let { propBuilder.addAnnotation(it) } + // Program D (spec §0): a field.object (value-object) member carries @field:Valid so a + // parent validator.validate(bean) / @Valid cascades into the nested VO's own + // constraints (the create-body POST path). jakarta validateValue does NOT cascade, so + // the PATCH path validates present VO values explicitly in the controller. MapField is + // NOT an ObjectField (dict-of-VO is staged out), so it never gets @Valid here. + if (field is ObjectField) { + propBuilder.addAnnotation(constraint(VALID)) + } + } + typeBuilder.addProperty(propBuilder.build()) + } + + // FR-017 TPH: a discriminator base's data class carries the UNION of subtype columns (each + // NULLABLE, no validation) so one wire shape backs the polymorphic + per-subtype endpoints. + // collectSubtypeFields is empty for a non-TPH entity (the loop is then a no-op). + for (field in KotlinTphPlan.collectSubtypeFields(obj, loader)) { + val propType = resolvePropertyType(field, obj, loader).copy(nullable = true) + val propName = field.name + ctorBuilder.addParameter(ParameterSpec.builder(propName, propType).defaultValue("null").build()) + typeBuilder.addProperty(PropertySpec.builder(propName, propType).initializer(propName).build()) + } + + val primaryCtor = ctorBuilder.build() + typeBuilder.primaryConstructor(primaryCtor) + addJavaBuilder(obj, typeBuilder, ClassName(pkg, shortName), primaryCtor.parameters) + + val fileSpec = FileSpec.builder(pkg, shortName) + .addType(typeBuilder.build()) + .build() + + // Guarded: `.kt` is the file an adopter is most likely to want to own, and a + // raw FileSpec.writeTo would overwrite a hand-written one unconditionally. The KDoc + // above carries the GENERATED marker, so our own output is still overwritten freely. + KotlinPoetFileWriter.write(fileSpec, outRoot) + } + + /** + * FR-036 — emit the annotated per-subtype validation class {@code Validation} for a concrete + * TPH subtype. A discriminator base's union data class is annotation-free (a row of any other + * subtype stores NULL in a subtype column, so the union carries no {@code @required}/ + * {@code @maxLength}), so the base controller's per-subtype POST/PATCH cannot enforce field + * constraints against it. This lightweight, validation-ONLY data class (never persisted, never + * the request body — the union stays the bound + persisted type) carries the subtype's settable + * scalar fields ([KotlinTphPlan.subtypeSettableFields] — the SSOT the controller validates + * against), each with the SAME {@code @field:} jakarta annotations [validationAnnotations] + * produces for a non-TPH entity. Mirrors the Java port's standalone {@code Dto}. + * + *

Skipped when the subtype has no settable scalar (nothing to validate; a Kotlin data class + * needs at least one property) — the controller likewise emits no validation call there. + * + *

Property types resolve with the BASE as owner so a folded {@code field.enum} references the + * base-emitted enum class (matching the union column + the value the controller binds). All + * properties are nullable + defaulted: the shape is validated, never constructed, and a + * {@code @NotNull} still fires on a null value (the create absent-{@code @required} check). + */ + protected open fun emitTphSubtypeValidation(subtype: MetaObject, outRoot: Path, loader: MetaDataLoader) { + val fields = KotlinTphPlan.subtypeSettableFields(subtype) + if (fields.isEmpty()) return + val base = KotlinTphPlan.discriminatorBaseOf(subtype) ?: return + val (pkg, shortName) = PackageMapping.splitFqn(subtype.name) + val className = KotlinNaming.tphSubtypeValidationName(shortName) + + val typeBuilder = TypeSpec.classBuilder(className) + .addModifiers(KModifier.DATA) + .addKdoc( + "GENERATED — do not hand-edit. Regenerated from metadata.\n" + + "FR-036 validation-only shape for the $shortName TPH subtype: the base controller's\n" + + "per-subtype POST/PATCH validates present values against these field constraints\n" + + "(the folded union base data class is annotation-free). Never persisted or bound.\n" + ) + + val ctorBuilder = FunSpec.constructorBuilder() + for (field in fields) { + val propType = resolvePropertyType(field, base, loader).copy(nullable = true) + val propName = field.name + ctorBuilder.addParameter( + ParameterSpec.builder(propName, propType).defaultValue("null").build() + ) + val propBuilder = PropertySpec.builder(propName, propType).initializer(propName) + for (annotation in validationAnnotations(field)) propBuilder.addAnnotation(annotation) + typeBuilder.addProperty(propBuilder.build()) + } + + KotlinPoetFileWriter.write( + FileSpec.builder(pkg, className) + .addType(typeBuilder.primaryConstructor(ctorBuilder.build()).build()) + .build(), + outRoot + ) + } + + /** + * Emit an abstract entity as a Kotlin `interface` shape: one read-only `val` property per + * field, reusing the same property-type resolution + required/nullable rules as [emit]. + * No `@Serializable` and no constructor — an interface is not instantiable, mirroring the + * "abstract = scaffolding, never a write artifact" invariant while still giving concrete + * subtypes a shared shape to implement. Enum-field files are still emitted so property + * types resolve. Written to the same package path/file (`.kt`) as [emit] would use. + */ + protected open fun emitAbstractShape(obj: MetaObject, outRoot: Path, loader: MetaDataLoader, emittedEnumFqns: MutableSet) { + for (field in obj.metaFields) { + // FR-019: a @provided shared enum is referenced externally, never materialized — skip it. + if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field)) + KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns) + } + + val (pkg, shortName) = PackageMapping.splitFqn(obj.name) + + val typeBuilder = TypeSpec.interfaceBuilder(shortName) + .addKdoc("GENERATED — do not hand-edit. Regenerated from metadata.\n") + + for (field in obj.metaFields) { + val baseType = resolvePropertyType(field, obj, loader) + val nullable = !KotlinGenUtil.isRequiredField(field) + val propType = if (nullable) baseType.copy(nullable = true) else baseType + typeBuilder.addProperty( + PropertySpec.builder(field.name, propType).build() + ) + } + + KotlinPoetFileWriter.write( + FileSpec.builder(pkg, shortName) + .addType(typeBuilder.build()) + .build(), + outRoot + ) + } + + /** + * Resolve the Kotlin TypeName for a single property. For `field.object` fields, + * the type is a reference to the generated data class of the field's `@objectRef` + * (e.g., `Address` for `field.object @objectRef="Address"`). For `field.enum` + * fields, the type is the typed enum class generated alongside the entity + * (e.g., `PlayerStatus` for `Player.status`). The `@storage` attr is intentionally + * NOT consulted here — flattened vs jsonb only affects the persistence column shape, + * not the in-memory shape. + */ + protected open fun resolvePropertyType(field: MetaField<*>, owner: MetaObject, loader: MetaDataLoader): TypeName { + val element = resolveElementType(field, owner, loader) + // @isArray fields are a List of the element type (List). isArrayType() + // covers both the `isArray: true` shorthand and a child @isArray attr. + // Nullable handling is applied by the caller to the List itself (List?), + // not the elements — an array of non-null items. + return if (field.isArrayType) LIST.parameterizedBy(element) else element + } + + /** The Kotlin TypeName for a single (non-array) element of [field]. */ + protected open fun resolveElementType(field: MetaField<*>, owner: MetaObject, loader: MetaDataLoader): TypeName { + // FR-019: a @provided shared enum is referenced at its configured external namespace + // (.E) instead of the materialized in-package class; an unresolved namespace throws. + Fr019SharedEnum.providedClassName(field, fr019Config)?.let { return it } + // FR-044: a report's derived enum field → the class of the entity field it reads. + if (owner is ReportReadModel) reportEnumClasses[field.name]?.let { return it } + // field.enum → typed enum class generated alongside this entity. + KotlinTypeMapper.enumTypeName(field, owner)?.let { return it } + if (field is ObjectField) { + val ref = readObjectRef(field) + val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + if (target != null) { + val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name) + return ClassName(targetPkg, targetShort) + } + } + // field.map → Map. The value is the @valueType scalar (handled by + // kotlinTypeName below) or, for an @objectRef map, the referenced VO data class — + // which needs the loader to resolve, so build the Map here. + if (field is MapField) { + val ref = readObjectRef(field) + val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + if (target != null) { + val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name) + return MAP.parameterizedBy(STRING, ClassName(targetPkg, targetShort)) + } + } + return KotlinTypeMapper.kotlinTypeName(field) + } + + /** Read the `@objectRef` attr off a field (own-only); null if absent. */ + private fun readObjectRef(field: MetaField<*>): String? { + if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, true)) return null + return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, true).valueAsString } + .getOrNull() + } + + // === validation (SP-C validator parity, Unit 5) ========================= + + /** + * Build the `@field:`-site-targeted jakarta.validation annotations for a generated + * property from the field's constraint metadata — both field attrs (`@required`, + * `@maxLength`) and `validator.*` children (length / regex / numeric / array). + * Returns an empty list when the field carries no constraints. + * + *

The Kotlin entity data class IS the controller request body + * (`@RequestBody dto: ` in [KotlinSpringControllerGenerator]); these + * annotations make Spring's `@Valid` enforce the constraints at the boundary. + * Site target is [AnnotationSpec.UseSiteTarget.FIELD] because jakarta's reflective + * validator reads the backing field, not the Kotlin property/getter. + * + *

Cross-port semantics (the SP-C validator-parity contract — mirrors the Spring + * port's `SpringDtoGenerator`): + *

    + *
  • required (field `@required` or `validator.required`) → `@NotNull`. FR-036 Pin 1: + * NO `@NotBlank` — a `@required` string is NON-EMPTY BY DEFAULT (rejects null / "") + * but WHITESPACE-ONLY is ACCEPTED, so the implicit non-empty floor is a + * `@Size(min >= 1)` on the string-length annotation (which `@NotBlank`'s trim-based + * check would over-reject). An explicitly authored `validator.length @min` is always + * authoritative over that implicit floor (#224); `@min: 0` opts back to + * presence-only and emits no `@Size` minimum at all.
  • + *
  • `validator.length @min/@max` folded with field `@maxLength` (strictest-wins on max) + * into ONE `@Size(min=…, max=…)` — non-array strings only.
  • + *
  • `validator.regex @pattern` → `@Pattern(regexp=…)`.
  • + *
  • `validator.numeric @min/@max` → `@Min(…)` / `@Max(…)`.
  • + *
  • `validator.array @min/@max` → `@Size(min=…, max=…)` on the `List`.
  • + *
+ */ + protected open fun validationAnnotations(field: MetaField<*>): List { + val isArray = field.isArrayType + val isString = field is StringField + val out = mutableListOf() + + val required = KotlinGenUtil.isRequiredField(field) || hasValidator(field, RequiredValidator::class.java) || + KotlinGenUtil.isAssignedPrimaryKeyField(field) + if (required) { + // FR-036 Pin 1: @required → @NotNull only. NO @NotBlank (it rejects whitespace-only, + // which the cross-port owner ruling ACCEPTS). The non-empty floor for a required + // non-array string is a @Size(min >= 1) folded into the string-length block below. + out.add(constraint(NOT_NULL)) + } + + // String length (non-array strings only): ONE @Size folding validator.length @min/@max, + // the field-level @maxLength cap, and — for a required string — a min of at least 1. + // Guarded by isString && !isArray so a string-ARRAY's @maxLength never emits a char-count + // @Size on the List (its element-count @Size comes from validator.array below). + if (isString && !isArray) { + var lengthMin: Int? = null + var lengthMax: Int? = null + validator(field, LengthValidator::class.java)?.let { length -> + lengthMin = attrInt(length, LengthValidator.ATTR_MIN) + lengthMax = attrInt(length, LengthValidator.ATTR_MAX) + } + // A3 strictest-wins: the effective max is the SMALLER of @maxLength and + // validator.length @max (either may be absent). + attrInt(field, StringField.ATTR_MAX_LENGTH)?.let { cap -> + lengthMax = lengthMax?.let { minOf(it, cap) } ?: cap + } + // FR-036 Pin 1: a required string is non-empty by default → fold in an + // implicit min of 1, but ONLY when no validator.length @min was + // authored. An explicitly authored @min is always AUTHORITATIVE over + // that implicit floor (#224 / ADR-0044): @min: 0 opts back to + // presence-only and, per the cross-port emission pin, emits NO minimum + // constraint at all (no @Size(min=0)) — cleanest, deterministic output + // across ports. + if (required) { + lengthMin = when (lengthMin) { + null -> 1 + 0 -> null + else -> lengthMin + } + } + if (lengthMin != null || lengthMax != null) { + out.add(sizeAnnotation(lengthMin, lengthMax)) + } + } + + // Regex pattern. + validator(field, RegexValidator::class.java)?.let { regex -> + regex.resolvePattern()?.let { pattern -> + out.add( + AnnotationSpec.builder(PATTERN) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .addMember("regexp = %S", pattern) + .build() + ) + } + } + + // Numeric value bounds. + validator(field, NumericValidator::class.java)?.let { numeric -> + attrInt(numeric, NumericValidator.ATTR_MIN)?.let { min -> + out.add( + AnnotationSpec.builder(MIN) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .addMember("value = %L", min.toLong()) + .build() + ) + } + attrInt(numeric, NumericValidator.ATTR_MAX)?.let { max -> + out.add( + AnnotationSpec.builder(MAX) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .addMember("value = %L", max.toLong()) + .build() + ) + } + } + + // Array element-count bounds → @Size on the List property. + validator(field, ArrayValidator::class.java)?.let { array -> + val min = attrInt(array, ArrayValidator.ATTR_MIN) + val max = attrInt(array, ArrayValidator.ATTR_MAX) + if (min != null || max != null) out.add(sizeAnnotation(min, max)) + } + + // ADR-0036/0037 Wave 3 — @stringFormat on a plain string field. The canonical + // matcher per format lives HERE in codegen (NOT author validator.regex), since + // cross-language regex engines diverge. email → @field:Email; hostname → a + // canonical DNS-hostname @field:Pattern check. + if (isString && !isArray && field.hasMetaAttr(StringField.ATTR_STRING_FORMAT)) { + when (field.getMetaAttr(StringField.ATTR_STRING_FORMAT).value?.toString()?.trim()) { + StringField.STRING_FORMAT_EMAIL -> out.add(constraint(EMAIL)) + StringField.STRING_FORMAT_HOSTNAME -> out.add( + AnnotationSpec.builder(PATTERN) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .addMember("regexp = %S", HOSTNAME_REGEX) + .build() + ) + } + } + + return out + } + + /** A no-member `@field:`-targeted constraint annotation (e.g. `@field:NotNull`). */ + private fun constraint(type: ClassName): AnnotationSpec = + AnnotationSpec.builder(type) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .build() + + /** + * #234: true iff `field` is a STRICT (non-`@lenient`) field.uri / field.inet — the properties + * whose data-class component binds through the generated [NET_JSON_SUPPORT] literal deserializer. + * A `@lenient` uri/inet degrades to a plain String ([KotlinTypeMapper.kotlinTypeName]) with NO + * deserializer, so it is NOT strict. + */ + private fun isStrictNetField(field: MetaField<*>): Boolean = + (field is UriField || field is InetField) && !KotlinTypeMapper.lenientNetField(field) + + /** + * #234: the `@field:JsonDeserialize(using = …)` annotation binding a STRICT field.uri / + * field.inet property to the same-`pkg` generated `MetaNetJson` deserializer — an + * absolute-scheme URI or an IPv4/IPv6 literal (never a DNS lookup). `contentUsing` for an + * `@isArray` property (the ELEMENTS deserialize strictly). Null for any other field. + */ + private fun netDeserializeAnnotation(field: MetaField<*>, pkg: String): AnnotationSpec? { + if (!isStrictNetField(field)) return null + val deserializer = ClassName(pkg, NET_JSON_SUPPORT, + if (field is UriField) "AbsoluteUriDeserializer" else "InetLiteralDeserializer") + val member = if (field.isArrayType) "contentUsing" else "using" + return AnnotationSpec.builder(JSON_DESERIALIZE) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .addMember("$member = %T::class", deserializer) + .build() + } + + /** + * `@field:Size(min = …, max = …)` — each bound omitted when null. Emitted as ONE member + * (a single literal) so a two-bound `@Size` stays single-line, matching the historical + * single-bound shape (KotlinPoet wraps multi-`addMember` annotations across lines). + */ + private fun sizeAnnotation(min: Int?, max: Int?): AnnotationSpec { + val bounds = buildList { + if (min != null) add("min = $min") + if (max != null) add("max = $max") + } + return AnnotationSpec.builder(SIZE) + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .addMember("%L", bounds.joinToString(", ")) + .build() + } + + /** Read an int-valued attr from a node, trying each name in order; null when absent/unparseable. */ + private fun attrInt(node: MetaData, vararg attrNames: String): Int? { + for (attr in attrNames) { + if (node.hasMetaAttr(attr)) { + return runCatching { node.getMetaAttr(attr).valueAsString.trim().toInt() }.getOrNull() + } + } + return null + } + + private fun validator(field: MetaField<*>, type: Class): V? { + // ADR-0039: resolving member iteration — getChildren(type) walks the extends + // super chain, so a concrete field inherits validators from an abstract base. + // A raw field.children read would miss inherited validators. + for (child in field.getChildren(MetaValidator::class.java, true)) { + if (type.isInstance(child)) return type.cast(child) + } + return null + } + + private fun hasValidator(field: MetaField<*>, type: Class): Boolean = + validator(field, type) != null + + private companion object { + /** MetaObject subtypes this generator emits a Kotlin data class for. + * FR-024 (ADR-0028): object.projection included — a derived read-only + * representation still needs its data class for query results. */ + val EMITTED_SUBTYPES = setOf( + MetaObject.SUBTYPE_ENTITY, + MetaObject.SUBTYPE_VALUE, + MetaObject.SUBTYPE_PROJECTION, + ) + + // jakarta.validation.constraints annotation types (SP-C validator parity). + private const val JAKARTA_CONSTRAINTS = "jakarta.validation.constraints" + val NOT_NULL = ClassName(JAKARTA_CONSTRAINTS, "NotNull") + val SIZE = ClassName(JAKARTA_CONSTRAINTS, "Size") + val PATTERN = ClassName(JAKARTA_CONSTRAINTS, "Pattern") + val MIN = ClassName(JAKARTA_CONSTRAINTS, "Min") + val MAX = ClassName(JAKARTA_CONSTRAINTS, "Max") + val EMAIL = ClassName(JAKARTA_CONSTRAINTS, "Email") + + /** Program D: `@field:Valid` on a value-object member so a parent `@Valid` / whole-bean + * validate cascades into the nested VO's own constraints (lives in `jakarta.validation`, + * NOT `jakarta.validation.constraints`). */ + val VALID = ClassName("jakarta.validation", "Valid") + + /** #234: `@field:JsonDeserialize(using = …)` binds a STRICT field.uri / field.inet property + * to the same-package [NET_JSON_SUPPORT] literal deserializer. */ + val JSON_DESERIALIZE = ClassName("com.fasterxml.jackson.databind.annotation", "JsonDeserialize") + /** The per-package generated support object holding the #234 strict Jackson deserializers. */ + const val NET_JSON_SUPPORT = "MetaNetJson" + + /** The fixed `MetaNetJson.kt` body (below the package header). Two strict Jackson + * deserializers — an absolute-scheme URI and an IPv4/IPv6 literal (never a DNS lookup) — + * implementing the #234 Contract-A rules. Uses `+`-concatenated messages (no Kotlin string + * templates) so THIS generator's raw string needs no `$` escaping. */ + val NET_JSON_SUPPORT_BODY: String = """ + |import com.fasterxml.jackson.core.JsonParser + |import com.fasterxml.jackson.databind.DeserializationContext + |import com.fasterxml.jackson.databind.JsonDeserializer + |import java.net.InetAddress + |import java.net.URI + |import java.net.URISyntaxException + | + |/** GENERATED (#234) — strict Jackson deserializers for field.uri / field.inet. + | * Do not hand-edit; regenerated from metadata. Dependency-free (java.net + Jackson). */ + |internal object MetaNetJson { + | /** field.uri — an ABSOLUTE URI carrying a scheme (leading/trailing whitespace trimmed, + | * WHATWG). A relative reference (example.com, /path) or malformed value is rejected. */ + | class AbsoluteUriDeserializer : JsonDeserializer() { + | override fun deserialize(p: JsonParser, ctxt: DeserializationContext): URI? { + | val raw = p.valueAsString ?: return null + | val uri = try { + | URI(raw.trim()) + | } catch (e: URISyntaxException) { + | throw IllegalArgumentException("field.uri: not a valid URI: " + raw, e) + | } + | require(uri.isAbsolute) { "field.uri: must be an absolute URI carrying a scheme: " + raw } + | return uri + | } + | } + | + | /** field.inet — an IPv4 or IPv6 LITERAL only. Never a DNS lookup: a value containing + | * ':' can only be an IPv6 literal (getByName parses it offline), otherwise a strict + | * dotted-quad IPv4. Hostnames, CIDR, padding, out-of-range octets are rejected. */ + | class InetLiteralDeserializer : JsonDeserializer() { + | override fun deserialize(p: JsonParser, ctxt: DeserializationContext): InetAddress? { + | val raw = p.valueAsString ?: return null + | // No trim: IP-literal grammar has no whitespace, so padding is rejected. + | if (raw.indexOf(':') >= 0) { + | return try { + | InetAddress.getByName(raw) // ':' => IPv6 literal — getByName does no DNS + | } catch (e: Exception) { + | throw IllegalArgumentException("field.inet: not a valid IPv6 literal: " + raw, e) + | } + | } + | return parseIpv4(raw) + | } + | + | private fun parseIpv4(s: String): InetAddress { + | val parts = s.split(".") + | require(parts.size == 4) { "field.inet: not a valid IPv4 literal: " + s } + | val octets = ByteArray(4) + | for (i in 0 until 4) { + | val part = parts[i] + | // #234 review H1: reject a leading-zero octet (010 / 01) — the octal/decimal + | // -parse ambiguity — matching Python ipaddress + the TS regex. + | require(part.isNotEmpty() && part.length <= 3 && part.all { it.isDigit() } + | && !(part.length > 1 && part[0] == '0')) { + | "field.inet: not a valid IPv4 literal: " + s + | } + | val v = part.toInt() + | require(v <= 255) { "field.inet: IPv4 octet out of range: " + s } + | octets[i] = v.toByte() + | } + | return InetAddress.getByAddress(octets) + | } + | } + |} + |""".trimMargin() + + /** + * Canonical DNS-hostname matcher for `@stringFormat: hostname` (ADR-0036/0037 Wave 3). + * Codegen-owned (NOT author `validator.regex`) — one or more dot-separated labels, each + * 1–63 chars of alphanumerics/hyphens not starting/ending with a hyphen. Byte-identical + * to the Spring port's `SpringDtoGenerator.HOSTNAME_REGEX`. + */ + const val HOSTNAME_REGEX = + "^(?=.{1,253}\$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\$" + } + + // === MultiFileDirectGeneratorBase abstract-method stubs ==================== + // KotlinPoet writes whole files in execute(); the parent's print-writer pipeline is unused. + override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ } + override fun ?> getSingleWriter( + loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter? + ): T? = null + override fun ?> getFinalWriter( + loader: MetaDataLoader?, out: OutputStream? + ): T? = null + override fun writeFinalFile(metadata: MutableCollection?, writer: GeneratorIOWriter<*>?) { /* none */ } + override fun getSingleOutputFilePath(md: MetaObject): String = + PackageMapping.splitFqn(md.name).first.replace('.', '/') + override fun getSingleOutputFilename(md: MetaObject): String = + PackageMapping.splitFqn(md.name).second + ".kt" + + /** + * Emit a nested `Builder` plus a `@JvmStatic builder()` factory. + * + * Kotlin default arguments are a COMPILER feature, not a bytecode one. A data class whose + * properties are all defaulted exposes only three constructors to Java — the full N-arg one, + * a synthetic bitmask one Java cannot call, and (when every parameter is defaulted) a no-arg + * one that yields an all-null instance of an IMMUTABLE class. So a Java caller setting 3 of + * 14 fields had to pass 14 arguments with 11 nulls, and a generated value object replacing a + * hand-written Lombok `@Builder` made its Java call sites strictly worse (#365). Kotlin + * callers were never affected — named arguments cover it — which is why every existing test + * here, all Kotlin-side, missed it. + * + * A nested builder rather than `@JvmOverloads`: overloads are positional, so they only help a + * caller whose omissions are all TRAILING, and a 14-member class would emit 15 constructors + * to say so. A builder needs no dependency (deliberately NOT Lombok — generated code must not + * force a third-party annotation processor on an adopter) and reads the same from both + * languages. + * + * Every backing field is nullable even where the property is not: a builder is filled + * incrementally, so it cannot hold the constructor's non-null guarantee. `build()` restores it + * with `requireNotNull`, naming the property — the same trade every builder makes, and the + * data-class constructor remains the real enforcement point. + */ + private fun addJavaBuilder( + obj: MetaObject, + typeBuilder: TypeSpec.Builder, + className: ClassName, + params: List, + ) { + if (params.isEmpty()) return + // NOT on an object.projection. A projection is a DERIVED read-only representation — + // it arrives from a view query and nothing constructs one, so a builder would be an + // affordance for something callers must not do. It would also put `var` backing fields + // into a type KotlinProjectionCompileTest requires to be immutable, which is how this + // exclusion was found rather than reasoned about. + if (obj.subType == MetaObject.SUBTYPE_PROJECTION) return + // Nor on a report's row (FR-044), for the same reason: it arrives from the view. + if (obj is ReportReadModel) return + + val builderClass = ClassName(className.packageName, className.simpleName, "Builder") + val builder = TypeSpec.classBuilder("Builder") + .addKdoc("Fluent builder — lets a JAVA caller set a subset of properties.\n") + + for (param in params) { + val backingType = param.type.copy(nullable = true) + builder.addProperty( + PropertySpec.builder(param.name, backingType) + .addModifiers(KModifier.PRIVATE) + .mutable(true) + .initializer("null") + .build() + ) + builder.addFunction( + FunSpec.builder(param.name) + .addParameter("v", backingType) + .returns(builderClass) + .addStatement("this.%N = v", param.name) + .addStatement("return this") + .build() + ) + } + + // Each assignment is joined with KotlinPoet's NON-BREAKING SPACE (U+00B7), which it + // renders as an ordinary space but never treats as a line-break candidate. That matters + // because this text is handed to addStatement as raw format-string content: KotlinPoet + // wraps at column 100 on any plain space it sees, and it cannot tell that the spaces in + // `" is required"` are INSIDE a string literal. A property name long enough to push + // the line past the margin therefore had its message wrapped mid-literal, emitting Kotlin + // that does not parse (a regular string literal has no line continuation) — see #388. + // Making the whole assignment atomic leaves the explicit ",\n " separators below as the + // only break points, which are the ones that are actually safe. + val args = params.joinToString(",\n ") { p -> + if (p.type.isNullable) "${p.name}\u00b7=\u00b7${p.name}" + else "${p.name}\u00b7=\u00b7requireNotNull(${p.name})\u00b7{\u00b7" + + "\"${p.name}\u00b7is\u00b7required\"\u00b7}" + } + builder.addFunction( + FunSpec.builder("build") + .returns(className) + .addStatement("return %T(\n $args\n)", className) + .build() + ) + + typeBuilder.addType(builder.build()) + typeBuilder.addType( + TypeSpec.companionObjectBuilder() + .addFunction( + FunSpec.builder("builder") + .addAnnotation(JvmStatic::class) + .returns(builderClass) + .addStatement("return Builder()") + .build() + ) + .build() + ) + } + +} diff --git a/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinExposedTableGenerator.kt.txt b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinExposedTableGenerator.kt.txt new file mode 100644 index 000000000..0d66b4a90 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinExposedTableGenerator.kt.txt @@ -0,0 +1,2320 @@ +package com.metaobjects.generator.kotlin + +// Eject note (ADR-0034, JVM eject design): these siblings stay in +// com.metaobjects.generator.kotlin when this generator is copied out via +// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not +// same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.generator.kotlin.ExposedApi +import com.metaobjects.generator.kotlin.ExposedImports +import com.metaobjects.generator.kotlin.KotlinGenUtil +import com.metaobjects.generator.kotlin.KotlinNaming +import com.metaobjects.generator.kotlin.KotlinTphPlan +import com.metaobjects.generator.kotlin.KotlinTypeMapper +import com.metaobjects.generator.kotlin.PackageMapping + +import com.metaobjects.MetaData +import com.metaobjects.database.CoreDBMetaDataProvider +import com.metaobjects.database.IndexNaming +import com.metaobjects.MetaRoot +import com.metaobjects.field.EnumField +import com.metaobjects.field.MapField +import com.metaobjects.field.MetaField +import com.metaobjects.field.DecimalField +import com.metaobjects.field.ObjectField +import com.metaobjects.generator.GeneratorException +import com.metaobjects.generator.GeneratorIOWriter +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase +import com.metaobjects.identity.MetaIdentity +import com.metaobjects.identity.ReferenceIdentity +import com.metaobjects.index.LookupIndex +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject +import com.metaobjects.relationship.CompositionRelationship +import com.metaobjects.relationship.MetaRelationship +import com.metaobjects.relationship.RelationshipReferences +import com.metaobjects.reporting.ReportReadModel +import com.metaobjects.reporting.ReportShape +import com.metaobjects.source.MetaSource +import com.metaobjects.source.RdbSource +import com.squareup.kotlinpoet.ClassName +import java.io.OutputStream +import java.io.PrintWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import org.slf4j.LoggerFactory +import com.metaobjects.generator.util.GeneratedFileWriter +import com.metaobjects.generator.util.RestSurfaceGate + +/** + * Generator: one Exposed Table `object` per `object.entity` that has a `source.rdb` child. + * Entities without source.rdb are skipped (no persistence layer). + * + *

A view-backed `object.report` (FR-044) also gets one — the read-only mapping of the view + * `meta migrate` creates, with one column per derived field. See [emitReport] for which + * reports emit and which do not. + * + *

Exposed's `Column` types are inferred by the Kotlin compiler from the initialiser + * expressions (e.g., `val name = varchar("name", 100)`). KotlinPoet's [com.squareup.kotlinpoet.PropertySpec] + * requires an explicit type, which would force `val name: Column = ...` — verbose and + * brittle. Rather than fight the API, this generator hand-rolls the file body as a string; + * the syntactic surface is small (~20 lines per file) and matches the idiomatic Exposed style. + * + *

Args: + *

    + *
  • {@code outputDir} (required): output directory root.
  • + *
+ */ +open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase() { + + /** + * The project's column-naming strategy for fields carrying no explicit `@column`, + * from the `columnNaming` generator arg + * (`literal` in the pom). Values match the + * cross-port vocabulary: `snake_case` (this port's default, and its historical + * behaviour), `literal`, `kebab-case`. + * + * It is CONFIG, not metadata — ADR-0023 does not apply. The same model must be able + * to drive a snake_case Postgres schema and a literal-column one, which is precisely + * why the strategy lives beside the generator rather than in the metadata, and why + * the byte-gated registry prose for `@column` describes it as the default *"via + * columnNamingStrategy"*. + */ + protected fun columnNaming(): String = + getArg(KotlinGenUtil.ARG_COLUMN_NAMING, KotlinGenUtil.DEFAULT_COLUMN_NAMING) + + /** + * Task 6 — whether the table binding references `Names.NAME` / + * `Names._COLUMN` instead of respelling the physical name/column as a + * string literal. + * + * The literal fallback below is the answer for a DIRECT programmatic call only. In a + * Maven run the arg is DERIVED: [com.metaobjects.generator.EmitsPhysicalNameConstants] + * marks [KotlinNamesGenerator], and `AbstractMetaDataMojo.buildGenerators` builds the + * whole `` list before executing any of it, so a pom that wires the names + * generator gets `useNames=true` without asking. An explicit `` still wins. + * + * (The premise this defaulted OFF for — "Kotlin generators are selected by FQCN with no + * runner aggregating markers" — was simply false; the mojo IS that aggregation point. + * The default stays `false` because it is the honest answer when nothing aggregated: + * referencing `Names` in a run without the names generator emits code that does + * not compile.) A PRESENCE guard, not a divergence/equality guard — see + * [KotlinGenUtil.primaryRdbSource]. + */ + protected fun useNames(): Boolean = + (getArg(KotlinGenUtil.ARG_USE_NAMES, "false") ?: "false").toBoolean() + + /** + * Exposed output-API version (issue #390) — `0` (default) emits Exposed 0.x + * (`org.jetbrains.exposed.sql.*`, byte-identical to every release before this arg + * existed); `1` emits Exposed 1.x (`org.jetbrains.exposed.v1.*`). From the + * `exposedApi` generator arg (`1` in the pom). + */ + protected fun exposedApi(): ExposedApi = ExposedApi.parse(getArg(ExposedApi.ARG_EXPOSED_API)) + + override fun getFilterClass(): Class = MetaObject::class.java + + override fun execute(loader: MetaDataLoader) { + parseArgs() + val outRoot = Paths.get(outDir.absolutePath) + + // Pass 1a: identity.reference decorations — per-entity field→FK target + // metadata used to decorate the field column with `.references(...)` + // inline (rather than emit a separate FK row, which would duplicate the + // field's regular column emission). + val refDecorationMap = buildIdentityReferenceDecorations(loader) + + // Pass 1b: compute FK columns globally across all entities so the "many" + // side (`relationship.composition @cardinality: many`) on Author can + // contribute the FK column to PostTable even when Post has no reciprocal. + val fkMap = buildGlobalFkMap(loader, refDecorationMap) + + // Packages that emit at least one table carrying a + // instant (default, non-`@localTime`) column. Each such package gets ONE + // shared `MetaInstantWithTimeZoneColumnType.kt` support file (emitted in + // pass 3 below) instead of inlining the helper into every table file — + // multiple instant-timestamp tables in one package would otherwise + // redeclare the top-level support class/extension and fail to compile. + val packagesNeedingInstantTzHelper = sortedSetOf() + + // Packages that emit at least one table carrying a `field.uri` or `field.inet` + // column (ADR-0036/0037 Wave 3). Each such package gets ONE shared + // `MetaInetUriColumnType.kt` support file (the custom `Column`/`Column` + // column types + the `uriColumn(...)`/`inetColumn(...)` builder extensions), emitted in + // pass 3 — same multi-table-per-package sharing rationale as the instant-tz helper. + val packagesNeedingInetUriHelper = sortedSetOf() + + // Packages that emit at least one table carrying a typed `field.object @storage:jsonb` + // or `field.map` column. Each such package gets ONE shared `MetaJsonbMapper.kt` support + // file (the `internal metaJsonbMapper` ObjectMapper the generated jsonb() codecs call) — + // same `internal` per-package sharing rationale as the instant-tz / inet-uri helpers. + val packagesNeedingJacksonMapper = sortedSetOf() + + // Packages that emit at least one table carrying a `field.string @dbColumnType=uuid` + // column (R6 Plan 2b physical escape hatch). Each such package gets ONE shared + // `MetaUuidStringColumnType.kt` support file (the custom `Column` column type + + // the `uuidString(...)` builder extension), emitted in pass 3 — same multi-table-per- + // package sharing rationale as the other helpers. + val packagesNeedingUuidStringHelper = sortedSetOf() + + // Pass 2: emit one Table per entity using its own metadata + the + // inbound FKs accumulated in Pass 1. + // FR-024 (ADR-0028): object.projection is emitted too — a projection with + // an rdb view-kind source is queried through an Exposed Table exactly like + // a view-kind entity was pre-FR-024 (read-only by construction; projections + // carry no relationships/references, so passes 1+3 stay entity-only). + for (entity in loader.metaObjects) { + // FR-044: a report declares no fields, so it is emitted from its derived shape + // rather than through the entity/projection path below. + if (entity.subType == MetaObject.SUBTYPE_REPORT) { + emitReport( + entity, outRoot, loader, + packagesNeedingInstantTzHelper, packagesNeedingInetUriHelper, packagesNeedingJacksonMapper, + packagesNeedingUuidStringHelper, + ) + continue + } + if (entity.subType != MetaObject.SUBTYPE_ENTITY && + entity.subType != MetaObject.SUBTYPE_PROJECTION) continue + // Abstract entities are inheritance scaffolding — never emit a persistence table. + if (KotlinGenUtil.isAbstractEntity(entity)) continue + // FR-017 TPH: a discriminator subtype folds into its base's single table — the base + // emits the union table. A per-subtype table would map the SAME physical table with + // a partial column set (a footgun). The subtype inherits source.rdb via extends, so + // the resolving lookup below would otherwise emit one — mirror the controller's skip. + if (KotlinTphPlan.isTphSubtype(entity)) continue + // #214 FR-024 §7: a WRITE-THROUGH entity read-view (own writable table source + own + // read-only replica view source + derived origin.* fields) emits TWO Exposed objects: + // the derived-free write `Table` AND the derived-carrying read-only `View`. + // Detected order-independently via MetaObject.isWriteThrough() — NEVER firstRdbSource, + // which returns the FIRST-declared source and can't pick table-vs-view. A projection + // owns only a read-only source (isWriteThrough() = false), so it stays on the single- + // object path below. + if (entity.subType == MetaObject.SUBTYPE_ENTITY && entity.isWriteThrough) { + emitWriteThrough( + entity, outRoot, loader, fkMap, refDecorationMap, + packagesNeedingInstantTzHelper, packagesNeedingInetUriHelper, packagesNeedingJacksonMapper, + packagesNeedingUuidStringHelper, + ) + continue + } + // R27: role-scoped resolving source lookup — an entity inheriting its + // source.rdb via extends must still emit a table (own-only .children + // returned null → no table). MUST be primaryRdbSource, not firstRdbSource: + // an object may declare two own sources of the SAME writable-or-read-only-ness + // (two read-only views, or two writable tables) in either declaration order, + // and firstRdbSource picks whichever was declared FIRST rather than whichever + // is role=primary. This is the SAME selector KotlinGenUtil.resolveObjectNames + // uses to build Names.NAME/@kind, so the table binding and the names + // artifact can never resolve two different source nodes for the same entity + // (see KotlinGenUtil.primaryRdbSource's doc for the full shape + why the + // write-through branch above never reaches this line). + val sourceRdb = KotlinGenUtil.primaryRdbSource(entity) ?: continue + val kind = sourceRdb.effectiveKind + // table + view + materializedView → emit; view-like kinds are emitted read-only. + // storedProc → skip; consumer should wire KotlinStoredProcGenerator for those entities. + // tableFunction → skip with a warning (no dedicated generator yet). + if (kind != MetaSource.KIND_TABLE && !isViewKind(sourceRdb)) { + if (kind == MetaSource.KIND_STORED_PROC) { + LOG.warn( + "skipping {} — source.rdb @kind='storedProc' is not handled by KotlinExposedTableGenerator; use KotlinStoredProcGenerator for stored procs", + entity.name + ) + } else { + LOG.warn( + "skipping {} — source.rdb @kind='{}' is not supported by KotlinExposedTableGenerator (table/view/materializedView only)", + entity.name, kind + ) + } + continue + } + val fkColumns = if (isViewKind(sourceRdb)) emptyList() else fkMap[entity.name].orEmpty() + val refDecorations = + if (isViewKind(sourceRdb)) emptyMap() else refDecorationMap[entity.name].orEmpty() + val usedInstantTzHelper = emit(entity, sourceRdb, outRoot, loader, fkColumns, refDecorations) + if (usedInstantTzHelper) { + packagesNeedingInstantTzHelper += PackageMapping.splitFqn(entity.name).first + } + // ADR-0036/0037 Wave 3: does this table carry a `field.uri`/`field.inet` column? + // Walk direct fields AND flattened object sub-fields — the same two surfaces that + // contribute columns (mirrors the instant-tz detection inside emit()). + if (entityNeedsInetUriHelper(entity, loader)) { + packagesNeedingInetUriHelper += PackageMapping.splitFqn(entity.name).first + } + // Does this table carry a typed jsonb column (field.object non-flattened, or field.map)? + // If so its package needs the shared metaJsonbMapper support file. + if (entityNeedsJacksonMapper(entity, loader)) { + packagesNeedingJacksonMapper += PackageMapping.splitFqn(entity.name).first + } + // R6 Plan 2b: does this table carry a `field.string @dbColumnType=uuid` column? If + // so its package needs the shared MetaUuidStringColumnType support file. + if (entityNeedsUuidStringHelper(entity, loader)) { + packagesNeedingUuidStringHelper += PackageMapping.splitFqn(entity.name).first + } + } + + // Pass 3: emit ONE shared `MetaInstantWithTimeZoneColumnType.kt` per package + // that has at least one instant (default, non-`@localTime`) column. The helper + // (custom `ColumnType` + `Table.instantWithTimeZone(...)` extension) is + // `internal`, so every `*Table.kt` in the same package + module shares the single + // declaration with no redeclaration / private-access clash. + for (pkg in packagesNeedingInstantTzHelper) { + emitInstantTzSupportFile(pkg, outRoot) + } + + // Pass 3b (ADR-0036/0037 Wave 3): emit ONE shared `MetaInetUriColumnType.kt` per + // package that has at least one `field.uri`/`field.inet` column. Same `internal` + // per-package sharing rationale as the instant-tz helper above. + for (pkg in packagesNeedingInetUriHelper) { + emitInetUriSupportFile(pkg, outRoot) + } + + // Pass 3c: emit ONE shared `MetaJsonbMapper.kt` per package that has at least one typed + // `field.object @storage:jsonb` / `field.map` column. The `internal metaJsonbMapper` + // ObjectMapper is package+module-private, so every `*Table.kt` in the package shares it. + for (pkg in packagesNeedingJacksonMapper) { + emitJsonbMapperSupportFile(pkg, outRoot) + } + + // Pass 3d (R6 Plan 2b): emit ONE shared `MetaUuidStringColumnType.kt` per package that + // has at least one `field.string @dbColumnType=uuid` column. Same `internal` per-package + // sharing rationale as the other helpers. + for (pkg in packagesNeedingUuidStringHelper) { + emitUuidStringSupportFile(pkg, outRoot) + } + } + + /** + * #214 FR-024 §7 — emit the TWO Exposed objects of a write-through entity read-view: + * + * - the WRITE `Table` from the entity's OWN writable `source.rdb` (physical `@table` + * name), EXCLUDING derived (`origin.*`) fields but keeping the PK / autoIncrement / + * `.references(...)` / `uniqueIndex` write shape (`excludeDerivedFields = true`, + * `isView` = false since the source is a table kind); + * - the READ `View` from the entity's OWN read-only replica view source (physical + * name via [RdbSource.getPhysicalName]), carrying ALL fields incl. derived, in the + * read-only shape (no autoIncrement / references / uniqueIndex — the existing view-kind + * branch of [emit], reached because the source is a view kind). + * + * Both objects live in the same package, so the per-package instant-tz / inet-uri / jsonb + * support-file needs are recorded once (the sets dedupe). Order-independent: the sources are + * selected by writable-vs-read-only classification, not declaration order. + */ + private fun emitWriteThrough( + entity: MetaObject, + outRoot: Path, + loader: MetaDataLoader, + fkMap: Map>, + refDecorationMap: Map>, + packagesNeedingInstantTzHelper: MutableSet, + packagesNeedingInetUriHelper: MutableSet, + packagesNeedingJacksonMapper: MutableSet, + packagesNeedingUuidStringHelper: MutableSet, + ) { + val (pkg, shortName) = PackageMapping.splitFqn(entity.name) + val writeSource = KotlinGenUtil.writableRdbSource(entity) + val readSource = KotlinGenUtil.readOnlyRdbSource(entity) + // Defensive: isWriteThrough() already guaranteed both are present; a null here would be a + // metadata-model contract break, so fail loud rather than emit a half table. + if (writeSource == null || readSource == null) { + LOG.warn( + "skipping write-through {} — expected both a writable and a read-only source.rdb (write={}, read={})", + entity.name, writeSource != null, readSource != null + ) + return + } + // WRITE table: derived-free, full write shape (PK/autoIncrement/references/uniqueIndex). + val usedInstantTzWrite = emit( + entity, writeSource, outRoot, loader, + fkMap[entity.name].orEmpty(), refDecorationMap[entity.name].orEmpty(), + objectNameOverride = KotlinNaming.tableObjectName(shortName), + excludeDerivedFields = true, + ) + // READ view: carries ALL fields incl. derived; read-only shape (no FK/uniqueIndex — the + // isView branch of emit(), since readSource is a view kind). + val usedInstantTzRead = emit( + entity, readSource, outRoot, loader, + emptyList(), emptyMap(), + objectNameOverride = KotlinNaming.viewObjectName(shortName), + excludeDerivedFields = false, + ) + if (usedInstantTzWrite || usedInstantTzRead) packagesNeedingInstantTzHelper += pkg + if (entityNeedsInetUriHelper(entity, loader)) packagesNeedingInetUriHelper += pkg + if (entityNeedsJacksonMapper(entity, loader)) packagesNeedingJacksonMapper += pkg + if (entityNeedsUuidStringHelper(entity, loader)) packagesNeedingUuidStringHelper += pkg + } + + /** + * FR-044 — emit the read-only `Table` of a view-backed `object.report`. + * + * Which reports emit (contract Table A): + * + * - no read-only source: NOTHING. The report has no view, so there is nothing to map + * (a sourceless object generates nothing, #248). + * - `source.rdb @kind: view`: the table object, bound to the source's physical name. + * The same for a view marked `@unmanaged: true` or carrying an authored `@sql` body: + * migrate does not derive (or does not create) that view, but the view exists and + * Table B still defines the columns a reader gets, so the mapping is needed either way. + * - `@kind: materializedView` / `storedProc` / `tableFunction`: NOTHING. The lowering + * skips those kinds, so no relation with the Table B columns is promised. + * - an abstract report: NOTHING. + * + * The columns are the report's DERIVED fields — one per dimension, then one per measure — + * taken from the JVM's single definition of that shape ([ReportShape], via + * [ReportReadModel], which presents them as ordinary field nodes). So the report goes + * through the same [emit] as a view-kind projection, with three differences, which reach + * it through the report's [ReportTablePlan] and its being a [ReportReadModel]: + * + * - it binds its view and columns by LITERAL even when the names generator is in the + * run ([bindsThroughNames]): [KotlinNamesGenerator] emits nothing for a report; + * - an enum column references the enum class of the entity the dimension reads + * ([KotlinGenUtil.reportEnumClasses]): no generator emits a per-report enum; + * - a derived decimal with no declared precision reads at [REPORT_DECIMAL_PRECISION] / + * [REPORT_DECIMAL_SCALE] ([scalarColumnSpec]). + * + * A report has no identity, so the table has no `primaryKey`, and no index or reference. + * The data class, filter allowlist and read-only controller of the same report come from + * their own generators, each through `RestSurfaceGate.restShapeOf` (FR-044 Plan 3). + */ + private fun emitReport( + report: MetaObject, + outRoot: Path, + loader: MetaDataLoader, + packagesNeedingInstantTzHelper: MutableSet, + packagesNeedingInetUriHelper: MutableSet, + packagesNeedingJacksonMapper: MutableSet, + packagesNeedingUuidStringHelper: MutableSet, + ) { + // Table A, from the one JVM predicate every REST-surface generator asks: a report + // is served only when it is concrete and its read source is `@kind: view`. + if (!RestSurfaceGate.isServedReport(report)) return + // The source the report is READ from, by the rule that names the lowered view. + val source = ReportShape.readSource(report) as? RdbSource ?: return + + // Table B, derived ONCE for the report; everything below reads this one shape. + val shape = ReportShape.of(report, loader.root) + refuseUncompilableReportColumns(shape) + refuseObjectFieldReportColumns(shape) + val model = ReportReadModel.of(report) + val pkg = PackageMapping.splitFqn(report.name).first + reportPlans[model] = ReportTablePlan( + enumClasses = KotlinGenUtil.reportEnumClasses(shape), + unsizedDecimals = shape.fields() + .filter { it.typeSource == null && it.subType == DecimalField.SUBTYPE_DECIMAL } + .mapTo(HashSet()) { it.name }, + ) + try { + if (emit(model, source, outRoot, loader, emptyList(), emptyMap())) packagesNeedingInstantTzHelper += pkg + } finally { + reportPlans.remove(model) + } + if (entityNeedsInetUriHelper(model, loader)) packagesNeedingInetUriHelper += pkg + if (entityNeedsJacksonMapper(model, loader)) packagesNeedingJacksonMapper += pkg + if (entityNeedsUuidStringHelper(model, loader)) packagesNeedingUuidStringHelper += pkg + } + + /** + * What [emit] needs to know about the report it is emitting that the read model's + * field nodes do not say. Built once per report by [emitReport]. + * + * @property enumClasses derived enum field name → the generated enum class its column is typed by + * @property unsizedDecimals derived decimal fields the shape gives no type source, so no precision + */ + private class ReportTablePlan(val enumClasses: Map, val unsizedDecimals: Set) + + /** + * The plan of the report being emitted, keyed by its read model for the duration of + * that one [emit] call. Identity-keyed because node equality is structural. It is how + * the three report differences reach [emit] without a parameter on a `protected open` + * function an adopter's subclass may override. + */ + private val reportPlans = java.util.IdentityHashMap() + + /** + * Refuse a report whose derived field names cannot become the column properties of one + * Kotlin `object`. `gen` would otherwise exit 0 and the adopter's build would be the + * first thing to disagree. + * + * Two cases, both loadable. A derived field named after a Kotlin hard keyword (`in`, + * `is`, `object`, …) is not a legal property name. And [KotlinNaming.safeColumnProperty] + * renames a field that collides with an Exposed `Table` member (`source` becomes + * `sourceColumn`), which can land on a second derived field already called that. + * + * Scoped to reports: an entity field has the same two hazards and they are left as they + * were. A report that generates no table (see [emitReport]) is never checked. + */ + private fun refuseUncompilableReportColumns(shape: ReportShape) { + val report = shape.report() + val seen = HashMap() + for (f in shape.fields()) { + if (f.name in KOTLIN_HARD_KEYWORDS) { + throw GeneratorException( + "report \"${report.shortName}\": its ${describeItem(f)} generates the Exposed column " + + "property \"${f.name}\", and `${f.name}` is a Kotlin keyword, so the generated " + + "table would not compile. Rename the ${f.role.wireName()}." + ) + } + val property = KotlinNaming.safeColumnProperty(f.name, exposedApi()) + val prior = seen.put(property, f) ?: continue + throw GeneratorException( + "report \"${report.shortName}\": its ${describeItem(prior)} and its ${describeItem(f)} both " + + "generate the Exposed column property \"$property\" (a name that collides with a member " + + "of Exposed's Table gets a \"Column\" suffix), so the generated table would not " + + "compile. Rename one of them." + ) + } + } + + /** + * Refuse a report with a derived field typed by a `field.object` (a dimension over an + * embedded value object, say). The loader accepts it, but no port reads one through a + * report's row: the read model the table is built from ([ReportReadModel]) refuses it + * too, with the same sentence. Refused here first so `gen` fails as a generator error + * naming the report and the dimension or measure. + */ + private fun refuseObjectFieldReportColumns(shape: ReportShape) { + for (f in shape.fields()) { + val src = f.typeSource ?: continue + // ADR-0039: resolving — an @objectRef the @of field inherits counts. + if (src.subType != ObjectField.SUBTYPE_OBJECT && !src.hasMetaAttr(MetaField.ATTR_OBJECT_REF)) continue + throw GeneratorException( + "report \"${shape.report().shortName}\": its ${describeItem(f)} reads " + + "\"${f.typeSourceKey()}\", a field.${src.subType}. A report over a field.object is " + + "not supported; group by a scalar field." + ) + } + } + + /** + * `measure "x"` or `dimension "x"`. The derived name IS the item name here: only a time + * dimension derives a different one (``), and that is never a keyword, a + * `Table` member or a `…Column` name, so a time dimension is never refused. + */ + private fun describeItem(f: ReportShape.Field): String = "${f.role.wireName()} \"${f.name}\"" + + /** + * Whether the table of [entity] references `Names` constants: only when the + * names generator is in the run ([useNames]) AND emits an artifact for [entity]. It + * emits none for a report (FR-044), so a report binds its view and columns by literal. + */ + private fun bindsThroughNames(entity: MetaObject): Boolean = useNames() && entity !is ReportReadModel + + /** + * The generated enum class a `field.enum` column of [entity]'s table is typed by: + * [KotlinTypeMapper.enumTypeName] for an entity or projection — the class + * [KotlinEntityGenerator] emits for it — and [KotlinGenUtil.reportEnumClasses] for a report. + */ + private fun enumClassFor(field: MetaField<*>, entity: MetaObject): ClassName? = + reportPlans[entity]?.enumClasses?.get(field.name) ?: KotlinTypeMapper.enumTypeName(field, entity) + + /** + * The Exposed column spec of a non-enum scalar [field] of [entity]: the type mapper's, + * except for a report's derived decimal that carries no declared precision (an `avg`, a + * ratio, or a `sum` of a decimal — Table B gives those no type source). + * + * Exposed's decimal column rounds every value it reads to the column's declared scale, + * so the mapper's default of four places would silently turn a ratio of 2/3 into 0.6667. + * These columns are an unconstrained NUMERIC in the view, so they are read at the widest + * precision and scale a Postgres NUMERIC is commonly declared with. The object maps a + * view, so the two numbers never reach DDL. + */ + private fun scalarColumnSpec(entity: MetaObject, field: MetaField<*>, colExpr: String, api: ExposedApi): String { + if (reportPlans[entity]?.unsizedDecimals?.contains(field.name) == true) { + return "decimal($colExpr, $REPORT_DECIMAL_PRECISION, $REPORT_DECIMAL_SCALE)" + } + return KotlinTypeMapper.exposedColumnSpec(field, colExpr, api) + } + + /** + * True iff [entity] carries at least one `field.uri`/`field.inet` column — on a direct + * field OR a flattened `object.value` sub-field. Mirrors the instant-tz detection inside + * [emit] (which walks the same two surfaces). + */ + private fun entityNeedsInetUriHelper(entity: MetaObject, loader: MetaDataLoader): Boolean { + if (entity.metaFields.any { it !is ObjectField && KotlinTypeMapper.usesInetUriHelper(it) }) { + return true + } + for (field in entity.metaFields) { + if (field !is ObjectField) continue + if (readStorage(field) != STORAGE_FLATTENED) continue + val ref = readObjectRef(field) ?: continue + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: continue + if (target.metaFields.any { KotlinTypeMapper.usesInetUriHelper(it) }) return true + } + return false + } + + /** + * True iff [entity] contributes at least one typed jsonb column that calls the shared + * `metaJsonbMapper` — a `field.map` (always jsonb) OR a `field.object` with jsonb/default + * storage (`@storage` != `flattened`) on a direct field, OR a `field.map` nested inside a + * flattened `object.value` sub-field. Mirrors what [buildObjectColumns] + the flattened + * sub-field path actually emit, so the support file is present exactly when referenced. + */ + private fun entityNeedsJacksonMapper(entity: MetaObject, loader: MetaDataLoader): Boolean { + for (field in entity.metaFields) { + if (field is MapField) return true // field.map is always a jsonb Jackson column + if (field is ObjectField) { + if (readStorage(field) != STORAGE_FLATTENED) return true // typed jsonb object column + // A flattened object.value sub-field can itself be a field.map (a jsonb Jackson column). + val ref = readObjectRef(field) ?: continue + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: continue + if (target.metaFields.any { it is MapField }) return true + } + } + return false + } + + /** + * True iff [entity] carries at least one `field.string @dbColumnType=uuid` column (R6 Plan + * 2b) — on a direct field only (unlike the inet-uri/jsonb helpers, a flattened `object.value` + * sub-field is materialised per-subfield by [KotlinTypeMapper]'s flattened path, which never + * calls [KotlinTypeMapper.exposedColumnSpec]'s `StringField`/`@dbColumnType` branch for a + * nested sub-field the same way, so there is no flattened surface to walk here). Mirrors + * [entityNeedsInetUriHelper]'s direct-field walk. + */ + private fun entityNeedsUuidStringHelper(entity: MetaObject, loader: MetaDataLoader): Boolean = + entity.metaFields.any { it !is ObjectField && KotlinTypeMapper.usesUuidStringHelper(it) } + + /** + * Emit the per-package `MetaJsonbMapper.kt` support file: an `internal` shared Jackson + * [com.fasterxml.jackson.databind.ObjectMapper] (`metaJsonbMapper`) that the generated typed + * `jsonb()` column codecs call to encode/decode `field.object @storage:jsonb` value objects + * and `field.map` maps. Jackson (kotlin + jsr310 modules) is used INSTEAD of kotlinx so the + * generated data classes stay plain (no `@Serializable`, no compiler plugin) — every java.time + * / java.util / java.math / java.net field a jsonb VO may carry round-trips with no per-type + * plumbing. `internal` keeps it package+module-private while every `*Table.kt` shares it. + * + * Consumer classpath: the generated code needs `com.fasterxml.jackson.core:jackson-databind`, + * `com.fasterxml.jackson.module:jackson-module-kotlin`, and + * `com.fasterxml.jackson.datatype:jackson-datatype-jsr310` on its runtime classpath. + */ + private fun emitJsonbMapperSupportFile(pkg: String, outRoot: Path) = + // The jsonb block is fully-qualified (no imports needed). + emitSupportFile(pkg, outRoot, JSONB_MAPPER_SUPPORT_FILE_NAME, imports = "", block = JSONB_MAPPER_SUPPORT_BLOCK) + + /** + * Emit the per-package `MetaInetUriColumnType.kt` support file (ADR-0036/0037 Wave 3): + * `internal` custom Exposed column types for `field.uri` (a `Column` over + * plain `text`) and `field.inet` (a `Column` over the Postgres-native + * `inet` type), plus the `Table.uriColumn(name)` / `Table.inetColumn(name)` column-builder + * extensions the generated tables call. `internal` keeps it package+module-private while + * every `*Table.kt` in the package shares the one declaration. + */ + private fun emitInetUriSupportFile(pkg: String, outRoot: Path) { + val api = exposedApi() + emitSupportFile(pkg, outRoot, INET_URI_SUPPORT_FILE_NAME, inetUriSupportFileImports(api), inetUriSupportBlock(api)) + } + + /** + * Emit the per-package `MetaUuidStringColumnType.kt` support file (R6 Plan 2b): an + * `internal` custom Exposed column type for `field.string @dbColumnType=uuid` — a + * `Column` persisted through the Postgres-native `uuid` type, delegating every + * JDBC/DDL concern to Exposed's own `UUIDColumnType` — plus the `Table.uuidString(name)` + * column-builder extension the generated tables call. `internal` keeps it package+module- + * private while every `*Table.kt` in the package shares the one declaration. + */ + private fun emitUuidStringSupportFile(pkg: String, outRoot: Path) { + val api = exposedApi() + emitSupportFile( + pkg, outRoot, UUID_STRING_SUPPORT_FILE_NAME, + uuidStringSupportFileImports(api), uuidStringSupportBlock(api), + ) + } + + /** + * Emit the per-package `MetaInstantWithTimeZoneColumnType.kt` support file for + * instant (default, non-`@localTime`) columns: an `internal` custom `ColumnType` + * (DDL `TIMESTAMP WITH TIME ZONE`) plus the `internal Table.instantWithTimeZone(name)` + * column-builder extension that the generated table files call. `internal` keeps it + * package+module-private (no leakage) while letting EVERY `*Table.kt` in the package use + * the single shared declaration — fixing the multi-table-per-package redeclaration that the + * earlier inline-per-file emission caused. + */ + private fun emitInstantTzSupportFile(pkg: String, outRoot: Path) { + val api = exposedApi() + emitSupportFile(pkg, outRoot, INSTANT_TZ_SUPPORT_FILE_NAME, instantTzSupportFileImports(api), instantTzSupportBlock(api)) + } + + /** + * Emit one `internal` per-package support file (`.kt`): a package header (when the + * package is non-empty), optional import lines, then the declaration block. Shared by the + * jsonb-mapper / inet-uri / instant-tz emitters, which differ only in file name, imports, and + * block — the `internal` declarations let every `*Table.kt` in the package reuse one copy. + */ + private fun emitSupportFile(pkg: String, outRoot: Path, fileName: String, imports: String, block: String) { + val body = buildString { + if (pkg.isNotEmpty()) append("package $pkg\n\n") + if (imports.isNotEmpty()) { append(imports); append("\n") } + append(block) + append("\n") + } + val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("$fileName.kt") + GeneratedFileWriter.write(outFile, body) + } + + /** + * True when {@code source.rdb @kind} names a view-like construct + * (view / materializedView). View-like sources are read-only: the generator + * emits the Table declaration without auto-increment on the PK and without + * FK constraints (views inherit FKs from their underlying tables; declaring + * them again on the view confuses Exposed and serves no purpose). + */ + private fun isViewKind(sourceRdb: RdbSource): Boolean { + val k = sourceRdb.effectiveKind + return k == MetaSource.KIND_VIEW || k == MetaSource.KIND_MATERIALIZED_VIEW + } + + /** + * Emit one `*Table.kt` for [entity]. Returns `true` when the table carries at + * least one instant (default, non-`@localTime`) column — the caller uses that to + * record the package as needing the shared `MetaInstantWithTimeZoneColumnType.kt` + * support file (emitted once per package, not per table). + */ + protected open fun emit( + entity: MetaObject, + sourceRdb: RdbSource, + outRoot: Path, + loader: MetaDataLoader, + fkColumns: List, + refDecorations: Map, + // #214: a write-through entity read-view emits TWO objects from the SAME entity — the + // write `Table` and the read `View` — so the caller passes an explicit + // object name (default: `tableObjectName(shortName)`, byte-identical for a vanilla entity). + objectNameOverride: String? = null, + // #214: on the write table, DERIVED fields (`origin.*` children) are excluded — they are + // not stored on the writable table, only computed by the read view. Vanilla entities carry + // no derived fields, so the default `false` keeps their output byte-identical. + excludeDerivedFields: Boolean = false, + ): Boolean { + val (pkg, shortName) = PackageMapping.splitFqn(entity.name) + // issue #390: read once per emit() call and threaded through every Exposed FQN / + // column-spec decision below via KotlinTypeMapper + ExposedImports. + val api = exposedApi() + val isView = isViewKind(sourceRdb) + val tableObjectName = objectNameOverride ?: KotlinNaming.tableObjectName(shortName) + // FR-016: getPhysicalName() implements the four-step rule (kind-matching + // alias -> legacy @table -> snake_case(source.name) -> pluralize(snake_case(entity))), + // so it returns a valid name for any rdb @kind. The local fallback is + // kept as a defensive default for synthetic sources with no parent. + val tableName = sourceRdb.physicalName?.takeIf { it.isNotEmpty() } + ?: (shortName.lowercase() + "s") + + // Task 6 / R27 — reference the constant whenever the names generator is in the run + // (gated on the arg, never on string equality — see KotlinGenUtil.primaryRdbSource's + // doc). Names keys its sources by @role, so the reference is selected by the + // ROLE of the source THIS call binds: `SOURCE_PRIMARY_TABLE` for the write table, + // `SOURCE_REPLICA_VIEW` for the replica. + // + // That role key is what changed the answer for emitWriteThrough, which calls this + // function twice for ONE entity against two DIFFERENT own sources. The artifact used + // to carry the primary source's physical name and nothing else, so the read view had + // no slot to reference and kept its literal "deliberately" — a comment describing a + // missing member as a design. Both calls reference a constant now. + // + // The selection is no longer reference identity with the primary source either. The + // question is not "is this the primary?" but "which slot describes this source?", + // and the artifact answers it by role; two sources in one role that DISAGREE are + // refused when the artifact is built (KotlinGenUtil.sourcesOf), so a role key cannot + // silently stand for the wrong name. + val names = if (bindsThroughNames(entity)) KotlinGenUtil.resolveObjectNames(entity, columnNaming()) else null + // The slot describing THIS source, and the only thing that decides whether the names + // arm is taken at all. Null when the names generator is out of the run, or when the + // source's @kind carries no physical-name alias — a future @kind with no alias + // declares no member, and falling back to the literal keeps this from emitting a + // reference to nothing. + val srcNames = names?.sources?.get(sourceRdb.role)?.takeIf { it.alias != null } + // The member prefix is built from the source node's own metamodel type and role, and + // the physical-name segment from the metamodel's kind→alias map — the SAME two + // transforms KotlinNamesGenerator declares the member with, so the reference and the + // declaration cannot name two different members. + val sourcePrefix = KotlinNaming.namesObjectName(shortName) + + ".${KotlinNaming.namesMember(sourceRdb.type)}_${KotlinNaming.namesMember(sourceRdb.role)}_" + val tableExpr = + if (srcNames != null) "$sourcePrefix${KotlinNaming.namesMember(srcNames.alias!!)}" + else "\"$tableName\"" + + // @schema — Exposed takes the qualified name in the SAME string argument + // (`Table("sales.widgets")`), so qualification is a prefix on `tableExpr` rather than + // a separate parameter. It must be honoured because migrate-ts creates the table + // qualified (`CREATE TABLE "sales"."widgets"`) and owns where a table lives under + // ADR-0015; an unqualified Exposed table binds whatever the connection's search_path + // resolves to, so the generated code read the right table or a different one + // depending on the deployment, never failing loudly either way. + // + // Read off the SAME source node this call is binding, not off the entity, so a + // write-through entity's replica view keeps its own schema rather than borrowing the + // write table's. On the names arm it concatenates the constant; on the literal arm it + // spells the schema, and both produce the same string at runtime. + val tableSchema = sourceRdb.schema?.takeIf { it.isNotEmpty() } + val qualifiedTableExpr = when { + // The names arm reads the SCHEMA member off the same role slot the table name + // came from, so a replica view with its own @schema qualifies with its own. + srcNames?.schema != null -> "${sourcePrefix}SCHEMA + \".\" + $tableExpr" + srcNames != null || tableSchema == null -> tableExpr + // Names off: fold the two literals into one rather than emitting + // `"sales." + "widgets"`. + else -> "\"$tableSchema.$tableName\"" + } + + // Task 6 — see [columnExpr]. Kept as a local alias so the many call sites in this + // function stay short; the rule itself lives in one place. Both column loops below + // — the entity's own fields AND the TPH fold of its subtypes' fields — go through + // it, so "which artifact carries this column" is decided once. + fun columnExprFor(f: MetaField<*>): String = columnExpr(entity, f) + + // Walk the `extends` chain so identities declared on an abstract base + // entity (the BaseEntity pattern: `identity.primary` on `id`) are + // picked up by tables for concrete entities that extend it. Own-only + // collection misses inherited primary identities and the generated + // table comes out with no `override val primaryKey = PrimaryKey(...)` + // declaration. `getIdentities(true)` returns own + super-chain (with + // MetaData's dedupe by type+name letting an own-declared identity + // override an inherited one of the same name). + val allIdentities = entity.getIdentities(true) + val primary = allIdentities.firstOrNull { it.isPrimary } + // Secondary identities → Exposed `uniqueIndex(name, col1, ...)` calls. + // Views inherit uniqueness from their underlying tables; never emit + // uniqueIndex on a view (Exposed can't add indexes to a view object). + // Walks the `extends` chain via the same `getIdentities(true)` path + // used for the primary identity so secondaries on an abstract base + // entity (e.g. `Named` with `identity.secondary by_name`) are picked + // up by concrete child tables. Stable order = declaration order. + val secondaries = if (isView) emptyList() else allIdentities.filter { it.isSecondary } + // Composite PKs (e.g. a junction table keyed by (userId, roleId)) must + // emit `PrimaryKey(userId, roleId)` — earlier code only took the FIRST + // field and silently truncated. Every PK-member field is non-nullable + // (it's part of the primary key). autoIncrement only applies to the + // single-field case; a composite tuple can't be auto-generated, so the + // generator falls back to the LLM/DB-side default. + val primaryFieldNames = primary?.let { KotlinGenUtil.keyFields(entity, it) }.orEmpty() + val primaryFieldSet = primaryFieldNames.toSet() + val singlePrimaryFieldName = primaryFieldNames.singleOrNull() + // Views inherit PKs from underlying tables — never emit autoIncrement on a + // view column, even when @generation=increment is declared on the primary + // identity (a relic of the parent entity's declaration). + val incrementPk = primary?.isIncrement == true && !isView && singlePrimaryFieldName != null + // R6 Plan 2a: a single-field uuid PK with @generation:uuid gets a server-side + // gen_random_uuid() DEFAULT (the Postgres-side mint). Routed through the SAME + // generation signal as increment — distinct only in the emitted column suffix. + // Views never carry a generated default (they inherit the underlying PK). + val uuidGeneratedPk = primary?.isUuid == true && !isView && singlePrimaryFieldName != null + + val objectColumns = buildObjectColumns(entity, primaryFieldSet, loader, excludeDerivedFields) + // A `field.string @dbColumnType=jsonb` open bag now decodes to a kotlinx `JsonElement` + // (issue #98) via `{ Json.parseToJsonElement(it) }`, so its table file needs the + // `kotlinx.serialization.json.Json` import too (that open bag is the ONLY column that still + // uses kotlinx — the object/map jsonb columns use the Jackson metaJsonbMapper). Detect it on + // direct fields AND flattened object sub-fields — the same two surfaces that contribute + // columns / imports. Folding it into the jsonb-import decision (rather than + // `columnFunctionImports`) keeps the `jsonb` extension import emitted exactly once. + val hasStringJsonbOpenBag = entity.metaFields.any { KotlinTypeMapper.isJsonbOpenBag(it) } || + entity.metaFields.any { f -> + f is ObjectField && readStorage(f) == STORAGE_FLATTENED && + (readObjectRef(f)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + ?.metaFields?.any { KotlinTypeMapper.isJsonbOpenBag(it) } ?: false) + } + // Object/map jsonb columns → the Jackson metaJsonbMapper codec (a same-package `internal` + // val in MetaJsonbMapper.kt); the open-bag `field.string @dbColumnType=jsonb` → kotlinx + // `Json.parseToJsonElement` (a runtime API needing NO compiler plugin). They share the + // `jsonb` extension import but need DIFFERENT codec support: the object/map path needs the + // Jackson mapper file, the open-bag path needs the `kotlinx...Json` import. + val hasJacksonJsonbColumn = objectColumns.any { it.kind == ObjectColumnKind.JSONB } + val needsJsonbExtensionImport = hasJacksonJsonbColumn || hasStringJsonbOpenBag + val needsKotlinxJsonImport = hasStringJsonbOpenBag + val needsRefOptForDecor = refDecorations.values.any { it.hasReferenceOption } + + // Does any column on this table use the TZ-aware instant (default, non-`@localTime`) + // opt-in? If so the table calls the `instantWithTimeZone(...)` column-builder + // extension, which lives in the package-shared `MetaInstantWithTimeZoneColumnType.kt` + // support file (emitted once per package — see emitInstantTzSupportFile). The + // extension is `internal` + same-package, so the table needs NO import for it (and the + // return value's `Column` type is inferred). Walk direct fields AND flattened + // object sub-fields (same surfaces that contribute columns / imports above). + var needsInstantTzHelper = entity.metaFields.any { + it !is ObjectField && KotlinTypeMapper.usesInstantWithTimeZone(it) + } + if (!needsInstantTzHelper) { + for (field in entity.metaFields) { + if (field !is ObjectField) continue + if (readStorage(field) != STORAGE_FLATTENED) continue + val ref = readObjectRef(field) ?: continue + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: continue + if (target.metaFields.any { KotlinTypeMapper.usesInstantWithTimeZone(it) }) { + needsInstantTzHelper = true + break + } + } + } + + // Walk every field that will actually become a Table column and collect the + // imports its column function requires. Member functions on Table (varchar, + // integer, long, etc.) return null and are skipped; extension functions from + // org.jetbrains.exposed.sql.javatime (date / timestampWithTimeZone / ...) + // return their FQN so the generated file imports them. Without this the + // generated tables compile-fail with unresolved-reference errors. Flattened + // object sub-fields are walked too — they emit columns of their own. + val columnFunctionImports = sortedSetOf() + for (field in entity.metaFields) { + // field.object AND field.map columns are produced by buildObjectColumns (single jsonb + // column) — their imports (the `jsonb` extension + the metaJsonbMapper support file) are + // handled via the jsonb-import decision above, not here. + if (field is ObjectField || field is MapField) continue + // EnumField uses enumerationByName (a Table member) when generated, regardless + // of what the type-mapper would return for a bare column emission — skip it + // here so it doesn't accidentally drag in a non-applicable import. + if (field is EnumField) continue + // The `field.string @dbColumnType=jsonb` open bag's `jsonb` extension import is + // emitted via the jsonb-import decision (alongside the `Json` import its codec needs) — + // skip here so the `import ...json.jsonb` line isn't emitted twice. + if (KotlinTypeMapper.isJsonbOpenBag(field)) continue + KotlinTypeMapper.exposedColumnImport(field, api)?.let { columnFunctionImports += it } + } + // Flattened object sub-columns also contribute column functions (and thus + // potentially imports). Walk the field.object children's referenced object.value + // sub-fields the same way buildObjectColumns does. + for (field in entity.metaFields) { + if (field !is ObjectField) continue + if (readStorage(field) != STORAGE_FLATTENED) continue + val ref = readObjectRef(field) ?: continue + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: continue + for (subField in target.metaFields) { + if (subField is EnumField) continue + // jsonb open bag → its `jsonb`/`Json` imports come via the jsonb-import decision (skip here). + if (KotlinTypeMapper.isJsonbOpenBag(subField)) continue + KotlinTypeMapper.exposedColumnImport(subField, api)?.let { columnFunctionImports += it } + } + } + + // Cross-package FK-target imports: when a `.references(...)` call (from + // either an `identity.reference` decoration or a `relationship.composition` + // FK column) points at a table that lives in a different Kotlin package, + // the generated file must import the target's symbol or the + // bare reference fails to resolve. Same-package targets need no import + // (the Kotlin compiler resolves them via the file's package). Soft + // identity.reference decorations (targetFqn == null) emit no + // `.references(...)` call and so contribute no import. + val crossPackageTableImports = sortedSetOf().apply { + fun consider(targetFqn: String?, targetTable: String?) { + if (targetFqn == null || targetTable == null) return + val targetPkg = PackageMapping.splitFqn(targetFqn).first + if (targetPkg.isNotEmpty() && targetPkg != pkg) { + add("$targetPkg.$targetTable") + } + } + for (fk in fkColumns) consider(fk.targetFqn, fk.targetTable) + for (decor in refDecorations.values) consider(decor.targetFqn, decor.targetTable) + } + + // #246: cross-package shared-enum imports. A `field.enum` that `extends` an abstract enum + // super declared in a DIFFERENT package (the FR-019 shared-enum collapse) resolves — via + // KotlinTypeMapper.enumTypeName — to ONE enum class named for the super and generated in + // the SUPER'S package, not this table's. The `enumerationByName(...)` column token below + // references that class by simple name only; unlike KotlinEntityGenerator's data class + // (KotlinPoet auto-imports the full ClassName it is handed), this generator hand-rolls its + // file body as a string, so a cross-package reference is unresolved without an explicit + // import here. Walks the same two enum-field surfaces the column-emit loops below use: + // own fields, plus (TPH) the base's folded-in subtype-only fields. Same-package enums + // (including this entity's own per-entity naming) contribute no + // import, so a single-package model's output stays byte-identical. + val crossPackageEnumImports = sortedSetOf().apply { + fun consider(cn: ClassName?) { + if (cn != null && cn.packageName.isNotEmpty() && cn.packageName != pkg) { + add("${cn.packageName}.${cn.simpleName}") + } + } + for (field in entity.metaFields) { + if (field is EnumField) consider(enumClassFor(field, entity)) + } + for (field in KotlinTphPlan.collectSubtypeFields(entity, loader)) { + if (field is EnumField) consider(KotlinTypeMapper.enumTypeName(field, entity)) + } + } + + val source = buildString { + if (pkg.isNotEmpty()) { + append("package $pkg\n\n") + } + append("import ${ExposedImports.core(api, "Table")}\n") + if (fkColumns.any { it.hasReferenceOption } || needsRefOptForDecor) { + append("import ${ExposedImports.core(api, "ReferenceOption")}\n") + } + for (imp in columnFunctionImports) { + append("import $imp\n") + } + for (imp in crossPackageTableImports) { + append("import $imp\n") + } + for (imp in crossPackageEnumImports) { + append("import $imp\n") + } + if (needsJsonbExtensionImport) { + append("import ${ExposedImports.json(api, "jsonb")}\n") + } + // Only the open-bag `field.string @dbColumnType=jsonb` (JsonElement) uses kotlinx Json. + // Object/map jsonb columns use the same-package `metaJsonbMapper` (no import needed). + if (needsKotlinxJsonImport) { + append("import kotlinx.serialization.json.Json\n") + } + // R6 Plan 2a: the gen_random_uuid() server default uses CustomFunction + + // UUIDColumnType, which need explicit imports (neither is a Table member). Under + // 1.x the java.util.UUID-binding UUIDColumnType moved to core.java (issue #390) — + // uuid PK generation always needs java.util.UUID, never kotlin.uuid.Uuid, so this + // is unconditional on [ExposedApi], not gated by whether the column itself is a + // `field.uuid` vs a `@dbColumnType=uuid` string. + if (uuidGeneratedPk) { + append("import ${ExposedImports.core(api, "CustomFunction")}\n") + val uuidColumnTypeImport = + if (api == ExposedApi.V1) ExposedImports.javaUuid("UUIDColumnType") + else ExposedImports.core(api, "UUIDColumnType") + append("import $uuidColumnTypeImport\n") + } + // an instant (default) timestamp calls the `instantWithTimeZone(...)` extension + // from the package-shared MetaInstantWithTimeZoneColumnType.kt support file. That + // extension is `internal` + same-package, so the table file needs NO import for it + // and NO Instant/Column imports (the column's `Column` type is inferred). + append("\n") + if (isView) { + append("/** READ-ONLY VIEW — generated from view metadata; do not insert/update/delete directly. */\n") + } + append("/** GENERATED — do not hand-edit. Regenerated from metadata. */\n") + append("object $tableObjectName : Table($qualifiedTableExpr) {\n") + // PK column(s) FIRST so a self-referential FK column (e.g. + // `parentId = uuid(...).references(SelfTable.id)`) can reference the + // PK `val id` — Kotlin object initializers run top-to-bottom, so a + // forward reference to a later `val` is a compile error ("must be + // initialized"). Inherited PK fields (BaseEntity) otherwise sort last. + // Stable sort preserves declaration order within each group. + val pkFirstFields = entity.metaFields.sortedBy { if (it.name in primaryFieldSet) 0 else 1 } + for (field in pkFirstFields) { + // ObjectField AND MapField columns are produced by buildObjectColumns() so we + // can emit @storage flattened (N columns) or jsonb (1 column) uniformly; a + // field.map is always a single jsonb column. + if (field is ObjectField || field is MapField) continue + // #214: on the WRITE table of a write-through entity, a DERIVED (origin.*) field + // has no physical column — it is computed by the read view — so it is excluded. + // No-op for a vanilla entity or the read view (excludeDerivedFields = false). + if (excludeDerivedFields && KotlinGenUtil.isDerivedField(field)) continue + val isPk = field.name in primaryFieldSet + // #195: an origin.aggregate @agg:any|all|collect view column is COALESCE-guaranteed + // non-null (any→false, all→true, collect→[]), so it is emitted NON-null even when the + // field is not @required — keeping the view column consistent with the projection + // data-class read type ([KotlinEntityGenerator]); the PR-#80 read/write invariant. + val nullable = !isPk && !KotlinGenUtil.isRequiredField(field) && + !KotlinGenUtil.originGuaranteedNonNull(field) + val baseSpec = if (field is EnumField) { + // field.enum → a typed Exposed column referencing the generated enum + // class: enumerationByName (VARCHAR) when string-backed, or + // customEnumeration (INTEGER) when the enum declares @intValueMap. + // #246: the enum may be a CROSS-PACKAGE shared type (FR-019 collapse + // onto an abstract super in another package) — the full ClassName is + // kept so its package is available; the import itself is emitted (only + // when cross-package) via crossPackageEnumImports above. + // + // Task 6 — the entity's OWN field, same as the non-enum branch below: + // route through columnExprFor so an enum column substitutes the + // Names._COLUMN constant exactly like every other + // column here, instead of keeping its own separate literal. + enumColumnSpec(field, entity, columnExprFor(field)) + } else { + // Task 6 — the entity's OWN field, so its physical column is exactly + // what Names._COLUMN names (KotlinNamesGenerator + // builds that constant from this SAME entity's resolving metaFields + // via the SAME resolveColumnName). No equality check needed: the + // reference and the value it stands for are derived from one shared + // transform (KotlinNaming.namesMember), so they cannot disagree. + scalarColumnSpec(entity, field, columnExprFor(field), api) + } + val withAuto = when { + isPk && incrementPk -> "$baseSpec.autoIncrement()" + // uuid PK + @generation:uuid → server-side gen_random_uuid() default. + isPk && uuidGeneratedPk -> "$baseSpec$GEN_RANDOM_UUID_DEFAULT_SUFFIX" + else -> baseSpec + } + // Decorate with .references(TargetTable.id[, onDelete=..., onUpdate=...]) + // when an enforced identity.reference on this entity names this field. + // Soft references (@enforce: false) carry a null targetTable — the dedup + // pass still uses the entry to suppress an inferred FK, but no physical + // .references(...) call is emitted here. Decoration applies BEFORE + // .nullable() so the chain reads naturally. + val decoration = refDecorations[field.name] + val decorated = if (decoration != null && decoration.emitsReference) + "$withAuto.references(${decoration.targetTable}.${decoration.targetPkProperty}${decoration.refSuffix})" else withAuto + val full = if (nullable) "$decorated.nullable()" else decorated + append(" val ${KotlinNaming.safeColumnProperty(field.name, exposedApi())} = $full\n") + } + // FR-017 TPH: a discriminator base's single table also carries every subtype-only + // column, emitted NULLABLE (a row of another subtype stores null there, even when the + // field is @required on its subtype). collectSubtypeFields is empty for a non-TPH entity. + // + // Task 6 — these columns go through the SAME columnExprFor as the base's own fields + // above. Each `field` belongs to a TPH SUBTYPE, not `entity`, so the base's + // Names does not carry it; [columnExpr] resolves that miss against the + // SUBTYPE's own artifact (`Names._COLUMN`, emitted in the same run + // because a subtype inherits the base's source). This loop used to build a quoted + // literal under a comment calling itself out of the substitution's reach — it never + // was: the constant existed the whole time, one object over. + for (field in KotlinTphPlan.collectSubtypeFields(entity, loader)) { + if (field is ObjectField || field is MapField) continue + val baseSpec = if (field is EnumField) { + // #246: same cross-package-aware handling as the base-column loop above — the + // import (when cross-package) is emitted via crossPackageEnumImports. Int-backed + // enums take the customEnumeration form here too. + enumColumnSpec(field, entity, columnExprFor(field)) + } else { + KotlinTypeMapper.exposedColumnSpec(field, columnExprFor(field), api) + } + append(" val ${KotlinNaming.safeColumnProperty(field.name, exposedApi())} = $baseSpec.nullable()\n") + } + for (oc in objectColumns) { + append(" val ${KotlinNaming.safeColumnProperty(oc.propertyName, exposedApi())} = ${oc.columnExpr}\n") + } + for (fk in fkColumns) { + append(" val ${KotlinNaming.safeColumnProperty(fk.propertyName, exposedApi())} = ${fk.columnExpr}\n") + } + if (primaryFieldNames.isNotEmpty()) { + val pkRefs = primaryFieldNames.joinToString(", ") { KotlinNaming.safeColumnProperty(it, exposedApi()) } + append("\n override val primaryKey = PrimaryKey($pkRefs)\n") + } + // Emit `init { uniqueIndex("", col1, ...) }` for identity.secondary + // and `init { index("", false, col1, ...) }` for index.lookup, all + // inside a single init block. Skips entries whose fields list is empty + // (defensive — the metadata constraint already requires at least one field). + // ADR-0039: getChildren(LookupIndex::class.java, true) uses the resolving + // accessor so inherited index.lookup nodes from abstract base entities are + // visible. Views never carry indexes (they inherit from underlying tables). + val emittableSecondaries = secondaries.filter { it.fields.isNotEmpty() } + val lookupIndexes = if (isView) emptyList() + else entity.getChildren(LookupIndex::class.java, true) + .filter { it.fields.isNotEmpty() } + if (emittableSecondaries.isNotEmpty() || lookupIndexes.isNotEmpty()) { + append("\n init {\n") + for (sec in emittableSecondaries) { + val cols = sec.fields.joinToString(", ") { KotlinNaming.safeColumnProperty(it, exposedApi()) } + append(" uniqueIndex(${indexNameExpr(sec, shortName)}, $cols)\n") + } + for (idx in lookupIndexes) { + val cols = idx.fields.joinToString(", ") { KotlinNaming.safeColumnProperty(it, exposedApi()) } + append(" index(${indexNameExpr(idx, shortName)}, false, $cols)\n") + } + append(" }\n") + } + append("}\n") + // NOTE: the instant (default, non-`@localTime`) support helper + // (MetaInstantWithTimeZoneColumnType + instantWithTimeZone extension) is NO LONGER + // inlined here. It is emitted ONCE PER PACKAGE as a shared internal + // MetaInstantWithTimeZoneColumnType.kt file (see emitInstantTzSupportFile), so + // multiple instant-timestamp tables in one package don't redeclare it. + } + + val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("$tableObjectName.kt") + GeneratedFileWriter.write(outFile, source) + return needsInstantTzHelper + } + + // === field.object + @storage column emission ============================ + + protected enum class ObjectColumnKind { FLATTENED, JSONB } + + /** + * The rendered column-name EXPRESSION for a field the table of [entity] binds: either + * the quoted physical-name literal, or — when the names artifact is in this run + * ([useNames]) — a reference to the `_COLUMN` constant on the artifact that + * CARRIES the field. + * + * THIS entity's artifact first. It already carries every field [entity] resolves — + * inherited ones included, re-exported by reference from an abstract base's fragment + * (see [KotlinNamesGenerator]) — so an `extends` chain needs no redirect, and taking one + * would be wrong: a column an abstract base declares must reference `Names`, + * the object whose table this is, not the fragment it happens to be spelled in. + * + * A MISS means the field belongs to a different entity entirely — under TPH, exactly + * the subtype whose own columns this base's table absorbs. Only then is the reference + * resolved against the DECLARING entity's artifact, reached through the field's own + * parent link. C# is the reference (its emitter passes the subtype to `ColumnRef`); + * TypeScript resolves the same way. The declaring object's artifact is emitted in this + * same run: every object under a TPH base — concrete subtype or abstract intermediate — + * inherits the base's source, and [KotlinNamesGenerator] emits for any object with a + * declared OR inherited primary source (#248), so the reference resolves. + * + * Unlike the TABLE-name reference this is not scoped to which source the emit call + * targets: a field's column name does not depend on which of a write-through entity's + * two own sources is being emitted, so it is correct for both the write table and the + * read view. + * + * EVERY site that names a column goes through here or [ownColumnExpr] — the scalar + * loop, the enum spec, the TPH fold, and the single-jsonb `field.object` / `field.map` + * columns in [buildObjectColumns]. Those last two used to build their own literal, which + * is how a `@storage: jsonb` value-object column went on spelling its physical name in + * a run where every scalar beside it referenced a constant; the TPH fold did the same + * for longer. A second copy of a substitution rule is a second chance to miss a branch. + */ + protected fun columnExpr(entity: MetaObject, f: MetaField<*>): String { + if (!bindsThroughNames(entity)) return "\"${KotlinGenUtil.resolveColumnName(f, columnNaming())}\"" + // ADR-0039: metaFields is the RESOLVING accessor — an inherited field is a HIT here, + // and the artifact of `entity` is where its constant is read from. + if (entity.metaFields.any { it.name == f.name }) return ownColumnExpr(entity, f) + // getDeclaringObject() is the field's actual parent node — for a field reached + // through a subtype's resolving accessor, the object that DECLARES it. Null only for + // a root-level abstract field, which no table loop can hand us; the literal is the + // same last-resort fallback as running without the names generator. + val declaring: MetaObject = f.declaringObject + ?: return "\"${KotlinGenUtil.resolveColumnName(f, columnNaming())}\"" + return "${namesObjectRef(entity, declaring)}.${KotlinNaming.namesMember(f.name)}_COLUMN" + } + + /** + * The rendered index-name EXPRESSION for an `identity.secondary` / `index.lookup` this + * table binds: the `Names.__INDEX` constant when the names + * artifact is in this run ([useNames]), the quoted name otherwise. + * + * BOTH arms go through [IndexNaming], which is the whole point. This loop used to answer + * the question itself with `sec.shortName ?: sec.name`, under a comment saying it was + * stripping the package prefix "the loader adds" — the loader adds none to a nested + * node, so that expression had been a no-op for its whole life while reading as the site + * that owned the rule. The names artifact computed the same string independently. + * `fdb4118f1` is what that arrangement costs when the two answers drift. + * + * [shortName] is the entity whose table this is, and its artifact is where the constant + * lives: `secondaries` and `lookupIndexes` are both read through RESOLVING accessors, so + * an index declared on an abstract base is part of THIS entity's resolved set and its + * artifact re-exports it. No declaring-entity redirect is needed, unlike [columnExpr] — + * the TPH fold folds a subtype's COLUMNS into the base's table, never its indexes. + */ + protected fun indexNameExpr(node: MetaData, shortName: String): String { + val name = IndexNaming.resolve(node) + if (!useNames()) return "\"$name\"" + val member = "${KotlinNaming.namesMember(node.type)}_${KotlinNaming.namesMember(name)}_INDEX" + return "${KotlinNaming.namesObjectName(shortName)}.$member" + } + + /** + * The rendered column-name EXPRESSION for one of [entity]'s OWN (or inherited) fields — + * the hit arm of [columnExpr]. Reads `Names._COLUMN` unconditionally: + * [KotlinNamesGenerator] builds that constant from this SAME entity's resolving + * metaFields via the SAME resolveColumnName, so the reference and the value it stands + * for derive from one shared transform ([KotlinNaming.namesMember]) and cannot disagree. + */ + protected fun ownColumnExpr(entity: MetaObject, f: MetaField<*>): String { + val (_, shortName) = PackageMapping.splitFqn(entity.name) + return if (bindsThroughNames(entity)) + "${KotlinNaming.namesObjectName(shortName)}.${KotlinNaming.namesMember(f.name)}_COLUMN" + else "\"${KotlinGenUtil.resolveColumnName(f, columnNaming())}\"" + } + + /** + * The `Names` object as referenced from a file emitted for [from]: bare when both + * live in the same Kotlin package (or [to] has none), package-qualified otherwise. A + * TPH subtype may live in a different package from its base, and this generator + * hand-rolls its file body, so a cross-package reference must be fully qualified or it + * does not resolve. Same rule [KotlinNamesGenerator] applies to a super reference. + */ + private fun namesObjectRef(from: MetaObject, to: MetaObject): String { + val (fromPkg, _) = PackageMapping.splitFqn(from.name) + val (toPkg, toShort) = PackageMapping.splitFqn(to.name) + val obj = KotlinNaming.namesObjectName(toShort) + return if (toPkg == fromPkg || toPkg.isEmpty()) obj else "$toPkg.$obj" + } + + /** A single Exposed column derived from a `field.object` (one per flattened sub-field, or one total for jsonb). */ + protected data class ObjectColumnSpec( + val propertyName: String, + val columnExpr: String, + val kind: ObjectColumnKind, + ) + + /** + * Build the Exposed columns contributed by each `field.object` on [entity]. + * + *
    + *
  • {@code @storage="flattened"}: one column per field of the referenced + * `object.value`, with property name {@code } and + * physical column {@code _} (snake-joined). Nullable + * sub-fields → nullable columns.
  • + *
  • {@code @storage="jsonb"} or absent (default-to-jsonb per CLAUDE.md): + * one {@code jsonb(name, encoder, decoder)} column encoded/decoded through the shared + * Jackson {@code metaJsonbMapper} (the per-package MetaJsonbMapper.kt support file).
  • + *
+ * + * Skips field.object children whose `@objectRef` cannot be resolved (defensive — the loader's + * validation phase already gates the attr being present). + */ + protected open fun buildObjectColumns( + entity: MetaObject, + primaryFieldNames: Set, + loader: MetaDataLoader, + // #214: on the WRITE table of a write-through entity, a DERIVED (origin.*) field.object / + // field.map has no physical column (it is computed by the read view) — exclude it, exactly + // as the scalar-column loop does. Without this a derived value-object column becomes a + // phantom on the write `Table` (declared but never written/selected; a @required one + // would break Exposed insert). Default false → byte-identical for a vanilla entity + the view. + excludeDerived: Boolean = false, + ): List { + val api = exposedApi() + val result = mutableListOf() + for (field in entity.metaFields) { + if (excludeDerived && KotlinGenUtil.isDerivedField(field)) continue + // field.map → a single jsonb column (the JSON object). Keys are always strings; + // never flattened, never a native array. Same JSONB emission as a jsonb-stored + // field.object. + if (field is MapField) { + val parentName = field.name + val parentNullable = parentName !in primaryFieldNames && + !KotlinGenUtil.isRequiredField(field) + // The map column IS a field of `entity`, so it reaches the names artifact like + // any scalar — see [ownColumnExpr]. + val colExpr = ownColumnExpr(entity, field) + // Jackson codec: a Map stored in one JSONB column, encoded/decoded via + // the shared metaJsonbMapper (MetaJsonbMapper.kt support file). A TypeReference + // captures the erased generic so the read decodes back to Map — NOT the + // reified `Json.encodeToString(it)` (which needs the kotlinx compiler plugin). + val valueType = mapValueTypeFqn(field, loader) + val expr = "jsonb($colExpr, { metaJsonbMapper.writeValueAsString(it) }, " + + "{ metaJsonbMapper.readValue(it, object : com.fasterxml.jackson.core.type.TypeReference>() {}) })" + val full = if (parentNullable) "$expr.nullable()" else expr + result.add(ObjectColumnSpec(parentName, full, ObjectColumnKind.JSONB)) + continue + } + if (field !is ObjectField) continue + val parentName = field.name + val parentNullable = parentName !in primaryFieldNames && !KotlinGenUtil.isRequiredField(field) + val storage = readStorage(field) // null → default to jsonb + if (storage == STORAGE_FLATTENED) { + val ref = readObjectRef(field) ?: continue + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: continue + for (subField in target.metaFields) { + val propertyName = parentName + subField.name.replaceFirstChar { it.uppercase() } + // Physical column name: the parent's resolved column + "_" + the + // sub-field's. Both halves go through resolveColumnName, so an explicit + // `@column` on either wins — the same rule as the TS reference's + // flattenObjectField (`resolveColumnName(field, strategy) + "_" + inner.name`). + // E.g. parent "homeAddress" + sub "streetLine1" → "home_address_street_line1". + val colName = KotlinGenUtil.resolveColumnName(field, columnNaming()) + "_" + + KotlinGenUtil.resolveColumnName(subField, columnNaming()) + // Task 6 — out of scope: this composite name belongs to neither a + // single field of `entity` nor of `target` — Names has no + // constant for it. Always literal; useNames() does not reach here. + val baseSpec = KotlinTypeMapper.exposedColumnSpec(subField, "\"$colName\"", api) + // Sub-column is nullable iff the parent is nullable OR the sub-field itself is. + val nullable = parentNullable || !KotlinGenUtil.isRequiredField(subField) + val full = if (nullable) "$baseSpec.nullable()" else baseSpec + result.add(ObjectColumnSpec(propertyName, full, ObjectColumnKind.FLATTENED)) + } + } else { + // jsonb (explicit) OR absent (default per CLAUDE.md back-compat rule). + // Physical column name snake_case-d to match the rest of the column emission. + // The column stores the referenced value object (or List for @isArray) as a + // single JSONB column, encoded/decoded through the shared Jackson metaJsonbMapper + // (the per-package MetaJsonbMapper.kt support file). Jackson — NOT kotlinx — because + // the generated entity/value data classes are plain (no @Serializable): a kotlinx + // serializer (`VO.serializer()`) would require the compiler plugin, and the moment + // that plugin is enabled every VO carrying a java.util.UUID / java.time.* / + // java.math.BigDecimal / java.net.* field fails to compile. Jackson round-trips them + // via its kotlin + jsr310 modules with zero per-type plumbing. + // Like the map branch above: a single-jsonb value-object column is ONE field of + // `entity`, so it carries a constant — see [ownColumnExpr]. (The flattened + // branch above is the genuine exception: its composite belongs to no single + // field of either object.) + val colExpr = ownColumnExpr(entity, field) + val ref = readObjectRef(field) + val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + val decode = if (target != null) { + // Fully-qualify the VO (Kotlin dotted form) so the emitted column needs no extra import. + val voFqn = PackageMapping.toKotlin(target.name) + if (field.isArrayType) { + // List → a Jackson TypeReference captures the erased generic. + "metaJsonbMapper.readValue(it, object : com.fasterxml.jackson.core.type.TypeReference>() {})" + } else { + "metaJsonbMapper.readValue(it, $voFqn::class.java)" + } + } else { + // No resolvable @objectRef (defensive — validation gates the attr): decode to a + // generic Jackson tree so the column still type-checks without kotlinx. + "metaJsonbMapper.readValue(it, com.fasterxml.jackson.databind.JsonNode::class.java)" + } + val expr = "jsonb($colExpr, { metaJsonbMapper.writeValueAsString(it) }, { $decode })" + val full = if (parentNullable) "$expr.nullable()" else expr + result.add(ObjectColumnSpec(parentName, full, ObjectColumnKind.JSONB)) + } + } + return result + } + + /** Read the `@storage` attr (own-only); null when absent. `protected` so subclasses that + * override [buildObjectColumns] can reuse it. */ + protected fun readStorage(field: ObjectField): String? { + if (!field.hasMetaAttr(ATTR_STORAGE, true)) return null + return runCatching { field.getMetaAttr(ATTR_STORAGE, true).valueAsString }.getOrNull() + } + + /** Read the `@objectRef` attr (resolving); null when absent. Accepts any [MetaField] so both + * `field.object` and an `@objectRef`-valued `field.map` can share the lookup. `protected` so + * subclasses that override [buildObjectColumns] can reuse it. */ + protected fun readObjectRef(field: MetaField<*>): String? { + if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, true)) return null + return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, true).valueAsString } + .getOrNull() + } + + /** + * The Kotlin/Java source type of a [MapField]'s value V (the `V` in `Map`), + * fully-qualified so the emitted Jackson `TypeReference` needs no import. An `@objectRef` map → + * the referenced VO's FQN; a scalar `@valueType` map → the scalar's canonical name (e.g. + * `kotlin.String`, `java.math.BigDecimal`); neither resolvable → `Any` (an untyped JSON bag). + */ + private fun mapValueTypeFqn(field: MapField, loader: MetaDataLoader): String { + val target = readObjectRef(field)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + if (target != null) return PackageMapping.toKotlin(target.name) + return KotlinTypeMapper.mapValueScalarTypeName(field)?.toString() ?: "Any" + } + + // `protected companion` (was `private`) so subclasses overriding [buildObjectColumns] can + // reference the @storage vocabulary constants. + protected companion object { + + /** Cross-language @storage attr on field.object — values: flattened | jsonb (default). */ + const val ATTR_STORAGE = "storage" + const val STORAGE_FLATTENED = "flattened" + + /** + * FR-044 — the precision and scale a report's derived decimal is READ at when the + * shape gives it none (an `avg`, a ratio, a `sum` of a decimal). See [scalarColumnSpec]. + */ + const val REPORT_DECIMAL_PRECISION = 38 + const val REPORT_DECIMAL_SCALE = 18 + + /** + * Kotlin's hard keywords: never usable as an identifier without backticks, so a + * report's derived field named after one cannot be a column property. See + * [refuseUncompilableReportColumns]. + */ + val KOTLIN_HARD_KEYWORDS: Set = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", + "true", "try", "typealias", "typeof", "val", "var", "when", "while", + ) + + /** + * Exposed column suffix that renders a Postgres `DEFAULT gen_random_uuid()` + * server-side mint on a native uuid column (R6 Plan 2a, `@generation: uuid`). + * `CustomFunction("gen_random_uuid", UUIDColumnType())` emits the bare SQL + * function call; `.defaultExpression(...)` makes it the column DEFAULT. + */ + const val GEN_RANDOM_UUID_DEFAULT_SUFFIX = + ".defaultExpression(CustomFunction(\"gen_random_uuid\", UUIDColumnType()))" + + @JvmStatic + val LOG = LoggerFactory.getLogger(KotlinExposedTableGenerator::class.java) + + /** + * File name (sans `.kt`) of the per-package shared support file emitted for + * instant (default, non-`@localTime`) columns. One per package that has ≥1 such column. + */ + const val INSTANT_TZ_SUPPORT_FILE_NAME = "MetaInstantWithTimeZoneColumnType" + + /** + * Imports for the per-package [instantTzSupportBlock] support file, keyed by + * [ExposedApi] (issue #390 — V1's symbols fan out across `v1.core` / + * `v1.core.statements.api` / `v1.core.vendors` / `v1.javatime`). Kept here (rather + * than FQN-ing every reference inline) so the emitted support file reads idiomatically. + */ + fun instantTzSupportFileImports(api: ExposedApi): String = if (api == ExposedApi.V1) """ + |import java.time.Instant + |import ${ExposedImports.core(api, "Column")} + |import ${ExposedImports.core(api, "ColumnType")} + |import ${ExposedImports.core(api, "IDateColumnType")} + |import ${ExposedImports.core(api, "Table")} + |import ${ExposedImports.javatime(api, "JavaInstantColumnType")} + |import ${ExposedImports.statementsApi(api, "PreparedStatementApi")} + |import ${ExposedImports.statementsApi(api, "RowApi")} + |import ${ExposedImports.vendors(api, "currentDialect")} + |""".trimMargin() else """ + |import java.time.Instant + |import org.jetbrains.exposed.sql.Column + |import org.jetbrains.exposed.sql.ColumnType + |import org.jetbrains.exposed.sql.IDateColumnType + |import org.jetbrains.exposed.sql.Table + |import org.jetbrains.exposed.sql.javatime.JavaInstantColumnType + |import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi + |import org.jetbrains.exposed.sql.vendors.currentDialect + |""".trimMargin() + + /** + * Shared support body emitted ONCE PER PACKAGE (into [INSTANT_TZ_SUPPORT_FILE_NAME].kt) + * for any package that has at least one instant (default, non-`@localTime`) column. Defines: + * + * - `MetaInstantWithTimeZoneColumnType` — a `ColumnType` that delegates ALL + * value/JDBC handling (read, bind, normalize, millisecond-truncate, wire string) to + * Exposed's tested `JavaInstantColumnType`, overriding ONLY `sqlType()` to return the + * dialect's `TIMESTAMP WITH TIME ZONE` (Postgres: `timestamp with time zone`). This + * yields a `Column` — matching the `Instant` data-class property (no + * `Instant`↔`OffsetDateTime` coercion) — while keeping the TZ-aware column so the + * seeded-offset → read-back-UTC normalization contract holds. + * - `Table.instantWithTimeZone(name)` — the column-builder extension the generated + * tables call (`val createdAt = instantWithTimeZone("created_at")`). + * + * Both are declared `internal` (package + module-private) so EVERY `*Table.kt` in the + * same package shares the ONE declaration — multiple instant-timestamp tables per + * package no longer redeclare a top-level class/extension (the bug the prior per-file + * inline emission caused). Note: the `$` lines below have no Kotlin string templates, + * so this trimMargin block is emitted verbatim. The ONLY textual difference between + * [ExposedApi] versions is `readObject`'s row-accessor parameter type (issue #390 — + * `java.sql.ResultSet` under 0.x, `RowApi` under 1.x; see [instantTzSupportFileImports] + * for the matching import). + */ + fun instantTzSupportBlock(api: ExposedApi): String { + val readObjectParamType = if (api == ExposedApi.V1) "RowApi" else "java.sql.ResultSet" + return """ + |/** + | * GENERATED — do not hand-edit. + | * Custom Exposed column type for instant (default, non-`@localTime`): a + | * `Column` whose SQL DDL is `TIMESTAMP WITH TIME ZONE`. + | * Delegates all value/JDBC handling to Exposed's `JavaInstantColumnType` and + | * overrides only `sqlType()`, so the column type matches the `Instant` data-class + | * property (no Instant↔OffsetDateTime coercion) while staying timezone-aware. + | */ + |internal class MetaInstantWithTimeZoneColumnType : + | ColumnType(), + | IDateColumnType { + | private val delegate = JavaInstantColumnType() + | override val hasTimePart: Boolean get() = delegate.hasTimePart + | override fun sqlType(): String = + | currentDialect.dataTypeProvider.timestampWithTimeZoneType() + | override fun valueFromDB(value: Any): Instant? = delegate.valueFromDB(value) + | override fun notNullValueToDB(value: Instant): Any = delegate.notNullValueToDB(value) + | override fun nonNullValueToString(value: Instant): String = delegate.nonNullValueToString(value) + | override fun nonNullValueAsDefaultString(value: Instant): String = + | delegate.nonNullValueAsDefaultString(value) + | override fun readObject(rs: $readObjectParamType, index: Int): Any? = delegate.readObject(rs, index) + | override fun setParameter( + | stmt: PreparedStatementApi, + | index: Int, + | value: Any?, + | ) = delegate.setParameter(stmt, index, value) + |} + | + |/** + | * Column builder for instant (default, non-`@localTime`): a `Column` backed by + | * a `TIMESTAMP WITH TIME ZONE` Postgres column (see [MetaInstantWithTimeZoneColumnType]). + | */ + |internal fun Table.instantWithTimeZone(name: String): Column = + | registerColumn(name, MetaInstantWithTimeZoneColumnType()) + |""".trimMargin() + } + + /** + * File name (sans `.kt`) of the per-package support file emitted for `field.uri` / + * `field.inet` columns (ADR-0036/0037 Wave 3). One per package that has ≥1 such column. + */ + const val INET_URI_SUPPORT_FILE_NAME = "MetaInetUriColumnType" + + /** + * Imports for the per-package [inetUriSupportBlock] support file, keyed by [ExposedApi] + * (issue #390). V1 carries two EXTRA imports over the mechanical FQN fan-out: + * `JdbcPreparedStatementImpl` and `java.sql.Types`, both needed by + * `MetaInetColumnType.setParameter`'s raw-statement bind (see [inetUriSupportBlock]). + */ + fun inetUriSupportFileImports(api: ExposedApi): String = if (api == ExposedApi.V1) """ + |import java.net.InetAddress + |import java.net.URI + |import java.sql.Types + |import ${ExposedImports.core(api, "Column")} + |import ${ExposedImports.core(api, "ColumnType")} + |import ${ExposedImports.core(api, "Table")} + |import ${ExposedImports.statementsApi(api, "PreparedStatementApi")} + |import ${ExposedImports.vendors(api, "currentDialect")} + |import org.jetbrains.exposed.v1.jdbc.statements.jdbc.JdbcPreparedStatementImpl + |""".trimMargin() else """ + |import java.net.InetAddress + |import java.net.URI + |import org.jetbrains.exposed.sql.Column + |import org.jetbrains.exposed.sql.ColumnType + |import org.jetbrains.exposed.sql.Table + |import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi + |import org.jetbrains.exposed.sql.vendors.currentDialect + |""".trimMargin() + + /** + * Shared support body emitted ONCE PER PACKAGE (into [INET_URI_SUPPORT_FILE_NAME].kt) + * for any package with at least one `field.uri` / `field.inet` column. Defines two + * `internal` custom column types + their `Table` column-builder extensions: + * + * - `MetaUriColumnType` — a `ColumnType` over plain `text` (Postgres has + * no uri type). Reads the JDBC string and parses it to a `URI`; writes the `URI`'s + * string form. Yields `Column` so the column matches the data-class property. + * - `MetaInetColumnType` — a `ColumnType` over the Postgres-native + * `inet` type. Reads the JDBC string and resolves it to an `InetAddress`; binds the + * address' host-literal string (`getHostAddress`) so the JDBC driver routes it to the + * native `inet` column. + * + * Both are `internal` so every `*Table.kt` in the package shares the one declaration. + * The `$` lines below carry no Kotlin string templates — emitted verbatim. The ONLY + * textual difference between [ExposedApi] versions is `MetaInetColumnType.setParameter`'s + * bind call (issue #390). 0.x's `stmt[index] = value.hostAddress` binds the address + * STRING with no explicit JDBC type, which the Postgres driver defaults to `VARCHAR` — + * `Postgres then refuses the implicit varchar→inet cast ("column is of type inet but + * expression is of type character varying"). [A LATENT 0.x DEFECT, left untouched per + * exposedApi=0's byte-identical contract: `integration-tests-kotlin`'s persistence- + * conformance reference harness already works around it with a HAND-MAINTAINED + * `MetaInetStringColumnType` that never exercises the generator's own 0.x emission for a + * real write.] 1.x's `PreparedStatementApi.set` dropped the 2-arg form entirely (only a + * 3-arg `set(index, value, columnType)` remains, decompiled to confirm it discards + * `columnType` and still calls the bare 2-arg `PreparedStatement.setObject` — so routing + * through it would not even reach a DIFFERENT bind), so 1.x reaches the raw + * `java.sql.PreparedStatement` (via `JdbcPreparedStatementImpl.statement`, the same + * escape hatch the reference harness already uses) and binds with `Types.OTHER` — the + * exact pattern that already round-trips for 0.x's reference harness and OMDB's JVM + * `InetCodec`. + */ + fun inetUriSupportBlock(api: ExposedApi): String { + // `setParameter`'s `value` has ALREADY been through `notNullValueToDB` by the time + // `fillParameters`'s default implementation calls here (confirmed by decompiling it: + // it calls `columnType.valueToDB(value)` — which for a non-null value delegates to + // `notNullValueToDB` — THEN `columnType.setParameter(this, index, )`). + // So `value` is the STRING `notNullValueToDB` returned, never the original + // `InetAddress` — an `is InetAddress` check here is always false, on EITHER + // [ExposedApi]. 0.x's `stmt[index] = value.hostAddress` therefore always falls to its + // own `else super.setParameter(...)`, binding that string with NO JDBC type hint — + // the SAME latent defect [inetUriSupportBlock]'s doc above names (left untouched for + // exposedApi=0's byte-identical contract: this whole block is the ONE place this + // generator's text differs by [ExposedApi], so the 0.x branch below reproduces the + // ORIGINAL lines verbatim, comment included, rather than varying just the call). 1.x's + // replacement checks nullness, not type, matching the reference harness's + // `MetaInetStringColumnType` exactly — and REPLACES the two-line `if/else`, so it + // carries its own trailing brace (no shared `else super.setParameter(...)` line below). + val inetSetParameterBlock = if (api == ExposedApi.V1) { + " val raw = (stmt as JdbcPreparedStatementImpl).statement\n" + + " if (value == null) raw.setNull(index, Types.OTHER)\n" + + " else raw.setObject(index, value.toString(), Types.OTHER)\n" + + " }" + } else { + " // Bind as a string and let the Postgres JDBC driver coerce it to the native\n" + + " // `inet` column. Anything else — a null for a nullable column above all —\n" + + " // goes to the base implementation, which knows how to bind it: `stmt[index]`\n" + + " // takes a non-null Any, so widening the local to Any? did not compile.\n" + + " if (value is InetAddress) stmt[index] = value.hostAddress\n" + + " else super.setParameter(stmt, index, value)\n" + + " }" + } + return """ + |/** + | * GENERATED — do not hand-edit. + | * Custom Exposed column type for `field.uri`: a `Column` whose SQL DDL + | * is plain `text` (Postgres has no uri type). The native URI binding matches the data + | * class property; the value persists as its string form. + | */ + |internal class MetaUriColumnType : ColumnType() { + | override fun sqlType(): String = currentDialect.dataTypeProvider.textType() + | override fun valueFromDB(value: Any): URI = when (value) { + | is URI -> value + | else -> URI.create(value.toString()) + | } + | override fun notNullValueToDB(value: URI): Any = value.toString() + | override fun nonNullValueToString(value: URI): String = "'${'$'}value'" + |} + | + |/** Column builder for `field.uri`: a `Column` over a `text` column. */ + |internal fun Table.uriColumn(name: String): Column = + | registerColumn(name, MetaUriColumnType()) + | + |/** + | * GENERATED — do not hand-edit. + | * Custom Exposed column type for `field.inet`: a `Column` whose + | * SQL DDL is the Postgres-native `inet`. The native InetAddress binding matches the data + | * class property; the value binds/reads as its host-literal string. + | */ + |internal class MetaInetColumnType : ColumnType() { + | override fun sqlType(): String = "inet" + | override fun valueFromDB(value: Any): InetAddress = when (value) { + | is InetAddress -> value + | else -> InetAddress.getByName(value.toString()) + | } + | override fun notNullValueToDB(value: InetAddress): Any = value.hostAddress + | override fun nonNullValueToString(value: InetAddress): String = "'${'$'}{value.hostAddress}'" + | override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + |${inetSetParameterBlock} + |} + | + |/** Column builder for `field.inet`: a `Column` over an `inet` column. */ + |internal fun Table.inetColumn(name: String): Column = + | registerColumn(name, MetaInetColumnType()) + |""".trimMargin() + } + + /** + * File name (sans `.kt`) of the per-package support file emitted for a `field.string + * @dbColumnType=uuid` column (R6 Plan 2b). One per package that has ≥1 such column. + */ + const val UUID_STRING_SUPPORT_FILE_NAME = "MetaUuidStringColumnType" + + /** + * Imports for the per-package [uuidStringSupportBlock] support file, keyed by + * [ExposedApi]. The delegate's FQN is the one textual difference between versions + * (issue #390 — 1.x's bare `UUIDColumnType` binds `kotlin.uuid.Uuid`; `javaUuid`'s + * `UUIDColumnType` is the java.util.UUID-backed replacement — the SAME substitution + * [KotlinTypeMapper.exposedColumnSpec] already makes for a native `field.uuid` column + * under 1.x). + */ + fun uuidStringSupportFileImports(api: ExposedApi): String { + val uuidColumnTypeImport = + if (api == ExposedApi.V1) ExposedImports.javaUuid("UUIDColumnType") + else ExposedImports.core(api, "UUIDColumnType") + return """ + |import ${ExposedImports.core(api, "Column")} + |import ${ExposedImports.core(api, "ColumnType")} + |import ${ExposedImports.core(api, "Table")} + |import $uuidColumnTypeImport + |import ${ExposedImports.statementsApi(api, "PreparedStatementApi")} + |""".trimMargin() + } + + /** + * Shared support body emitted ONCE PER PACKAGE (into [UUID_STRING_SUPPORT_FILE_NAME].kt) + * for any package with at least one `field.string @dbColumnType=uuid` column (R6 Plan 2b + * physical escape hatch). + * + * ADR-0037: `@dbColumnType` is a PHYSICAL-only escape hatch — the field's LOGICAL/native + * Kotlin type is unchanged, so [KotlinTypeMapper.kotlinTypeName] keeps the data-class + * property `String` (matching every other port — Java's OMDB converts String↔UUID at its + * JDBC codec, the layer BELOW its ORM; TS/Python/C# keep the column's own `string`/`str`/ + * `string` type too — see `JdbcCodecs.UuidCodec`, the per-port notes under `docs/ports`). + * Exposed has no codec layer below its typed `Column` (unlike a JDBC + * `PreparedStatement`/`ResultSet`), so + * THIS custom `ColumnType` — not a raw `uuid(...)`/`javaUUID(...)` native + * `Column` — IS that lowest layer for Exposed: it persists through the real + * Postgres `uuid` column type (delegating every JDBC/DDL concern to Exposed's own + * battle-tested `UUIDColumnType`, never re-implementing uuid binding) while surfacing a + * plain `String` to every Kotlin caller above it (the generated entity data class, the + * controller's filter dispatch and PATCH bind) — so they never see `java.util.UUID` and + * never have to convert. + * + * Defines ONE `internal` custom column type + its `Table` column-builder extension: + * - `MetaUuidStringColumnType` — a `ColumnType` that delegates `sqlType()` / + * `setParameter()` verbatim to a `UUIDColumnType()` instance, converting ONLY at the + * Kotlin-value boundary (`UUID.fromString`/`.toString()`) for `notNullValueToDB` / + * `valueFromDB` / `nonNullValueToString`. + * - `Table.uuidString(name)` — the builder extension the generated table calls. + * + * `internal` so every `*Table.kt` in the package shares the one declaration (the same + * multi-table-per-package redeclaration concern as the instant-tz / inet-uri helpers). + */ + fun uuidStringSupportBlock(api: ExposedApi): String = """ + |/** + | * GENERATED — do not hand-edit. + | * Custom Exposed column type for `field.string @dbColumnType=uuid` (ADR-0037 + | * physical-only escape hatch): a `Column` persisted through the real + | * Postgres `uuid` type. Every SQL/JDBC concern is DELEGATED to Exposed's own + | * `java.util.UUID`-backed `UUIDColumnType` — this class converts ONLY at the + | * Kotlin-value boundary, so the generated entity/controller see a plain `String` + | * (matching every other MetaObjects port) while the column keeps its native uuid + | * physical type. + | */ + |internal class MetaUuidStringColumnType : ColumnType() { + | private val delegate = UUIDColumnType() + | override fun sqlType(): String = delegate.sqlType() + | override fun valueFromDB(value: Any): String = + | requireNotNull(delegate.valueFromDB(value)) { "null uuid column value" }.toString() + | override fun notNullValueToDB(value: String): Any = + | delegate.notNullValueToDB(java.util.UUID.fromString(value)) + | override fun nonNullValueToString(value: String): String = + | delegate.nonNullValueToString(java.util.UUID.fromString(value)) + | override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) = + | delegate.setParameter(stmt, index, value) + |} + | + |/** Column builder for `field.string @dbColumnType=uuid`: a `Column` over a native `uuid` column. */ + |internal fun Table.uuidString(name: String): Column = + | registerColumn(name, MetaUuidStringColumnType()) + |""".trimMargin() + + /** + * File name (sans `.kt`) of the per-package support file holding the shared Jackson + * `metaJsonbMapper`. One per package that emits ≥1 typed `field.object @storage:jsonb` + * or `field.map` column. + */ + const val JSONB_MAPPER_SUPPORT_FILE_NAME = "MetaJsonbMapper" + + /** + * Shared support body emitted ONCE PER PACKAGE (into [JSONB_MAPPER_SUPPORT_FILE_NAME].kt). + * Declares the `internal metaJsonbMapper` [com.fasterxml.jackson.databind.ObjectMapper] the + * generated typed `jsonb()` codecs call. Fully-qualified references so the file needs no + * imports. The kotlin module binds Kotlin data classes; jsr310 handles java.time; dates + * serialize as ISO-8601 strings (not epoch millis). `internal` → package+module-private, + * shared by every `*Table.kt` in the package. + */ + val JSONB_MAPPER_SUPPORT_BLOCK: String = """ + |/** + | * GENERATED — do not hand-edit. + | * Shared Jackson ObjectMapper backing the typed `field.object @storage:jsonb` and + | * `field.map` Exposed `jsonb()` columns in this package. Jackson (not kotlinx) is the + | * codec, so the generated entity/value data classes carry NO `@Serializable` and need + | * NO per-type serializer plumbing — every java.time / java.util / java.math / java.net + | * field a jsonb value object may carry round-trips through the kotlin + jsr310 modules + | * with no compiler plugin. + | * + | * Consumer classpath: jackson-databind, jackson-module-kotlin, jackson-datatype-jsr310. + | */ + |internal val metaJsonbMapper: com.fasterxml.jackson.databind.ObjectMapper = + | com.fasterxml.jackson.databind.json.JsonMapper.builder() + | .addModule(com.fasterxml.jackson.module.kotlin.kotlinModule()) + | .addModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + | .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + | .build() + |""".trimMargin() + } + + // === FK column emission from relationship.composition ==================== + + /** A foreign-key column derived from a `relationship.composition` child. */ + protected data class FkColumnSpec( + val propertyName: String, + val columnExpr: String, + /** + * Suffix appended after the target column inside `.references(...)`; either + * "" or {@code ", onDelete = ..., onUpdate = ..."}. Non-empty implies the + * file must import {@code ReferenceOption}. + */ + val refSuffix: String, + /** + * `true` when authored directly on the FK-owning entity (to-one side); + * `false` when inferred from the inverse to-many declaration on the + * other entity. Declared specs take precedence over inferred specs + * pointing at the same target table. + */ + val declared: Boolean, + /** + * Target table object the FK references (e.g. {@code "AuthorTable"}). + * Used to suppress an inferred FK when a declared FK already covers + * the same target table — the FK-owning side has already named the + * relationship physically (possibly with a custom column name) and we + * must not double-emit a second FK to the same parent. + */ + val targetTable: String, + /** + * Metadata FQN of the target entity (e.g. {@code "acme::blog::Author"}). + * Used by the emit pass to add a cross-package Kotlin import when the + * target table lives in a different package than the entity owning the FK. + */ + val targetFqn: String, + ) { + /** True when {@link #refSuffix} mentions a ReferenceOption (drives the import). */ + val hasReferenceOption: Boolean get() = refSuffix.isNotEmpty() + } + + /** + * Pass 1: build a global FK map keyed by FQN of the entity that should + * carry the column. Combines two sources: + * + *
    + *
  • Declared — every `relationship.composition @cardinality: one` (default) + * child on entity X contributes a column to {@code X}'s table pointing at + * {@code @objectRef}'s table. Same behavior as the old per-entity scan.
  • + *
  • Inferred — every `relationship.composition @cardinality: many` on + * entity X contributes a column to the {@code @objectRef} entity's table + * (since the FK in a one-to-many lives on the many side). Column name + * defaults to {@code Id}; {@code @onDelete} / + * {@code @onUpdate} propagate so the side that authored the lifecycle + * intent (e.g. {@code "the parent cascades into its children"}) shapes + * the inferred FK.
  • + *
+ * + * Dedup: when a column name on a given target entity is contributed by both + * a declared (to-one) spec and an inferred (inverse to-many) spec, the + * declared spec wins — the FK-owning entity authored its own physical name + * and we don't double-emit. + */ + protected open fun buildGlobalFkMap( + loader: MetaDataLoader, + refDecorationMap: Map>, + ): Map> { + // Use list-per-entity so the emit order is deterministic (insertion-ordered). + val acc = linkedMapOf>() + + for (entity in loader.metaObjects) { + if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue + // FR-017 TPH: a subtype shares its base's single table, so its INHERITED to-many + // composition is already covered by the base's inverse FK — emitting one per subtype + // would target the folded-away Table. Mirror the table-emission skip. + if (KotlinTphPlan.isTphSubtype(entity)) continue + // ADR-0039: relationships are inheritable — RESOLVE via entity.relationships; + // entity.children (own-only) would miss an inherited relationship. + for (child in entity.relationships) { + if (child.subType != CompositionRelationship.SUBTYPE_COMPOSITION) continue + + val objectRef = child.objectRef ?: continue + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, objectRef) ?: continue + + if (child.cardinality == MetaRelationship.CARDINALITY_MANY) { + // Inferred: FK belongs on `target`, pointing back at `entity`. + val spec = buildInverseFkSpec(entity, child) ?: continue + acc.getOrPut(target.name) { mutableListOf() }.add(spec) + } else { + // Declared (cardinality "one" or unspecified): FK belongs on `entity`. + val spec = buildDeclaredFkSpec(target, child) ?: continue + acc.getOrPut(entity.name) { mutableListOf() }.add(spec) + } + } + } + + // Dedup pass 1: a declared (to-one) FK suppresses any inferred FK pointing at + // the same target table. Catches both shapes: + // (a) declared + inferred share a property name (rare but possible + // when the user picks `Id` themselves). + // (b) declared uses a custom column name (e.g. `creatorId`) — the + // inferred `Id` (e.g. `authorId`) must NOT also be + // emitted; the FK-owning side already named the relationship. + // Dedup pass 2: an identity.reference decoration on a field column ALSO + // suppresses any inferred FK to the same target table — the canonical + // shape (Program many→Week, Week identity.reference→Program) would + // otherwise emit `programId` twice on WeekTable (once as the decorated + // field column, once as a separate FK row). Soft references (no targetTable) + // still suppress by FIELD NAME — the field column already exists so the + // inferred FK row would duplicate the column. + return acc.mapValues { (entityName, specs) -> + val declaredTargets = specs.asSequence().filter { it.declared }.map { it.targetTable }.toSet() + val declaredNames = specs.asSequence().filter { it.declared }.map { it.propertyName }.toSet() + val decorations = refDecorationMap[entityName].orEmpty() + val decoratedTargets = decorations.values.mapNotNull { it.targetTable }.toSet() + val decoratedNames = decorations.keys + specs.filterNot { spec -> + !spec.declared && ( + spec.targetTable in declaredTargets || spec.propertyName in declaredNames || + spec.targetTable in decoratedTargets || spec.propertyName in decoratedNames + ) + } + } + } + + /** + * Build the FK spec for a to-one (declared) composition relationship on the + * FK-owning entity. Returns null when {@code @objectRef} fails to resolve. + * + * Naming: property name = {@code @column} attr (verbatim) if present, else + * literal {@code Id}. {@code shortName} is used + * because relationship.name is fully-qualified after loading (e.g. + * "acme::demo::author") and would produce an illegal Kotlin identifier with + * "::" embedded. + */ + private fun buildDeclaredFkSpec(target: MetaObject, rel: MetaRelationship): FkColumnSpec? { + // FW-3: `target` may be a TPH subtype (or an abstract mid-level folded into a + // discriminator base) with no Exposed Table object of its own — the FK must reference + // the STORAGE object's table instead, or the emitted `.references(Table.pk)` names + // a table this generator never emits. + val storageTarget = KotlinTphPlan.storageObjectOf(target) + val targetTable = PackageMapping.splitFqn(storageTarget.name).second + "Table" + val relShortName = rel.shortName ?: rel.name + val propertyName = readColumnAttr(rel) ?: (relShortName + "Id") + val colName = KotlinGenUtil.applyColumnNamingStrategy(propertyName, columnNaming()) + val refSuffix = referentialActionSuffix(rel.onDeleteRaw, rel.onUpdateRaw) + return FkColumnSpec( + propertyName = propertyName, + columnExpr = "${fkColumnBuilder(storageTarget, colName)}.references($targetTable.${primaryKeyProperty(storageTarget)}$refSuffix)", + refSuffix = refSuffix, + declared = true, + targetTable = targetTable, + targetFqn = storageTarget.name, + ) + } + + /** + * Build the FK spec for the inverse side of a to-many composition + * relationship: when entity X declares `cardinality: many` to Y, Y's table + * carries a column pointing back at X's table. + * + * Naming: column = {@code Id} (e.g. Author → + * {@code "authorId"}). The to-many side is the lifecycle authority, so its + * {@code @onDelete} / {@code @onUpdate} attrs propagate into the inferred + * FK's ReferenceOption arguments. + */ + private fun buildInverseFkSpec(owner: MetaObject, rel: MetaRelationship): FkColumnSpec? { + // FW-3: `owner` may itself be a TPH subtype or an abstract mid-level (e.g. a to-many + // composition declared ON a subtype, or on an abstract level between the base and its + // subtypes) — its rows, and its primary key, live in the discriminator base's shared + // table. Redirect before naming/referencing the owner's table. + val storageOwner = KotlinTphPlan.storageObjectOf(owner) + // Skip when the storage owner has no rdb source — there would be no OwnerTable to + // reference. ADR-0039: resolving (an inherited source.rdb still means it is persisted). + if (!KotlinGenUtil.hasRdbSource(storageOwner)) return null + val ownerShort = PackageMapping.splitFqn(storageOwner.name).second + val ownerTable = ownerShort + "Table" + val propertyName = ownerShort.replaceFirstChar { it.lowercaseChar() } + "Id" + val colName = KotlinGenUtil.applyColumnNamingStrategy(propertyName, columnNaming()) + val refSuffix = referentialActionSuffix(rel.onDeleteRaw, rel.onUpdateRaw) + return FkColumnSpec( + propertyName = propertyName, + columnExpr = "${fkColumnBuilder(storageOwner, colName)}.references($ownerTable.${primaryKeyProperty(storageOwner)}$refSuffix)", + refSuffix = refSuffix, + declared = false, + targetTable = ownerTable, + targetFqn = storageOwner.name, + ) + } + + /** + * Lower a pair of {@code @onDelete} / {@code @onUpdate} raw attr values + * (kebab-case per the metamodel, e.g. {@code "cascade"} / {@code "set-null"}) + * into the suffix portion of an Exposed {@code .references(...)} call. + * + * Returns either "" (no options) or {@code ", onDelete = ..., onUpdate = ..."} + * ready to splice after the target column. Shared by both + * `relationship.composition` (declared + inferred) and `identity.reference` + * paths so the lowering rules stay in lockstep. + */ + private fun referentialActionSuffix(onDeleteRaw: String?, onUpdateRaw: String?): String { + val parts = mutableListOf() + onDeleteRaw?.let { mapReferentialAction(it) }?.let { parts += "onDelete = ReferenceOption.$it" } + onUpdateRaw?.let { mapReferentialAction(it) }?.let { parts += "onUpdate = ReferenceOption.$it" } + return if (parts.isEmpty()) "" else ", " + parts.joinToString(", ") + } + + // === identity.reference FK decoration ==================================== + + /** + * Decoration applied to a regular field column when an `identity.reference` + * names that field. The codegen appends `.references(targetTable.id[, refSuffix])` + * to the field's column initializer (rather than emitting a separate FK row, + * which would duplicate the column). + * + * A {@code null} {@link #targetTable} indicates a soft reference (`@enforce: false`): + * the entry still occupies the field-name key so dedup against an inferred FK works, + * but the column emission pass skips the `.references(...)` decoration. + */ + protected data class RefDecoration( + /** Target table object name (e.g. {@code "ProgramTable"}); null for soft refs. */ + val targetTable: String?, + /** Kotlin property name of the target's PK column (e.g. {@code "id"}, {@code "goalId"}). */ + val targetPkProperty: String, + /** Suffix portion after the target column — either "" or {@code ", onDelete = ..., onUpdate = ..."}. */ + val refSuffix: String, + /** + * Metadata FQN of the target entity (e.g. {@code "acme::edu::Program"}); null for soft refs. + * Used by the emit pass to add a cross-package Kotlin import when the target table lives + * in a different package than the entity owning the decorated field. + */ + val targetFqn: String?, + ) { + /** True when {@link #refSuffix} mentions a ReferenceOption (drives the import). */ + val hasReferenceOption: Boolean get() = refSuffix.isNotEmpty() + /** True when this decoration emits a `.references(...)` call (enforced + resolvable). */ + val emitsReference: Boolean get() = targetTable != null + } + + /** + * Pass 1a: walk every entity's `identity.reference` children and build a + * map of {@code entity.name -> (fieldName -> RefDecoration)} so the column + * emission pass can decorate the matching field column inline. + * + * Single-field references only for v1; multi-field composite FKs + * ({@code @fields: "a,b"}) are deferred — they require Exposed's compound-FK + * API and are uncommon in practice. Skipped composites are logged as a WARN + * so a user authoring `@fields: ["a", "b"]` isn't silently ignored. + * + * Soft references ({@code @enforce: false}) still register an entry (with + * {@code targetTable = null}) so the FK-dedup pass can suppress a redundant + * inferred FK to the same column — but the column emission pass skips + * decoration so no physical constraint is generated. + */ + protected open fun buildIdentityReferenceDecorations( + loader: MetaDataLoader, + ): Map> { + val acc = linkedMapOf>() + for (entity in loader.metaObjects) { + if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue + // ADR-0039: identity.reference children are inheritable — RESOLVE via + // getIdentities(true); entity.children (own-only) would miss inherited FK refs. + for (child in entity.getIdentities(true)) { + if (child !is ReferenceIdentity) continue + val fields = child.fields + if (fields.size != 1) { + LOG.warn( + "skipping identity.reference '{}' on {} — multi-field composite FKs (@fields={}) are not yet supported by KotlinExposedTableGenerator", + child.name, entity.name, fields + ) + continue + } + val fieldName = fields[0] + val targetEntityName = child.targetEntity ?: continue + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, targetEntityName) ?: continue + // FW-3: `target` may be a TPH subtype (e.g. `@references: BridgeAuth`) or an + // abstract mid-level, neither of which has an Exposed Table of its own — its rows + // live in, and its primary key belongs to, the discriminator base's shared table. + // The referential-action correlation below stays keyed to the DECLARED `target` + // (a relationship may specifically name the subtype); only the PHYSICAL + // table/column decoration redirects to the storage object. + val storageTarget = KotlinTphPlan.storageObjectOf(target) + // Skip when the storage target has no rdb source — Exposed cannot reference a + // non-table. ADR-0039: resolving (an inherited source.rdb still means a table). + if (!KotlinGenUtil.hasRdbSource(storageTarget)) continue + // Soft references register a null-targetTable entry so dedup-vs-inferred works, + // but column emission will skip the `.references(...)` decoration. + val targetTable = if (child.isEnforced) + PackageMapping.splitFqn(storageTarget.name).second + "Table" else null + val targetFqn = if (child.isEnforced) storageTarget.name else null + // ADR-0047 — resolve the decorated FK's referential actions with the + // cross-port precedence (mirrors the TS migrate engine's + // resolveReferentialActions): (1) @onDelete / @onUpdate declared directly + // on the identity.reference (registered cross-port — the explicit per-FK + // override); (2) a correlated relationship on the FK-owning entity; + // (3) the reverse relationship on the TARGET entity (the documented + // parent-side authoring shape) — a relationship contributes its explicit + // action, else its subtype default (composition→cascade, + // aggregation→set-null, association→restrict; onUpdate default cascade). + // Guards: @through (M:N) relationships never correlate with a direct FK; + // the child-side tier correlates by INVERTING the #368 relationship-> + // reference ladder (not by target alone, which let a second FK inherit + // the first relationship's actions); and the reverse relationship + // contributes nothing when this entity holds more than one enforced + // reference to the same target, or when the target declares more than + // one relationship back at this entity. + val (resolvedOnDelete, resolvedOnUpdate) = + resolveDecorationActions(loader, entity, child, target) + val refSuffix = referentialActionSuffix(resolvedOnDelete, resolvedOnUpdate) + acc.getOrPut(entity.name) { linkedMapOf() }[fieldName] = + RefDecoration(targetTable, primaryKeyProperty(storageTarget), refSuffix, targetFqn) + } + } + return acc + } + + /** + * ADR-0047 — resolve the referential actions for a decorated `identity.reference` + * FK column. Returns a (onDelete, onUpdate) pair of RAW kebab-case action values + * (null = no clause). Precedence: reference-level attr → correlated child-side + * relationship → correlated parent-side (reverse) relationship, the relationship + * tiers resolving explicit-action-else-subtype-default. See + * [buildIdentityReferenceDecorations] for the guard rationale. + */ + private fun resolveDecorationActions( + loader: MetaDataLoader, + entity: MetaObject, + ref: ReferenceIdentity, + target: MetaObject, + ): Pair { + val refOnDelete: String? = ref.onDeleteRaw + val refOnUpdate: String? = ref.onUpdateRaw + if (refOnDelete != null && refOnUpdate != null) return refOnDelete to refOnUpdate + + // (2) child-side correlation, then (3) parent-side reverse correlation. + // ADR-0039: relationships are inheritable — RESOLVE via getRelationships(true). + // + // #368: [entity] may declare MORE THAN ONE identity.reference onto this same + // target (Match.homeTeamRef / awayTeamRef both -> Team). Matching `rel` on the + // target ALONE cannot say which of those references `rel` supplies actions FOR, + // so every FK past the first silently inherited the FIRST relationship's + // @onDelete / @onUpdate — homeTeamRef's `restrict` landing on awayTeamRef's + // `cascade` FK, in a table Exposed compiles and the database accepts. Resolve it + // with the INVERSE of the relationship->reference ladder: `rel` belongs to `ref` + // iff the ladder, applied to `rel`, resolves back to `ref` ITSELF — not merely + // "to some reference on this target". `rel` and `ref` are both declared on (or + // inherited into) `entity`, the exact shape the ladder is built for, so this is + // a direct inversion rather than a second parallel rule. Mirrors the TS migrate + // engine's resolveReferentialActions and the C# ReferentialActions port. + val childSide = entity.relationships.firstOrNull { rel -> + rel.through == null && rel.objectRef != null && + KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === target && + RelationshipReferences.resolveRelationshipReference( + entity, rel.shortName, rel.objectRef, rel.sourceRefField) === ref + } + var rel = childSide + // When the tier-3 satisfiability guard fires, the reverse relationship's + // AUTHORED @onUpdate still applies (only the inferred contributions drop). + var suppressedReverseOnUpdate: String? = null + if (rel == null) { + val reverse = findReverseRelationship(loader, entity, ref, target) + if (reverse != null) { + // Tier-3 satisfiability guard (mirrors TS resolveReferentialActions): + // an INFERRED set-null default (parent-side aggregation, no explicit + // @onDelete) is unsatisfiable on a NOT NULL FK column — the INFERRED + // contributions drop rather than emitting a ReferenceOption the + // database would reject, while anything explicitly authored survives + // (an explicit set-null flows through; an explicit @onUpdate is + // honored rather than silently discarded with the guard). + val fkField = ref.fields.singleOrNull()?.let { fn -> + entity.metaFields.firstOrNull { it.name == fn } + } + val unsatisfiableInferredSetNull = reverse.onDeleteRaw == null && + MetaRelationship.ON_DELETE_DEFAULT_BY_SUBTYPE[reverse.subType] == MetaRelationship.ACTION_SET_NULL && + fkField != null && KotlinGenUtil.isRequiredField(fkField) + if (unsatisfiableInferredSetNull) { + suppressedReverseOnUpdate = reverse.onUpdateRaw + } else { + rel = reverse + } + } + } + + val onDelete = refOnDelete + ?: rel?.let { it.onDeleteRaw ?: MetaRelationship.ON_DELETE_DEFAULT_BY_SUBTYPE[it.subType] } + val onUpdate = refOnUpdate + ?: (rel?.let { it.onUpdateRaw ?: MetaRelationship.ON_UPDATE_DEFAULT } ?: suppressedReverseOnUpdate) + return onDelete to onUpdate + } + + /** + * ADR-0047 tier-3 correlation — the relationship declared on the TARGET (parent) + * entity pointing back at the FK-owning entity. Fails closed (null) when the + * relationship is M:N (`@through`), when [entity] does not hold exactly one + * enforced reference to [target] that is [ref] itself (the reverse relationship + * cannot say which FK carries the ownership edge; a soft `@enforce: false` ref + * never correlates), or when [target] declares more than one relationship back at + * [entity]. Mirrors the TS migrate engine's findReverseRelationship. + */ + private fun findReverseRelationship( + loader: MetaDataLoader, + entity: MetaObject, + ref: ReferenceIdentity, + target: MetaObject, + ): MetaRelationship? { + // ADR-0039: identity.reference children are inheritable — RESOLVE via getIdentities(true). + val enforcedRefsToTarget = entity.getIdentities(true) + .filterIsInstance() + .filter { r -> + r.isEnforced && r.targetEntity != null && + KotlinGenUtil.resolveObjectByShortOrFqn(loader, r.targetEntity) === target + } + if (enforcedRefsToTarget.singleOrNull() !== ref) return null + // #368: fail closed on ambiguity rather than taking the first match — when more + // than one non-@through relationship on [target] resolves back to [entity] (e.g. + // a "posts" composition and a separate "latestPost" association both pointing at + // Post), none of them is preferred. This is the tier-2 ambiguity's mirror image + // (multiple RELATIONSHIPS rather than multiple REFERENCES) and the ladder cannot + // resolve it: the ladder picks among references declared on the SAME object as + // the relationship, whereas here the candidates live on [target] while [ref] + // lives on [entity]. Mirrors the TS findReverseRelationship. + return target.relationships.filter { rel -> + rel.through == null && rel.objectRef != null && + KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === entity + }.singleOrNull() + } + + /** + * The Kotlin property name of [target]'s single-field primary key — `"id"` for the + * BaseEntity pattern, but `"goalId"`, `"code"`, … when the entity's `identity.primary` + * names a different field. FK emission must reference the TARGET's real PK column, not a + * hardcoded `.id` (which fails to compile when the target PK isn't named `id`). Falls back + * to `"id"` when no primary identity resolves (defensive). Safe-renamed for reserved members. + */ + private fun primaryKeyProperty(target: MetaObject): String { + val pkField = target.getIdentities(true).firstOrNull { it.isPrimary }?.fields?.firstOrNull() + return KotlinNaming.safeColumnProperty(pkField ?: "id", exposedApi()) + } + + /** + * The Exposed column builder for a FK column pointing at [target]'s primary key. + * The FK column's SQL type MUST match the referenced PK's — Exposed's typed + * `Column.references(Column)` rejects a mismatch at compile time, so a + * hard-coded `long("author_id")` referencing a uuid PK broke the build of ANY + * relationship aimed at a uuid-keyed entity. Derived from the target's single-field + * `identity.primary` via [KotlinTypeMapper.exposedColumnSpec] (uuid → `uuid("col")`, + * long → `long("col")`, int → `integer("col")`, string → text/varchar), with the FK's + * own physical column name. Falls back to `long("col")` when the PK field can't be + * resolved — the historical default (same lossy-tolerant policy as + * [primaryKeyProperty]). + */ + private fun fkColumnBuilder(target: MetaObject, colName: String): String { + // ADR-0039: resolving lookups — the identity and its field may be inherited. + val pkFieldName = target.getIdentities(true).firstOrNull { it.isPrimary } + ?.fields?.firstOrNull() ?: return "long(\"$colName\")" + val pkField = target.metaFields.firstOrNull { it.name == pkFieldName } + ?: return "long(\"$colName\")" + // Task 6 — out of scope: colName here is a RELATIONSHIP-synthesized FK column + // name (e.g. "author_id" from the composition's shortName + "Id"), not a + // metaField of any single object — no Names constant names it. Always + // literal; useNames() does not reach here. + return runCatching { KotlinTypeMapper.exposedColumnSpec(pkField, "\"$colName\"", exposedApi()) } + .getOrDefault("long(\"$colName\")") + } + + /** Read the `@column` attr on a relationship (inheritance allowed); null when absent. */ + private fun readColumnAttr(rel: MetaRelationship): String? { + if (!rel.hasMetaAttr(CoreDBMetaDataProvider.COLUMN, true)) return null + return runCatching { rel.getMetaAttr(CoreDBMetaDataProvider.COLUMN, true).valueAsString } + .getOrNull() + } + + /** + * Map a kebab-case metadata referential action to Exposed's `ReferenceOption` name. + * Metadata: `cascade | set-null | restrict | no-action` (per MetaRelationship). + * Exposed: `CASCADE | SET_NULL | RESTRICT | NO_ACTION | SET_DEFAULT`. + * Returns null for unknown values rather than throwing — keeps codegen resilient + * against future-vocabulary metadata; the loader already validates the enum set. + */ + private fun mapReferentialAction(kebab: String): String? = when (kebab) { + MetaRelationship.ACTION_CASCADE -> "CASCADE" + MetaRelationship.ACTION_SET_NULL -> "SET_NULL" + MetaRelationship.ACTION_RESTRICT -> "RESTRICT" + MetaRelationship.ACTION_NO_ACTION -> "NO_ACTION" + else -> null + } + + /** + * Exposed column spec for a `field.enum`. + * + * String-backed (the default) → `enumerationByName`: a VARCHAR column holding the + * member symbol. Int-backed (`@intValueMap`) → `customEnumeration` over an INTEGER + * column, with the declared symbol↔int mapping inlined as `when` expressions. + * + * The mapping is emitted INLINE rather than through a generated lookup-map support + * file, for two reasons. A `when` over an enum is exhaustive, so a member with no + * mapping is a COMPILE error in the adopter's build rather than a runtime surprise; + * and it allocates nothing per row, where a `mapOf(...)` inside the lambda would + * rebuild the map on every read and every write. + * + * ADR-0039: `@values` and `@intValueMap` are both read RESOLVING, so a field that + * `extends` a shared abstract enum inherits the members AND their mapping. + * + * [colExpr] is the ready-to-splice Kotlin column-name expression — a quoted string + * literal, or (Task 6, `useNames()`) a bare `Names._COLUMN` + * reference — supplied by the caller so this one code path (previously a + * hardcoded literal, never routed through [KotlinExposedTableGenerator.emit]'s + * `columnExprFor`) substitutes exactly like every other column site. + */ + private fun enumColumnSpec(field: EnumField, entity: MetaObject, colExpr: String): String { + val enumCn = enumClassFor(field, entity) + ?: error("enumTypeName returned null for EnumField '${field.name}' on ${entity.name}") + val simple = enumCn.simpleName + val intMap = readIntValueMap(field) + ?: return "enumerationByName($colExpr, ${KotlinTypeMapper.ENUM_VARCHAR_LEN}, $simple::class)" + + // `; ` separates the branches: this is a one-line `when`, and space-separated + // branches do not parse (`Foo.DRAFT 5 -> ...`). + val fromDb = intMap.entries.joinToString("; ") { (sym, i) -> "$i -> $simple.$sym" } + val toDb = intMap.entries.joinToString("; ") { (sym, i) -> "$simple.$sym -> $i" } + // An int with no member is corrupt data the model does not describe: fail loudly + // rather than substituting a member. The write side needs no `else` — it is + // exhaustive over the enum by construction, since @intValueMap's keys are + // validated to match @values exactly. + return "customEnumeration($colExpr, \"INTEGER\", " + + "{ v -> when ((v as Number).toInt()) { $fromDb; " + + "else -> error(\"unmapped stored value \$v for $simple\") } }, " + + "{ e -> when (e) { $toDb } })" + } + + /** The declared symbol→int map (`@intValueMap`), or null when the enum is string-backed. */ + private fun readIntValueMap(field: EnumField): Map? { + if (!field.hasMetaAttr(EnumField.ATTR_INT_VALUE_MAP)) return null + val raw = runCatching { field.getMetaAttr(EnumField.ATTR_INT_VALUE_MAP).value }.getOrNull() + val m = (raw as? Map<*, *>) ?: return null + val out = LinkedHashMap() + for ((k, v) in m) { + val key = k?.toString() ?: continue + val i = (v as? Number)?.toInt() ?: continue + out[key] = i + } + return out.ifEmpty { null } + } + + // === MultiFileDirectGeneratorBase abstract-method stubs ==================== + override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ } + override fun ?> getSingleWriter( + loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter? + ): T? = null + override fun ?> getFinalWriter( + loader: MetaDataLoader?, out: OutputStream? + ): T? = null + override fun writeFinalFile(metadata: MutableCollection?, writer: GeneratorIOWriter<*>?) { /* none */ } + override fun getSingleOutputFilePath(md: MetaObject): String = + PackageMapping.splitFqn(md.name).first.replace('.', '/') + override fun getSingleOutputFilename(md: MetaObject): String = + PackageMapping.splitFqn(md.name).second + "Table.kt" +} diff --git a/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinOutputParserGenerator.kt.txt b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinOutputParserGenerator.kt.txt new file mode 100644 index 000000000..8bfef309f --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinOutputParserGenerator.kt.txt @@ -0,0 +1,228 @@ +package com.metaobjects.generator.kotlin + +// Eject note (ADR-0034, JVM eject design): these siblings stay in +// com.metaobjects.generator.kotlin when this generator is copied out via +// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not +// same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.generator.kotlin.FindInbound +import com.metaobjects.generator.kotlin.KotlinExtractSchemaEmitter +import com.metaobjects.generator.kotlin.KotlinNaming +import com.metaobjects.generator.kotlin.PackageMapping + +import com.metaobjects.generator.GeneratorIOWriter +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject +import com.metaobjects.template.MetaTemplate +import java.io.OutputStream +import java.io.PrintWriter +import java.nio.file.Path +import java.nio.file.Paths +import org.slf4j.LoggerFactory +import com.metaobjects.generator.util.GeneratedFileWriter + +/** + * Generator: one `Parser.kt` per RESPONDING `template.prompt` (one that + * declares `@responseRef`, ADR-0052), parsing a model's reply into the `@responseRef` value + * object's own data class — the one [KotlinEntityGenerator] emits (ADR-0056). This generator + * declares no strict type of its own. + * + *

FR-006 — the Kotlin port of the cross-language parser-on-receipt codegen. See + * `docs/superpowers/specs/2026-05-25-fr6-template-output-parser-codegen.md` and ADR-0010 for + * the cross-port contract; this generator is the Kotlin sibling of TS's `outputParser()`, C#'s + * `OutputParserGenerator`, and Python's `OutputParserGenerator`. + * + *

API shape (idiomatic Kotlin dual-API per ADR-0010 §3): + *

+ *   object <TemplateShortName>Parser {
+ *     // JSON replies only. Throws com.fasterxml.jackson.core.JsonProcessingException on bad input.
+ *     fun parse<TemplateShortName>(text: String): <ResponseVo>
+ *     fun safeParse<TemplateShortName>(text: String): Result<<ResponseVo>>
+ *
+ *     // Every reply format. Never throws.
+ *     fun extractLenient(loader, text, opts): ExtractionResult<<ResponseVo>Extracted>
+ *   }
+ * 
+ * + *

The strict tier decodes with Jackson (`jackson-module-kotlin`), the codec the entity-tier + * data classes are built for, and fails on an unknown property. The lenient tier returns the + * value object's all-nullable mirror, which this generator also writes — once per value object + * per run, beside the value object — via [KotlinExtractSchemaEmitter.emitMirrorFiles]. + * + *

Requires [KotlinEntityGenerator] in the same run: the parser and the mirror reference + * the value object's data class, which only that generator declares. + * + *

Consumer dependency. The strict tier needs + * `com.fasterxml.jackson.module:jackson-module-kotlin` on the classpath (Spring Boot's web starter + * brings Jackson; add the Kotlin module if it is absent). The lenient tier needs the MetaObjects + * runtime (`metaobjects-render`, `metaobjects-om`). + * + *

Substrate justification (hand-rolled string builder rather than KotlinPoet): the parser file + * is short, straight-line Kotlin, so the hand-rolled emit is clearer than the equivalent KotlinPoet + * dance. Same trade-off as [KotlinSpringControllerGenerator] / [KotlinExposedTableGenerator]. + * + *

Args: + *

    + *
  • {@code outputDir} (required): output directory root.
  • + *
+ */ +open class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase() { + + override fun getFilterClass(): Class = MetaObject::class.java + + override fun execute(loader: MetaDataLoader) { + parseArgs() + val outRoot = Paths.get(outDir.absolutePath) + // ADR-0056 — a mirror is keyed by its value object, so two prompts replying with the same + // shape share one mirror file. Run-wide dedupe by value-object FQN. + val emittedMirrors = mutableSetOf() + // ADR-0052: the direction rule lives in FindInbound, never re-derived here. Only a + // RESPONDING template.prompt gets a parser file; template.output renders outbound and + // parses nothing. + for (tmpl in FindInbound.inboundTemplates(loader)) { + emit(tmpl, loader, outRoot, emittedMirrors) + } + } + + protected open fun emit( + template: MetaTemplate, + loader: MetaDataLoader, + outRoot: Path, + emittedMirrors: MutableSet, + ) { + // ADR-0052: the shape parsed INTO is @responseRef — the reply — never @payloadRef, which + // types the request this prompt renders outbound. responseShape resolves through the value + // object resolver every template-tier generator shares. + val shape = FindInbound.responseShape(loader, template) + if (shape == null) { + LOG.warn( + "skipping output-parser for {} — no @responseRef, or it does not resolve to an object.value or sourceless object.projection", + template.name + ) + return + } + val responseVo = shape.vo + + val (templatePkg, templateShort) = PackageMapping.splitFqn(template.name) + val outPkg = KotlinNaming.promptsPackage(templatePkg) + val parserClass = KotlinNaming.parserName(templateShort) + KotlinNaming.requireReferenceable(outPkg, responseVo, "template '${template.name}'") + // ADR-0056 — the parse result IS the response value object's own data class. + val responseClass = KotlinNaming.strictRef(responseVo) + val mirrorClass = KotlinNaming.mirrorRef(responseVo) + val parseFn = "parse$templateShort" + val safeParseFn = "safeParse$templateShort" + + // ADR-0053: the reply's syntax is @responseFormat (json|xml, default json) — never + // @format, which is the syntax of the rendered prompt BODY. + val format = shape.format + // The strict tier is JSON-ONLY: strict all-or-nothing semantics layered over the REPAIRING + // XML reader would throw or accept based on how much repair happened, which is not a + // contract anyone can reason about. Every responding prompt gets the tolerant tier. + val emitStrict = !FindInbound.isXml(format) + val formatEnum = if (FindInbound.isXml(format)) "Format.XML" else "Format.JSON" + + KotlinExtractSchemaEmitter.emitMirrorFiles(responseVo, outRoot, emittedMirrors) + + val src = buildString { + append("// GENERATED — DO NOT EDIT — response parser for template.prompt `") + append(template.name) + append("`\n") + append(KotlinNaming.packageHeader(outPkg)) + if (emitStrict) append("import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\n") + append("import com.metaobjects.loader.MetaDataLoader\n") + append("import com.metaobjects.`object`.extract.MetaObjectExtractor\n") + append("import com.metaobjects.render.extract.Format\n") + append("import com.metaobjects.render.extract.ExtractOptions\n") + append("import com.metaobjects.render.extract.ExtractionResult\n") + append("\n") + append("/** Parser for LLM responses matching the `") + append(templateShort) + append("` template.prompt. */\n") + append("object ") + append(parserClass) + append(" {\n") + append("\n") + if (emitStrict) { + append(" // Fails on an unknown property: a reply carrying a field the response shape does not\n") + append(" // declare is a contract break, not something to drop silently.\n") + append(" private val mapper = jacksonObjectMapper().findAndRegisterModules()\n") + append("\n") + append(" /**\n") + append(" * Parse an LLM response into a typed [") + append(responseClass) + append("].\n") + append(" *\n") + append(" * @throws com.fasterxml.jackson.core.JsonProcessingException when the input is not valid JSON for the response shape.\n") + append(" */\n") + append(" fun ") + append(parseFn) + append("(text: String): ") + append(responseClass) + append(" =\n") + append(" mapper.readValue(text, ") + append(responseClass) + append("::class.java)\n") + append("\n") + append(" /**\n") + append(" * Parse with explicit error handling (Result-style — does not throw).\n") + append(" */\n") + append(" fun ") + append(safeParseFn) + append("(text: String): Result<") + append(responseClass) + append("> =\n") + append(" runCatching { ") + append(parseFn) + append("(text) }\n") + append("\n") + } + append(" /** Payload FQN this parser extracts — resolved against the supplied loader at runtime. */\n") + append(" const val PAYLOAD_FQN: String = \"") + append(KotlinExtractSchemaEmitter.kotlinStringLiteral(responseVo.name)) + append("\"\n") + append("\n") + append(" /**\n") + append(" * Tolerant best-effort extraction delegating to the runtime MetaObjectExtractor;\n") + append(" * never throws. Fully populates nested-object and array-of-object components by\n") + append(" * reading the live metadata directly. Resolves this payload's\n") + append(" * MetaObject by [PAYLOAD_FQN] from [loader].\n") + append(" */\n") + append(" fun extractLenient(loader: MetaDataLoader, text: String, opts: ExtractOptions = ExtractOptions.defaults()): ExtractionResult<") + append(mirrorClass) + append("> {\n") + append(" val mo = loader.getMetaObjectByName(PAYLOAD_FQN)\n") + append(" val raw = MetaObjectExtractor.extract(mo, text, ") + append(formatEnum) + append(", opts)\n") + append(" // The assembled graph is a ValueObject (a Map) with nested\n") + append(" // ValueObjects / List — map it onto the typed mirror graph.\n") + append(" @Suppress(\"UNCHECKED_CAST\")\n") + append(" val d = raw.data as? Map\n") + append(" return ExtractionResult(") + append(mirrorClass) + append(".fromMap(d), raw.report)\n") + append(" }\n") + append("}\n") + } + + val outFile = outRoot.resolve(outPkg.replace('.', '/')).resolve("$parserClass.kt") + GeneratedFileWriter.write(outFile, src) + } + + // === MultiFileDirectGeneratorBase abstract-method stubs ==================== + override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ } + override fun ?> getSingleWriter( + loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter? + ): T? = null + override fun ?> getFinalWriter( + loader: MetaDataLoader?, out: OutputStream? + ): T? = null + override fun writeFinalFile(metadata: MutableCollection?, writer: GeneratorIOWriter<*>?) { /* none */ } + override fun getSingleOutputFilePath(md: MetaObject): String = "" + override fun getSingleOutputFilename(md: MetaObject): String = "${md.name}.kt" + + companion object { + private val LOG = LoggerFactory.getLogger(KotlinOutputParserGenerator::class.java) + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRelationsGenerator.kt.txt b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRelationsGenerator.kt.txt new file mode 100644 index 000000000..2383f37e9 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRelationsGenerator.kt.txt @@ -0,0 +1,393 @@ +package com.metaobjects.generator.kotlin + +// Eject note (ADR-0034, JVM eject design): these siblings stay in +// com.metaobjects.generator.kotlin when this generator is copied out via +// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not +// same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.generator.kotlin.ExposedApi +import com.metaobjects.generator.kotlin.ExposedImports +import com.metaobjects.generator.kotlin.KotlinGenUtil +import com.metaobjects.generator.kotlin.KotlinM2mSupport +import com.metaobjects.generator.kotlin.KotlinNaming +import com.metaobjects.generator.kotlin.KotlinTphPlan +import com.metaobjects.generator.kotlin.KotlinTypeMapper +import com.metaobjects.generator.kotlin.PackageMapping + +import com.metaobjects.generator.GeneratorIOWriter +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase +import com.metaobjects.identity.MetaIdentity +import com.metaobjects.identity.ReferenceIdentity +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject +import com.metaobjects.relationship.CompositionRelationship +import com.metaobjects.relationship.MetaRelationship +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.TypeName +import java.io.OutputStream +import java.io.PrintWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import com.metaobjects.generator.util.GeneratedFileWriter + +/** + * Generator: one `Relations.kt` per `object.entity` carrying at least + * one `relationship.composition @cardinality: many` child. Emits an ergonomic + * Kotlin extension function per to-many declaration so consumers can write + * `AuthorTable.postsQuery(author.id).toList()` instead of hand-rolling the + * `selectAll().where { ... }` JOIN every time. + * + *

Pairs with {@link KotlinExposedTableGenerator}, which emits the SCHEMA + * side (the inferred FK column on the many entity's table). This generator + * owns the QUERY side — the inverse fetch from parent → children — and stays + * separate from the table emitter so the two concerns don't get tangled. + * + *

The returned {@link org.jetbrains.exposed.sql.Query} keeps the helper + * compositional: consumers can chain `.where { ... }`, `.orderBy(...)`, + * `.limit(...)` etc. before materialising. + * + *

Convention (matches the inferred FK column emitted by + * {@link KotlinExposedTableGenerator#buildInverseFkSpec}): + *

    + *
  • FK column on the target table = {@code Id} + * (e.g. {@code Author} → {@code authorId} on {@code PostTable}).
  • + *
  • Helper fn name = {@code Query} (e.g. {@code postsQuery}).
  • + *
  • Helper fn parameter = the parent's primary-key field type + * (resolved via {@code identity.primary.@fields[0]} + KotlinTypeMapper).
  • + *
+ * + *

Skips silently when: + *

    + *
  • The entity has no {@code source.rdb} child (no persistence layer).
  • + *
  • The entity has no to-many composition relationships.
  • + *
  • The target entity's short name cannot be resolved (defensive).
  • + *
+ * + *

Args: + *

    + *
  • {@code outputDir} (required): output directory root.
  • + *
+ */ +open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase() { + + /** + * Exposed output-API version (issue #390) — `0` (default) emits Exposed 0.x + * (`org.jetbrains.exposed.sql.*`, byte-identical to every release before this arg + * existed); `1` emits Exposed 1.x (`org.jetbrains.exposed.v1.*`). From the + * `exposedApi` generator arg (`1` in the pom). + */ + protected fun exposedApi(): ExposedApi = ExposedApi.parse(getArg(ExposedApi.ARG_EXPOSED_API)) + + override fun getFilterClass(): Class = MetaObject::class.java + + override fun execute(loader: MetaDataLoader) { + parseArgs() + val outRoot = Paths.get(outDir.absolutePath) + + for (entity in loader.metaObjects) { + if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue + // Abstract entities are inheritance scaffolding — never emit relation helpers. + if (KotlinGenUtil.isAbstractEntity(entity)) continue + // FR-017 TPH: a subtype folds into its base — its inherited relationships are emitted on + // the base's helper; a per-subtype helper would reference the missing Table. Mirror + // the table/controller skip. + if (KotlinTphPlan.isTphSubtype(entity)) continue + // No source.rdb → no persistence layer → no FK column to query against. + // ADR-0039: resolving (an inherited source.rdb still means the entity is persisted). + if (!KotlinGenUtil.hasRdbSource(entity)) continue + + // ADR-0039: relationships are inheritable — RESOLVE via entity.relationships; + // entity.children (own-only) would miss an inherited relationship. + val manyRels = entity.relationships + .filter { + it.subType == CompositionRelationship.SUBTYPE_COMPOSITION && + it.cardinality == MetaRelationship.CARDINALITY_MANY + } + // FR-018: M:N navs (any subtype, @cardinality:"many" + @through). Derived + // junction FK fields via the cross-port SSOT (KotlinM2mSupport → M2MFields). + // + // FW-8 x FR-017: when `entity` is a TPH discriminator base, ALSO fold in the M:N + // navs a concrete SUBTYPE declares on its own (e.g. `linkedAuths` on `BridgeAuth`) — + // subtypes are skipped by the guard above, so nothing else ever visits them. A base- + // inherited nav a subtype merely RESOLVES (never re-derived here — same relation, + // same junction FK fields either way) is deduped by relation name: the base's own + // pass above already emits its query helper, and the controller's per-subtype mount + // reuses that SAME helper (adding only a source-discriminator gate in front of it). + val m2mNavs = tphAugmentedM2mNavs(entity, loader) + // ADR-0038: reverse navigation — one finder pair per FK this entity holds + // (each single-field identity.reference). + val reverseFks = reverseFksFor(entity) + if (manyRels.isEmpty() && m2mNavs.isEmpty() && reverseFks.isEmpty()) continue + + emit(entity, manyRels, m2mNavs, reverseFks, outRoot, loader) + } + } + + protected open fun emit( + entity: MetaObject, + manyRels: List, + m2mNavs: List, + reverseFks: List, + outRoot: Path, + loader: MetaDataLoader, + ) { + val api = exposedApi() + val (pkg, ownerShort) = PackageMapping.splitFqn(entity.name) + val ownerTable = ownerShort + "Table" + // TypeName.toString() yields the FQN (`kotlin.Long`, `java.util.UUID`, ...); + // grab the trailing simple name for the generated source. + val pkParamSimpleName = primaryKeyKotlinType(entity).toString().substringAfterLast('.') + // FK column matches KotlinExposedTableGenerator.buildInverseFkSpec: Id. + val fkColName = ownerShort.replaceFirstChar { it.lowercaseChar() } + "Id" + + // Resolve each to-many composition target → (relShortName, targetShort). Skip + // relationships whose @objectRef cannot be resolved (defensive — the loader's + // validation phase already gates the attr being present). + data class Helper(val relShortName: String, val targetTable: String) + val helpers = manyRels.mapNotNull { rel -> + val ref = rel.objectRef ?: return@mapNotNull null + val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: return@mapNotNull null + val targetShort = PackageMapping.splitFqn(target.name).second + val relShort = rel.shortName ?: rel.name + Helper(relShort, targetShort + "Table") + } + if (helpers.isEmpty() && m2mNavs.isEmpty() && reverseFks.isEmpty()) return + + val source = buildString { + if (pkg.isNotEmpty()) { + append("package $pkg\n\n") + } + append("import ${ExposedImports.jdbc(api, "Query")}\n") + if (api == ExposedApi.V1) { + // issue #390: `eq` is a SqlExpressionBuilder-scoped member under 0.x, brought + // into scope implicitly by `.where{}`'s `SqlExpressionBuilder.() -> Op` + // receiver — so 0.x only imports it explicitly when a call site (the m2mNavs + // join/where blocks below) needs it OUTSIDE a `.where{}`. `Query.where()` takes a + // receiver-LESS lambda under 1.x (`() -> Op`), so every call site below — + // the plain composition helpers' `.where{}`, the reverse-FK finders', AND the + // m2mNavs joins — needs the plain top-level import; unconditional here because + // this function only reaches this point when at least one of helpers/m2mNavs/ + // reverseFks is non-empty (the early return above), and all three use `eq`. + append("import ${ExposedImports.core(api, "eq")}\n") + // `inList` (the batched reverse-FK finder) is the same 0.x-implicit/1.x-explicit + // story as `eq` — conditional because it is the ONLY site that uses it. + if (reverseFks.isNotEmpty()) append("import ${ExposedImports.core(api, "inList")}\n") + } + if (m2mNavs.isNotEmpty()) { + append("import ${ExposedImports.core(api, "JoinType")}\n") + if (api == ExposedApi.V0) { + // 0.x — UNCHANGED (byte-identical). See the V1 branch above for why this + // import is conditional on m2mNavs under 0.x but unconditional under 1.x. + append("import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq\n") + } + // `and` backs both the symmetric branch's directional ON clause (no neq + // exclusion — keeps the self endpoint) AND a non-symmetric nav whose TARGET is a + // TPH subtype (FW-3): the shared storage table's rows are ANDed with the + // discriminator so a sibling subtype's row can't come back. `or` is symmetric-only. + if (m2mNavs.any { it.nav.symmetric }) { + append("import ${ExposedImports.core(api, "or")}\n") + } + if (m2mNavs.any { it.nav.symmetric || it.nav.targetDiscriminator != null }) { + append("import ${ExposedImports.core(api, "and")}\n") + } + } + append("import ${ExposedImports.jdbc(api, "selectAll")}\n") + // java.util.UUID appears in helper signatures when the owner's PK (or a + // reverse-FK value type) is uuid — the param types were already derived, but + // without this import the generated file had an unresolved reference. Emitted + // conditionally so uuid-free files stay byte-identical. + if (pkParamSimpleName == "UUID" || reverseFks.any { it.valueType == "UUID" }) { + append("import java.util.UUID\n") + } + append("\n") + append("/** GENERATED — extension fns for `$ownerShort` to-many relationships. Do not hand-edit. */\n") + var first = true + for (h in helpers) { + if (!first) append("\n") + first = false + append("/** Query `$ownerShort.${h.relShortName}` (cardinality=many) on the ${h.targetTable.removeSuffix("Table")} side. */\n") + append("fun $ownerTable.${h.relShortName}Query($fkColName: $pkParamSimpleName): Query =\n") + append(" ${h.targetTable}.selectAll().where { ${h.targetTable}.$fkColName eq $fkColName }\n") + } + + // FR-018 M:N junction-join query helpers. Each emits an Exposed Query that + // INNER-JOINs the target table to the junction on the derived target-side FK, + // returning the target rows whose junction row matches the source id. The + // junction FK columns are derived (KotlinM2mSupport → M2MFields), never restated. + // * hetero / directed self-join: WHERE junction. = :id + // * symmetric self-join: WHERE junction. = :id + // OR junction. = :id (union-on-read) + // The join keys the target by its PRIMARY KEY against the junction's target-side + // FK; the source filter uses the junction's source-side FK. Related-row order is + // not contractual. + for (named in m2mNavs) { + val nav = named.nav + if (!first) append("\n") + first = false + val symMarker = if (nav.symmetric) " (symmetric — union on read)" else "" + append("/** Query `$ownerShort.${nav.relationName}` (M:N via ${nav.junctionShortName})$symMarker — the related ${nav.targetShortName} rows. */\n") + append("fun $ownerTable.${named.queryFnName}(sourceId: $pkParamSimpleName): Query =\n") + if (nav.symmetric) { + // Symmetric storage is single-row; a friend appears via EITHER FK column. + // Union-on-read: for each junction row touching the source, the related id is + // the FK column that is NOT the source. We encode that directly in the join ON + // clause as two directional disjuncts: + // * the target PK = sourceField WHEN targetField = sourceId (source on B side) + // * the target PK = targetField WHEN sourceField = sourceId (source on A side) + // so each junction row contributes exactly its NON-source endpoint — and a + // self-pair row (a,a) KEEPS the self endpoint (both disjuncts bind target.pk = a + // when the source = a), matching the runtime M2mJoinResolver + every other port + // (Alice is her own friend). No `neq sourceId` exclusion (that would drop (a,a)), + // and the directional ON clause already constrains to junction rows touching the + // source, so no extra WHERE is needed. + append(" ${nav.targetTableObj}.join(${nav.junctionTableObj}, JoinType.INNER) {\n") + append(" ((${nav.junctionTableObj}.${nav.sourceField} eq ${nav.targetTableObj}.${nav.targetPkField}) and (${nav.junctionTableObj}.${nav.targetField} eq sourceId)) or\n") + append(" ((${nav.junctionTableObj}.${nav.targetField} eq ${nav.targetTableObj}.${nav.targetPkField}) and (${nav.junctionTableObj}.${nav.sourceField} eq sourceId))\n") + append(" }.selectAll()") + val symDisc = nav.targetDiscriminator + if (symDisc != null) { + append(".where { ${nav.targetTableObj}.${symDisc.column} eq ${symDisc.valueExpr} }\n") + } else { + append("\n") + } + } else { + // Hetero / directed self-join: join the target by its PK to the junction's + // target-side FK, filter the junction on the derived source-side FK. FW-3: a + // TPH-subtype target ANDs the discriminator into the SAME where — the shared + // storage table's join keeps every subtype's rows, so this is what narrows the + // result back to just the declared target's own rows. + append(" ${nav.targetTableObj}.join(${nav.junctionTableObj}, JoinType.INNER) { ${nav.targetTableObj}.${nav.targetPkField} eq ${nav.junctionTableObj}.${nav.targetField} }\n") + append(" .selectAll()\n") + val disc = nav.targetDiscriminator + if (disc != null) { + append(" .where { (${nav.junctionTableObj}.${nav.sourceField} eq sourceId) and (${nav.targetTableObj}.${disc.column} eq ${disc.valueExpr}) }\n") + } else { + append(" .where { ${nav.junctionTableObj}.${nav.sourceField} eq sourceId }\n") + } + } + } + + // ADR-0038 reverse navigation — one finder PAIR per FK this entity holds + // (each single-field identity.reference). The referenced entity T navigates to + // its referencing E rows by calling these with a T id. The fn name derives from + // the FK FIELD (PascalCased, trailing `Id` dropped), unique within E by + // construction — so same-pair FKs (GameSession → Scene ×3) yield DISTINCT finders + // (findByCurrentScene / findByLastOpeningNarrativeScene / + // findByTransitioningFromScene), never colliding, with no @reverseName vocabulary. + // NOT a lazy Exposed `referrersOn` reverse collection (N+1 + needs an open + // transaction): each is a plain single indexed query (WHERE fk = ?), with a + // batched variant (WHERE fk IN (…)) for anti-N+1 loading of many parents. The + // returned Query stays compositional (chain .orderBy/.limit before materialising). + for (fk in reverseFks) { + if (!first) append("\n") + first = false + val col = KotlinNaming.safeColumnProperty(fk.fkField, exposedApi()) + val single = KotlinNaming.reverseFinderName(fk.fkField) + val batched = KotlinNaming.reverseFinderInName(fk.fkField) + append("/** Reverse nav: the $ownerShort rows whose `${fk.fkField}` FK points at the given ${fk.targetShortName} id (single indexed query). */\n") + append("fun $ownerTable.$single(${fk.fkField}: ${fk.valueType}): Query =\n") + append(" $ownerTable.selectAll().where { $ownerTable.$col eq ${fk.fkField} }\n") + append("\n") + append("/** Reverse nav (batched, anti-N+1): the $ownerShort rows whose `${fk.fkField}` FK is IN the given ${fk.targetShortName} ids. */\n") + append("fun $ownerTable.$batched(${fk.fkField}s: List<${fk.valueType}>): Query =\n") + append(" $ownerTable.selectAll().where { $ownerTable.$col inList ${fk.fkField}s }\n") + } + } + + val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve(ownerShort + "Relations.kt") + outFile.parent?.let { Files.createDirectories(it) } + GeneratedFileWriter.write(outFile, source) + } + + /** + * FW-8 x FR-017 — [entity]'s own M:N navs, UNIONED with every concrete TPH subtype's own + * M:N navs when [entity] is a discriminator base, each paired with the query-helper function + * name it must be emitted under. A plain (non-TPH) entity is unaffected: [KotlinTphPlan.planFor] + * returns null and each of [entity]'s own navs keeps its [KotlinM2mSupport.defaultQueryFnName]. + * + * Distinctness (and therefore the helper-name assignment) is delegated to + * [KotlinM2mSupport.tphQueryFnNames] — the shared decision [KotlinSpringControllerGenerator] + * also reads, so both generators land on the SAME name for the SAME nav. One a subtype merely + * INHERITS from the base resolves to the IDENTICAL nav (relationships resolve through + * `extends`), so it collapses into [entity]'s own entry and emits ONE query helper, not two — + * the controller mounts that inherited nav a second time too (once at the base path, once + * under each subtype's segment), but both mounts call this ONE helper, the subtype-scoped + * mount only adding a source-discriminator gate in front of it. A subtype that instead + * SHADOWS the name with a genuinely different nav (own children shadow super on `(type, + * name)`, ADR-0039) gets its OWN distinctly-named helper — see [KotlinM2mSupport.tphQueryFnNames]. + */ + private fun tphAugmentedM2mNavs(entity: MetaObject, loader: MetaDataLoader): List { + val ownNavs = KotlinM2mSupport.resolve(entity, loader) + val plan = KotlinTphPlan.planFor(entity, loader) + ?: return ownNavs.map { KotlinM2mSupport.NamedM2mNav(it, KotlinM2mSupport.defaultQueryFnName(it.relationName)) } + return KotlinM2mSupport.tphQueryFnNames(entity, plan, loader) + .map { (nav, fnName) -> KotlinM2mSupport.NamedM2mNav(nav, fnName) } + } + + /** + * One reverse FK an entity holds (ADR-0038), derived from an `identity.reference`: + * the FK field name, the target entity short name, and the Kotlin value type of the + * FK (the type a `findBy` arg / element takes — the FK column's own Kotlin type). + */ + protected data class ReverseFk(val fkField: String, val targetShortName: String, val valueType: String) + + /** + * Collect every reverse FK [entity] declares, in declaration order — one per + * single-field [ReferenceIdentity] child. Compound references (multi-field FKs) are + * skipped (no single-column finder shape). The value type is the FK field's own Kotlin + * simple type via [KotlinTypeMapper.kotlinTypeName], falling back to `Long`. + */ + protected open fun reverseFksFor(entity: MetaObject): List { + val out = mutableListOf() + // ADR-0039: identity.reference children are inheritable — RESOLVE via + // getIdentities(true); entity.children (own-only) would miss inherited FK refs. + for (child in entity.getIdentities(true)) { + if (child !is ReferenceIdentity) continue + val fields = child.fields + if (fields.size != 1) continue // single-column FKs only + val fkField = fields.first() + val targetShort = child.targetEntity?.let { PackageMapping.splitFqn(it).second } ?: "" + val valueType = entity.metaFields.firstOrNull { it.name == fkField }?.let { f -> + runCatching { KotlinTypeMapper.kotlinTypeName(f).toString().substringAfterLast('.') } + .getOrDefault("Long") + } ?: "Long" + out.add(ReverseFk(fkField, targetShort, valueType)) + } + return out + } + + /** + * Resolve the Kotlin type of [entity]'s primary key. Falls back to [LONG] when + * the primary identity is missing, multi-field, or its field can't be resolved + * — Long matches the dominant convention (a single `field.long` PK) and the + * same fallback KotlinExposedTableGenerator's FK column builder and + * KotlinSpringControllerGenerator's route signatures use, so the derived types + * stay in lockstep across the three generators. Same lossy-tolerant policy as + * the table generator: defensive defaults rather than throwing on + * partially-formed metadata. + */ + protected open fun primaryKeyKotlinType(entity: MetaObject): TypeName { + // ADR-0039: identities are inheritable — RESOLVE via getIdentities(true); + // entity.children (own-only) would miss an inherited primary identity. + val primary = entity.getIdentities(true) + .filterIsInstance() + .firstOrNull { it.isPrimary } ?: return LONG + val pkFieldName = KotlinGenUtil.keyFields(entity, primary).firstOrNull() ?: return LONG + val pkField = entity.metaFields.firstOrNull { it.name == pkFieldName } ?: return LONG + return runCatching { KotlinTypeMapper.kotlinTypeName(pkField) }.getOrDefault(LONG) + } + + // === MultiFileDirectGeneratorBase abstract-method stubs ==================== + override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ } + override fun ?> getSingleWriter( + loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter? + ): T? = null + override fun ?> getFinalWriter( + loader: MetaDataLoader?, out: OutputStream? + ): T? = null + override fun writeFinalFile(metadata: MutableCollection?, writer: GeneratorIOWriter<*>?) { /* none */ } + override fun getSingleOutputFilePath(md: MetaObject): String = + PackageMapping.splitFqn(md.name).first.replace('.', '/') + override fun getSingleOutputFilename(md: MetaObject): String = + PackageMapping.splitFqn(md.name).second + "Relations.kt" +} diff --git a/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRenderHelperGenerator.kt.txt b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRenderHelperGenerator.kt.txt new file mode 100644 index 000000000..8445b9ba1 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRenderHelperGenerator.kt.txt @@ -0,0 +1,392 @@ +package com.metaobjects.generator.kotlin + +// Eject note (ADR-0034, JVM eject design): these siblings stay in +// com.metaobjects.generator.kotlin when this generator is copied out via +// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not +// same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.generator.kotlin.KotlinGenUtil +import com.metaobjects.generator.kotlin.KotlinNaming +import com.metaobjects.generator.kotlin.PackageMapping + +import com.metaobjects.field.ObjectField +import com.metaobjects.generator.GeneratorException +import com.metaobjects.generator.GeneratorIOWriter +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject +import com.metaobjects.render.FilesystemProvider +import com.metaobjects.render.PayloadField +import com.metaobjects.render.Verify +import com.metaobjects.render.VerifyOptions +import com.metaobjects.template.MetaTemplate +import com.metaobjects.template.OutputTemplate +import com.metaobjects.template.PromptTemplate +import com.metaobjects.template.TemplateConstants +import java.io.OutputStream +import java.io.PrintWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import com.metaobjects.generator.util.GeneratedFileWriter + +/** + * Generator: one `RenderHelper.kt` per RENDERABLE template + * declaration — `template.output` AND `template.prompt`, since both render an outbound + * body (ADR-0052) — wrapping the EXISTING JVM [com.metaobjects.render.Renderer] engine + * (Kotlin reuses the shared JVM render lib) with a typed `render(payload, provider)` + * entry point — and enforcing the mustache↔VO drift check ([Verify]) at BUILD time. + * + * The Kotlin port of the cross-port render-helper (`SpringRenderHelperGenerator` + * (Java/reference) + the TS/C#/Python siblings). The emitted Kotlin source calls + * the JVM render types by FQN; no Kotlin render engine is introduced. + * + * Two shapes keyed off `@kind`: + * - `document` (default) → renders `@textRef` in `@format` to a single `String`. + * - `email` → renders `@subjectRef` (text) + `@htmlBodyRef` (html) (+ optional + * `@textBodyRef`, text) into a [com.metaobjects.render.EmailDocument]. + * + * **The headline is the BUILD-TIME drift gate.** BEFORE emitting, every referenced + * mustache (`document`: `@textRef`; `email`: subject + html (+ optional text) + * part-refs) is resolved through a [FilesystemProvider] rooted at the `templateRoot` + * arg and run through [Verify.check]. If a referenced text is unresolvable OR carries + * any NON-warning error (anything other than [Verify.ERR_REQUIRED_SLOT_UNUSED] — a + * `{{field}}` not on the payload VO produces `ERR_VAR_NOT_ON_PAYLOAD`), this THROWS a + * [GeneratorException] and FAILS codegen, naming the template, the ref, the error + * code, and the offending field. The drift message style matches the other ports: + * `render-helper drift: template "" ref "" — : {{}} not on payload VO`. + * + * The same payload [PayloadField] tree drives the build-time gate AND is baked into + * the emitted `RenderRequest.verify` argument so the engine's runtime drift check + * matches the gate that ran when the file was generated. + * + * `` is the `@payloadRef` value object's own data class, emitted by + * [KotlinEntityGenerator] in the value object's package (ADR-0056) and referenced fully + * qualified. **Requires [KotlinEntityGenerator] in the same run.** `RenderRequest.payload` is + * `Object`, so the typed payload binds directly. + * + * Skips (same contract as the other output generators): missing `@payloadRef`, or a + * `@payloadRef` that doesn't resolve to an `object.value`. + * + * Args: + * - `outputDir` (required): output directory root. + * - `templateRoot` (required): on-disk template dir the build-time drift gate + * resolves each referenced mustache against. + */ +open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase() { + + override fun getFilterClass(): Class = MetaObject::class.java + + override fun execute(loader: MetaDataLoader) { + parseArgs() + val outRoot = Paths.get(outDir.absolutePath) + + val templateRoot = getArg(ARG_TEMPLATE_ROOT, false) + if (templateRoot.isNullOrEmpty()) { + throw GeneratorException( + "KotlinRenderHelperGenerator requires the '$ARG_TEMPLATE_ROOT' arg " + + "(the on-disk template dir) for the build-time drift gate" + ) + } + val provider = FilesystemProvider(Paths.get(templateRoot)) + + // Stable name order — matches the other ports' deterministic emission. + // ADR-0039: root-scan discipline — resolving children accessor. + // + // BOTH renderable subtypes: ADR-0052 makes the template axis DIRECTION, and a + // PromptTemplate renders an outbound body exactly as an OutputTemplate does — + // only what comes back differs, and that inbound tier belongs to the parser and + // output-prompt generators. Collecting OutputTemplate alone left a + // template.prompt with no generated render helper on this port at all. + val renderable: List = + (loader.root.getChildren(OutputTemplate::class.java, true).toList() + + loader.root.getChildren(PromptTemplate::class.java, true).toList()) + .sortedBy { it.name } + + for (tmpl in renderable) { + emit(tmpl, loader, outRoot, provider) + } + } + + protected open fun emit(template: MetaTemplate, loader: MetaDataLoader, outRoot: Path, + provider: FilesystemProvider) { + val payloadRef = template.payloadRef + if (payloadRef.isNullOrEmpty()) { + return // loader validation normally catches this first + } + // ADR-0042 — resolve @payloadRef under the loader's package-local contract (#228). + val payloadVo = KotlinGenUtil.resolveValueObjectRef(loader, payloadRef, template.getPackage()) + ?: return // not a VO + + val (templatePkg, templateShort) = PackageMapping.splitFqn(template.name) + val outPkg = KotlinNaming.promptsPackage(templatePkg) + val helperClass = KotlinNaming.renderHelperName(templateShort) + // ADR-0056 — the payload IS the @payloadRef value object's own data class, the one + // KotlinEntityGenerator emits in the value object's package. Referenced fully qualified; + // this generator declares no payload type of its own. + KotlinNaming.requireReferenceable(outPkg, payloadVo, "template '${template.name}'") + val payloadClass = KotlinNaming.strictRef(payloadVo) + + // Payload field tree — reused by the build-time gate AND baked into the emitted + // RenderRequest.verify so the runtime check matches the gate. + val fields = derivePayloadFieldTree(loader, payloadVo, LinkedHashSet()) + val fieldTreeLiteral = fieldTreeLiteral(fields) + + val isEmail = TemplateConstants.KIND_EMAIL.equals(kindOf(template), ignoreCase = true) + + val body = StringBuilder() + if (isEmail) { + val subjectRef = attr(template, TemplateConstants.ATTR_SUBJECT_REF) + ?: throw GeneratorException( + "template.output \"${template.name}\" (email) missing @subjectRef") + val htmlBodyRef = attr(template, TemplateConstants.ATTR_HTML_BODY_REF) + ?: throw GeneratorException( + "template.output \"${template.name}\" (email) missing @htmlBodyRef") + val textBodyRef = attr(template, TemplateConstants.ATTR_TEXT_BODY_REF) + + // BUILD-TIME drift gate — every email part-ref is resolved + verified. + gateRef(template.name, subjectRef, provider, fields) + gateRef(template.name, htmlBodyRef, provider, fields) + if (textBodyRef != null) gateRef(template.name, textBodyRef, provider, fields) + + body.append(" fun render(payload: ").append(payloadClass) + .append(", provider: com.metaobjects.render.Provider): com.metaobjects.render.EmailDocument {\n") + body.append(" val r = com.metaobjects.render.Renderer()\n") + body.append(" return com.metaobjects.render.EmailDocument(\n") + body.append(" r.render(com.metaobjects.render.RenderRequest(\n") + .append(" null, ").append(quote(subjectRef)) + .append(", payload, provider, \"text\",\n") + .append(" ").append(fieldTreeLiteral).append(", null)),\n") + body.append(" r.render(com.metaobjects.render.RenderRequest(\n") + .append(" null, ").append(quote(htmlBodyRef)) + .append(", payload, provider, \"html\",\n") + .append(" ").append(fieldTreeLiteral).append(", null)),\n") + if (textBodyRef != null) { + body.append(" r.render(com.metaobjects.render.RenderRequest(\n") + .append(" null, ").append(quote(textBodyRef)) + .append(", payload, provider, \"text\",\n") + .append(" ").append(fieldTreeLiteral).append(", null)))\n") + } else { + body.append(" null)\n") + } + body.append(" }\n") + } else { + val textRef = attr(template, TemplateConstants.ATTR_TEXT_REF) + ?: throw GeneratorException( + "template.${template.subType} \"${template.name}\" missing @textRef") + val format = template.format + val maxChars = template.maxChars + + // BUILD-TIME drift gate. + gateRef(template.name, textRef, provider, fields) + + body.append(" fun render(payload: ").append(payloadClass) + .append(", provider: com.metaobjects.render.Provider): String =\n") + body.append(" com.metaobjects.render.Renderer().render(com.metaobjects.render.RenderRequest(\n") + body.append(" null, ").append(quote(textRef)).append(", payload, provider, ") + .append(quote(format)).append(",\n") + body.append(" ").append(fieldTreeLiteral).append(", ") + .append(maxChars?.toString() ?: "null").append("))\n") + } + + val src = buildString { + append("// GENERATED — DO NOT EDIT — render helper for template.") + append(template.subType) + append(" `") + append(template.name) + append("`\n") + append(KotlinNaming.packageHeader(outPkg)) + append("/** Typed render helper for the `") + append(templateShort) + append("` template.") + append(template.subType) + append(". Wraps the JVM render() engine; the payload field tree is\n") + append(" * baked into the RenderRequest so render()'s runtime drift check matches the\n") + append(" * build-time gate enforced when this file was generated. */\n") + append("object ") + append(helperClass) + append(" {\n") + append(body) + append("}\n") + } + + val outFile = outRoot.resolve(outPkg.replace('.', '/')).resolve("$helperClass.kt") + GeneratedFileWriter.write(outFile, src) + } + + // ------------------------------------------------------------------------- + // Build-time drift gate + // ------------------------------------------------------------------------- + + /** + * Run the build-time drift gate for one referenced mustache. Throws a + * [GeneratorException] (fails codegen) when the ref is unresolvable OR + * [Verify.check] reports a non-warning error. Warnings + * ([Verify.ERR_REQUIRED_SLOT_UNUSED]) are tolerated. Matches the cross-port + * drift message style exactly. + */ + private fun gateRef(templateName: String, ref: String, + provider: FilesystemProvider, fields: List) { + val text = provider.resolve(ref) + ?: throw GeneratorException( + "render-helper drift: template \"$templateName\" ref \"$ref\" " + + "— unresolved (provider returned no text)") + val opts = VerifyOptions(provider, null, null) + for (e in Verify.check(text, fields, opts)) { + if (Verify.ERR_REQUIRED_SLOT_UNUSED == e.code()) continue // warning + throw GeneratorException( + "render-helper drift: template \"$templateName\" ref \"$ref\" " + + "— ${e.code()}: {{${e.path()}}} not on payload VO") + } + } + + // ------------------------------------------------------------------------- + // Payload field-tree walk (mirrors SpringRenderHelperGenerator.derivePayloadFieldTree + // + the TS derivePayloadFieldTree). Object-ref fields recurse by BARE short-name; + // a `seen` set guards reference cycles. Recurse only into object.value (SUBTYPE_VALUE). + // ------------------------------------------------------------------------- + + private fun derivePayloadFieldTree( + loader: MetaDataLoader, vo: MetaObject, seen: MutableSet + ): List { + if (seen.contains(vo.name)) return emptyList() + seen.add(vo.name) + val fields = ArrayList() + for (field in vo.metaFields) { + if (field is ObjectField) { + // Resolve the nested @objectRef by BARE short-name — the cross-port + // render-helper consensus. Do NOT use the package-FOLDING resolver here; + // the render-helper field-tree must resolve identically across all five ports. + val target = resolveNestedObjectRef(loader, field) + if (target != null && MetaObject.SUBTYPE_VALUE == target.subType) { + val children = derivePayloadFieldTree(loader, target, LinkedHashSet(seen)) + fields.add(PayloadField.`object`(field.name, children)) + continue + } + } + fields.add(PayloadField.scalar(field.name)) + } + return fields + } + + /** + * Resolve a `field.object`'s `@objectRef` to its target `object.value` under the + * ADR-0042 package-local contract — mirroring the TS render-helper's + * `resolveObjectRef(root, ref, declaringFieldPkg)`: + * - an **FQN** `@objectRef` (contains `::`) resolves EXACTLY on the value-object's + * package-qualified name — never a bare-tail fallback binding a same-named + * `object.value` in the WRONG package; + * - a **bare** `@objectRef` resolves in the DECLARING field's package + * (`::` — correct for a `field.object` inherited via extends across + * packages), else a root-level `object.value`. Package-local BEFORE root-level; + * no cross-package short-name fallback. + * Returns null when the field has no `@objectRef` or nothing resolves. + */ + private fun resolveNestedObjectRef(loader: MetaDataLoader, field: ObjectField): MetaObject? { + // ADR-0039: @objectRef is an inheritable effective field property — RESOLVE + // (includeParentData=true) so a field.object inheriting @objectRef via extends + // still resolves its target VO. + if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF, true)) return null + val ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF, true).valueAsString + if (ref.isNullOrEmpty()) return null + val fqn = ref.contains("::") + // The DECLARING field's package (walks parents; for an inherited field.object it is + // the package where the field is DECLARED, not where it is used). + val referrerPkg = com.metaobjects.util.MetaDataUtil.findPackageForMetaData(field) ?: "" + val localKey = if (fqn || referrerPkg.isEmpty()) ref else "$referrerPkg::$ref" + var own: MetaObject? = null + var rootLevel: MetaObject? = null + for (obj in loader.metaObjects) { + if (MetaObject.SUBTYPE_VALUE != obj.subType) continue + if (obj.name == localKey) own = obj + if (!fqn && obj.name == ref) rootLevel = obj + } + return if (fqn) own else own ?: rootLevel + } + + /** + * Emit a `List` as deterministic Kotlin construction code + * (`java.util.List.of(...)`): `PayloadField.scalar("name")` and + * `PayloadField.object("nested", List.of(...))`. An empty tree emits + * `java.util.List.of()`. `java.util.List.of` matches what `RenderRequest.verify` + * (a `List`) expects and mirrors the Java render-helper's literal. + */ + private fun fieldTreeLiteral(fields: List): String { + if (fields.isEmpty()) return "java.util.List.of()" + val sb = StringBuilder("java.util.List.of(") + for ((i, f) in fields.withIndex()) { + if (i > 0) sb.append(", ") + if (f.fields() != null) { + sb.append("com.metaobjects.render.PayloadField.`object`(") + .append(quote(f.name())).append(", ") + .append(fieldTreeLiteral(f.fields())).append(")") + } else { + sb.append("com.metaobjects.render.PayloadField.scalar(") + .append(quote(f.name())).append(")") + } + } + sb.append(")") + return sb.toString() + } + + // ------------------------------------------------------------------------- + // Local helpers + // ------------------------------------------------------------------------- + + // ADR-0039: template.* attrs RESOLVE through extends (includeParentData=true, the + // default) — a template is a registered type that can be an `extends` target, so its + // refs inherit. Matches the TS reference + C#. + + /** Resolve `@kind` (RESOLVING attr — ADR-0039), defaulting to `document`. */ + private fun kindOf(template: MetaTemplate): String { + if (template is OutputTemplate && template.hasMetaAttr(TemplateConstants.ATTR_KIND)) { + val v = template.getMetaAttr(TemplateConstants.ATTR_KIND).valueAsString + if (!v.isNullOrEmpty()) return v + } + return TemplateConstants.KIND_DEFAULT + } + + /** Read a template attr value (RESOLVING — ADR-0039), or null if absent. */ + private fun attr(template: MetaTemplate, attr: String): String? = + if (template.hasMetaAttr(attr)) + template.getMetaAttr(attr).valueAsString + else null + + /** Kotlin string-literal quoting with the common escapes. */ + private fun quote(s: String?): String { + if (s == null) return "null" + val sb = StringBuilder(s.length + 2) + sb.append('"') + for (c in s) { + when (c) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '$' -> sb.append("\\$") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> sb.append(c) + } + } + sb.append('"') + return sb.toString() + } + + + // === MultiFileDirectGeneratorBase abstract-method stubs ==================== + override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ } + override fun ?> getSingleWriter( + loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter? + ): T? = null + override fun ?> getFinalWriter( + loader: MetaDataLoader?, out: OutputStream? + ): T? = null + override fun writeFinalFile(metadata: MutableCollection?, writer: GeneratorIOWriter<*>?) { /* none */ } + override fun getSingleOutputFilePath(md: MetaObject): String = "" + override fun getSingleOutputFilename(md: MetaObject): String = + "${KotlinNaming.renderHelperName(PackageMapping.splitFqn(md.name).second)}.kt" + + companion object { + /** Arg key for the on-disk template dir used by the build-time drift gate. */ + const val ARG_TEMPLATE_ROOT: String = "templateRoot" + } +} diff --git a/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinSpringControllerGenerator.kt.txt b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinSpringControllerGenerator.kt.txt new file mode 100644 index 000000000..c207c43b6 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinSpringControllerGenerator.kt.txt @@ -0,0 +1,2248 @@ +package com.metaobjects.generator.kotlin + +// Eject note (ADR-0034, JVM eject design): these siblings stay in +// com.metaobjects.generator.kotlin when this generator is copied out via +// `mvn metaobjects:eject` and its own package is renamed — an explicit import, not +// same-package bare-name resolution, is what keeps the ejected copy compiling. +import com.metaobjects.generator.kotlin.ExposedApi +import com.metaobjects.generator.kotlin.ExposedImports +import com.metaobjects.generator.kotlin.KotlinGenUtil +import com.metaobjects.generator.kotlin.KotlinM2mSupport +import com.metaobjects.generator.kotlin.KotlinNaming +import com.metaobjects.generator.kotlin.KotlinTphPlan +import com.metaobjects.generator.kotlin.KotlinTypeMapper +import com.metaobjects.generator.kotlin.PackageMapping + +import com.metaobjects.field.BooleanField +import com.metaobjects.field.CurrencyField +import com.metaobjects.field.DateField +import com.metaobjects.field.DecimalField +import com.metaobjects.field.DoubleField +import com.metaobjects.field.EnumField +import com.metaobjects.field.FloatField +import com.metaobjects.field.IntegerField +import com.metaobjects.field.LongField +import com.metaobjects.field.MapField +import com.metaobjects.field.MetaField +import com.metaobjects.field.ObjectField +import com.metaobjects.field.StringField +import com.metaobjects.field.TimeField +import com.metaobjects.field.TimestampField +import com.metaobjects.field.UuidField +import com.metaobjects.generator.GeneratorIOWriter +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase +import com.metaobjects.identity.MetaIdentity +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject +import com.metaobjects.query.FilterOps +import com.metaobjects.source.MetaSource +import com.metaobjects.source.RdbSource +import java.io.OutputStream +import java.io.PrintWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import org.slf4j.LoggerFactory +import com.metaobjects.generator.util.RestSurfaceGate +import com.metaobjects.generator.util.GeneratedFileWriter + +/** + * Generator: one Spring `@RestController` Kotlin file per `object.entity` that has a + * `source.rdb` child with `@kind="table"` (writable; the default). View and + * materializedView kinds are skipped (read-only — would need a separate read-only + * controller); storedProc kind is skipped (already covered by + * [KotlinStoredProcGenerator]); tableFunction is skipped with a warning. + * + *

Conforms to the cross-port REST API contract + * (see {@code docs/features/api-contract.md}): + *

    + *
  • Routes: {@code /api/} (e.g. + * {@code /api/authors}, {@code /api/post_categories} — the one cross-port + * collection-URL rule, via [KotlinNaming.controllerPath]).
  • + *
  • 5 CRUD verbs: GET list, GET by id, POST create, PATCH+PUT update, DELETE.
  • + *
  • {@code ?withCount=1} switches list response to {@code { rows, total }}.
  • + *
  • {@code ?sort=field:asc|desc} parsed via a static per-entity allowlist (HTTP 400 + * on unknown field).
  • + *
  • {@code ?limit=N&offset=N} pagination with defaults (limit=50, offset=0).
  • + *
  • HTTP 404 envelope: {@code { "error": "not_found" }}.
  • + *
  • HTTP 400 envelope: {@code { "error": "invalid_" }}.
  • + *
+ * + *

FR-009 filter operators ({@code eq / ne / gt / gte / lt / lte / in / + * like / isNull}) ship via the {@code FilterAllowlist} constant + * emitted by {@link KotlinFilterAllowlistGenerator}. The controller emits an + * inline {@code parseFilter} + {@code WhereOp} helper pair — + * the former parses the bracketed-qs grammar against the allowlist, the + * latter translates each predicate into an Exposed {@code Op} + * bound against the generated {@code Table}. Multiple predicates + * AND together; error envelopes are the cross-port + * {@code invalid_filter_field / invalid_filter_op / invalid_filter_value}. + * + *

The generated controller delegates to the {@code Table} Exposed object + * emitted by {@link KotlinExposedTableGenerator}, wrapped in {@code transaction { }} + * blocks. The consumer's Spring {@code @Configuration} (from + * {@link KotlinSpringConfigGenerator}) wires {@code Database.connect()}; the controller + * does not own a {@code DataSource} itself. + * + *

Substrate justification (hand-rolled string builder rather than KotlinPoet): same + * trade-off as {@link KotlinExposedTableGenerator}. Spring annotations + the Exposed DSL + * (e.g. {@code AuthorTable.selectAll().where { AuthorTable.id eq id }}) don't translate + * cleanly to KotlinPoet's {@code PropertySpec}/{@code FunSpec} APIs without a verbose + * dance of {@code %T} placeholders; the syntactic surface is small (~80 lines per file) + * and matches the idiomatic Kotlin/Spring style. + * + *

Args: + *

    + *
  • {@code outputDir} (required): output directory root.
  • + *
+ */ +open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase() { + + /** + * Exposed output-API version (issue #390) — `0` (default) emits Exposed 0.x + * (`org.jetbrains.exposed.sql.*`, byte-identical to every release before this arg + * existed); `1` emits Exposed 1.x (`org.jetbrains.exposed.v1.*`). From the + * `exposedApi` generator arg (`1` in the pom). + */ + protected fun exposedApi(): ExposedApi = ExposedApi.parse(getArg(ExposedApi.ARG_EXPOSED_API)) + + override fun getFilterClass(): Class = MetaObject::class.java + + override fun execute(loader: MetaDataLoader) { + parseArgs() + val outRoot = Paths.get(outDir.absolutePath) + + for (declared in loader.metaObjects) { + // FR-044: a served report is emitted from its read model, the same object the + // filter-allowlist, entity and table generators emit from, so the four files name + // the same fields. Any other report maps to null and gets no controller. + val entity = RestSurfaceGate.restShapeOf(declared) ?: continue + // Abstract entities are inheritance scaffolding — never emit a CRUD controller. + if (KotlinGenUtil.isAbstractEntity(entity)) continue + // FR-017 TPH: a subtype is folded into its base's single table + base controller (it + // also carries no own source.rdb, so the guard below would skip it too). + if (KotlinTphPlan.isTphSubtype(entity)) continue + // ADR-0039: resolving source lookup (inherited source.rdb via extends). + val sourceRdb = KotlinGenUtil.firstRdbSource(entity) ?: continue + val kind = sourceRdb.effectiveKind + // THE shared gate (codegen-base RestSurfaceGate), the same CALL + // KotlinFilterAllowlistGenerator makes — the generated controller references + // FilterAllowlist by name, so two predicates that merely agree today are + // a compile failure waiting for one of them to move. It admits a writable table + // entity, a write-through entity (detected order-independently, NEVER + // firstRdbSource — the pre-#214 gate used that and skipped a view-source-first + // one), and since F22 a view-kind object.projection. + if (!RestSurfaceGate.emitsRestSurface(entity)) { + when (kind) { + // A read-only kind reaching here is a projection the gate declined (a + // proc-kind one) or an ENTITY over a view — ADR-0028 forbids the latter, + // but the message stays accurate either way. + MetaSource.KIND_VIEW, MetaSource.KIND_MATERIALIZED_VIEW -> { + LOG.debug( + "skipping controller for {} — source.rdb @kind='{}' is read-only and this is not a projection", + entity.name, kind + ) + } + MetaSource.KIND_STORED_PROC -> { + LOG.debug( + "skipping controller for {} — source.rdb @kind='storedProc' is handled by KotlinStoredProcGenerator", + entity.name + ) + } + else -> { + LOG.warn( + "skipping controller for {} — source.rdb @kind='{}' has no controller generator yet", + entity.name, kind + ) + } + } + continue + } + // F22 — a view-only projection gets a READ-ONLY controller: reads served, every + // write verb answering the cross-port 405 envelope. + if (RestSurfaceGate.isReadOnly(entity)) { + emitReadOnly(entity, outRoot, loader) + continue + } + // FR-017 TPH: a discriminator base emits ONE controller mounting the polymorphic + // collection routes plus a full per-subtype CRUD set scoped by the discriminator. + val tph = KotlinTphPlan.planFor(entity, loader) + if (tph != null) emitTph(entity, tph, outRoot, loader) else emit(entity, outRoot, loader) + } + } + + protected open fun emit(entity: MetaObject, outRoot: Path, loader: MetaDataLoader) { + val api = exposedApi() + val (pkg, shortName) = PackageMapping.splitFqn(entity.name) + val tableObjectName = KotlinNaming.tableObjectName(shortName) + // #214 FR-024 §7: writes (create / update / delete) target the write `Table`; reads + // (rowTo, list, get, the filter + sort surface, and the create/update re-reads) route to the + // `View` for a write-through entity so the derived fields come back. For a vanilla + // entity readObj == tableObjectName, so the emitted controller is byte-identical. + val writeThrough = entity.isWriteThrough + val readObj = if (writeThrough) KotlinNaming.viewObjectName(shortName) else tableObjectName + val routeBase = KotlinNaming.controllerPath(shortName) + + // Primary key: single-field PKs only for v1. Composite PKs are uncommon for HTTP + // CRUD (you'd need a URL grammar for composite ids — out of scope; the C# port + // degrades to collection-GET-only there). The by-id route signatures bind the PK + // FIELD's OWN Kotlin type (uuid → UUID, long → Long, int → Int, string → String) + // — a hard-coded Long against a uuid-PK entity's Exposed Column does not + // compile. Fallback when no single-field PK resolves: "id" : Long (the + // historical default). + // ADR-0039: identities are inheritable — an entity may inherit its primary + // identity from a BaseEntity via extends. RESOLVE via getIdentities(true); + // entity.children (own-only) would miss it. + val primary = entity.getIdentities(true) + .filterIsInstance() + .firstOrNull { it.isPrimary } + val pkFieldName = primary?.let { KotlinGenUtil.keyFields(entity, it).firstOrNull() } ?: DEFAULT_PK_FIELD + val pkParamType = primaryKeyParamType(entity, pkFieldName) + + // FR-035: the PATCH-settable columns = scalar + value-object fields minus the PK. Program D: + // a field.object value-object jsonb column IS settable — bound via Jackson treeToValue into + // the generated VO record / List and validated (spec §0). Still EXCLUDED: MapField + // (dict-of-VO, staged out) and the `field.string @dbColumnType=jsonb` open bag (its + // in-process type is a kotlinx JsonElement the raw-JsonNode patch path can't bind — a + // create-only column, see KNOWN_GAPS). + // When this is EMPTY (a PK + only map/open-bag columns) the controller emits no ObjectMapper + // ctor param / TypeReference import (they'd be unused → allWarningsAsErrors). + val patchSettableFields = entity.metaFields.filter { + it !is MapField && it.name != pkFieldName && + !KotlinTypeMapper.isJsonbOpenBag(it) && + // Program D: only a jsonb value-object column is settable. A flattened object + // field (@storage:flattened) is materialised by the Exposed table as per-subfield + // columns — there is no single `Table.` to bind — so it stays excluded. + (it !is ObjectField || isJsonbObjectColumn(it)) && + // #214: a DERIVED (origin.*) field on a write-through entity has no column on the + // write table — it is computed by the read view — so it is never a write/patch target. + !(writeThrough && KotlinGenUtil.isDerivedField(it)) && + // #203/ADR-0045: an @autoSet column is server-owned — the generated controller + // stamps it (the API surface owns the write semantic); a caller cannot set it via PATCH. + !KotlinGenUtil.isAutoSetField(it) && + // FR-037 R1: both non-readWrite @mutability modes leave the PATCH settable set — + // `readOnly` is never written at all, `writeOnce` is frozen after create. A value + // presented for one is STRIPPED (it is simply not in the settable set), not 400'd: + // that is the uniform behaviour of every excluded key on this path, and the + // generated edit form submits every registered field. + !KotlinGenUtil.isReadOnlyMutability(it) && + !KotlinGenUtil.isWriteOnceMutability(it) + } + val hasPatchFields = patchSettableFields.isNotEmpty() + + // #203/ADR-0045: @autoSet columns are stamped by the generated controller, not bound from + // the caller. insert stamps BOTH onCreate+onUpdate (a fresh row's updatedAt == createdAt — + // captured from ONE now() per temporal type so they are exactly equal); a patch re-stamps + // onUpdate on EVERY write (even an empty body) and never rewrites onCreate. An @autoSet + // field is a real write-table column (never derived), so only the PK / write-through-derived + // guards apply. + val insertAutoSetFields = entity.metaFields.filter { + KotlinGenUtil.isAutoSetField(it) && it.name != pkFieldName && + !(writeThrough && KotlinGenUtil.isDerivedField(it)) + } + val onUpdateAutoSetFields = insertAutoSetFields.filter { + KotlinGenUtil.autoSetPolicy(it) == KotlinGenUtil.AUTO_SET_ON_UPDATE + } + // One captured now()-val per distinct temporal type, so all same-type @autoSet columns in a + // single insert receive the identical value (createdAt == updatedAt exactly). + val insertNowVal = LinkedHashMap() // nowExpr → local val name + insertAutoSetFields.forEach { f -> + insertNowVal.getOrPut(KotlinTypeMapper.nowExpr(f)) { "autoSetNow${insertNowVal.size}" } + } + + // Sort allowlist: every scalar field is sortable. Skip ObjectField (no SQL column + // surface on the Exposed Table; @storage controls a separate column shape) and the + // `field.string @dbColumnType=jsonb` open bag — a JSONB value is not a scalar sort + // target (its in-process type is a kotlinx JsonElement, not a comparable scalar). + val sortFields = entity.metaFields + .filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) } + .map { it.name } + + // Per-field dispatch map (filter dispatch); only used inside the generated + // file. Each entry carries the field name, its subtype (drives the dispatch arm + // shape + value-coercion path) and the Exposed column's element Kotlin type + // (drives the eq/ne/in value cast — Exposed's typed `Column.eq` rejects a bare + // `Any?`, so each predicate value is cast to the column's element type). + // The `field.string @dbColumnType=jsonb` open bag is excluded: it is not @filterable + // (so the FilterAllowlist omits it) AND its column element type is a kotlinx + // JsonElement — emitting a filter-dispatch arm (`p.value as JsonElement`) would + // reference an un-imported type and produce a controller that does not compile. + val scalarFields: List = entity.metaFields + .filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) } + .map { ScalarFieldSpec(it.name, it.subType, columnElementType(it), intBackedEnumType(it, entity)) } + + val allowlistName = "${shortName}FilterAllowlist" + // FR-018: M:N navs declared on this entity (derived junction FK fields via the + // cross-port SSOT). Drives the GET /{id}/ traversal sub-resources. + val m2mNavs = KotlinM2mSupport.resolve(entity, loader) + val source = buildString { + if (pkg.isNotEmpty()) { + append("package $pkg\n\n") + } + append("import ${ExposedImports.core(api, "Op")}\n") + append("import ${ExposedImports.core(api, "SortOrder")}\n") + append("import ${ExposedImports.core(api, "ResultRow")}\n") + // issue #390 (review fix): under 1.x, SqlExpressionBuilder is gone — every operator + // the filter-pipeline's WhereOp (and delete's bare `eq`) got for free through + // `with(SqlExpressionBuilder) { ... }` is now a plain top-level import, scoped to + // exactly what this entity's scalarFields will make emitFilterPipeline emit — see + // v1FilterOperatorImports. + if (api == ExposedApi.V1) { + for (op in v1FilterOperatorImports(scalarFields)) append("import ${ExposedImports.core(api, op)}\n") + } else { + append("import org.jetbrains.exposed.sql.SqlExpressionBuilder\n") + } + append("import ${ExposedImports.core(api, "and")}\n") + append("import ${ExposedImports.jdbc(api, "deleteWhere")}\n") + append("import ${ExposedImports.jdbc(api, "insert")}\n") + append("import ${ExposedImports.jdbc(api, "selectAll")}\n") + append("import ${ExposedImports.jdbc(api, "update")}\n") + append("import ${ExposedImports.transactions(api, "transaction")}\n") + append("import com.fasterxml.jackson.databind.JsonNode\n") + // ObjectMapper + TypeReference are used ONLY by the per-field patch bind, which is + // emitted only when there ARE settable fields — omit them otherwise (unused import / + // ctor param would fail the module's allWarningsAsErrors compile). + if (hasPatchFields) { + append("import com.fasterxml.jackson.core.type.TypeReference\n") + append("import com.fasterxml.jackson.databind.ObjectMapper\n") + } + // FR-036: the controller enforces the entity's field constraints over HTTP through a + // jakarta Validator (POST body + present PATCH values) — always injected in this + // writable-table path (a read-only projection controller never reaches emit()). + append("import jakarta.validation.Validator\n") + append("import org.springframework.http.HttpStatus\n") + append("import org.springframework.http.ResponseEntity\n") + append("import org.springframework.web.bind.annotation.DeleteMapping\n") + append("import org.springframework.web.bind.annotation.ExceptionHandler\n") + append("import org.springframework.web.bind.annotation.GetMapping\n") + append("import org.springframework.web.bind.annotation.PathVariable\n") + append("import org.springframework.web.bind.annotation.PostMapping\n") + append("import org.springframework.web.bind.annotation.RequestBody\n") + append("import org.springframework.web.bind.annotation.RequestMapping\n") + append("import org.springframework.web.bind.annotation.RequestMethod\n") + append("import org.springframework.web.bind.annotation.RequestParam\n") + append("import org.springframework.web.bind.annotation.RestController\n") + append("import jakarta.servlet.http.HttpServletRequest\n") + append("import java.io.ByteArrayOutputStream\n") + append("import java.nio.charset.StandardCharsets\n") + append("import java.sql.Timestamp\n") + // ADR-0036 Wave 2: a default field.timestamp column maps to java.time.Instant + // (the WHERE-arm casts `p.value as Instant`); import it only when such a column + // is present so entities without one stay byte-identical. + if (scalarFields.any { it.elementType == "Instant" }) { + append("import java.time.Instant\n") + } + // A uuid PK / uuid scalar column surfaces java.util.UUID in the by-id route + // signatures and the filter-dispatch casts; import it only when present so + // uuid-free entities stay byte-identical. + if (pkParamType == "UUID" || scalarFields.any { it.elementType == "UUID" }) { + append("import java.util.UUID\n") + } + // Pre-existing gap found while adding issue #390's controller-tier compile pass + // (review fix #2): a filterable field.decimal's WHERE-arm casts `p.value as + // BigDecimal` (see emitPerFieldDispatchArm) with no import anywhere in this file — + // "Unresolved reference: BigDecimal" on BOTH exposedApi values (this generator never + // excludes DecimalField from scalarFields the way emitTph's filterSpecs does; no + // existing test compiled this entity+controller combination to catch it). Fixed for + // both: nothing depends on broken, never-compiled output, so there is no + // byte-identical contract to preserve here. Same conditional-import shape as + // Instant/UUID above. + if (scalarFields.any { it.elementType == "BigDecimal" }) { + append("import java.math.BigDecimal\n") + } + // Same pre-existing gap, same fix, for a filterable field.uri / field.inet (a + // @lenient one degrades to String — see KotlinTypeMapper.lenientNetField — and + // needs neither import, which is exactly why this checks elementType, not subType). + if (scalarFields.any { it.elementType == "URI" }) { + append("import java.net.URI\n") + } + if (scalarFields.any { it.elementType == "InetAddress" }) { + append("import java.net.InetAddress\n") + } + // FR-009 (#179): a filterable enum column is compared as its stored string via + // `col.castTo(TextColumnType())`; import both only when such a column exists + // so entities without a filterable enum stay byte-identical. + if (scalarFields.any { needsStringCastDispatch(it.subType, it.elementType, it.intBackedEnumType) }) { + append("import ${ExposedImports.core(api, "TextColumnType")}\n") + append("import ${ExposedImports.core(api, "castTo")}\n") + } + append("import java.time.LocalDate\n") + append("import java.time.LocalDateTime\n") + append("import java.time.LocalTime\n") + append("import java.time.format.DateTimeFormatter\n") + append("\n") + appendSortSurface(this, shortName, entity, sortFields) + + // FR-009 filter pipeline. Three stages: + // 1. data class ${shortName}FilterPredicate — parsed + validated one + // filter[][]= entry (value already coerced to a Kotlin type). + // 2. parse${shortName}Filter — walks the raw query string (decoded per + // component, tolerating a malformed escape), matching the grammar against + // ${allowlistName}.FIELDS + .OPS_BY_FIELD. Returns either predicates or + // one of the cross-port error envelope keys. + // 3. ${shortName}WhereOp — converts the predicate list into an Exposed + // Op bound against ${tableObjectName}'s columns. Multiple + // predicates AND together. Per-field arms know each column's Kotlin + // type at compile time (no reflection / runtime cast surprises). + // #214: the filter + sort surface reads through the READ object (the view for a + // write-through entity), so its per-field Exposed dispatch binds against `readObj`. + emitFilterPipeline(this, shortName, readObj, allowlistName, scalarFields) + + appendRowToMapper(this, shortName, entity, readObj) + + append("/**\n") + append(" * GENERATED — REST controller for ${shortName} entity. Implements the cross-port API contract.\n") + append(" *\n") + // No `/**` glob in the example: Kotlin NESTS block comments, so a `/*` inside this + // KDoc would open a comment that never closes and the file would not compile. + append(" * Auth: these endpoints are unauthenticated. In your Spring Security config, require\n") + append(" * authentication for `$routeBase` and every path under it (`requestMatchers` + `authenticated()`).\n") + append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n") + append(" * config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.\n") + append(" */\n") + append("@RestController\n") + append("@RequestMapping(\"$routeBase\")\n") + // FR-036: `validator` is always injected (POST + present-PATCH-value enforcement). + // FR-035 present-key PATCH: the update handler binds the RAW JsonNode (not a + // @Valid data class, which cannot see absent-vs-null) and per-field-binds present + // values via the Spring-configured ObjectMapper — injected only when there ARE + // settable fields (a PK + only object/jsonb columns needs no mapper). + if (hasPatchFields) { + append("class ${shortName}Controller(private val objectMapper: ObjectMapper, private val validator: Validator) {\n\n") + } else { + append("class ${shortName}Controller(private val validator: Validator) {\n\n") + } + + appendListHandler(this, shortName, readObj, sortFields) + + appendGetHandler(this, shortName, readObj, pkFieldName, pkParamType) + + // POST — create. FR-036: validate the bound body through the jakarta Validator and + // 400 with the cross-port envelope on any violation BEFORE the insert. (@Valid's + // default 400 body is NOT the cross-port {error:"validation"} shape and does not fire + // under MockMvc standaloneSetup — so the check is explicit here.) + append(" @PostMapping\n") + append(" fun create(@RequestBody dto: ${shortName}): ResponseEntity = transaction {\n") + append(" if (validator.validate(dto).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + // #203/ADR-0045: capture one now() per temporal type so createdAt == updatedAt exactly. + for ((expr, valName) in insertNowVal) append(" val $valName = $expr\n") + append(" val newId = ${tableObjectName}.insert {\n") + // A `@generation: uuid` key is minted HERE, not left to the column's database default: + // Exposed hands back `[Table.id]` only for a value it inserted or an autoIncrement + // column, so a DB-defaulted uuid key read back as "id is not in record set" on every + // driver that will not return it. Minting app-side is what the TS runtime does too. + if (uuidGeneratedPk(entity, pkFieldName)) append(" it[${pkFieldName}] = java.util.UUID.randomUUID()\n") + for (field in entity.metaFields) { + // Program D: a field.object value-object jsonb column IS written on create — the + // DTO property is the typed VO (record / List) and the Exposed Column codec + // encodes it to jsonb. MapField (dict-of-VO) stays skipped (staged out); a flattened + // object field (no single `Table.`) is skipped too. + if (field is MapField || (field is ObjectField && !isJsonbObjectColumn(field))) continue + // Skip the PK column on insert — the table's @generation=increment owns it. + // If the entity has no auto-incrementing PK the consumer can override the + // generated handler; this is the 95% case. + if (field.name == pkFieldName) continue + // #214: a DERIVED (origin.*) field on a write-through entity has no column on the + // write table (computed by the view), so it is never written on create. + if (writeThrough && KotlinGenUtil.isDerivedField(field)) continue + // #203/ADR-0045: insert stamps onCreate+onUpdate @autoSet columns from the captured + // now() (the caller's value is ignored — never bound from the dto). + if (KotlinGenUtil.isAutoSetField(field)) { + append(" it[${field.name}] = ${insertNowVal[KotlinTypeMapper.nowExpr(field)]}\n") + continue + } + // NOTE: a `field.string @dbColumnType=jsonb` open bag IS written here (create + // binds it from the @Valid DTO's kotlinx JsonElement property, gated by the + // jsonb-open-bag-roundtrip corpus). PATCH cannot (the raw-JsonNode path can't bind + // a JsonElement without surfacing the un-imported type the #179 guard forbids), so + // an open bag is a create-only column on the generated CRUD — see KNOWN_GAPS. + append(" it[${field.name}] = dto.${field.name}\n") + } + append(" }[${tableObjectName}.${pkFieldName}]\n") + // #214: re-read the persisted row through the READ object (the view for a write-through + // entity) by PK so the returned entity carries the derived fields (read-your-writes). + append(" val saved = ${readObj}.selectAll().where { ${readObj}.${pkFieldName} eq newId }.single()\n") + append(" ResponseEntity.status(HttpStatus.CREATED).body(rowTo${shortName}(saved) as Any)\n") + append(" }\n\n") + + // PATCH + PUT — single handler (per API contract; same body shape both verbs). + // Both verbs MUST be expressed on one @RequestMapping with method=[PATCH, PUT]. + // Stacking @PatchMapping + @PutMapping on the same method does NOT register both + // in Spring MVC — only one composed @RequestMapping per method is honored, so the + // other verb 405s. (Surfaced by the SP-F generated-controller HTTP lane.) + // FR-035 present-key tristate: bind the RAW JsonNode so absent-vs-null is visible. + // A non-object body, or an explicit null on a @required field, is a 400; an explicit + // null on a nullable field CLEARS it; an omitted field is untouched; a present value + // binds through the configured ObjectMapper (same codecs as create). An empty + // effective patch is a no-op read-back (avoids Exposed's empty-SET error). + // Settable columns are `patchSettableFields` (computed up top): scalar + value-object + // fields minus the PK, EXCLUDING map/jsonb-open-bag (an open bag is a create-only CRUD + // column — PATCH can't bind a kotlinx JsonElement; see KNOWN_GAPS). Program D: a present + // field.object VO binds via Jackson treeToValue and is validated in FULL (spec §0). + // When empty, no update{} block is emitted (an empty Exposed SET throws) — a no-op read-back. + append(" @RequestMapping(value = [\"/{id}\"], method = [RequestMethod.PATCH, RequestMethod.PUT])\n") + append(" fun update(@PathVariable id: $pkParamType, @RequestBody body: JsonNode): ResponseEntity = transaction {\n") + append(" if (!body.isObject) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + for (field in patchSettableFields) { + if (KotlinGenUtil.isRequiredField(field)) { + append(" if (body.has(\"${field.name}\") && body.get(\"${field.name}\").isNull) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } + } + val mustStampOnUpdate = onUpdateAutoSetFields.isNotEmpty() + if (hasPatchFields) { + val settableNamesList = patchSettableFields.joinToString(", ") { "\"${it.name}\"" } + // #203/ADR-0045: with onUpdate @autoSet columns the update must run on EVERY patch + // (to bump them) — no "any present" guard; otherwise the guard keeps the update out + // when the caller sent no settable field (byte-identical to the pre-#203 output). + if (!mustStampOnUpdate) append(" if (listOf($settableNamesList).any { body.has(it) }) {\n") + // A present value that cannot bind to its column's Kotlin type (e.g. a JSON object + // for a String column) throws from treeToValue — map that to 400, not a 500. Catch + // the specific Jackson JsonMappingException so no unrelated exception is swallowed. + append(" try {\n") + // FR-036: bind each PRESENT value into a typed local, then validate present NON-NULL + // values against the entity's field constraints (validator.validateValue) — a bind + // failure OR a constraint violation → 400 BEFORE any write. Binding happens here (not + // inside the Exposed update{} lambda) so `return@transaction` on a violation is legal + // (a qualified return cannot cross the non-inline update{} lambda). present-null on a + // @required field was already 400'd above; present-null on a nullable field clears it + // (no bind, no validation). + for (field in patchSettableFields) { + // The DATA-CLASS element type: a field.enum's materialized enum class; a + // field.object's referenced value-object record (Program D — bound via Jackson + // treeToValue into the VO / List); else the scalar Kotlin type — so the bound + // value matches the Exposed Column. A VO type is emitted fully-qualified so the + // controller needs no extra import (mirrors the Exposed column codec). + val elem: String = if (field is ObjectField) { + voElementTypeFqn(field, loader) + } else { + (KotlinTypeMapper.enumTypeName(field, entity) ?: KotlinTypeMapper.kotlinTypeName(field)).toString() + } + val ktType = if (field.isArrayType) "kotlin.collections.List<$elem>" else elem + val fn = field.name + val cap = capitalizeFirst(fn) + append(" val has$cap = body.has(\"$fn\")\n") + if (KotlinGenUtil.isRequiredField(field)) { + // required: present ⇒ non-null (present-null was 400'd above). + append(" val v$cap: $ktType? = if (has$cap) objectMapper.treeToValue(body.get(\"$fn\"), object : TypeReference<$ktType>() {}) else null\n") + append(" if (has$cap && validator.validateValue(${shortName}::class.java, \"$fn\", v$cap).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } else { + append(" val null$cap = has$cap && body.get(\"$fn\").isNull\n") + append(" val v$cap: $ktType? = if (has$cap && !null$cap) objectMapper.treeToValue(body.get(\"$fn\"), object : TypeReference<$ktType>() {}) else null\n") + append(" if (has$cap && !null$cap && validator.validateValue(${shortName}::class.java, \"$fn\", v$cap).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } + // Program D (spec §0): jakarta validateValue does NOT cascade @Valid into a nested + // value-object's constraints, so a present VO is validated EXPLICITLY — + // validator.validate(voElement) per element (single VO whole; array VO per element), + // 400 on any violation. (A present-null / cleared value is already handled above.) + if (field is ObjectField) { + if (field.isArrayType) { + append(" if (v$cap != null && v$cap.any { validator.validate(it).isNotEmpty() }) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } else { + append(" if (v$cap != null && validator.validate(v$cap).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } + } + } + // Apply the already-bound + validated values. Columns are qualified (`Table.col`) + // so a field named like the `body`/`id` params can't shadow them. + append(" ${tableObjectName}.update({ ${tableObjectName}.${pkFieldName} eq id }) {\n") + // #203/ADR-0045: bump every onUpdate @autoSet column first (server-owned); onCreate + // columns are never touched on update (createdAt is immutable). + for (field in onUpdateAutoSetFields) { + append(" it[${tableObjectName}.${field.name}] = ${KotlinTypeMapper.nowExpr(field)}\n") + } + for (field in patchSettableFields) { + val fn = field.name + val cap = capitalizeFirst(fn) + val col = "$tableObjectName.$fn" + if (KotlinGenUtil.isRequiredField(field)) { + append(" if (has$cap) it[$col] = v$cap!!\n") + } else { + append(" if (has$cap) { if (null$cap) it[$col] = null else it[$col] = v$cap }\n") + } + } + append(" }\n") + append(" } catch (e: com.fasterxml.jackson.databind.JsonMappingException) {\n") + append(" return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + append(" }\n") + // Close the "any present" guard only when it was opened (see mustStampOnUpdate above). + if (!mustStampOnUpdate) append(" }\n") + } else if (mustStampOnUpdate) { + // No caller-settable columns, but onUpdate @autoSet must still bump on every patch. + append(" ${tableObjectName}.update({ ${tableObjectName}.${pkFieldName} eq id }) {\n") + for (field in onUpdateAutoSetFields) { + append(" it[${tableObjectName}.${field.name}] = ${KotlinTypeMapper.nowExpr(field)}\n") + } + append(" }\n") + } + // #214: read the (possibly-updated) row back through the READ object so the response + // carries the derived fields; the write above targeted the write table. + append(" val row = ${readObj}.selectAll().where { ${readObj}.${pkFieldName} eq id }.singleOrNull()\n") + append(" if (row == null) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") + append(" else ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" }\n\n") + + // DELETE — Exposed's `deleteWhere` is an extension fn on Table; the import + // above pulls it in. 204 No Content on success, 404 envelope on miss. + // The `eq` op must be resolved through SqlExpressionBuilder: deleteWhere's + // lambda receiver does NOT bring the comparison ops into scope on its own + // (unlike `selectAll().where { }`), so a bare `Table.id eq id` is an + // "Unresolved reference: eq" compile error. (Surfaced by the SP-F + // generated-controller HTTP lane; mirrors the hand-rolled reference server.) + append(" @DeleteMapping(\"/{id}\")\n") + append(" fun delete(@PathVariable id: $pkParamType): ResponseEntity = transaction {\n") + if (api == ExposedApi.V1) { + // issue #390: `eq` is a plain top-level import under 1.x — deleteWhere's lambda + // takes the table as an ordinary (unused) parameter, not an implicit receiver, so + // the `with(SqlExpressionBuilder)` wrapper 0.x needed is both unavailable and + // unnecessary (bare top-level `eq` resolves regardless of lambda receiver). + append(" val deleted = ${tableObjectName}.deleteWhere { ${tableObjectName}.${pkFieldName} eq id }\n") + } else { + append(" val deleted = ${tableObjectName}.deleteWhere { with(SqlExpressionBuilder) { ${tableObjectName}.${pkFieldName} eq id } }\n") + } + append(" if (deleted == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") + append(" else ResponseEntity.noContent().build()\n") + append(" }\n") + + // FR-018 M:N traversal — GET /{id}/ exposes each + // @cardinality:"many" + @through relationship as a sub-resource of the source, + // returning the related target rows. The source URL segment is the entity name + // pluralized (handled by @RequestMapping above; getShortName() drives the + // segment, NOT the physical @table); the relation segment is the relationship + // name. Related-row order is not contractual. The traversal delegates to the + // Table.Query(id) Exposed join helper emitted by + // KotlinRelationsGenerator (hetero / directed self-join / symmetric union-on-read, + // junction FK fields derived via the cross-port M2MFields SSOT). + for (nav in m2mNavs) { + emitM2mEndpoint(this, pkg, shortName, nav, pkParamType) + } + + appendConstraintHandler() + append("}\n") + } + + val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("${shortName}Controller.kt") + GeneratedFileWriter.write(outFile, source) + } + + /** + * FR-017 TPH: emit the discriminator-base controller — ONE self-contained `@RestController` + * at `/api/` mounting the polymorphic collection (`GET /`, `GET /{id}`) plus, per + * subtype `` (the `@discriminatorValue` lowercased), a full CRUD set scoped by the + * discriminator: `GET /` (subtype list), `GET //{id}` (404 cross-subtype), + * `POST /` (discriminator injected from the URL — never the body), + * `PATCH|PUT //{id}` (partial patch; discriminator immutable; 404 cross-subtype), + * `DELETE //{id}` (404 cross-subtype). + * + * Every endpoint trades the base `` data class — for a TPH base that class is the UNION + * of subtype columns (folded nullable by [KotlinEntityGenerator]), so a polymorphic row surfaces + * its subtype values and a per-subtype POST/PATCH body binds its own columns. The controller + * embeds Exposed against the union `Table` ([KotlinExposedTableGenerator]); the + * discriminator is the generated enum, so `type` is scoped/injected as `.`. + */ + protected open fun emitTph(base: MetaObject, plan: KotlinTphPlan.Plan, outRoot: Path, loader: MetaDataLoader) { + val api = exposedApi() + val (pkg, shortName) = PackageMapping.splitFqn(base.name) + val table = shortName + "Table" + val routeBase = KotlinNaming.controllerPath(shortName) + // The single TPH table is keyed by the BASE's primary identity — every polymorphic + // + per-subtype by-id route binds the PK field's OWN Kotlin type (uuid → UUID, …), + // matching the Exposed Column it is compared against (a hard-coded Long does + // not compile against a uuid PK). Same fallback policy as the vanilla emit. + val pkFieldName = base.getIdentities(true) + .filterIsInstance() + .firstOrNull { it.isPrimary }?.fields?.firstOrNull() ?: DEFAULT_PK_FIELD + val pkParamType = primaryKeyParamType(base, pkFieldName) + val discField = base.metaFields.first { it.name == plan.discriminatorField } + val discEnum = KotlinTypeMapper.enumTypeName(discField, base)?.simpleName + ?: error("TPH base ${base.name}: discriminator field '${plan.discriminatorField}' is not an enum") + // The controller scopes/injects the discriminator as `.` (an enum constant), so + // every @discriminatorValue MUST be a valid Kotlin identifier — fail loud at generation with a + // clear message rather than emit code that won't compile (the Java lane uses string literals + // and has no such constraint). + for (st in plan.subtypes) { + require(st.value.matches(Regex("[A-Za-z_][A-Za-z0-9_]*"))) { + "FR-017 TPH base ${base.name}: @discriminatorValue '${st.value}' on ${st.entity.name} is not a valid " + + "Kotlin enum-constant identifier; the generated $discEnum.${st.value} reference would not compile." + } + } + + // Union scalar fields (base own + subtype-only), in the data class / table order. + val scalarFields = (base.metaFields.filterNot { it is ObjectField || it is MapField } + + KotlinTphPlan.collectSubtypeFields(base, plan).filterNot { it is ObjectField || it is MapField }) + val sortFields = base.metaFields + .filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) } + .map { it.name } + val baseFieldNames = base.metaFields.map { it.name }.toSet() + val allowlistName = "${shortName}FilterAllowlist" + // Union filter-dispatch specs (base + subtype columns) for the FR-009 pipeline — EXCLUDING the + // discriminator (route-addressable; enum column), decimal columns (outside the cross-port + // HTTP filter contract) and the `field.string @dbColumnType=jsonb` open bag (a JSONB value is + // not a scalar filter target; its element type is an un-imported kotlinx JsonElement). Mirrors + // the allowlist's exclusions, so the dispatch never references a cast the generic pipeline + // can't coerce. (scalarFields itself is left intact — rowTo reuses it to map every column.) + // The discriminator stays IN: the base allowlist admits it (FR-017), so it needs a + // coercion + WHERE arm like any filterable enum column. + val filterSpecs = scalarFields + .filter { it !is com.metaobjects.field.DecimalField && !KotlinTypeMapper.isJsonbOpenBag(it) } + .map { ScalarFieldSpec(it.name, it.subType, columnElementType(it), intBackedEnumType(it, base)) } + // #203/ADR-0045: @autoSet columns on the union table are stamped by the controller, never bound + // from the per-subtype create body. Same computation as the vanilla emit() (lines ~195-207); the + // columns live on the BASE (shared by every subtype row in the single table) — a TPH subtype + // never declares its own @autoSet column. + val insertAutoSetFields = base.metaFields.filter { + KotlinGenUtil.isAutoSetField(it) && it.name != pkFieldName + } + val onUpdateAutoSetFields = insertAutoSetFields.filter { + KotlinGenUtil.autoSetPolicy(it) == KotlinGenUtil.AUTO_SET_ON_UPDATE + } + val autoSetNames = insertAutoSetFields.map { it.name }.toSet() + // One captured now()-val per distinct temporal type, so all same-type @autoSet columns in a + // single per-subtype insert receive the identical value (createdAt == updatedAt exactly). + val insertNowVal = LinkedHashMap() // nowExpr -> local val name + insertAutoSetFields.forEach { f -> + insertNowVal.getOrPut(KotlinTypeMapper.nowExpr(f)) { "autoSetNow${insertNowVal.size}" } + } + + // Non-discriminator, non-PK, non-@autoSet columns the create handler writes from the body. + val writableFields = scalarFields.map { it.name } + .filter { it != plan.discriminatorField && it != pkFieldName && it !in autoSetNames } + + // FR-035/FR-036 Program B: the per-subtype settable columns = the subtype's EFFECTIVE scalar + // fields (base + own, resolved via extends) minus the PK and the discriminator, EXCLUDING + // object/map/jsonb-open-bag (an open bag is a create-only column, see KNOWN_GAPS). Delegated to + // [KotlinTphPlan.subtypeSettableFields] — the SSOT the entity generator's Validation shape + // is built from, so the create/PATCH validated set never drifts from the annotated class. This + // is per-subtype so a PriorAuth patch never touches a Bridge-only column. + fun subtypePatchFields(st: KotlinTphPlan.Subtype) = + KotlinTphPlan.subtypeSettableFields(st.entity) + + // FW-8 x FR-017: M:N traversal inside a TPH hierarchy. A relationship declared on the + // BASE is legitimate for every row of the shared table — mounted once, unscoped, exactly + // like a vanilla entity's (see emitM2mEndpoint). Every relationship a SUBTYPE resolves — + // its own, or inherited from the base — is ALSO mounted under that subtype's segment, + // scoped by a Stage-0 discriminator check (see emitTphSubtypeM2mEndpoint): a subtype + // resource carries the same sub-resources as any other, and the junction FK addresses + // the shared base table, so without that check a sibling's id would traverse it too. + val baseM2mNavs = KotlinM2mSupport.resolve(base, loader) + fun subtypeM2mNavs(st: KotlinTphPlan.Subtype) = KotlinM2mSupport.resolve(st.entity, loader) + // FW-8 follow-up: the TPH-group-wide query-helper NAME for every distinct nav shape + // (base's own + every subtype's own) — the SAME decision KotlinRelationsGenerator makes + // for AuthRelations.kt (KotlinM2mSupport.tphQueryFnNames), keyed by structural M2mNav + // equality so a nav resolved here and the identical nav resolved there agree on the + // helper to call. A subtype that SHADOWS a relation name with a genuinely different nav + // (own children shadow super on (type, name), ADR-0039) gets its OWN disambiguated entry. + val m2mHelperNames = KotlinM2mSupport.tphQueryFnNames(base, plan, loader) + // When NO subtype has a settable column (a PK + discriminator + only object/jsonb columns) the + // controller emits no ObjectMapper/Validator ctor param or TypeReference import (they would be + // unused → allWarningsAsErrors). A real TPH base always contributes ≥1 base scalar, so this is + // true in practice; the guard keeps the pathological hierarchy byte-clean. + val anyPatchFields = plan.subtypes.any { subtypePatchFields(it).isNotEmpty() } + + val src = buildString { + if (pkg.isNotEmpty()) append("package $pkg\n\n") + append("import ${ExposedImports.core(api, "Op")}\n") + append("import ${ExposedImports.core(api, "ResultRow")}\n") + append("import ${ExposedImports.core(api, "SortOrder")}\n") + // issue #390 (review fix): see emit()'s identical comment above — same scoping, + // against this TPH base's filterSpecs (the union of its own + every subtype's + // scalar columns) rather than a single entity's scalarFields. + if (api == ExposedApi.V1) { + for (op in v1FilterOperatorImports(filterSpecs)) append("import ${ExposedImports.core(api, op)}\n") + } else { + append("import org.jetbrains.exposed.sql.SqlExpressionBuilder\n") + } + append("import ${ExposedImports.core(api, "and")}\n") + append("import ${ExposedImports.jdbc(api, "deleteWhere")}\n") + append("import ${ExposedImports.jdbc(api, "insert")}\n") + append("import ${ExposedImports.jdbc(api, "selectAll")}\n") + append("import ${ExposedImports.jdbc(api, "update")}\n") + append("import ${ExposedImports.transactions(api, "transaction")}\n") + // FR-035 present-key PATCH: the per-subtype update handler binds the RAW JsonNode (not the + // union data class, which cannot see absent-vs-null) so the tristate (omitted / present-null + // / present-value) is visible. + append("import com.fasterxml.jackson.databind.JsonNode\n") + // FR-036: each present PATCH value binds via the Spring-configured ObjectMapper and is + // validated through a jakarta Validator — imported only when at least one subtype has a + // settable column (an unused import/ctor param would fail the module's allWarningsAsErrors + // compile). + if (anyPatchFields) { + append("import com.fasterxml.jackson.core.type.TypeReference\n") + append("import com.fasterxml.jackson.databind.ObjectMapper\n") + append("import jakarta.validation.Validator\n") + } + append("import org.springframework.http.HttpStatus\n") + append("import org.springframework.http.ResponseEntity\n") + append("import org.springframework.web.bind.annotation.DeleteMapping\n") + append("import org.springframework.web.bind.annotation.ExceptionHandler\n") + append("import org.springframework.web.bind.annotation.GetMapping\n") + append("import org.springframework.web.bind.annotation.PathVariable\n") + append("import org.springframework.web.bind.annotation.PostMapping\n") + append("import org.springframework.web.bind.annotation.RequestBody\n") + append("import org.springframework.web.bind.annotation.RequestMapping\n") + append("import org.springframework.web.bind.annotation.RequestMethod\n") + append("import org.springframework.web.bind.annotation.RequestParam\n") + append("import org.springframework.web.bind.annotation.RestController\n") + // FR-009 filter pipeline support (mirrors the vanilla controller's import set). + append("import jakarta.servlet.http.HttpServletRequest\n") + append("import java.io.ByteArrayOutputStream\n") + append("import java.nio.charset.StandardCharsets\n") + append("import java.sql.Timestamp\n") + // ADR-0036 Wave 2: import java.time.Instant only when a default field.timestamp + // column (→ Instant) is in the filter surface — keeps Instant-free entities + // byte-identical. + if (filterSpecs.any { it.elementType == "Instant" }) { + append("import java.time.Instant\n") + } + // A uuid PK / uuid union column surfaces java.util.UUID in the by-id route + // signatures and the filter-dispatch casts — same conditional import as the + // vanilla controller, so uuid-free hierarchies stay byte-identical. + if (pkParamType == "UUID" || filterSpecs.any { it.elementType == "UUID" }) { + append("import java.util.UUID\n") + } + // Pre-existing gap — see emit()'s identical BigDecimal/URI/InetAddress comments + // above; filterSpecs excludes DecimalField (see its own filter above) but not + // UriField/InetField, so this hierarchy needs the URI/InetAddress half of the fix. + if (filterSpecs.any { it.elementType == "URI" }) { + append("import java.net.URI\n") + } + if (filterSpecs.any { it.elementType == "InetAddress" }) { + append("import java.net.InetAddress\n") + } + // FR-009 (#179): a filterable enum in the union — the discriminator included — is + // compared as its stored string via `col.castTo(TextColumnType())`; same + // conditional imports as the vanilla controller. + if (filterSpecs.any { needsStringCastDispatch(it.subType, it.elementType, it.intBackedEnumType) }) { + append("import ${ExposedImports.core(api, "TextColumnType")}\n") + append("import ${ExposedImports.core(api, "castTo")}\n") + } + append("import java.time.LocalDate\n") + append("import java.time.LocalDateTime\n") + append("import java.time.LocalTime\n") + append("import java.time.format.DateTimeFormatter\n\n") + + // sort allowlist (base scalar columns — the polymorphic sort surface) + append("/** GENERATED — sort allowlist for $shortName (cross-port API contract). */\n") + append("private val ${shortName}SortAllowlist = setOf(\n") + for (f in sortFields) append(" \"$f\",\n") + append(")\n\n") + // The polymorphic sort surface is the BASE's own columns, so the declared + // orders are read off the base too — same owner the allowlist came from. + appendSortDefaultOrders(this, shortName, base, sortFields) + append("private fun parse${shortName}Sort(raw: String): Pair? {\n") + append(" val parts = raw.split(\":\", limit = 2)\n") + append(" val field = parts.getOrNull(0) ?: return null\n") + append(" if (field !in ${shortName}SortAllowlist) return null\n") + append(" val dir = when (parts.getOrNull(1)?.lowercase()\n") + append(" ?: ${shortName}SortDefaultOrder[field] ?: \"asc\") {\n") + append(" \"asc\" -> SortOrder.ASC\n") + append(" \"desc\" -> SortOrder.DESC\n") + append(" else -> return null\n") + append(" }\n") + append(" return field to dir\n") + append("}\n\n") + + // FR-009 filter pipeline over the UNION columns (parse + per-field Exposed Op dispatch), + // shared by the polymorphic + per-subtype list handlers — matches the Python/TS TPH lanes. + emitFilterPipeline(this, shortName, table, allowlistName, filterSpecs) + + // rowTo: union ResultRow → data class + append("/** GENERATED — map an Exposed ResultRow to the union $shortName data class. */\n") + append("private fun rowTo${shortName}(row: ResultRow): $shortName = $shortName(\n") + for (field in scalarFields) append(" ${field.name} = row[$table.${field.name}],\n") + append(")\n\n") + + append("/**\n") + append(" * GENERATED — TPH discriminator-base controller for $shortName (polymorphic + per-subtype CRUD).\n") + append(" *\n") + // No `/**` glob in the example: Kotlin NESTS block comments (see emit()). + append(" * Auth: these endpoints are unauthenticated. In your Spring Security config, require\n") + append(" * authentication for `$routeBase` and every path under it (`requestMatchers` + `authenticated()`).\n") + append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n") + append(" * config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.\n") + append(" */\n") + append("@RestController\n") + append("@RequestMapping(\"$routeBase\")\n") + // FR-036: the per-subtype PATCH tristate binds present values through the Spring-configured + // ObjectMapper and validates them via the jakarta Validator — both injected only when a + // subtype has a settable column (else a no-arg controller, keeping unused params out). + if (anyPatchFields) { + append("class ${shortName}Controller(private val objectMapper: ObjectMapper, private val validator: Validator) {\n\n") + } else { + append("class ${shortName}Controller {\n\n") + } + + // polymorphic list + append(" @GetMapping\n") + append(" fun list(\n") + append(" @RequestParam(required = false) limit: Int?,\n") + append(" @RequestParam(required = false) offset: Int?,\n") + append(" @RequestParam(required = false) sort: String?,\n") + append(" @RequestParam(required = false, name = \"withCount\") withCount: Int?,\n") + append(" request: HttpServletRequest,\n") + append(" ): ResponseEntity = transaction {\n") + append(" val filterResult = parse${shortName}Filter(request.queryString)\n") + append(" if (filterResult.error != null) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to filterResult.error, \"field\" to filterResult.field) as Any)\n") + append(" val whereOp = ${shortName}WhereOp(filterResult.predicates)\n") + append(" var q = if (whereOp != null) $table.selectAll().where { whereOp } else $table.selectAll()\n") + append(" if (sort != null) {\n") + append(" val parsed = parse${shortName}Sort(sort)\n") + append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"invalid_sort\", \"field\" to sort.substringBefore(':')) as Any)\n") + append(" val (field, dir) = parsed\n") + append(" q = q.orderBy(${sortColumnExpr(table, shortName, sortFields, exposedApi())} to dir)\n") + append(" }\n") + append(" val total: Long = if (withCount == 1) q.count() else -1L\n") + // issue #390: Exposed 1.x's Query.limit(count) dropped the 0.x 2-arg + // limit(count, offset) overload — offset is now its OWN chained call. + if (api == ExposedApi.V1) { + append(" val rows = q.limit(limit ?: 50).offset((offset ?: 0).toLong()).map { rowTo${shortName}(it) }\n") + } else { + append(" val rows = q.limit(limit ?: 50, (offset ?: 0).toLong()).map { rowTo${shortName}(it) }\n") + } + append(" if (withCount == 1) ResponseEntity.ok(mapOf(\"rows\" to rows, \"total\" to total) as Any)\n") + append(" else ResponseEntity.ok(rows as Any)\n") + append(" }\n\n") + + // polymorphic get + append(" @GetMapping(\"/{id}\")\n") + append(" fun get(@PathVariable id: $pkParamType): ResponseEntity = transaction {\n") + append(" val row = $table.selectAll().where { $table.$pkFieldName eq id }.singleOrNull()\n") + append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") + append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" }\n\n") + + // Base-declared M:N — unscoped, same shape emitM2mEndpoint gives a vanilla entity. + for (nav in baseM2mNavs) { + emitM2mEndpoint(this, pkg, shortName, nav, pkParamType, m2mHelperNames.getValue(nav)) + } + + for (st in plan.subtypes) { + val seg = st.routeSegment + val disc = "$discEnum.${st.value}" // e.g. AuthType.Bridge + val sfx = capitalizeFirst(st.value) // method-name suffix, e.g. Bridge + // FR-036: the annotated Validation shape (emitted by KotlinEntityGenerator) that + // the per-subtype POST + PATCH validate present values against — the bound union base + // data class is annotation-free. Referenced FQN-qualified when the subtype lives in a + // different package (this hand-rolled builder owns no import machinery, like emitM2m*), + // else bare. + val (stPkg, stShort) = PackageMapping.splitFqn(st.entity.name) + val validationRef = KotlinNaming.tphSubtypeValidationName(stShort).let { cls -> + if (stPkg == pkg || stPkg.isEmpty()) cls else "$stPkg.$cls" + } + // The subtype's settable columns (SSOT shared with the Validation shape) — the + // create body + present PATCH values are both validated against exactly this set. + val stPatch = subtypePatchFields(st) + + append(" // --- subtype ${st.value} (segment /$seg) ---\n") + + // per-subtype list (discriminator scope AND'd with the FR-009 filter) + append(" @GetMapping(\"/$seg\")\n") + append(" fun list$sfx(\n") + append(" @RequestParam(required = false) limit: Int?,\n") + append(" @RequestParam(required = false) offset: Int?,\n") + append(" @RequestParam(required = false) sort: String?,\n") + append(" request: HttpServletRequest,\n") + append(" ): ResponseEntity = transaction {\n") + append(" val filterResult = parse${shortName}Filter(request.queryString)\n") + append(" if (filterResult.error != null) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to filterResult.error, \"field\" to filterResult.field) as Any)\n") + append(" val whereOp = ${shortName}WhereOp(filterResult.predicates)\n") + append(" var q = $table.selectAll().where {\n") + append(" val d = $table.${plan.discriminatorField} eq $disc\n") + append(" if (whereOp != null) d and whereOp else d\n") + append(" }\n") + append(" if (sort != null) {\n") + append(" val parsed = parse${shortName}Sort(sort)\n") + append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"invalid_sort\", \"field\" to sort.substringBefore(':')) as Any)\n") + append(" val (field, dir) = parsed\n") + append(" q = q.orderBy(${sortColumnExpr(table, shortName, sortFields, exposedApi())} to dir)\n") + append(" }\n") + // issue #390: Exposed 1.x's Query.limit(count) dropped the 0.x 2-arg + // limit(count, offset) overload — offset is now its OWN chained call. + if (api == ExposedApi.V1) { + append(" val rows = q.limit(limit ?: 50).offset((offset ?: 0).toLong()).map { rowTo${shortName}(it) }\n") + } else { + append(" val rows = q.limit(limit ?: 50, (offset ?: 0).toLong()).map { rowTo${shortName}(it) }\n") + } + append(" ResponseEntity.ok(rows as Any)\n") + append(" }\n\n") + + // per-subtype get + append(" @GetMapping(\"/$seg/{id}\")\n") + append(" fun get$sfx(@PathVariable id: $pkParamType): ResponseEntity = transaction {\n") + append(" val row = $table.selectAll().where { ($table.$pkFieldName eq id) and ($table.${plan.discriminatorField} eq $disc) }.singleOrNull()\n") + append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") + append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" }\n\n") + + // per-subtype create (discriminator injected from URL). FR-036: validate each body + // value against the subtype's ANNOTATED Validation constraints BEFORE persisting, + // 400ing with the cross-port {"error":"validation"} envelope on any violation (an + // over-@maxLength or missing-@required column is a 400, not a silent 201). The bound + // body stays the union base data class (the shape the insert consumes), but the union is + // annotation-free — so validation runs PER FIELD against Validation, the SAME + // annotated shape + field set the per-subtype PATCH validates. validateValue applies + // @NotNull to a null value, so an absent @required column 400s too — mirroring the + // vanilla create's whole-bean validate, scoped to this subtype. + append(" @PostMapping(\"/$seg\")\n") + append(" fun create$sfx(@RequestBody dto: $shortName): ResponseEntity = transaction {\n") + for (field in stPatch) { + append(" if (validator.validateValue($validationRef::class.java, \"${field.name}\", dto.${field.name}).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } + // #203/ADR-0045: capture one now() per temporal type so createdAt == updatedAt exactly. + for ((expr, valName) in insertNowVal) append(" val $valName = $expr\n") + append(" val newId = $table.insert {\n") + append(" it[${plan.discriminatorField}] = $disc\n") + // `@generation: uuid` key minted app-side — see the vanilla create. + if (uuidGeneratedPk(base, pkFieldName)) append(" it[$pkFieldName] = java.util.UUID.randomUUID()\n") + for (f in writableFields) { + // A column is non-null in the union table iff it is a BASE @required field + // (subtype-only columns are folded NULLABLE). For a non-null column the union + // DTO field is still nullable, so force non-null on create (create bodies for + // that subtype always supply it); nullable columns bind the nullable value as-is. + val colNonNull = baseFieldNames.contains(f) && KotlinGenUtil.isRequiredField(scalarFields.first { it.name == f }) + if (colNonNull) append(" it[$f] = dto.$f!!\n") + else append(" it[$f] = dto.$f\n") + } + // #203/ADR-0045: insert stamps onCreate+onUpdate @autoSet columns from the captured + // now() (the caller's dto value is ignored — never bound). + for (f in insertAutoSetFields) { + append(" it[${f.name}] = ${insertNowVal[KotlinTypeMapper.nowExpr(f)]}\n") + } + append(" }[$table.$pkFieldName]\n") + append(" val saved = $table.selectAll().where { $table.$pkFieldName eq newId }.single()\n") + append(" ResponseEntity.status(HttpStatus.CREATED).body(rowTo${shortName}(saved) as Any)\n") + append(" }\n\n") + + // per-subtype update — FR-035/FR-036 present-key PATCH tristate (mirrors the vanilla + // update handler), scoped to THIS subtype's own effective columns; discriminator immutable; + // 404 cross-subtype via the discriminator-scoped read-back. Bind the RAW JsonNode so + // absent-vs-null is visible: a non-object body, or an explicit null on a @required column + // (base or subtype), is a 400; an explicit null on a nullable column CLEARS it; an omitted + // column is untouched; a present value binds through the ObjectMapper (same codecs as + // create) and its present non-null value is validated via the jakarta Validator against the + // subtype's ANNOTATED Validation shape (the folded union base data class is + // annotation-free — so an over-@maxLength present value is a 400, matching the vanilla + // path + the per-subtype create). Binding + validation run at the transaction-lambda top + // level (a qualified return@transaction cannot cross the non-inline Exposed update{} + // lambda). An empty effective patch skips the update{} (Exposed rejects an empty SET) — a + // no-op read-back. + append(" @RequestMapping(value = [\"/$seg/{id}\"], method = [RequestMethod.PATCH, RequestMethod.PUT])\n") + append(" fun update$sfx(@PathVariable id: $pkParamType, @RequestBody body: JsonNode): ResponseEntity = transaction {\n") + append(" if (!body.isObject) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + for (field in stPatch) { + if (KotlinGenUtil.isRequiredField(field)) { + append(" if (body.has(\"${field.name}\") && body.get(\"${field.name}\").isNull) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } + } + // #203/ADR-0045: mirrors the vanilla update handler's mustStampOnUpdate control flow — + // an onUpdate @autoSet column must bump on EVERY patch, so the "any present" guard is + // dropped when one exists (else the guard would keep a no-settable-field patch from + // bumping it); with no caller-settable columns at all, a standalone update{} still bumps. + val mustStampOnUpdate = onUpdateAutoSetFields.isNotEmpty() + if (stPatch.isNotEmpty()) { + val settableNamesList = stPatch.joinToString(", ") { "\"${it.name}\"" } + if (!mustStampOnUpdate) append(" if (listOf($settableNamesList).any { body.has(it) }) {\n") + append(" try {\n") + for (field in stPatch) { + // The union column's element type — a field.enum's materialized enum class resolved + // with the BASE as owner (exactly as the union data class + table type the folded + // column), NOT the wire String — so the bound value matches Exposed's Column. + val elem = KotlinTypeMapper.enumTypeName(field, base) ?: KotlinTypeMapper.kotlinTypeName(field) + val ktType = if (field.isArrayType) "kotlin.collections.List<$elem>" else "$elem" + val fn = field.name + val cap = capitalizeFirst(fn) + append(" val has$cap = body.has(\"$fn\")\n") + if (KotlinGenUtil.isRequiredField(field)) { + // required: present ⇒ non-null (present-null was 400'd above). + append(" val v$cap: $ktType? = if (has$cap) objectMapper.treeToValue(body.get(\"$fn\"), object : TypeReference<$ktType>() {}) else null\n") + append(" if (has$cap && validator.validateValue($validationRef::class.java, \"$fn\", v$cap).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } else { + append(" val null$cap = has$cap && body.get(\"$fn\").isNull\n") + append(" val v$cap: $ktType? = if (has$cap && !null$cap) objectMapper.treeToValue(body.get(\"$fn\"), object : TypeReference<$ktType>() {}) else null\n") + append(" if (has$cap && !null$cap && validator.validateValue($validationRef::class.java, \"$fn\", v$cap).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + } + } + // Apply the already-bound + validated values, scoped to (pk eq id) and (disc eq value). + // Columns are qualified ($table.col) so a field named like the body/id params can't shadow. + append(" $table.update({ ($table.$pkFieldName eq id) and ($table.${plan.discriminatorField} eq $disc) }) {\n") + // #203/ADR-0045: bump every onUpdate @autoSet column first (server-owned); onCreate + // columns are never touched on update (createdAt is immutable). + for (field in onUpdateAutoSetFields) { + append(" it[$table.${field.name}] = ${KotlinTypeMapper.nowExpr(field)}\n") + } + for (field in stPatch) { + val fn = field.name + val cap = capitalizeFirst(fn) + val col = "$table.$fn" + if (KotlinGenUtil.isRequiredField(field)) { + append(" if (has$cap) it[$col] = v$cap!!\n") + } else { + append(" if (has$cap) { if (null$cap) it[$col] = null else it[$col] = v$cap }\n") + } + } + append(" }\n") + append(" } catch (e: com.fasterxml.jackson.databind.JsonMappingException) {\n") + append(" return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n") + append(" }\n") + // Close the "any present" guard only when it was opened (see mustStampOnUpdate above). + if (!mustStampOnUpdate) append(" }\n") + } else if (mustStampOnUpdate) { + // No caller-settable columns for this subtype, but onUpdate @autoSet must still bump. + append(" $table.update({ ($table.$pkFieldName eq id) and ($table.${plan.discriminatorField} eq $disc) }) {\n") + for (field in onUpdateAutoSetFields) { + append(" it[$table.${field.name}] = ${KotlinTypeMapper.nowExpr(field)}\n") + } + append(" }\n") + } + append(" val row = $table.selectAll().where { ($table.$pkFieldName eq id) and ($table.${plan.discriminatorField} eq $disc) }.singleOrNull()\n") + append(" if (row == null) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") + append(" else ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" }\n\n") + + // per-subtype delete (404 cross-subtype) + append(" @DeleteMapping(\"/$seg/{id}\")\n") + append(" fun delete$sfx(@PathVariable id: $pkParamType): ResponseEntity = transaction {\n") + if (api == ExposedApi.V1) { + // issue #390 — see the vanilla controller's delete() for why V1 drops the + // `with(SqlExpressionBuilder)` wrapper (bare top-level eq/and resolve here). + append(" val deleted = $table.deleteWhere { ($table.$pkFieldName eq id) and ($table.${plan.discriminatorField} eq $disc) }\n") + } else { + append(" val deleted = $table.deleteWhere { with(SqlExpressionBuilder) { ($table.$pkFieldName eq id) and ($table.${plan.discriminatorField} eq $disc) } }\n") + } + append(" if (deleted == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") + append(" else ResponseEntity.noContent().build()\n") + append(" }\n\n") + + // M:N traversal scoped to THIS subtype — every relationship it resolves, + // inherited ones (e.g. a base-declared relationship) included. + for (nav in subtypeM2mNavs(st)) { + emitTphSubtypeM2mEndpoint( + this, pkg, table, pkFieldName, plan.discriminatorField, disc, seg, sfx, nav, pkParamType, + m2mHelperNames.getValue(nav), + ) + } + } + appendConstraintHandler() + append("}\n") + } + + val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("${shortName}Controller.kt") + outFile.parent?.let { Files.createDirectories(it) } + GeneratedFileWriter.write(outFile, src) + } + + + /** + * Emits the constraint-violation handler every generated controller carries. + * + * An `@ExceptionHandler` rather than a try/catch per write verb, and that is a + * correctness choice, not a brevity one: an Exposed `transaction { }` that throws must be + * allowed to ROLL BACK. Catching inside the lambda and returning a value would have Exposed + * commit a transaction whose statement already failed, which Postgres rejects outright + * ("current transaction is aborted"). Letting the exception leave the transaction block and + * handling it here rolls back first, then answers. + * + * It also covers every write verb at once — vanilla and per-subtype, and any added later — + * where a per-handler wrapper is six edits that a seventh handler silently misses. + * + * The classification is the same algorithm as runtime-ts's `constraint-errors.ts`, C#'s + * and Java's `ConstraintErrors`: codes first, constraint-name vocabulary as fallback, 409 + * for referential/uniqueness conflicts with existing state and 400 for a value the request + * got wrong. It is emitted INLINE rather than imported, because a generated Kotlin + * controller carries no compile-time MetaObjects dependency — the same reason this port + * emits its filter parser inline instead of calling a shared runtime one. + * + * An unrecognised throwable is RETHROWN, so the operator keeps the full diagnostic and the + * caller gets a plain 500 carrying none of it. + */ + private fun StringBuilder.appendConstraintHandler() { + append("\n") + append(" /**\n") + append(" * A database constraint violation is a CLIENT error, not a 500: the\n") + append(" * identity.reference / identity.secondary that declare these constraints are the\n") + append(" * same metadata this controller already validates against. Anything else is\n") + append(" * rethrown, so the operator keeps the diagnostic and the caller gets none of it.\n") + append(" */\n") + // java.sql.SQLException TOO, not RuntimeException alone: Exposed raises a violation as + // ExposedSQLException, which extends the CHECKED java.sql.SQLException, so a handler for + // RuntimeException never matched and Spring answered every conflict with a 500. + append(" @ExceptionHandler(RuntimeException::class, java.sql.SQLException::class)\n") + append(" fun handleConstraintViolation(e: Exception): ResponseEntity {\n") + // Depth-bounded and self-reference-guarded: a wrapper whose cause is itself is legal. + // SQLState is read via the JDK's own java.sql.SQLException (FQN inline — the #179 guard + // forbids surfacing an un-imported type), so no driver is referenced. + append(" val text = generateSequence(e) { if (it.cause === it) null else it.cause }\n") + append(" .take(8)\n") + append(" .flatMap { t -> sequenceOf((t as? java.sql.SQLException)?.sqlState, t.message) }\n") + append(" .filterNotNull()\n") + append(" .joinToString(\" \")\n") + append(" .uppercase()\n") + append(" val kind = when {\n") + append(" \"23503\" in text || \"FOREIGN KEY\" in text || \"SQLITE_CONSTRAINT_FOREIGNKEY\" in text -> \"foreign_key\"\n") + append(" \"23505\" in text || \"UNIQUE CONSTRAINT\" in text || \"SQLITE_CONSTRAINT_UNIQUE\" in text -> \"unique\"\n") + append(" \"23514\" in text || \"CHECK CONSTRAINT\" in text || \"SQLITE_CONSTRAINT_CHECK\" in text -> \"check\"\n") + append(" \"23502\" in text || \"NOT NULL\" in text || \"SQLITE_CONSTRAINT_NOTNULL\" in text -> \"not_null\"\n") + append(" else -> throw e\n") + append(" }\n") + append(" val status = if (kind == \"foreign_key\" || kind == \"unique\") HttpStatus.CONFLICT else HttpStatus.BAD_REQUEST\n") + append(" return ResponseEntity.status(status).body(mapOf(\"error\" to \"constraint_violation\", \"constraint\" to kind) as Any)\n") + append(" }\n\n") + } + + /** Capitalize the first char (method-name suffix from a discriminator value). */ + private fun capitalizeFirst(s: String): String = + if (s.isEmpty()) s else s[0].uppercaseChar() + s.substring(1) + + /** + * The FR-009 filter-operator band a field of [subType] (or, when non-null, + * [intBackedEnumType]'s int-backed enum band) may legally be filtered with — the + * SAME single source of truth ([com.metaobjects.query.FilterOps]) the generated + * `FilterAllowlist` draws from ([KotlinFilterAllowlistGenerator]), so this controller + * can never emit an operator the allowlist itself would refuse to admit. One source of truth, + * not a second hand-rolled isStringLike/isBoolean gate — which happened to agree with + * `FilterOps` for every subtype EXCEPT `uuid`/`uri`/`inet` (none string-like, none boolean, so + * the old gate fell through to "emit ordering ops"): `field.inet`'s Kotlin type + * (`java.net.InetAddress`) is not `Comparable`, so `greater`/`greaterEq`/`less`/`lessEq` never + * compiled for it — a bug this derivation makes structurally impossible, because + * `FilterOps.opsForSubType("inet")` has no ordering ops to begin with (identity-comparison-only + * band, same as `uuid`). `field.uuid`/`field.uri` happened to compile their (allowlist- + * unreachable) ordering arms only because `UUID`/`URI` ARE `Comparable` — this derivation now + * omits those unreachable arms too, which is strictly a subset of what compiled before. + */ + private fun fieldFilterBand(subType: String?, intBackedEnumType: String?): Set = + if (intBackedEnumType != null) FilterOps.OPS_ENUM_INT_BACKED else FilterOps.opsForSubType(subType) + + /** + * True iff a field's WHERE-op dispatch needs the `castTo(TextColumnType())` import — + * either it is a string-backed [EnumField] (its `col` is cast for EVERY op — FR-009 #179) or + * its [fieldFilterBand] admits `like` while its OWN Kotlin element type is not already + * `String` (a non-lenient `field.uri`: a free-text/string filter band over a native + * `Column` — see [emitPerFieldDispatchArm]'s `like` arm, the only op that needs the + * cast for such a field). Shared by every import-gate call site so the gate and the arm it + * guards can never drift apart. + */ + private fun needsStringCastDispatch(subType: String?, elementType: String, intBackedEnumType: String?): Boolean { + if (subType == EnumField.SUBTYPE_ENUM) return true + return FilterOps.FILTER_OP_LIKE in fieldFilterBand(subType, intBackedEnumType) && elementType != "String" + } + + /** + * The exact top-level Exposed operator import names (issue #390 — review fix) + * [emitFilterPipeline]'s `WhereOp` will emit for [fields] under `exposedApi=1`, + * derived from [fieldFilterBand] — the SAME SSOT [emitPerFieldDispatchArm] gates its emitted + * arms on, so the import set and the arms it is importing FOR can never drift apart. `eq`/ + * `isNull`/`isNotNull` are unconditional (every arm — scalar or int-backed-enum — emits all + * three); `ne`→`neq` and `in`→`inList` import whenever the field's band admits them; the four + * ordering ops import together (bands never offer a subset); `like` imports whenever the + * band admits it. Returns empty for an empty [fields] (no per-field arm, hence no operator, is + * ever emitted — the WhereOp function still compiles on `and` alone). + */ + private fun v1FilterOperatorImports(fields: List): List { + if (fields.isEmpty()) return emptyList() + val ops = sortedSetOf("eq", "isNull", "isNotNull") + for (f in fields) { + val band = fieldFilterBand(f.subType, f.intBackedEnumType) + if (FilterOps.FILTER_OP_NE in band) ops += "neq" + if (FilterOps.FILTER_OP_IN in band) ops += "inList" + if (FilterOps.FILTER_OP_GT in band) { + ops += "greater"; ops += "greaterEq"; ops += "less"; ops += "lessEq" + } + if (FilterOps.FILTER_OP_LIKE in band) ops += "like" + } + return ops.toList() + } + + /** + * Emit the three-piece FR-009 filter pipeline (data class + parse function + + * Exposed dispatch function) into [out]. The dispatcher uses + * {@code SqlExpressionBuilder.{eq,less,greater,inList,like}} extension functions + * — these resolve against the generated {@code Table}'s typed + * {@code Column} members so each per-field arm is type-safe at compile time. + * + *

[scalarFields] is the {@code (name, subType)} pair list filtered to scalar + * columns (no [ObjectField]). The subtype drives: + *

    + *
  • which value-coercion path runs in {@code parseFilter} + * (string passthrough vs {@code Long.parseLong} vs {@code Timestamp.valueOf} + * vs {@code Boolean} parse, etc.);
  • + *
  • the dispatch arm shape in {@code WhereOp} (a {@code Long} field + * handles {@code gt/gte/lt/lte} via Exposed's overload-on-T comparison + * extensions; a {@code String} field only handles {@code like}).
  • + *
+ */ + protected open fun emitFilterPipeline( + out: StringBuilder, + shortName: String, + tableObjectName: String, + allowlistName: String, + scalarFields: List, + ) { + val api = exposedApi() + // Cross-port cap on `in`-list size — a larger list is rejected with the + // `filter.in_too_large` envelope, matching the TS runtime-ts parser's + // DEFAULT_MAX_IN_LIST. Emitted as a file-private const so parseFilter + // can reference it unqualified. + out.append("/** GENERATED — cross-port cap on `in`-list size (matches TS DEFAULT_MAX_IN_LIST). */\n") + out.append("private const val MAX_IN_LIST = 100\n\n") + + out.append("/** GENERATED — single parsed + validated FR-009 filter predicate. */\n") + out.append("private data class ${shortName}FilterPredicate(val field: String, val op: String, val value: Any?)\n\n") + + out.append("/** GENERATED — parse outcome: predicates, or a cross-port error envelope key + the field it rejected. */\n") + // `field` is non-null exactly when `error` is — every cross-port filter + // envelope names the field it rejected, so the controller can put it + // straight into the 400 body. + out.append("private data class ${shortName}FilterResult(val predicates: List<${shortName}FilterPredicate>, val error: String?, val field: String? = null)\n\n") + + // Parser — walks the RAW query string (not Spring's decoded parameter map) and + // matches each filter[][]= key against the per-entity FIELDS + + // OPS_BY_FIELD allowlist. coerceValue() lookups own the per-subtype value + // coercion. Raw, because Tomcat DROPS a parameter whose value holds a malformed + // escape ("Character decoding failed ... has been ignored") — so a browser's + // `?filter[name][like]=A%` reached the map without its filter and the list came + // back unfiltered with a 200. Same input as the Java port's FilterParser.parse. + out.append("/**\n") + out.append(" * GENERATED — decode one query component, keeping a malformed escape as written:\n") + out.append(" * {@code %XX} is a byte (runs decode as UTF-8), {@code +} is a space, and a {@code %}\n") + out.append(" * not followed by two hex digits stays a literal {@code %} — what the TypeScript, C#\n") + out.append(" * and Python servers do, where {@code URLDecoder} would throw.\n") + out.append(" */\n") + out.append("private fun decodeQueryComponent(raw: String): String {\n") + out.append(" if ('%' !in raw && '+' !in raw) return raw\n") + out.append(" val out = StringBuilder(raw.length)\n") + out.append(" val bytes = ByteArrayOutputStream()\n") + out.append(" var i = 0\n") + out.append(" while (i < raw.length) {\n") + out.append(" val c = raw[i]\n") + out.append(" if (c == '%' && i + 2 < raw.length && raw[i + 1].isHexDigitAscii() && raw[i + 2].isHexDigitAscii()) {\n") + out.append(" bytes.write(raw.substring(i + 1, i + 3).toInt(16))\n") + out.append(" i += 3\n") + out.append(" continue\n") + out.append(" }\n") + out.append(" if (bytes.size() > 0) { out.append(bytes.toString(StandardCharsets.UTF_8)); bytes.reset() }\n") + out.append(" out.append(if (c == '+') ' ' else c)\n") + out.append(" i++\n") + out.append(" }\n") + out.append(" if (bytes.size() > 0) out.append(bytes.toString(StandardCharsets.UTF_8))\n") + out.append(" return out.toString()\n") + out.append("}\n\n") + out.append("private fun Char.isHexDigitAscii(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F'\n\n") + out.append("/**\n") + out.append(" * GENERATED — parse the bracketed-qs FR-009 filter grammar from the raw query\n") + out.append(" * string. Returns either a list of validated predicates or one of\n") + out.append(" * the cross-port error envelope keys ({@code invalid_filter_field /\n") + out.append(" * invalid_filter_op / invalid_filter_value / filter.in_too_large}) plus the\n") + out.append(" * field each one is about.\n") + out.append(" */\n") + out.append("private fun parse${shortName}Filter(rawQuery: String?): ${shortName}FilterResult {\n") + out.append(" val out = mutableListOf<${shortName}FilterPredicate>()\n") + out.append(" for (pair in rawQuery.orEmpty().split('&')) {\n") + out.append(" if (pair.isEmpty()) continue\n") + out.append(" val eq = pair.indexOf('=')\n") + out.append(" val rawKey = decodeQueryComponent(if (eq < 0) pair else pair.substring(0, eq))\n") + out.append(" val value = decodeQueryComponent(if (eq < 0) \"\" else pair.substring(eq + 1))\n") + out.append(" if (!rawKey.startsWith(\"filter[\")) continue\n") + out.append(" val firstClose = rawKey.indexOf(']', 7)\n") + out.append(" if (firstClose < 0) continue\n") + out.append(" val field = rawKey.substring(7, firstClose)\n") + out.append(" val rest = firstClose + 1\n") + out.append(" val op: String = when {\n") + out.append(" rest >= rawKey.length -> \"eq\"\n") + out.append(" rawKey[rest] == '[' -> {\n") + out.append(" val secondClose = rawKey.indexOf(']', rest + 1)\n") + out.append(" if (secondClose < 0) continue\n") + out.append(" rawKey.substring(rest + 1, secondClose)\n") + out.append(" }\n") + out.append(" else -> continue\n") + out.append(" }\n") + out.append(" if (field !in ${allowlistName}.FIELDS) return ${shortName}FilterResult(emptyList(), \"invalid_filter_field\", field)\n") + out.append(" val ops = ${allowlistName}.OPS_BY_FIELD[field]\n") + out.append(" if (ops == null || op !in ops) return ${shortName}FilterResult(emptyList(), \"invalid_filter_op\", field)\n") + out.append(" val coerced = coerce${shortName}Value(field, op, value)\n") + out.append(" ?: return ${shortName}FilterResult(emptyList(), \"invalid_filter_value\", field)\n") + out.append(" val coercedValue = coerced.value\n") + out.append(" if (op == \"in\" && coercedValue is List<*> && coercedValue.size > MAX_IN_LIST) {\n") + out.append(" return ${shortName}FilterResult(emptyList(), \"filter.in_too_large\", field)\n") + out.append(" }\n") + out.append(" out.add(${shortName}FilterPredicate(field, op, coercedValue))\n") + out.append(" }\n") + out.append(" return ${shortName}FilterResult(out, null)\n") + out.append("}\n\n") + + // Boxed result so we can distinguish "coercion failed" (null return) from + // "valid null value" (Box(null)). + out.append("/** Box result: null = invalid; Box(value) = coerced. Distinguishes failure from a legitimate null. */\n") + out.append("private data class ${shortName}CoercedValue(val value: Any?)\n\n") + + // Value coercion — split by field then by op. `isNull` is universal (true/false). + // `in` returns a List. Scalar ops return the bare Kotlin type for that field. + out.append("private fun coerce${shortName}Value(field: String, op: String, raw: String): ${shortName}CoercedValue? {\n") + out.append(" if (op == \"isNull\") return when (raw) {\n") + out.append(" \"true\" -> ${shortName}CoercedValue(true)\n") + out.append(" \"false\" -> ${shortName}CoercedValue(false)\n") + out.append(" else -> null\n") + out.append(" }\n") + out.append(" return when (field) {\n") + for ((fname, subType) in scalarFields) { + out.append(" \"$fname\" -> ") + when (subType) { + StringField.SUBTYPE_STRING, EnumField.SUBTYPE_ENUM -> + out.append("if (op == \"in\") ${shortName}CoercedValue(raw.split(\",\").map { it.trim() }) else ${shortName}CoercedValue(raw)\n") + IntegerField.SUBTYPE_INT -> + out.append("coerce${shortName}Int(op, raw)\n") + LongField.SUBTYPE_LONG, CurrencyField.SUBTYPE_CURRENCY -> + out.append("coerce${shortName}Long(op, raw)\n") + DoubleField.SUBTYPE_DOUBLE -> + out.append("coerce${shortName}Double(op, raw)\n") + // The dispatch arm casts the coerced value to the COLUMN's Kotlin type + // (`p.value as Float`, `p.value as BigDecimal`), so the coercer must produce + // exactly that type. Both used to ride on the Double coercer, and a boxed + // Double is neither: every filter on a float or decimal column threw + // ClassCastException out of the handler, a 500 on a request the allowlist + // had admitted. + FloatField.SUBTYPE_FLOAT -> + out.append("coerce${shortName}Float(op, raw)\n") + DecimalField.SUBTYPE_DECIMAL -> + out.append("coerce${shortName}Decimal(op, raw)\n") + BooleanField.SUBTYPE_BOOLEAN -> + out.append("coerce${shortName}Boolean(op, raw)\n") + DateField.SUBTYPE_DATE -> + out.append("coerce${shortName}Date(op, raw)\n") + TimestampField.SUBTYPE_TIMESTAMP -> + out.append("coerce${shortName}Timestamp(op, raw)\n") + TimeField.SUBTYPE_TIME -> + out.append("coerce${shortName}Time(op, raw)\n") + // uuid columns are Column — the dispatch arm casts `p.value as UUID`, + // so the coerced value MUST be a java.util.UUID (allowlist band: + // eq/ne/in/isNull). A string passthrough would ClassCastException at runtime. + UuidField.SUBTYPE_UUID -> + out.append("coerce${shortName}Uuid(op, raw)\n") + else -> + // Unrecognized subtype — fall through to a string pass-through. This branch + // is unreachable for fields that are actually @filterable (those subtypes + // would have been rejected by the allowlist's op gating already) but covers + // the dispatch's `when` exhaustiveness. + out.append("if (op == \"in\") ${shortName}CoercedValue(raw.split(\",\").map { it.trim() }) else ${shortName}CoercedValue(raw)\n") + } + } + out.append(" else -> null\n") + out.append(" }\n") + out.append("}\n\n") + + // Per-type coercion helpers. Each tolerates the `in` operator (returns List) + // and the scalar comparison ops (returns the bare type). Parse failure + // → null → bubbled up as `invalid_filter_value`. + emitTypedCoercer(out, shortName, "Long", "java.lang.Long.parseLong") + emitTypedCoercer(out, shortName, "Int", "java.lang.Integer.parseInt") + emitTypedCoercer(out, shortName, "Double", "java.lang.Double.parseDouble") + // Emitted only when such a column exists, so a model without one keeps its bytes. + if (scalarFields.any { it.subType == FloatField.SUBTYPE_FLOAT }) { + emitTypedCoercer(out, shortName, "Float", "java.lang.Float.parseFloat") + } + if (scalarFields.any { it.subType == DecimalField.SUBTYPE_DECIMAL }) { + emitTypedCoercer(out, shortName, "Decimal", "java.math.BigDecimal") + } + emitTypedCoercer(out, shortName, "Date", "LocalDate.parse") + emitTypedCoercer(out, shortName, "Time", "LocalTime.parse") + // uuid coercer — emitted only when a uuid column exists (its `UUID.fromString` + // needs the conditional java.util.UUID import) so uuid-free entities stay + // byte-identical to pre-uuid output. + if (scalarFields.any { it.subType == UuidField.SUBTYPE_UUID }) { + emitTypedCoercer(out, shortName, "Uuid", "UUID.fromString") + } + // Timestamp coercer. ADR-0036 Wave 2: a default `field.timestamp` column is an + // absolute `java.time.Instant` (its WHERE-arm casts `p.value as Instant`), so the + // coerced value MUST be an Instant — coercing into LocalDateTime would ClassCastException + // at the dispatch cast. The cross-port wire form is offset-less wall-clock + // ('yyyy-MM-dd'T'HH:mm:ss', no zone); an offset-less value is interpreted as UTC + // (append `Z`) before Instant.parse. The `@localTime:true` opt-out column is a naive + // `LocalDateTime` and uses the zone-less formatter directly. + // No timestamp field present → the coercer is dead scaffolding; keep its historical + // LocalDateTime shape so Instant-free entities stay byte-identical (and don't + // reference the un-imported Instant). + val timestampElementType = scalarFields + .firstOrNull { it.subType == TimestampField.SUBTYPE_TIMESTAMP } + ?.elementType + ?: "LocalDateTime" + out.append("private val ${shortName}TimestampFmt: DateTimeFormatter = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n\n") + out.append("private fun coerce${shortName}Timestamp(op: String, raw: String): ${shortName}CoercedValue? {\n") + if (timestampElementType == "Instant") { + out.append(" val parse: (String) -> Instant? = { s ->\n") + out.append(" val withZone = if (s.endsWith(\"Z\") || s.contains(\"+\")) s else s + \"Z\"\n") + out.append(" runCatching { Instant.parse(withZone) }.getOrNull()\n") + out.append(" }\n") + } else { + out.append(" val parse: (String) -> LocalDateTime? = { s -> runCatching { LocalDateTime.parse(s, ${shortName}TimestampFmt) }.getOrNull() }\n") + } + out.append(" if (op == \"in\") {\n") + out.append(" val parts = raw.split(\",\").map { it.trim() }\n") + out.append(" val list = parts.map { parse(it) ?: return null }\n") + out.append(" return ${shortName}CoercedValue(list)\n") + out.append(" }\n") + out.append(" return ${shortName}CoercedValue(parse(raw) ?: return null)\n") + out.append("}\n\n") + // Boolean has its own non-throwing parse (when/else) — can't ride on + // runCatching, which would convert a malformed input to a thrown + // IllegalArgumentException then swallow it. + out.append("private fun coerce${shortName}Boolean(op: String, raw: String): ${shortName}CoercedValue? {\n") + out.append(" val parse: (String) -> Boolean? = { s -> when (s) { \"true\" -> true; \"false\" -> false; else -> null } }\n") + out.append(" if (op == \"in\") {\n") + out.append(" val parts = raw.split(\",\").map { it.trim() }\n") + out.append(" val list = parts.map { parse(it) ?: return null }\n") + out.append(" return ${shortName}CoercedValue(list)\n") + out.append(" }\n") + out.append(" return ${shortName}CoercedValue(parse(raw) ?: return null)\n") + out.append("}\n\n") + + // Exposed `Op` dispatch. Per-field arm is inlined directly in + // WhereOp under a single `with(SqlExpressionBuilder)` block. This + // keeps the column reference's compile-time Column type and gives each + // arm access to the typed eq/less/greater/inList/like extension functions + // without needing context receivers (which would require an experimental + // compiler flag). + out.append("/**\n") + out.append(" * GENERATED — fold a list of validated predicates into an Exposed\n") + out.append(" * {@code Op}, AND-combining each predicate's column-op-value triple\n") + out.append(" * against ${tableObjectName}. Returns null when [predicates] is empty so the\n") + out.append(" * caller can elide the WHERE clause entirely.\n") + out.append(" */\n") + out.append("@Suppress(\"UNCHECKED_CAST\")\n") + out.append("private fun ${shortName}WhereOp(predicates: List<${shortName}FilterPredicate>): Op? {\n") + out.append(" if (predicates.isEmpty()) return null\n") + // issue #390: under 1.x the column operators emitPerFieldDispatchArm emits below + // (eq/less/greater/inList/like/castTo) are plain top-level imports, not + // SqlExpressionBuilder-scoped members — this function needs no `with(...)` receiver + // at all. The per-field loop body is IDENTICAL text either way (indentation is + // cosmetic in Kotlin); only the opening/closing frame differs. + if (api == ExposedApi.V1) { + out.append(" var combined: Op? = null\n") + } else { + out.append(" return with(SqlExpressionBuilder) {\n") + out.append(" var combined: Op? = null\n") + } + out.append(" for (p in predicates) {\n") + out.append(" val op: Op = when (p.field) {\n") + for ((fname, subType, elementType, intBackedEnumType) in scalarFields) { + emitPerFieldDispatchArm( + out, + tableObjectName = tableObjectName, + fieldName = fname, + elementType = elementType, + subType = subType, + isEnum = (subType == EnumField.SUBTYPE_ENUM), + intBackedEnumType = intBackedEnumType, + ) + } + out.append(" else -> continue\n") + out.append(" }\n") + out.append(" combined = combined?.and(op) ?: op\n") + out.append(" }\n") + if (api == ExposedApi.V1) { + out.append(" return combined\n") + } else { + out.append(" combined\n") + out.append(" }\n") + } + out.append("}\n\n") + } + + /** + * Emit one per-field arm of the {@code WhereOp}'s {@code when (p.field)}. + * Each arm opens a nested {@code when (p.op)} on the 9 FR-009 ops, gated by + * [fieldFilterBand] — the SAME [com.metaobjects.query.FilterOps] single source of truth the + * generated {@code FilterAllowlist} draws from, so this dispatch can never emit an + * operator the allowlist itself would refuse to admit (e.g. an ordering comparison on a + * non-{@code Comparable} Kotlin type — {@code field.inet}'s {@code java.net.InetAddress}). + */ + protected open fun emitPerFieldDispatchArm( + out: StringBuilder, + tableObjectName: String, + fieldName: String, + elementType: String, + subType: String?, + isEnum: Boolean, + intBackedEnumType: String? = null, + ) { + // The column PROPERTY, not the field name: the table generator declares a field named + // after a member of Exposed's Table under a `Column` suffix (`source` is + // `sourceColumn`), and `Table.source` is that member, not the column. Identity for + // every other name, so existing output is unchanged. + val column = "${tableObjectName}.${KotlinNaming.safeColumnProperty(fieldName, exposedApi())}" + // An INT-BACKED enum (@intValueMap) stores the member's declared INTEGER, so the CAST(col AS + // text) comparison below — right for a string-backed enum — compared '30' to 'DELIVERED': + // eq/in matched nothing and ne matched everything, all with a 200. Compare through the + // enum-typed column instead, whose codec writes the declared integer. A symbol that names no + // member matches no row (ne: every non-null row), as the string comparison did. The + // int-backed band has no `like` (the allowlist omits it), so no arm is emitted for it. + if (intBackedEnumType != null) { + val member = "$intBackedEnumType.entries.firstOrNull { it.name == (p.value as String) }" + val col = column + out.append(" \"$fieldName\" -> when (p.op) {\n") + out.append(" \"eq\" -> $member?.let { $col eq it } ?: Op.FALSE\n") + out.append(" \"ne\" -> $member?.let { $col neq it } ?: $col.isNotNull()\n") + out.append(" \"in\" -> $col inList (p.value as List).mapNotNull { s -> $intBackedEnumType.entries.firstOrNull { it.name == s } }\n") + out.append(" \"isNull\" -> if (p.value as Boolean) $col.isNull() else $col.isNotNull()\n") + out.append(" else -> throw IllegalStateException(\"unsupported op for $fieldName: \" + p.op)\n") + out.append(" }\n") + return + } + // Exposed's typed `Column.eq(t: T)` / `.neq` / `.inList(Iterable)` reject a + // bare `Any?` — cast each predicate value to the column's element Kotlin type so + // the comparison resolves. (Surfaced by the SP-F generated-controller HTTP lane: + // `(p.value as Any?)` was an `Unresolved reference: eq` compile error.) The + // coercer already produced a value of exactly this type; the cast is total. + // + // FR-009 enum columns (#179): an Exposed enum column is typed `Column`, so a + // String-valued predicate would not resolve `eq`/`like`. Filter it by its STORED STRING + // via `CAST(col AS text)` (`.castTo(TextColumnType())`) — matching every other + // port's string-band enum-filter semantics — so eq/ne/in/like all compare Strings. + // (`isNull` still checks the raw column's nullability.) + val band = fieldFilterBand(subType, null) + val col = if (isEnum) "$column.castTo(TextColumnType())" + else column + out.append(" \"$fieldName\" -> when (p.op) {\n") + out.append(" \"eq\" -> $col eq (p.value as $elementType)\n") + if (FilterOps.FILTER_OP_NE in band) { + out.append(" \"ne\" -> $col neq (p.value as $elementType)\n") + } + if (FilterOps.FILTER_OP_GT in band) { + out.append(" \"gt\" -> $col greater (p.value as $elementType)\n") + out.append(" \"gte\" -> $col greaterEq (p.value as $elementType)\n") + out.append(" \"lt\" -> $col less (p.value as $elementType)\n") + out.append(" \"lte\" -> $col lessEq (p.value as $elementType)\n") + } + if (FilterOps.FILTER_OP_IN in band) { + out.append(" \"in\" -> $col inList (p.value as List<$elementType>)\n") + } + if (FilterOps.FILTER_OP_LIKE in band) { + // Exposed's `like` extension requires a String-typed expression. An isEnum field's + // `col` is already the castTo form above (its eq/ne/in/like ALL compare + // strings). A `field.uri` is the other `like`-banded subtype FilterOps admits (its + // band is free-text/string — ADR-0037 Wave 3) whose native Exposed column is + // `Column`, not `Column` — so ITS `like` arm alone needs the SAME cast, + // scoped to just this op (its eq/ne/in arms stay against the native URI column, + // matching the coercer's and the native-uuid/field.uuid arm's existing per-op + // native-type precedent). + val likeCol = if (isEnum || elementType == "String") col + else "$column.castTo(TextColumnType())" + out.append(" \"like\" -> $likeCol like (p.value as String)\n") + } + out.append(" \"isNull\" -> if (p.value as Boolean) $column.isNull() else $column.isNotNull()\n") + out.append(" else -> throw IllegalStateException(\"unsupported op for $fieldName: \" + p.op)\n") + out.append(" }\n") + } + + /** + * Emit a generic-shaped value coercer (handles both scalar ops and `in`) named + * {@code coerce(op, raw)} that uses [parseFn] to parse a + * single string into the target type. Parse failure → null → propagates as + * {@code invalid_filter_value}. + */ + protected open fun emitTypedCoercer( + out: StringBuilder, + shortName: String, + typeSuffix: String, + parseFn: String, + ) { + out.append("private fun coerce${shortName}${typeSuffix}(op: String, raw: String): ${shortName}CoercedValue? {\n") + out.append(" val parse: (String) -> Any? = { s -> runCatching { $parseFn(s) }.getOrNull() }\n") + out.append(" if (op == \"in\") {\n") + out.append(" val parts = raw.split(\",\").map { it.trim() }\n") + out.append(" val list = parts.map { parse(it) ?: return null }\n") + out.append(" return ${shortName}CoercedValue(list)\n") + out.append(" }\n") + out.append(" return ${shortName}CoercedValue(parse(raw) ?: return null)\n") + out.append("}\n\n") + } + + /** + * Emit one M:N traversal sub-resource: {@code GET /{id}/} returning + * the related target rows as the target's data class. Delegates the junction + * traversal to the {@code Table.(id)} Exposed join helper + * (emitted by [KotlinRelationsGenerator] under that exact name — [queryFnName] defaults to + * [KotlinM2mSupport.defaultQueryFnName], `Query`, the only name a non-TPH + * caller or a TPH base's own mount ever needs); maps each returned {@link ResultRow} to the + * target data class inline (the target's per-controller {@code rowTo} mapper + * is file-private, so the mapping is inlined here against the target's scalar fields). + * + * The target data class is referenced unqualified when it shares the source's package + * (the common case), else fully package-qualified so the generated file compiles across + * packages. + */ + protected open fun emitM2mEndpoint( + out: StringBuilder, + sourcePkg: String, + sourceShort: String, + nav: KotlinM2mSupport.M2mNav, + pkParamType: String, + queryFnName: String = KotlinM2mSupport.defaultQueryFnName(nav.relationName), + ) { + val sourceTable = sourceShort + "Table" + // FW-3: the data class rows are mapped into is the STORAGE object's type (`nav.targetType` + // — e.g. `Auth`, the union type, when the declared target is a TPH subtype), never + // `nav.targetShortName` (the DECLARED target's name, e.g. `BridgeAuth`) — a TPH subtype + // has no data class of its own to reference. + val targetType = if (nav.targetPackage == sourcePkg || nav.targetPackage.isEmpty()) { + nav.targetType + } else { + nav.targetPackage + "." + nav.targetType + } + val symMarker = if (nav.symmetric) " (symmetric — union on read)" else "" + out.append("\n") + out.append(" /** M:N traversal: the ${nav.targetShortName} rows related to this $sourceShort through ${nav.junctionShortName}$symMarker. */\n") + out.append(" @GetMapping(\"/{id}/${nav.relationName}\")\n") + out.append(" fun ${nav.relationName}(@PathVariable id: $pkParamType): ResponseEntity> = transaction {\n") + out.append(" val rows = $sourceTable.$queryFnName(id).map { row ->\n") + out.append(" $targetType(\n") + for (fname in nav.targetScalarFields) { + out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") + } + out.append(" )\n") + out.append(" }\n") + out.append(" ResponseEntity.ok(rows)\n") + out.append(" }\n") + } + + /** + * FW-8 x FR-017 — emit one M:N traversal sub-resource SCOPED to a TPH subtype: + * `GET //{id}/`. Stage 0 reads the source row's OWN discriminator column + * and returns an EMPTY list (HTTP 200 — not 404, not the query below) when [id] does not name + * a row of [discExpr]'s subtype, THEN delegates to [queryFnName] — the `Table.(id)` + * Exposed helper [KotlinRelationsGenerator] emitted for [nav]'s exact shape. In the common + * case that is the SAME helper every other mount of this relation NAME calls (the base's own + * mount, or a sibling subtype's, when [nav] is inherited unchanged); when the calling subtype + * instead SHADOWS the name with a genuinely different nav (own children shadow super on + * `(type, name)`, ADR-0039), [queryFnName] names ITS OWN disambiguated helper — see + * [KotlinM2mSupport.tphQueryFnNames]. Either way, the junction FK addresses the shared base + * table and cannot itself tell the subtypes apart, so without the Stage-0 gate a sibling's id + * would traverse it just as well and the `/` segment would be decorative. + */ + protected open fun emitTphSubtypeM2mEndpoint( + out: StringBuilder, + sourcePkg: String, + table: String, + pkFieldName: String, + discField: String, + discExpr: String, + seg: String, + sfx: String, + nav: KotlinM2mSupport.M2mNav, + pkParamType: String, + queryFnName: String = KotlinM2mSupport.defaultQueryFnName(nav.relationName), + ) { + val targetType = if (nav.targetPackage == sourcePkg || nav.targetPackage.isEmpty()) { + nav.targetType + } else { + nav.targetPackage + "." + nav.targetType + } + val symMarker = if (nav.symmetric) " (symmetric — union on read)" else "" + out.append("\n") + out.append(" /** M:N traversal scoped to $discExpr: the ${nav.targetShortName} rows related to this $discExpr through ${nav.junctionShortName}$symMarker. Verifies id names a $discExpr row, returning [] (not the sibling's rows) when it does not. */\n") + out.append(" @GetMapping(\"/$seg/{id}/${nav.relationName}\")\n") + out.append(" fun ${nav.relationName}$sfx(@PathVariable id: $pkParamType): ResponseEntity> = transaction {\n") + out.append(" if ($table.selectAll().where { ($table.$pkFieldName eq id) and ($table.$discField eq $discExpr) }.singleOrNull() == null) {\n") + out.append(" return@transaction ResponseEntity.ok(emptyList<$targetType>())\n") + out.append(" }\n") + out.append(" val rows = $table.$queryFnName(id).map { row ->\n") + out.append(" $targetType(\n") + for (fname in nav.targetScalarFields) { + out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") + } + out.append(" )\n") + out.append(" }\n") + out.append(" ResponseEntity.ok(rows)\n") + out.append(" }\n") + } + + /** + * A scalar (non-[ObjectField]) field's filter-dispatch metadata. [subType] drives the + * arm shape + value-coercion path; [elementType] is the Exposed column's element Kotlin + * type name, used to cast each predicate value in the `Column.eq/neq/inList` calls + * (Exposed's typed comparison ops reject a bare `Any?`). + */ + /** + * [intBackedEnumType] is the fully-qualified Kotlin enum class of an INT-BACKED `field.enum` + * (`@intValueMap`) — null for every other field, including a string-backed enum. + */ + protected data class ScalarFieldSpec( + val name: String, + val subType: String?, + val elementType: String, + val intBackedEnumType: String? = null, + ) + + /** The enum class an int-backed [field] maps to, or null — see [ScalarFieldSpec]. */ + private fun intBackedEnumType(field: com.metaobjects.field.MetaField<*>, owner: MetaObject): String? = + if (KotlinGenUtil.isIntBackedEnum(field)) KotlinTypeMapper.enumTypeName(field, owner)?.canonicalName else null + + /** + * The element Kotlin type the generated `Table`'s column for [field] holds — + * the cast target for the eq/ne/in predicate value. For scalar fields this is the same + * simple type name the DTO property uses ([KotlinTypeMapper.kotlinTypeName]); for an + * [EnumField] it is `String`, since the column is filtered by its stored string + * (a CAST-to-text — see [emitPerFieldDispatchArm]). + */ + /** + * The Kotlin simple-name type of [entity]'s by-id route parameter — the PK FIELD's + * own type via [KotlinTypeMapper.kotlinTypeName] (uuid → `UUID`, long → `Long`, + * int → `Int`, string → `String`), so `@PathVariable id` matches the Exposed + * `Column` it is compared against. Falls back to `Long` (the historical default) + * when the field can't be resolved or mapped — the same lossy-tolerant policy as + * [KotlinRelationsGenerator.primaryKeyKotlinType]. + */ + protected fun primaryKeyParamType(entity: MetaObject, pkFieldName: String): String { + // ADR-0039: resolving field lookup (the PK field may be inherited via extends). + val pkField = entity.metaFields.firstOrNull { it.name == pkFieldName } ?: return "Long" + return runCatching { KotlinTypeMapper.kotlinTypeName(pkField) } + .map { tn -> (tn as? com.squareup.kotlinpoet.ClassName)?.simpleName ?: tn.toString() } + .getOrDefault("Long") + } + + private fun columnElementType(field: com.metaobjects.field.MetaField<*>): String { + // FR-009 (#179): an enum column is filtered by its stored string (compared against a + // `CAST(col AS text)` in the WHERE arm — see emitPerFieldDispatchArm), so the predicate + // VALUE type is String. The coercer already produces String / List for enums. + if (field is EnumField) return "String" + return KotlinTypeMapper.kotlinTypeName(field).let { tn -> + (tn as? com.squareup.kotlinpoet.ClassName)?.simpleName ?: tn.toString() + } + } + + /** Read the `@objectRef` attr off a value-object [field] (resolving); null when absent. */ + private fun readObjectRef(field: ObjectField): String? { + if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, true)) return null + return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, true).valueAsString }.getOrNull() + } + + /** + * True iff [field] is a `field.object` stored as a single jsonb column (`@storage` != flattened, + * the default). The Exposed table emits one `Table.` column only for these — a flattened + * object field is materialised as per-subfield columns, so the controller must NOT reference + * `Table.` for it (it is skipped, defaulting to null in the data class). Mirrors + * [KotlinExposedTableGenerator]'s `readStorage != STORAGE_FLATTENED` column check. + */ + private fun isJsonbObjectColumn(field: ObjectField): Boolean { + if (!field.hasMetaAttr(ObjectField.ATTR_STORAGE, true)) return true // default = single jsonb column + val storage = runCatching { field.getMetaAttr(ObjectField.ATTR_STORAGE, true).valueAsString }.getOrNull() + return !STORAGE_FLATTENED.equals(storage?.trim(), ignoreCase = true) + } + + /** + * The fully-qualified Kotlin type of the value object a [field] `field.object` references + * (e.g. `acme.store.Marker`) — the Jackson bind target for the PATCH TypeReference. Emitted + * fully-qualified so the generated controller needs no extra import (mirrors the Exposed + * column codec in [KotlinExposedTableGenerator]). Falls back to a Jackson `JsonNode` when the + * `@objectRef` cannot resolve (defensive — the loader gates the attr's presence). + */ + private fun voElementTypeFqn(field: ObjectField, loader: MetaDataLoader): String { + val target = readObjectRef(field)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + return target?.let { PackageMapping.toKotlin(it.name) } + ?: "com.fasterxml.jackson.databind.JsonNode" + } + + + /** + * F22 — emit the READ-ONLY controller for a view-only `object.projection`: GET list + + * GET by id, with `POST` on the collection and `PATCH` / `PUT` / `DELETE` on the item + * each answering `405 {"error": "method_not_allowed"}`. + * + * 405 and not 404: the resource plainly exists — the same path answers GET — and a 404 + * would tell a caller the collection is absent when it is merely not writable. The verbs + * are mounted EXPLICITLY. They cannot be left to Spring, and they cannot be routed + * through this port's `@ExceptionHandler`: Spring signals an unmatched verb with + * `HttpRequestMethodNotSupportedException`, which extends `ServletException` and so is + * never caught by the `@ExceptionHandler(RuntimeException::class)` the writable + * controller carries. What Spring answers unaided is a 405 with an EMPTY body — a fifth + * body shape on a wire the other four ports spell one way. + * + * A SEPARATE assembly rather than `if (writable)` branches through [emit]. That method + * carries the FR-035 present-key patch bind, `@autoSet` stamping, the FR-037 frozen-field + * strip, DTO validation and constraint classification — none of which a projection can + * ever run. What the two genuinely share (the sort surface, the filter pipeline, rowTo, + * list, get) they share by calling the same emitters. + * + * The item verbs follow the item GET: a projection's identity is OPTIONAL (ADR-0028), + * and a keyless one mounts no `/{id}` read, so it refuses only the collection verb + * rather than advertising an address it never serves. + */ + protected open fun emitReadOnly(entity: MetaObject, outRoot: Path, loader: MetaDataLoader) { + val api = exposedApi() + val (pkg, shortName) = PackageMapping.splitFqn(entity.name) + val readObj = KotlinNaming.tableObjectName(shortName) + val routeBase = KotlinNaming.controllerPath(shortName) + + // ADR-0039: resolving — a projection's identity.primary typically EXTENDS the base + // entity's, and an own-only read would silently drop GET /{id} from the controller. + val primary = entity.getIdentities(true) + .filterIsInstance() + .firstOrNull { it.isPrimary } + val hasItem = RestSurfaceGate.hasItemRoute(entity) + val pkFieldName = primary?.let { KotlinGenUtil.keyFields(entity, it).firstOrNull() } ?: DEFAULT_PK_FIELD + val pkParamType = primaryKeyParamType(entity, pkFieldName) + + val sortFields = entity.metaFields + .filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) } + .map { it.name } + // FR-044: a report's int-backed enum column is typed by the enum of the entity it + // reads (the class its table and data class use), so its filter arm compares through + // that class. Empty for a projection, whose arms are unchanged. + val reportEnums = KotlinGenUtil.reportEnumClasses(entity, loader) + val scalarFields: List = entity.metaFields + .filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) } + .map { + ScalarFieldSpec( + it.name, it.subType, columnElementType(it), + if (KotlinGenUtil.isIntBackedEnum(it)) reportEnums[it.name]?.canonicalName else null, + ) + } + val allowlistName = "${shortName}FilterAllowlist" + // "report" or "projection", for generated prose only. + val noun = KotlinNaming.readOnlyNoun(entity) + + val source = buildString { + if (pkg.isNotEmpty()) { + append("package $pkg\n\n") + } + // The same import set the writable controller emits, MINUS everything only a + // write path reaches (insert / update / deleteWhere, the Jackson patch bind, the + // Validator, DeleteMapping / RequestBody, and the constraint @ExceptionHandler). + // Kept in the same order so a reader diffing the two files sees only the absences. + append("import ${ExposedImports.core(api, "Op")}\n") + append("import ${ExposedImports.core(api, "SortOrder")}\n") + append("import ${ExposedImports.core(api, "ResultRow")}\n") + // issue #390 (review fix): see emit()'s identical comment above. + if (api == ExposedApi.V1) { + for (op in v1FilterOperatorImports(scalarFields)) append("import ${ExposedImports.core(api, op)}\n") + } else { + append("import org.jetbrains.exposed.sql.SqlExpressionBuilder\n") + } + append("import ${ExposedImports.core(api, "and")}\n") + append("import ${ExposedImports.jdbc(api, "selectAll")}\n") + append("import ${ExposedImports.transactions(api, "transaction")}\n") + append("import org.springframework.http.HttpStatus\n") + append("import org.springframework.http.ResponseEntity\n") + append("import org.springframework.web.bind.annotation.GetMapping\n") + if (hasItem) { + append("import org.springframework.web.bind.annotation.PathVariable\n") + } + append("import org.springframework.web.bind.annotation.PostMapping\n") + append("import org.springframework.web.bind.annotation.RequestMapping\n") + if (hasItem) { + append("import org.springframework.web.bind.annotation.RequestMethod\n") + } + append("import org.springframework.web.bind.annotation.RequestParam\n") + append("import org.springframework.web.bind.annotation.RestController\n") + append("import jakarta.servlet.http.HttpServletRequest\n") + append("import java.io.ByteArrayOutputStream\n") + append("import java.nio.charset.StandardCharsets\n") + append("import java.sql.Timestamp\n") + if (scalarFields.any { it.elementType == "Instant" }) { + append("import java.time.Instant\n") + } + if (pkParamType == "UUID" || scalarFields.any { it.elementType == "UUID" }) { + append("import java.util.UUID\n") + } + // Pre-existing gap — see emit()'s identical comment above. + if (scalarFields.any { it.elementType == "BigDecimal" }) { + append("import java.math.BigDecimal\n") + } + if (scalarFields.any { it.elementType == "URI" }) { + append("import java.net.URI\n") + } + if (scalarFields.any { it.elementType == "InetAddress" }) { + append("import java.net.InetAddress\n") + } + if (scalarFields.any { needsStringCastDispatch(it.subType, it.elementType, it.intBackedEnumType) }) { + append("import ${ExposedImports.core(api, "TextColumnType")}\n") + append("import ${ExposedImports.core(api, "castTo")}\n") + } + append("import java.time.LocalDate\n") + append("import java.time.LocalDateTime\n") + append("import java.time.LocalTime\n") + append("import java.time.format.DateTimeFormatter\n") + append("\n") + + appendSortSurface(this, shortName, entity, sortFields) + emitFilterPipeline(this, shortName, readObj, allowlistName, scalarFields) + appendRowToMapper(this, shortName, entity, readObj) + + append("/**\n") + append(" * GENERATED — READ-ONLY REST controller for the ${shortName} $noun.\n") + append(" *\n") + // No `/**` glob in the example: Kotlin NESTS block comments (see emit()). + append(" * Auth: these read endpoints are unauthenticated. In your Spring Security config, require\n") + append(" * authentication for `$routeBase` and every path under it (`requestMatchers` + `authenticated()`).\n") + append(" * A row-ownership rule (\"only the owner may read this row\") can't be expressed in path\n") + append(" * config — eject this generator with `mvn metaobjects:eject` and hand-write those endpoints.\n") + append(" */\n") + append("@RestController\n") + append("@RequestMapping(\"$routeBase\")\n") + // No constructor at all: nothing here binds a request body, so injecting an + // ObjectMapper or a Validator would make a consumer wire beans this controller + // can never use. + append("class ${shortName}Controller {\n\n") + + appendListHandler(this, shortName, readObj, sortFields) + if (hasItem) { + appendGetHandler(this, shortName, readObj, pkFieldName, pkParamType) + } + + append(" @PostMapping\n") + append(" fun create(): ResponseEntity = methodNotAllowed()\n\n") + if (hasItem) { + // One mapping for all three item verbs: the bodies are identical. Stacking + // @PatchMapping + @PutMapping on one method does NOT register both in Spring + // MVC (see emit()), so the method= list is the form that works. The path + // variable is deliberately unbound — the row is never read. + append(" @RequestMapping(value = [\"/{id}\"], method = [RequestMethod.PATCH, RequestMethod.PUT, RequestMethod.DELETE])\n") + append(" fun rejectItemWrite(): ResponseEntity = methodNotAllowed()\n\n") + } + // `message` is free prose and deliberately outside the asserted contract, so this + // port spells it once rather than threading the verb through every handler. + append(" private fun methodNotAllowed(): ResponseEntity =\n") + append(" ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(\n") + append(" mapOf(\n") + append(" \"error\" to \"method_not_allowed\",\n") + append(" \"message\" to \"writes are not supported on a $noun (read-only).\",\n") + append(" ) as Any\n") + append(" )\n") + append("}\n") + } + + val outFile = outRoot.resolve(pkg.replace('.', '/')) + .resolve(KotlinNaming.controllerName(shortName) + ".kt") + GeneratedFileWriter.write(outFile, source) + } + + // ----------------------------------------------------------------------- + // Emitters shared by the WRITABLE controller (emit) and the READ-ONLY one + // (emitReadOnly). Extracted verbatim from emit(), split at exactly the points that + // keep its output byte-identical — the snapshot fixtures under + // src/test/resources/snapshots/ are the proof of that. + // + // A read-only projection serves the same list + get + sort + rowTo surface a table + // entity does, and the F22 change must not be able to reword one of them and not the + // other. emitTph() keeps its own copies: a TPH base's list and get are + // DISCRIMINATOR-scoped, so they are not the same emitter wearing a flag. + // ----------------------------------------------------------------------- + + /** The file-level sort allowlist + declared-default map + `parseSort` helper. */ + private fun appendSortSurface( + sb: StringBuilder, shortName: String, entity: MetaObject, sortFields: List + ) = with(sb) { + // Sort allowlist — static set; unknown fields → 400. Emitted at file level so it + // remains accessible to the handler functions without polluting the controller's + // surface. + append("/** GENERATED — sort allowlist for ${shortName} (cross-port API contract). */\n") + append("private val ${shortName}SortAllowlist = setOf(\n") + for (field in sortFields) { + append(" \"$field\",\n") + } + append(")\n\n") + appendSortDefaultOrders(this, shortName, entity, sortFields) + + // parseSort: returns (field, asc|desc) or null for malformed/disallowed input. + // Returning null lets the handler emit the 400 envelope itself rather than + // throwing — cleaner separation. Inlined per-entity so the allowlist closes + // over the right set without a runtime parameter. + append("private fun parse${shortName}Sort(raw: String): Pair? {\n") + append(" val parts = raw.split(\":\", limit = 2)\n") + append(" val field = parts.getOrNull(0) ?: return null\n") + append(" if (field !in ${shortName}SortAllowlist) return null\n") + // `?sort=field` with no `:order` takes the field's DECLARED @sortableDefaultOrder. + // The "asc" fallback stays at the READ, one place per port. + append(" val dirRaw = parts.getOrNull(1)?.lowercase()\n") + append(" ?: ${shortName}SortDefaultOrder[field] ?: \"asc\"\n") + append(" val dir = when (dirRaw) {\n") + append(" \"asc\" -> SortOrder.ASC\n") + append(" \"desc\" -> SortOrder.DESC\n") + append(" else -> return null\n") + append(" }\n") + append(" return field to dir\n") + append("}\n\n") + } + + /** `rowTo` — an Exposed ResultRow mapped to the generated data class. */ + private fun appendRowToMapper( + sb: StringBuilder, shortName: String, entity: MetaObject, readObj: String + ) = with(sb) { + // rowTo: ResultRow → data class. Program D: a field.object value-object jsonb + // column IS read here — the Exposed Column codec (the shared Jackson metaJsonbMapper) + // already decodes the jsonb text to the VO record / List, so `row[Table.col]` yields + // the typed value the data-class property expects. MapField (dict-of-VO) stays skipped + // (staged out). The `field.string @dbColumnType=jsonb` open bag is a StringField, so it is + // read here too (its column decodes to a kotlinx JsonElement). + // #214: reads route to `readObj` (the view for a write-through entity), which carries the + // DERIVED columns — so a derived scalar field (a StringField / etc.) maps here too. + append("/** GENERATED — map an Exposed ResultRow to the ${shortName} data class. */\n") + append("private fun rowTo${shortName}(row: ResultRow): ${shortName} = ${shortName}(\n") + for (field in entity.metaFields) { + // MapField (staged out) and a flattened object field (materialised as per-subfield + // columns, no single `Table.`) are skipped — the data class defaults them. + if (field is MapField || (field is ObjectField && !isJsonbObjectColumn(field))) continue + // The column PROPERTY, which the table generator renames when the field name is a + // member of Exposed's Table (`source` is declared `sourceColumn`). Identity otherwise. + append(" ${field.name} = row[${readObj}.${KotlinNaming.safeColumnProperty(field.name, exposedApi())}],\n") + } + append(")\n\n") + } + + /** GET (list) — pagination + sort + withCount + the FR-009 filter operators. */ + private fun appendListHandler( + sb: StringBuilder, shortName: String, readObj: String, sortFields: List + ) = with(sb) { + val api = exposedApi() + // List handler — pagination + sort + withCount + FR-009 filter operators. + // + // The filter reads `request.queryString` raw rather than Spring's parameter map: + // Tomcat drops a parameter holding a malformed escape, which silently unfiltered + // the list (see parseFilter). + append(" @GetMapping\n") + append(" fun list(\n") + append(" @RequestParam(required = false) limit: Int?,\n") + append(" @RequestParam(required = false) offset: Int?,\n") + append(" @RequestParam(required = false) sort: String?,\n") + append(" @RequestParam(required = false, name = \"withCount\") withCount: Int?,\n") + append(" request: HttpServletRequest,\n") + append(" ): ResponseEntity = transaction {\n") + append(" // FR-009 filter operators — short-circuit 400 on invalid field/op/value.\n") + append(" val filterResult = parse${shortName}Filter(request.queryString)\n") + append(" if (filterResult.error != null) {\n") + append(" return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to filterResult.error, \"field\" to filterResult.field) as Any)\n") + append(" }\n") + append(" val whereOp = ${shortName}WhereOp(filterResult.predicates)\n") + append(" var q = if (whereOp != null) ${readObj}.selectAll().where { whereOp } else ${readObj}.selectAll()\n") + append(" if (sort != null) {\n") + append(" val parsed = parse${shortName}Sort(sort)\n") + append(" ?: return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"invalid_sort\", \"field\" to sort.substringBefore(':')) as Any)\n") + append(" val (field, dir) = parsed\n") + append(" q = q.orderBy(${sortColumnExpr(readObj, shortName, sortFields, exposedApi())} to dir)\n") + append(" }\n") + append(" val total: Long = if (withCount == 1) q.count() else -1L\n") + append(" val effectiveLimit = limit ?: 50\n") + append(" val effectiveOffset = (offset ?: 0).toLong()\n") + // issue #390: see the vanilla/TPH controllers' identical comment above. + if (api == ExposedApi.V1) { + append(" val rows = q.limit(effectiveLimit).offset(effectiveOffset).map { rowTo${shortName}(it) }\n") + } else { + append(" val rows = q.limit(effectiveLimit, effectiveOffset).map { rowTo${shortName}(it) }\n") + } + append(" if (withCount == 1) ResponseEntity.ok(mapOf(\"rows\" to rows, \"total\" to total) as Any)\n") + append(" else ResponseEntity.ok(rows as Any)\n") + append(" }\n\n") + } + + /** GET /{id} — single row by primary key, 404 envelope on a miss. */ + private fun appendGetHandler( + sb: StringBuilder, shortName: String, readObj: String, pkFieldName: String, pkParamType: String + ) = with(sb) { + // GET /{id} + append(" @GetMapping(\"/{id}\")\n") + append(" fun get(@PathVariable id: $pkParamType): ResponseEntity = transaction {\n") + append(" val row = ${readObj}.selectAll().where { ${readObj}.${pkFieldName} eq id }.singleOrNull()\n") + append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") + append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" }\n\n") + } + + /** A single-field `identity.primary @generation: uuid` key — the one the create handler mints. */ + private fun uuidGeneratedPk(entity: MetaObject, pkFieldName: String): Boolean { + val pk = entity.primaryIdentity ?: return false + return pk.isUuid && pk.fields.singleOrNull() == pkFieldName + } + + private companion object { + /** Default primary-key field name when the entity declares no identity.primary. */ + const val DEFAULT_PK_FIELD = "id" + + /** `@storage:flattened` on a field.object — the one storage mode the controller excludes + * (materialised as per-subfield columns, not a single jsonb `Table.`). Cross-language + * @storage vocabulary; mirrors KotlinExposedTableGenerator's constant. */ + const val STORAGE_FLATTENED = "flattened" + + @JvmStatic + val LOG = LoggerFactory.getLogger(KotlinSpringControllerGenerator::class.java) + } + + // === MultiFileDirectGeneratorBase abstract-method stubs ==================== + override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ } + override fun ?> getSingleWriter( + loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter? + ): T? = null + override fun ?> getFinalWriter( + loader: MetaDataLoader?, out: OutputStream? + ): T? = null + override fun writeFinalFile(metadata: MutableCollection?, writer: GeneratorIOWriter<*>?) { /* none */ } + override fun getSingleOutputFilePath(md: MetaObject): String = + PackageMapping.splitFqn(md.name).first.replace('.', '/') + override fun getSingleOutputFilename(md: MetaObject): String = + KotlinNaming.controllerName(PackageMapping.splitFqn(md.name).second) + ".kt" +} + +/** + * Emit the per-entity `SortDefaultOrder` map the generated `parseSort` reads + * for a `?sort=field` carrying no `:order`. + * + * Carries DECLARED orders only — a field with no `@sortableDefaultOrder` is absent and the + * generated read supplies `"asc"`. That split matches the other four ports: one place per + * port spells the fallback, so no port can drift by baking a different default into its map. + * + * ADR-0039: resolving accessor — `@sortableDefaultOrder` may be inherited via `extends`. + */ +private fun appendSortDefaultOrders( + sb: StringBuilder, + shortName: String, + owner: MetaObject, + sortFields: List, +) { + val declared = sortFields.mapNotNull { name -> + val f = owner.metaFields.firstOrNull { it.name == name } ?: return@mapNotNull null + if (!f.hasMetaAttr(MetaField.ATTR_SORTABLE_DEFAULT_ORDER)) return@mapNotNull null + val v = f.getMetaAttr(MetaField.ATTR_SORTABLE_DEFAULT_ORDER).valueAsString + if (v == "asc" || v == "desc") name to v else null + } + sb.append("/** GENERATED — declared @sortableDefaultOrder per field for $shortName. */\n") + // The type is STATED, not inferred. `mapOf()` with zero pairs leaves K and V with + // nothing to resolve and Kotlin refuses to compile — and the common case is zero + // pairs, because most models declare no @sortableDefaultOrder at all. A text-golden + // snapshot cannot see this: it pins the emitted characters, and `mapOf(\n)` is a + // perfectly ordinary-looking string. Only compiling the output catches it, which is + // what the integration lane is for. + sb.append("private val ${shortName}SortDefaultOrder: Map = mapOf(\n") + for ((name, v) in declared) sb.append(" \"$name\" to \"$v\",\n") + sb.append(")\n\n") +} + +/** + * The generated Kotlin expression resolving a sort FIELD NAME to its Exposed column. + * + * Deliberately NOT `$table.columns.first { it.name == field }`. Exposed's `Column.name` is + * the PHYSICAL SQL identifier — `created_at` under the default snake_case strategy — while + * the query string carries the METADATA field name, `createdAt`. Comparing the two threw + * `NoSuchElementException: Collection contains no element matching the predicate` out of the + * request handler, i.e. an HTTP 500 on `?sort=` for every multi-word sortable field. + * The api-contract corpus never caught it because its only sort scenario sorted on `name`, + * a single word where both spellings coincide. + * + * The dispatch pairs each name with `$table.` using the SAME + * [KotlinNaming.safeColumnProperty] the table generator uses to emit that property, so the + * two cannot drift, and `@column` overrides need no special case — the reference is the + * property itself, never a name string. + * + * The `else` arm is unreachable by construction: the sort allowlist `parse${shortName}Sort` + * gates against is built from this same `sortFields` list. It throws rather than returning + * `null` so a future divergence between the two surfaces a generator bug instead of + * answering a valid request with a 400. + */ +private fun sortColumnExpr(tableVar: String, shortName: String, sortFields: List, api: ExposedApi): String { + if (sortFields.isEmpty()) return "error(\"$shortName has no sortable fields\")" + val sb = StringBuilder("when (field) {\n") + for (name in sortFields) { + val prop = KotlinNaming.safeColumnProperty(name, api) + sb.append(" \"").append(name).append("\" -> ") + .append(tableVar).append(".").append(prop).append("\n") + } + sb.append(" else -> error(\"").append(shortName) + .append(": sort field has no column (generator drift): \" + field)\n") + sb.append(" }") + return sb.toString() +} diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java index fe41ffbde..f84d91a83 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java @@ -1,13 +1,15 @@ package com.metaobjects.generator.spring; -import com.metaobjects.MetaRoot; import com.metaobjects.field.EnumField; import com.metaobjects.field.MetaField; import com.metaobjects.generator.GeneratorException; +import com.metaobjects.generator.util.GeneratedFileWriter; import com.metaobjects.generator.util.GeneratorUtil; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.object.MetaObject; +import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -21,10 +23,13 @@ * C# {@code Fr019SharedEnum}). * *

A reusable enum is a ROOT/package-level abstract {@code field.enum} (a sibling of - * {@code object.entity}) that concrete entity fields {@code extends}. Per ADR-0026 such a - * declaration is materialized ONCE per port as a standalone Java {@code enum} (one file, - * {@code Priority.java}) and referenced, instead of being redeclared inline (a nested - * {@code public enum E}) in every consuming DTO record.

+ * {@code object.entity}) that a concrete field anywhere — on an {@code object.entity}, + * {@code object.projection}, or {@code object.value} — reaches via (possibly multi-hop) + * {@code extends}, resolved by the single cross-generator authority + * {@link EnumField#resolveSharedEnumDeclaration}. Per ADR-0026 such a declaration is + * materialized ONCE per port as a standalone Java {@code enum} (one file, {@code Priority.java}) + * and referenced, instead of being redeclared inline (a nested {@code public enum E}) in every + * consuming record.

* *

Two cases on the abstract declaration:

*
    @@ -95,19 +100,16 @@ public static Map parsePackageMap(String arg) { } /** - * The root-level abstract {@code field.enum} a concrete enum field resolves to via - * {@code extends}, or {@code null} when the field is an inline enum (no root-abstract super). - * A super qualifies only when it is (a) an {@link EnumField}, (b) abstract, AND (c) a direct - * child of the metadata root ({@link MetaRoot}) — NOT an abstract field nested inside an object. + * The shared-enum declaration {@code field} resolves to via {@code extends}, or {@code null} + * when the field is an inline enum. Delegates entirely to the single cross-generator + * authority, {@link EnumField#resolveSharedEnumDeclaration} (metadata module) — the SAME + * function the Kotlin port and the #246 loader validator call, so the two JVM generators and + * the loader can never disagree about what "shared" means. See that method's Javadoc for the + * full rule (nearest-declaration-wins, an own-{@code @values}/{@code @intValueMap} node stops + * the walk, no hop limit, no hosting-object-subtype condition). */ public static MetaField resolveSharedEnumDecl(MetaField field) { - if (!(field instanceof EnumField)) return null; - MetaField sup = field.getSuperField(); - if (!(sup instanceof EnumField)) return null; - if (!GeneratorUtil.isAbstract(sup)) return null; - // The declaration must sit at the package/root level (a sibling of object.entity). - if (!(sup.getParent() instanceof MetaRoot)) return null; - return sup; + return EnumField.resolveSharedEnumDeclaration(field); } /** The shared-enum descriptor a field resolves to, or {@code null} for inline enums. */ @@ -145,16 +147,25 @@ private static boolean isProvided(EnumField decl) { /** * All shared (root-level abstract) enum declarations in the loaded model that are actually - * CONSUMED by at least one concrete entity field — keyed by materialized type name, sorted by + * CONSUMED by at least one concrete object field — keyed by materialized type name, sorted by * name (deterministic). A declaration nobody extends is not materialized (no dangling type). - * Only {@code object.entity} fields are considered (the DTO scope); payload value-objects keep - * their own inline nesting. + * Scans every concrete {@code object.entity}, {@code object.projection} AND + * {@code object.value} (there is no condition on the hosting object's SUBTYPE — a projection + * or value-object field reaching a shared declaration must-generate it too); abstract objects + * are skipped (nothing is generated for them directly — a concrete subtype's resolving + * {@link MetaObject#getMetaFields()} already sees their inherited fields). Scans the WHOLE + * loader regardless of which objects a particular generator invocation is narrowed to, so a + * run filtered down to a single consuming object still materializes the type it reaches. */ public static List collectSharedEnums(MetaDataLoader loader) { Map out = new LinkedHashMap<>(); - for (MetaObject entity : loader.getMetaObjects()) { - if (!MetaObject.SUBTYPE_ENTITY.equals(entity.getSubType())) continue; - for (MetaField field : entity.getMetaFields()) { + for (MetaObject owner : loader.getMetaObjects()) { + if (GeneratorUtil.isAbstract(owner)) continue; + String subType = owner.getSubType(); + if (!MetaObject.SUBTYPE_ENTITY.equals(subType) + && !MetaObject.SUBTYPE_PROJECTION.equals(subType) + && !MetaObject.SUBTYPE_VALUE.equals(subType)) continue; + for (MetaField field : owner.getMetaFields()) { SharedEnum shared = sharedEnumForField(field); if (shared == null) continue; out.putIfAbsent(shared.name(), shared); @@ -172,6 +183,44 @@ public static List materializedSharedEnums(MetaDataLoader loader) { return out; } + /** + * The opt-in generator arg ({@code emitSharedEnums=true}) with which the value-object and + * output-parser generators write the materialized shared enums into their own + * {@code outputDir}. {@link SpringDtoGenerator} owns those files and always writes them, so + * the arg is for a run that includes one of the other two WITHOUT the DTO generator. Leaving + * it off elsewhere is what keeps one {@code .java} per shared enum when the generators + * write to separate output directories. + */ + public static final String ARG_EMIT_SHARED_ENUMS = "emitSharedEnums"; + + /** + * Write each materialized shared enum as a standalone top-level Java {@code enum} in its + * declaring package — {@code /.java} containing {@code public enum { A, B, C }} + * — for EVERY shared enum the model consumes, whichever object reaches it. Provided enums + * emit nothing (referenced externally). Called by {@link SpringDtoGenerator} on every run and + * by the value-object and output-parser generators only under {@link #ARG_EMIT_SHARED_ENUMS}. + */ + public static void emitMaterialized(MetaDataLoader loader, Path outRoot) { + for (SharedEnum shared : materializedSharedEnums(loader)) { + StringBuilder src = new StringBuilder(); + if (!shared.javaPackage().isEmpty()) { + src.append("package ").append(shared.javaPackage()).append(";\n\n"); + } + src.append("/** GENERATED — shared enum ").append(shared.name()) + .append(". Do not hand-edit; regenerated from metadata. */\n"); + src.append(SpringTypeMapper.enumDeclaration(shared.name(), shared.values(), shared.intValueMap())) + .append("\n"); + try { + Path outFile = outRoot.resolve(shared.javaPackage().replace('.', '/')) + .resolve(shared.name() + ".java"); + GeneratedFileWriter.write(outFile, src.toString()); + } catch (IOException e) { + throw new GeneratorException( + "failed writing shared enum " + shared.name() + ".java: " + e, e); + } + } + } + /** * The Java type reference a consuming DTO component emits for a shared/provided enum: *
      @@ -188,7 +237,18 @@ public static String typeReference(SharedEnum shared, MetaObject owner, Provided if (shared.javaPackage().isEmpty() || shared.javaPackage().equals(ownerJavaPkg)) { return shared.name(); } - return shared.javaPackage() + "." + shared.name(); + } + return qualifiedTypeReference(shared, cfg); + } + + /** + * The always-qualified reference ({@code .E} / {@code .E}) for generated code that + * lives in a package of its own — the output parser sits in {@code .prompts}, never in + * the enum's package, so it cannot use the bare same-package form. + */ + public static String qualifiedTypeReference(SharedEnum shared, ProvidedEnumConfig cfg) { + if (!shared.provided()) { + return shared.javaPackage().isEmpty() ? shared.name() : shared.javaPackage() + "." + shared.name(); } String ns = cfg.packageNamespaces().get(shared.metaPackage()); if (ns == null || ns.isBlank()) ns = cfg.fallbackNamespace(); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java index 1e49cc613..9a5b6d53d 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java @@ -112,9 +112,10 @@ public void execute(MetaDataLoader loader) { Path outRoot = Paths.get(outDir.getAbsolutePath()); fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( getArg("providedEnumNamespace"), getArg("providedEnumPackages")); - // FR-019: materialize each shared (package-level abstract, non-@provided) enum ONCE as a - // standalone Java enum, so consuming DTOs reference it instead of redeclaring it inline. - emitSharedEnums(loader, outRoot); + // FR-019: this generator OWNS the shared enum files. It writes every materialized shared + // (package-level abstract, non-@provided) enum the model consumes, including one only a + // value object or an output parser references, so those generators write none by default. + Fr019SharedEnum.emitMaterialized(loader, outRoot); boolean emitAbstractShapes = Boolean.parseBoolean(getArg("emitAbstractShapes", "false")); for (MetaObject declared : loader.getMetaObjects()) { // FR-044: a served report's row DTO is emitted from its read model (one @@ -905,26 +906,6 @@ private String componentTypeFr019(MetaField field, MetaObject owner) { return componentType(field, owner); } - /** - * FR-019: emit each materialized shared enum (package-level abstract, non-{@code @provided}, - * consumed by ≥1 entity field) ONCE as a standalone top-level Java {@code enum} in its declaring - * package — {@code /.java} containing {@code public enum { A, B, C }}. Consuming - * DTOs reference this type. Provided enums emit nothing here (referenced externally). - */ - private void emitSharedEnums(MetaDataLoader loader, Path outRoot) { - for (Fr019SharedEnum.SharedEnum shared : Fr019SharedEnum.materializedSharedEnums(loader)) { - StringBuilder src = new StringBuilder(); - if (!shared.javaPackage().isEmpty()) { - src.append("package ").append(shared.javaPackage()).append(";\n\n"); - } - src.append("/** GENERATED — shared enum ").append(shared.name()) - .append(". Do not hand-edit; regenerated from metadata. */\n"); - src.append(SpringTypeMapper.enumDeclaration(shared.name(), shared.values(), shared.intValueMap())) - .append("\n"); - writeSharedEnumFile(shared, outRoot, src.toString()); - } - } - // === #234 strict field.uri / field.inet bind-layer enforcement ============= /** @@ -1075,18 +1056,6 @@ private void writeNetBindingsFile(String pkg, Path outRoot) { } } - /** Write {@code //.java} for a materialized shared enum. */ - private void writeSharedEnumFile(Fr019SharedEnum.SharedEnum shared, Path outRoot, String body) { - try { - Path outFile = outRoot.resolve(shared.javaPackage().replace('.', '/')) - .resolve(shared.name() + ".java"); - GeneratedFileWriter.write(outFile, body); - } catch (IOException e) { - throw new GeneratorException( - "failed writing shared enum " + shared.name() + ".java: " + e, e); - } - } - /** * Collect the nested {@code public enum { }} declarations for the enum * fields in {@code fields} (the record's components), deduped by enum-type name (two fields diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java index eb5102566..9703d7efc 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java @@ -35,6 +35,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import com.metaobjects.generator.spring.Fr019SharedEnum; import com.metaobjects.generator.util.GeneratedFileWriter; /** @@ -118,6 +119,16 @@ */ public class SpringOutputParserGenerator extends MultiFileDirectGeneratorBase { + /** + * FR-019 per-port config for resolving {@code @provided} enum namespaces, parsed from the + * same {@code providedEnumNamespace} / {@code providedEnumPackages} generator args + * {@link SpringDtoGenerator} and {@link SpringValueObjectGenerator} read — a parsed enum + * field here may equally reach a shared/{@code @provided} declaration, and all three + * generators must resolve it to the SAME reference. + */ + private Fr019SharedEnum.ProvidedEnumConfig fr019Config = + new Fr019SharedEnum.ProvidedEnumConfig(null, java.util.Map.of()); + @Override protected Class getFilterClass() { return MetaObject.class; @@ -127,6 +138,11 @@ protected Class getFilterClass() { public void execute(MetaDataLoader loader) { parseArgs(); Path outRoot = Paths.get(outDir.getAbsolutePath()); + fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( + getArg("providedEnumNamespace"), getArg("providedEnumPackages")); + if (Boolean.parseBoolean(getArg(Fr019SharedEnum.ARG_EMIT_SHARED_ENUMS, "false"))) { + Fr019SharedEnum.emitMaterialized(loader, outRoot); + } // ADR-0052: the direction rule lives in FindInbound, never re-derived here. for (MetaTemplate tmpl : FindInbound.inboundTemplates(loader)) { @@ -371,12 +387,17 @@ protected String mapperArgForField(MetaField field, MetaObject owner, String } // Enum fields: the assembled value is a validated member string; the record component - // is the enum nested in the value object's record. Bridge string → enum via - // `..valueOf(s)` (per-element for arrays, dropping nulls). Checked BEFORE + // is either the enum nested in the value object's record (inline) or a shared/ + // @provided top-level type (FR-019 — resolved by the SAME Fr019SharedEnum authority + // SpringDtoGenerator and SpringValueObjectGenerator use). Bridge string → enum via + // `.valueOf(s)` (per-element for arrays, dropping nulls). Checked BEFORE // the generic scalar-array branch (enum-before-isArray). Safe: @values == the generated // constants and the engine validated the member. if (field instanceof EnumField ef) { - String enumType = payloadClass + "." + SpringTypeMapper.enumTypeName(owner, ef); + Fr019SharedEnum.SharedEnum shared = Fr019SharedEnum.sharedEnumForField(ef); + String enumType = shared != null + ? Fr019SharedEnum.qualifiedTypeReference(shared, fr019Config) + : payloadClass + "." + SpringTypeMapper.enumTypeName(owner, ef); if (field.isArrayType()) { // asStringList returns null when the array is absent/malformed — guard with an // empty list so the stream never NPEs. diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java index 095ed5ca2..056aa6fda 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java @@ -326,6 +326,9 @@ private static List derivePayloadFieldTree( seen.add(vo.getName()); List fields = new ArrayList<>(); for (MetaField field : vo.getMetaFields()) { + // ADR-0039: isArrayType() is the EFFECTIVE (inherited-via-extends) array-ness; the + // own-only isArray() flag would read false for a field that inherits @isArray. + boolean isArray = field.isArrayType(); if (field instanceof ObjectField of) { // Resolve the nested @objectRef by BARE short-name — the cross-port // render-helper consensus (TS findObject: c.type === OBJECT && @@ -339,11 +342,11 @@ private static List derivePayloadFieldTree( if (target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) { List children = derivePayloadFieldTree(loader, target, new LinkedHashSet<>(seen)); - fields.add(PayloadField.object(field.getName(), children)); + fields.add(PayloadField.object(field.getName(), children, isArray)); continue; } } - fields.add(PayloadField.scalar(field.getName())); + fields.add(PayloadField.scalar(field.getName(), isArray)); } return fields; } @@ -390,7 +393,7 @@ protected static MetaObject resolveNestedObjectRef(MetaDataLoader loader, Object /** * Emit a {@code List} as deterministic Java construction code * ({@code java.util.List.of(...)}): {@code PayloadField.scalar("name")} and - * {@code PayloadField.object("nested", List.of(...))}. An empty tree emits + * {@code PayloadField.object("nested", List.of(...))}, each with a trailing {@code , true} when the field is an array. An empty tree emits * {@code java.util.List.of()}. */ protected static String fieldTreeLiteral(List fields) { @@ -402,10 +405,10 @@ protected static String fieldTreeLiteral(List fields) { if (f.fields() != null) { sb.append("com.metaobjects.render.PayloadField.object(") .append(quote(f.name())).append(", ") - .append(fieldTreeLiteral(f.fields())).append(")"); + .append(fieldTreeLiteral(f.fields())).append(f.isArray() ? ", true)" : ")"); } else { sb.append("com.metaobjects.render.PayloadField.scalar(") - .append(quote(f.name())).append(")"); + .append(quote(f.name())).append(f.isArray() ? ", true)" : ")"); } } sb.append(")"); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java index a5fc7b92f..2d888b4b2 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java @@ -317,16 +317,35 @@ public static String payloadJavaTypeName(MetaField field, MetaObject owner, S } /** - * The generated Java {@code enum} type name for an enum-subtype payload field — the SAME - * shared scheme the other ports use ({@code KotlinTypeMapper.enumTypeName} / - * C# {@code CSharpNaming.EnumTypeName}): when the field {@code extends} an abstract enum - * super, all extenders collapse onto ONE enum named for the top-most super - * ({@code Pascal(super.shortName)}); otherwise {@code Pascal(owner.shortName) + - * Pascal(field.name)}. Members verbatim. + * The generated Java {@code enum} type name for an enum-subtype payload field, in this order: + *
        + *
      1. {@code field} reaches a package-level shared enum DECLARATION via + * {@link EnumField#resolveSharedEnumDeclaration} (the single cross-generator authority — + * possibly multi-hop, stopped by an own {@code @values}/{@code @intValueMap} along the + * way): {@code Pascal(declaration.shortName)}.
      2. + *
      3. otherwise the enum is INLINE (nested in the owner's record). A field with a super is + * named for its top-most super, {@code Pascal(topMostSuper.shortName)}, so every field + * extending the same inline enum shares one nested type; a field with no super is + * {@code Pascal(owner.shortName) + Pascal(field.name)}.
      4. + *
      5. the one exception to the inline rule: the top-most super is itself a shared + * declaration, which the field does NOT resolve to because a node on the chain + * redeclares {@code @values}/{@code @intValueMap}. The super's name is the shared type's + * own name and would shadow it inside the record, so the inline enum is + * {@code Pascal(owner.shortName) + Pascal(field.name)}.
      6. + *
      + * Members verbatim. + * + *

      This is a BARE (unqualified) name — a shared declaration materialized in a different + * Java package, or marked {@code @provided}, is qualified by + * {@link Fr019SharedEnum#typeReference}; this method answers only "what is the type called."

      */ public static String enumTypeName(MetaObject owner, MetaField field) { + MetaField declaration = EnumField.resolveSharedEnumDeclaration(field); + if (declaration != null) { + return pascal(SpringNaming.splitFqn(declaration.getName())[1]); + } MetaField superRoot = resolveSuperRoot(field); - if (superRoot != null) { + if (superRoot != null && !EnumField.isSharedEnumDeclaration(superRoot)) { return pascal(SpringNaming.splitFqn(superRoot.getName())[1]); } return pascal(SpringNaming.splitFqn(owner.getName())[1]) + pascal(field.getName()); @@ -334,8 +353,8 @@ public static String enumTypeName(MetaObject owner, MetaField field) { /** * Walk a field's {@code extends} (super-field) chain to the top-most ancestor, returning it, - * or {@code null} when the field has no super. Naming the generated enum after the top-most - * super makes every extending field share one type. Cycle-guarded via a visited set. + * or {@code null} when the field has no super. Naming an inline enum after the top-most + * super makes every extending field share one nested type. Cycle-guarded via a visited set. */ static MetaField resolveSuperRoot(MetaField field) { MetaField current = field.getSuperField(); 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 d31750401..1a05544d5 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 @@ -28,6 +28,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import com.metaobjects.generator.spring.Fr019SharedEnum; import com.metaobjects.generator.util.GeneratedFileWriter; /** @@ -62,6 +63,17 @@ */ public class SpringValueObjectGenerator extends MultiFileDirectGeneratorBase { + /** + * FR-019 per-port config for resolving {@code @provided} enum namespaces, parsed from the + * same {@code providedEnumNamespace} / {@code providedEnumPackages} generator args + * {@link SpringDtoGenerator} reads — a value-object field may equally reach a shared + * declaration (a projection/value-object field extending an entity field that in turn + * extends a root-level abstract enum), and the two generators must resolve it to the SAME + * reference. + */ + private Fr019SharedEnum.ProvidedEnumConfig fr019Config = + new Fr019SharedEnum.ProvidedEnumConfig(null, java.util.Map.of()); + @Override protected Class getFilterClass() { return MetaObject.class; @@ -71,6 +83,11 @@ protected Class getFilterClass() { public void execute(MetaDataLoader loader) { parseArgs(); Path outRoot = Paths.get(outDir.getAbsolutePath()); + fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( + getArg("providedEnumNamespace"), getArg("providedEnumPackages")); + if (Boolean.parseBoolean(getArg(Fr019SharedEnum.ARG_EMIT_SHARED_ENUMS, "false"))) { + Fr019SharedEnum.emitMaterialized(loader, outRoot); + } for (MetaObject vo : valueShapes(loader)) { emit(vo, outRoot); } @@ -138,7 +155,7 @@ private void emit(MetaObject vo, Path outRoot) { for (int i = 0; i < fields.size(); i++) { MetaField field = fields.get(i); String a = annotations.get(i); - String type = SpringDtoGenerator.componentType(field, vo); + String type = componentTypeFr019(field, vo); // A field named notify/wait/toString/… cannot BE a record component (JLS 8.10.3), // so it is escaped here and pinned back to its declared name on the wire. String component = SpringNaming.recordComponentName(field.getName()); @@ -173,15 +190,40 @@ private void emit(MetaObject vo, Path outRoot) { } /** - * Nested {@code public enum { }} declarations for the value object's inline + * FR-019-aware component type for a value-object field. A {@code field.enum} resolving to a + * package-level shared enum is typed by the materialized standalone type (a bare {@code E} + * in the same package, else its FQN) or — when {@code @provided} — the configured external + * {@code .E}; an enum array wraps it in {@code List<…>}. Every other field (including + * inline enums) delegates verbatim to {@link SpringDtoGenerator#componentType}, so the inline + * default stays byte-identical. Mirrors {@link SpringDtoGenerator}'s own + * {@code componentTypeFr019} — the two generators resolve a shared field to the SAME + * reference because both call {@link Fr019SharedEnum#sharedEnumForField}. + */ + private String componentTypeFr019(MetaField field, MetaObject owner) { + if (field instanceof EnumField) { + Fr019SharedEnum.SharedEnum shared = Fr019SharedEnum.sharedEnumForField(field); + if (shared != null) { + String ref = Fr019SharedEnum.typeReference(shared, owner, fr019Config); + return field.isArrayType() ? "java.util.List<" + ref + ">" : ref; + } + } + return SpringDtoGenerator.componentType(field, owner); + } + + /** + * Nested {@code public enum { }} declarations for the value object's INLINE * enum members, deduped by enum-type name — mirrors {@link SpringDtoGenerator}'s record body. - * A VO with no enum members yields an empty list (the common case). + * A field resolving to a package-level shared enum (materialized standalone or + * {@code @provided} externally) is referenced, never nested — skipped here, the same guard + * {@code SpringDtoGenerator.collectEnumDecls} applies. A VO with no own enum members yields + * an empty list (the common case). */ private static List collectEnumDecls(MetaObject owner, List fields) { Set seen = new LinkedHashSet<>(); List decls = new ArrayList<>(); for (MetaField field : fields) { if (!(field instanceof EnumField ef)) continue; + if (Fr019SharedEnum.sharedEnumForField(field) != null) continue; List values = SpringTypeMapper.effectiveEnumValues(ef); if (values.isEmpty()) continue; String typeName = SpringTypeMapper.enumTypeName(owner, ef); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java index 6bbd2e372..e7ccc2a94 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java @@ -1,6 +1,11 @@ package com.metaobjects.generator.spring; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.field.EnumField; +import com.metaobjects.field.MetaField; import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; import com.metaobjects.registry.SharedRegistryTestBase; import org.junit.Rule; import org.junit.Test; @@ -9,9 +14,14 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -81,6 +91,20 @@ public void sharedEnumIsMaterializedOnceAndReferencedByBothEntities() throws Exc } } + @Test + public void multiHopEnumExtendsStillResolvesToTheSharedEnum() throws Exception { + // Task.urgency extends Ticket.priority, which extends the shared abstract Priority. + // Task ALSO carries phase/weight (own @values / @intValueMap, resp. — inline per-object + // enums per rule (b)), so this asserts only that Priority specifically is referenced, not + // redeclared — "no `public enum` at all" would wrongly fail on Task's legitimate inline + // fields. + Path out = run(loadShared(), "com.acme.ext"); + String task = Files.readString(out.resolve("acme/shop/TaskDto.java")); + assertTrue("urgency must be typed as the shared enum; saw:\n" + task, task.contains("Priority urgency")); + assertFalse("shared Priority must NOT be redeclared in TaskDto; saw:\n" + task, + task.contains("public enum Priority")); + } + @Test public void providedEnumIsNotMaterializedAndReferencedAtTheConfiguredNamespace() throws Exception { Path out = run(loadShared(), "com.acme.ext"); @@ -107,4 +131,256 @@ public void providedEnumWithNoNamespaceConfigIsACodegenErrorNamingTheEnum() thro e.getMessage() != null && e.getMessage().contains("Currency")); } } + + // ========================================================================= + // Decision table — one shared-enum rule for every object-hosted field.enum + // ========================================================================= + + /** + * For EVERY object-hosted {@code field.enum} in the shared fixture (entities, the projection, + * the value object), compute {@code {"shared": ""|null, "provided": bool}} + * from the SAME function the Java generators call ({@link Fr019SharedEnum#sharedEnumForField}) + * and assert exact equality with {@code expected-enum-types.json}'s {@code fields} map in both + * directions (no missing key, no extra key). Also asserts {@code materialized}. + */ + @Test + public void decisionTableMatchesSharedFixtureExpectation() throws Exception { + MetaDataLoader loader = loadShared(); + Map> actual = computeDecisionTable(loader); + JsonNode expected = readExpectedEnumTypes(); + + JsonNode expectedFields = expected.get("fields"); + Iterator expectedKeys = expectedFields.fieldNames(); + int expectedCount = 0; + while (expectedKeys.hasNext()) { + String key = expectedKeys.next(); + expectedCount++; + assertTrue("decision table missing key " + key + "; actual keys: " + actual.keySet(), + actual.containsKey(key)); + JsonNode expectedEntry = expectedFields.get(key); + Map actualEntry = actual.get(key); + Object expectedShared = expectedEntry.get("shared").isNull() + ? null : expectedEntry.get("shared").asText(); + assertEquals("mismatched 'shared' for " + key, expectedShared, actualEntry.get("shared")); + assertEquals("mismatched 'provided' for " + key, + expectedEntry.get("provided").asBoolean(), actualEntry.get("provided")); + } + assertEquals("decision table has extra keys beyond the fixture; actual: " + actual.keySet(), + expectedCount, actual.size()); + + List expectedMaterialized = new ArrayList<>(); + for (JsonNode n : expected.get("materialized")) expectedMaterialized.add(n.asText()); + List actualMaterialized = new ArrayList<>(); + for (Fr019SharedEnum.SharedEnum e : Fr019SharedEnum.materializedSharedEnums(loader)) { + actualMaterialized.add(e.name()); + } + assertEquals("materialized shared enums must match the fixture", expectedMaterialized, actualMaterialized); + } + + /** {@code ::.} -> {@code {"shared": |null, "provided": bool}}, + * computed via {@link Fr019SharedEnum#sharedEnumForField} for every object-hosted + * {@code field.enum} on every concrete entity/projection/value object — no condition on the + * hosting object's subtype. */ + private static Map> computeDecisionTable(MetaDataLoader loader) { + Map> out = new LinkedHashMap<>(); + for (MetaObject owner : loader.getMetaObjects()) { + String subType = owner.getSubType(); + if (!MetaObject.SUBTYPE_ENTITY.equals(subType) + && !MetaObject.SUBTYPE_PROJECTION.equals(subType) + && !MetaObject.SUBTYPE_VALUE.equals(subType)) continue; + for (MetaField field : owner.getMetaFields()) { + if (!(field instanceof EnumField)) continue; + String key = owner.getName() + "." + field.getName(); + Fr019SharedEnum.SharedEnum shared = Fr019SharedEnum.sharedEnumForField(field); + Map entry = new LinkedHashMap<>(); + if (shared == null) { + entry.put("shared", null); + entry.put("provided", false); + } else { + String fqn = shared.metaPackage().isEmpty() + ? shared.name() : shared.metaPackage() + "::" + shared.name(); + entry.put("shared", fqn); + entry.put("provided", shared.provided()); + } + out.put(key, entry); + } + } + return out; + } + + private JsonNode readExpectedEnumTypes() throws IOException { + Path repoRoot = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize(); + Path path = repoRoot.resolve("fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json"); + assertTrue("expected-enum-types.json must exist at " + path, Files.exists(path)); + return new ObjectMapper().readTree(Files.readString(path)); + } + + // ========================================================================= + // Generated-output assertions — the multi-hop / provided / negative cases + // ========================================================================= + + @Test + public void multiHopProjectionAndValueObjectFieldsReferenceTheSharedTypeAndItEmitsOnce() throws Exception { + MetaDataLoader loader = loadShared(); + Path dtoOut = run(loader, "com.acme.ext"); + + // Priority is materialized exactly once, regardless of how many objects/hops reach it. + try (java.util.stream.Stream walk = Files.walk(dtoOut)) { + long priorityFiles = walk.filter(p -> p.getFileName().toString().equals("Priority.java")).count(); + assertEquals("Priority.java must be emitted exactly once", 1, priorityFiles); + } + + // Task.importance (Ticket.severity -> Priority, two hops) and Subtask.rank + // (Task.importance -> Ticket.severity -> Priority, three hops). + String taskDto = Files.readString(dtoOut.resolve("acme/shop/TaskDto.java")); + assertTrue("Task.importance must be typed as the shared enum; saw:\n" + taskDto, + taskDto.contains("Priority importance")); + String subtaskDto = Files.readString(dtoOut.resolve("acme/shop/SubtaskDto.java")); + assertTrue("Subtask.rank must be typed as the shared enum; saw:\n" + subtaskDto, + subtaskDto.contains("Priority rank")); + assertFalse("shared Priority must not be redeclared in SubtaskDto; saw:\n" + subtaskDto, + subtaskDto.contains("public enum Priority")); + + // TicketSummary (a view-backed projection) reaches the shared Priority too. + String ticketSummaryDto = Files.readString(dtoOut.resolve("acme/shop/TicketSummaryDto.java")); + assertTrue("TicketSummary.level must be typed as the shared enum; saw:\n" + ticketSummaryDto, + ticketSummaryDto.contains("Priority level")); + assertFalse("shared Priority must not be redeclared in TicketSummaryDto; saw:\n" + ticketSummaryDto, + ticketSummaryDto.contains("public enum Priority")); + + // TicketBadge (an object.value) reaches the shared Priority too — via SpringValueObjectGenerator. + Path voOut = tmp.newFolder().toPath(); + Map args = new HashMap<>(); + args.put("outputDir", voOut.toString()); + args.put("providedEnumNamespace", "com.acme.ext"); + SpringValueObjectGenerator voGen = new SpringValueObjectGenerator(); + voGen.setArgs(args); + voGen.execute(loader); + String ticketBadge = Files.readString(voOut.resolve("acme/shop/TicketBadge.java")); + assertTrue("TicketBadge.badge must be typed as the shared enum; saw:\n" + ticketBadge, + ticketBadge.contains("Priority badge")); + assertFalse("shared Priority must not be redeclared in TicketBadge; saw:\n" + ticketBadge, + ticketBadge.contains("public enum Priority")); + } + + @Test + public void refundFieldsResolveToDistinctSharedDeclarationsOnTheSameObject() throws Exception { + // Refund.ticketTier (via Ticket.tier) -> Priority; Refund.invoiceTier (via Invoice.tier) + // -> Money — two fields on ONE object whose immediate supers are same-named fields + // ("tier") of DIFFERENT objects, resolving to DIFFERENT shared declarations. + MetaDataLoader loader = loadShared(); + Path out = run(loader, "com.acme.ext"); + String refundDto = Files.readString(out.resolve("acme/shop/RefundDto.java")); + + assertTrue("Refund.ticketTier must be typed Priority; saw:\n" + refundDto, + refundDto.contains("Priority ticketTier")); + assertTrue("Refund.invoiceTier must be typed Money; saw:\n" + refundDto, + refundDto.contains("Money invoiceTier")); + // Refund.unit -> Invoice.money -> the materialized Money. + assertTrue("Refund.unit must be typed as the materialized Money; saw:\n" + refundDto, + refundDto.contains("Money unit")); + // Refund.tender -> Ticket.currency -> the @provided Currency (never materialized). + assertTrue("Refund.tender must reference the configured external namespace; saw:\n" + refundDto, + refundDto.contains("com.acme.ext.Currency tender")); + assertFalse("provided Currency must never be nested in RefundDto; saw:\n" + refundDto, + refundDto.contains("public enum Currency")); + + // Negative: Refund.orderStatus -> Order.status, which owns its own @values (inline). + assertFalse("Refund.orderStatus must NOT be typed as a shared enum; saw:\n" + refundDto, + refundDto.contains("Priority orderStatus") || refundDto.contains("Money orderStatus")); + } + + @Test + public void ownValuesOrIntValueMapStopsTheWalkAndKeepsAnInlinePerObjectEnum() throws Exception { + // Task.phase (own @values) and Task.weight (own @intValueMap) each extend a field whose + // OWN chain reaches the shared Priority — but their own redeclaration stops the walk + // before it gets there, so they stay inline. Same for the Subtask fields that extend them. + MetaDataLoader loader = loadShared(); + Path out = run(loader, "com.acme.ext"); + + // Their top-most super is the shared Priority itself, whose name a nested enum would + // shadow inside the DTO, so each is named and carries its OWN members. + String taskDto = Files.readString(out.resolve("acme/shop/TaskDto.java")); + assertTrue("Task.phase must be the nested TaskPhase with its own members; saw:\n" + taskDto, + taskDto.contains("public enum TaskPhase { OPEN, DONE }") && taskDto.contains("TaskPhase phase")); + assertTrue("Task.weight must be the nested int-backed TaskWeight; saw:\n" + taskDto, + taskDto.contains("public enum TaskWeight {") && taskDto.contains("LOW(1)") + && taskDto.contains("HIGH(3)") && taskDto.contains("TaskWeight weight")); + // A shared-typed field in the SAME DTO still binds to the top-level shared Priority. + assertTrue("Task.urgency must still be typed by the shared Priority; saw:\n" + taskDto, + taskDto.contains("Priority urgency")); + assertFalse("TaskDto must not nest an enum named for the shared declaration; saw:\n" + taskDto, + taskDto.contains("public enum Priority")); + + String subtaskDto = Files.readString(out.resolve("acme/shop/SubtaskDto.java")); + assertTrue("Subtask.phase must be the nested SubtaskPhase with the members it inherits; saw:\n" + subtaskDto, + subtaskDto.contains("public enum SubtaskPhase { OPEN, DONE }") && subtaskDto.contains("SubtaskPhase phase")); + assertTrue("Subtask.weight must be the nested int-backed SubtaskWeight; saw:\n" + subtaskDto, + subtaskDto.contains("public enum SubtaskWeight {") && subtaskDto.contains("MEDIUM(2)") + && subtaskDto.contains("SubtaskWeight weight")); + assertTrue("Subtask.rank must still be typed by the shared Priority; saw:\n" + subtaskDto, + subtaskDto.contains("Priority rank")); + assertFalse("SubtaskDto must not nest an enum named for the shared declaration; saw:\n" + subtaskDto, + subtaskDto.contains("public enum Priority")); + + // Refund.orderStatus extends Order.status, an inline enum with no shared declaration + // anywhere on the chain: it keeps the name it always had, the top-most super's. + String refundDto = Files.readString(out.resolve("acme/shop/RefundDto.java")); + assertTrue("Refund.orderStatus must keep its nested Status enum; saw:\n" + refundDto, + refundDto.contains("public enum Status { NEW, PAID }") && refundDto.contains("Status orderStatus")); + } + + @Test + public void valueObjectFieldOneHopFromASharedOrProvidedDeclarationReferencesItAndNestsNoEnum() throws Exception { + // TicketLabel.grade extends the shared Priority and TicketLabel.denomination the + // @provided Currency, each in ONE hop. The record references the top-level type and the + // configured external type; it declares no enum of its own. + Path voOut = tmp.newFolder().toPath(); + Map args = new HashMap<>(); + args.put("outputDir", voOut.toString()); + args.put("providedEnumNamespace", "com.acme.ext"); + SpringValueObjectGenerator voGen = new SpringValueObjectGenerator(); + voGen.setArgs(args); + voGen.execute(loadShared()); + + String label = Files.readString(voOut.resolve("acme/shop/TicketLabel.java")); + assertTrue("TicketLabel.grade must be typed by the top-level shared Priority; saw:\n" + label, + label.contains("Priority grade")); + assertTrue("TicketLabel.denomination must reference the configured external Currency; saw:\n" + label, + label.contains("com.acme.ext.Currency denomination")); + assertFalse("TicketLabel must nest no enum; saw:\n" + label, label.contains("enum ")); + assertFalse("the DTO generator owns shared enum files; a value-object run writes none by default", + Files.exists(voOut.resolve("acme/shop/Priority.java"))); + assertFalse("the @provided Currency must never be generated", + Files.exists(voOut.resolve("acme/shop/Currency.java"))); + } + + @Test + public void sharedEnumMaterializesWhenTheOnlyGeneratedConsumerIsAProjection() throws Exception { + // No condition on the hosting object's subtype: a model whose ONLY object at all is a + // view-backed projection whose enum field extends a shared root-level abstract + // declaration still materializes that declaration — as narrow a "run" as there is. + String fixtureJson = """ + { + "metadata.root": { "package": "acme::only", "children": [ + { "field.enum": { "name": "Status", "abstract": true, "@values": ["DRAFT","PUBLISHED"] } }, + { "object.projection": { "name": "StatusView", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_status" } }, + { "field.enum": { "name": "status", "extends": "acme::only::Status" } } + ] } } + ] } + } + """; + MetaDataLoader loader = SpringTestFixtures.loadFixture( + tmp.newFolder().toPath(), "projectionOnly", fixtureJson); + Path out = run(loader, "com.acme.ext"); + + assertTrue("Status.java must be materialized when a projection is the only consumer", + Files.exists(out.resolve("acme/only/Status.java"))); + String statusViewDto = Files.readString(out.resolve("acme/only/StatusViewDto.java")); + assertTrue("StatusView.status must reference the shared Status; saw:\n" + statusViewDto, + statusViewDto.contains("Status status")); + assertFalse("shared Status must not be redeclared in StatusViewDto; saw:\n" + statusViewDto, + statusViewDto.contains("public enum Status")); + } } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java index 260d319dd..24a56d552 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java @@ -38,8 +38,9 @@ *
    • the record's {@code priority} accessor's return type {@link Class#isEnum() is an enum} * named {@code }, with value the {@code HIGH} constant;
    • *
    • {@code labels} is a {@code List} of the generated {@code Labels} enum, members [A, B];
    • - *
    • a shared abstract {@code field.enum} extended by two fields emits EXACTLY ONE nested - * {@code enum Priority} (named for the super), and both fields are typed {@code Priority}.
    • + *
    • a shared abstract {@code field.enum} extended by two fields is ONE top-level + * {@code enum Priority} both fields are typed by; the DTO generator owns that file, and + * the value-object generator writes it only under {@code emitSharedEnums=true}.
    • * * *

      Java structural note. Unlike the C#/Kotlin/TS/Python ports, the Java port has no @@ -125,18 +126,21 @@ public void sharedAbstractEnumEmitsOneTypeNamedForSuperUsedByBothFields() throws MetaDataLoader loader = SpringTestFixtures.loadFixture( ws, "shared-enum", SpringTestFixtures.SHARED_ENUM_FIXTURE); - generate(loader, gen); + // No DTO generator in this run, so the value-object generator is asked to write the + // shared enum files it references (emitSharedEnums=true). + generate(loader, gen, Map.of(Fr019SharedEnum.ARG_EMIT_SHARED_ENUMS, "true")); - // The source must declare EXACTLY ONE nested enum named for the super (Priority), not - // (CurrentPriority / PreviousPriority). + // The value object REFERENCES the shared declaration (ADR-0026 amendment: no condition on + // the hosting object's subtype), so its record declares no enum of its own and the + // shared type is emitted exactly once, as its own file. String src = Files.readString(gen.resolve("acme/orders/TicketPayload.java")); - int firstDecl = src.indexOf("public enum Priority {"); - assertTrue("must emit a nested `public enum Priority`; saw:\n" + src, firstDecl >= 0); - assertEquals("must emit EXACTLY ONE `enum Priority` (deduped); saw:\n" + src, - firstDecl, src.lastIndexOf("public enum Priority {")); - assertFalse("must NOT name the enum ; saw:\n" + src, - src.contains("public enum CurrentPriority") - || src.contains("public enum PreviousPriority")); + assertFalse("the record must reference the shared enum, not nest one; saw:\n" + src, + src.contains("enum ")); + String shared = Files.readString(gen.resolve("acme/orders/Priority.java")); + int firstDecl = shared.indexOf("public enum Priority {"); + assertTrue("must emit a standalone `public enum Priority`; saw:\n" + shared, firstDecl >= 0); + assertEquals("must emit EXACTLY ONE `enum Priority`; saw:\n" + shared, + firstDecl, shared.lastIndexOf("public enum Priority {")); Path classes = compile(gen); try (URLClassLoader cl = new URLClassLoader( @@ -151,16 +155,90 @@ public void sharedAbstractEnumEmitsOneTypeNamedForSuperUsedByBothFields() throws } } + @Test + public void valueObjectRunWritesNoSharedEnumFileByDefault() throws Exception { + Path gen = tmp.newFolder("gen-shared-enum-default").toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture( + tmp.newFolder("ws-shared-enum-default").toPath(), "shared-enum-default", + SpringTestFixtures.SHARED_ENUM_FIXTURE); + + generate(loader, gen); + + // The DTO generator owns shared enum files; without the opt-in neither the + // value-object nor the output-parser generator writes one. + assertEquals("no Priority.java without emitSharedEnums", List.of(), filesNamed("Priority.java", gen)); + // The record still REFERENCES the shared type and nests no copy. + String src = Files.readString(gen.resolve("acme/orders/TicketPayload.java")); + assertTrue("record must be typed by the shared enum; saw:\n" + src, src.contains("Priority currentPriority")); + assertFalse("record must not nest an enum; saw:\n" + src, src.contains("enum ")); + } + + /** + * The DTO and value-object generators write to SEPARATE output directories, and a build + * compiles both roots together (the Maven plugin registers each as a compile source root). + * One shared enum is reached by an entity and by a value object: exactly one + * {@code Priority.java} may exist across the two roots, or javac reports a duplicate class. + */ + @Test + public void splitOutputDirectoriesHoldOneSharedEnumFileAndCompileTogether() throws Exception { + String fixture = """ + { + "metadata.root": { "package": "acme::orders", "children": [ + { "field.enum": { "name": "Priority", "abstract": true, "@values": ["LOW","HIGH"] } }, + { "object.entity": { "name": "Ticket", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "priority", "extends": "Priority" } }, + { "source.rdb": { "@table": "tickets" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.value": { "name": "TicketPayload", "children": [ + { "field.enum": { "name": "priority", "extends": "Priority" } } + ] } } + ] } + } + """; + MetaDataLoader loader = SpringTestFixtures.loadFixture( + tmp.newFolder("ws-split").toPath(), "split-output-dirs", fixture); + Path dtoOut = tmp.newFolder("gen-split-dto").toPath(); + Path voOut = tmp.newFolder("gen-split-vo").toPath(); + + SpringDtoGenerator dtoGen = new SpringDtoGenerator(); + dtoGen.setArgs(Map.of("outputDir", dtoOut.toString())); + dtoGen.execute(loader); + SpringValueObjectGenerator voGen = new SpringValueObjectGenerator(); + voGen.setArgs(Map.of("outputDir", voOut.toString())); + voGen.execute(loader); + + assertEquals("exactly one Priority.java, in the DTO generator's directory", + List.of(dtoOut.resolve("acme/orders/Priority.java")), filesNamed("Priority.java", dtoOut, voOut)); + + Path classes = compile(dtoOut, voOut); + try (URLClassLoader cl = new URLClassLoader( + new URL[]{ classes.toUri().toURL() }, getClass().getClassLoader())) { + Class viaDto = cl.loadClass("acme.orders.TicketDto").getMethod("priority").getReturnType(); + Class viaVo = cl.loadClass("acme.orders.TicketPayload").getMethod("priority").getReturnType(); + assertEquals("acme.orders.Priority", viaDto.getName()); + assertSame("the DTO and the value object must be typed by the SAME enum class", viaDto, viaVo); + } + } + // ----------------------------------------------------------------------------------------- // shared harness // ----------------------------------------------------------------------------------------- private static void generate(MetaDataLoader loader, Path gen) { + generate(loader, gen, Map.of()); + } + + /** Value-object + output-parser run; {@code valueObjectArgs} are extra args for the former. */ + private static void generate(MetaDataLoader loader, Path gen, Map valueObjectArgs) { Map args = new HashMap<>(); args.put("outputDir", gen.toString()); + Map payloadArgs = new HashMap<>(args); + payloadArgs.putAll(valueObjectArgs); SpringValueObjectGenerator payloadGen = new SpringValueObjectGenerator(); - payloadGen.setArgs(args); + payloadGen.setArgs(payloadArgs); payloadGen.execute(loader); SpringOutputParserGenerator parserGen = new SpringOutputParserGenerator(); @@ -168,19 +246,30 @@ private static void generate(MetaDataLoader loader, Path gen) { parserGen.execute(loader); } - private Path compile(Path gen) throws Exception { - List sources; - try (Stream s = Files.walk(gen)) { - sources = s.filter(p -> p.toString().endsWith(".java")) - .map(Path::toFile) - .collect(Collectors.toList()); + private static List filesNamed(String fileName, Path... roots) throws Exception { + List found = new java.util.ArrayList<>(); + for (Path root : roots) { + try (Stream s = Files.walk(root)) { + s.filter(p -> p.getFileName().toString().equals(fileName)).forEach(found::add); + } + } + return found; + } + + /** Compile every generated root TOGETHER, as a build with several source roots does. */ + private Path compile(Path... gens) throws Exception { + List sources = new java.util.ArrayList<>(); + for (Path gen : gens) { + try (Stream s = Files.walk(gen)) { + s.filter(p -> p.toString().endsWith(".java")).map(Path::toFile).forEach(sources::add); + } } - assertFalse("expected generated .java files under " + gen, sources.isEmpty()); + assertFalse("expected generated .java files under " + List.of(gens), sources.isEmpty()); JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); assertNotNull("JDK (not JRE) required — getSystemJavaCompiler() returned null", javac); - Path classes = tmp.newFolder("classes-" + gen.getFileName()).toPath(); + Path classes = tmp.newFolder("classes-" + gens[0].getFileName()).toPath(); String cp = System.getProperty("java.class.path"); DiagnosticCollector diags = new DiagnosticCollector<>(); var fm = javac.getStandardFileManager(diags, null, null); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java new file mode 100644 index 000000000..e5d4e6822 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java @@ -0,0 +1,79 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** + * A model with NO shared enum declaration regenerates byte-identical Java. + * + *

      The contract under test is the generated output itself: {@code SpringDtoGenerator}, + * {@code SpringValueObjectGenerator} and {@code SpringOutputParserGenerator} run over + * {@code golden/inline-enum-names/input/meta.json} — inline entity enums, a projection field and + * a value-object field that extend an entity's inline enum, a value object's own inline enum, + * and a two-hop chain that ends at an inline enum — and every emitted file must equal its + * counterpart under {@code golden/inline-enum-names/expected/}. + * + *

      The oracle is independent of the code under test: the expected files were written by the + * generators as they stood at commit {@code 166c30786}, the commit the shared-enum rule + * (ADR-0026 Amendment 1) branched from. That commit's {@code server/java} tree was extracted + * with {@code git archive} into a temporary directory, built there, and the same three + * generators were run over this same input. What a wrong implementation gets caught on: naming + * an inline enum {@code } where it used to be named for its top-most super (so + * {@code ShipmentSummaryDto.Status} would become {@code ShipmentSummaryDto.ShipmentSummaryStatus}). + */ +public class InlineEnumNamesGoldenTest extends SharedRegistryTestBase { + + private static final Path GOLDEN = Path.of("src/test/resources/golden/inline-enum-names"); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static Map filesUnder(Path root) throws IOException { + Map files = new TreeMap<>(); + try (Stream walk = Files.walk(root)) { + for (Path p : walk.filter(Files::isRegularFile).toList()) { + files.put(root.relativize(p).toString().replace('\\', '/'), + Files.readString(p, StandardCharsets.UTF_8)); + } + } + return files; + } + + @Test + public void aModelWithNoSharedEnumRegeneratesByteIdenticalJava() throws Exception { + MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder().toPath(), "inlineEnumNames", + Files.readString(GOLDEN.resolve("input/meta.json"), StandardCharsets.UTF_8)); + Path out = tmp.newFolder().toPath(); + Map args = new HashMap<>(); + args.put("outputDir", out.toString()); + for (var generator : List.of( + new SpringDtoGenerator(), new SpringValueObjectGenerator(), new SpringOutputParserGenerator())) { + generator.setArgs(args); + generator.execute(loader); + } + + Map expected = filesUnder(GOLDEN.resolve("expected")); + Map actual = filesUnder(out); + assertFalse("the golden tree must not be empty", expected.isEmpty()); + assertEquals("the generated file set must match the golden tree", expected.keySet(), actual.keySet()); + for (Map.Entry e : expected.entrySet()) { + assertEquals(e.getKey() + " must be byte-identical to the golden file", e.getValue(), actual.get(e.getKey())); + } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java new file mode 100644 index 000000000..36bb6b96d --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java @@ -0,0 +1,27 @@ +package acme.shipping; + +import jakarta.validation.constraints.*; + +/** GENERATED — wire DTO for Crate. Do not hand-edit; regenerated from metadata. */ +public record CrateDto( + @NotNull Long id, + @NotNull Status stage +) { + public enum Status { PENDING, SHIPPED, DELIVERED } + + /** A fluent builder, so a caller can set a subset of the components. */ + public static Builder builder() { return new Builder(); } + + /** Builds a {@link CrateDto}; an unset component stays null. */ + public static final class Builder { + private Long id; + private Status stage; + + private Builder() {} + + public Builder id(Long v) { this.id = v; return this; } + public Builder stage(Status v) { this.stage = v; return this; } + + public CrateDto build() { return new CrateDto(id, stage); } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java new file mode 100644 index 000000000..6cb19bbf4 --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java @@ -0,0 +1,56 @@ +package acme.shipping; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.generator.spring.runtime.PatchValidationException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** GENERATED — presence-tracked partial-update (PATCH) shape for Crate. Do not hand-edit; regenerated from metadata. */ +public final class CratePatch { + + private final Map assigned; + + private CratePatch(Map assigned) { this.assigned = assigned; } + + public boolean hasStage() { return assigned.containsKey("stage"); } + public CrateDto.Status stage() { return (CrateDto.Status) assigned.get("stage"); } + + /** The field names ASSIGNED by this patch (present in the body), in order. */ + public Set assignedFields() { return assigned.keySet(); } + + /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */ + public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); } + + /** + * Build from a JSON body. A non-object body is rejected; an ABSENT key is + * untouched; a present null CLEARS a non-@required field and is a validation + * error on a @required one; a present value binds via Jackson exactly as + * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred). + */ + public static CratePatch fromJson(JsonNode body, ObjectMapper mapper) { + if (!body.isObject()) throw new PatchValidationException("(request body)"); + Map assigned = new LinkedHashMap<>(); + bind(body, mapper, assigned, "stage", new TypeReference() {}, true); + return new CratePatch(assigned); + } + + private static void bind(JsonNode body, ObjectMapper mapper, Map assigned, + String field, TypeReference type, boolean required) { + if (!body.has(field)) return; // absent → untouched + JsonNode node = body.get(field); + if (node.isNull()) { + if (required) throw new PatchValidationException(field); + assigned.put(field, null); // explicit null → clear + return; + } + try { + assigned.put(field, mapper.treeToValue(node, type)); + } catch (JsonProcessingException e) { + throw new PatchValidationException(field); + } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java new file mode 100644 index 000000000..3fd14af43 --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java @@ -0,0 +1,27 @@ +package acme.shipping; + +import jakarta.validation.constraints.*; + +/** GENERATED — wire DTO for Parcel. Do not hand-edit; regenerated from metadata. */ +public record ParcelDto( + @NotNull Long id, + @NotNull Status phase +) { + public enum Status { PENDING, SHIPPED, DELIVERED } + + /** A fluent builder, so a caller can set a subset of the components. */ + public static Builder builder() { return new Builder(); } + + /** Builds a {@link ParcelDto}; an unset component stays null. */ + public static final class Builder { + private Long id; + private Status phase; + + private Builder() {} + + public Builder id(Long v) { this.id = v; return this; } + public Builder phase(Status v) { this.phase = v; return this; } + + public ParcelDto build() { return new ParcelDto(id, phase); } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java new file mode 100644 index 000000000..7000886cf --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java @@ -0,0 +1,56 @@ +package acme.shipping; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.generator.spring.runtime.PatchValidationException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** GENERATED — presence-tracked partial-update (PATCH) shape for Parcel. Do not hand-edit; regenerated from metadata. */ +public final class ParcelPatch { + + private final Map assigned; + + private ParcelPatch(Map assigned) { this.assigned = assigned; } + + public boolean hasPhase() { return assigned.containsKey("phase"); } + public ParcelDto.Status phase() { return (ParcelDto.Status) assigned.get("phase"); } + + /** The field names ASSIGNED by this patch (present in the body), in order. */ + public Set assignedFields() { return assigned.keySet(); } + + /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */ + public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); } + + /** + * Build from a JSON body. A non-object body is rejected; an ABSENT key is + * untouched; a present null CLEARS a non-@required field and is a validation + * error on a @required one; a present value binds via Jackson exactly as + * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred). + */ + public static ParcelPatch fromJson(JsonNode body, ObjectMapper mapper) { + if (!body.isObject()) throw new PatchValidationException("(request body)"); + Map assigned = new LinkedHashMap<>(); + bind(body, mapper, assigned, "phase", new TypeReference() {}, true); + return new ParcelPatch(assigned); + } + + private static void bind(JsonNode body, ObjectMapper mapper, Map assigned, + String field, TypeReference type, boolean required) { + if (!body.has(field)) return; // absent → untouched + JsonNode node = body.get(field); + if (node.isNull()) { + if (required) throw new PatchValidationException(field); + assigned.put(field, null); // explicit null → clear + return; + } + try { + assigned.put(field, mapper.treeToValue(node, type)); + } catch (JsonProcessingException e) { + throw new PatchValidationException(field); + } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java new file mode 100644 index 000000000..071d54e9f --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java @@ -0,0 +1,31 @@ +package acme.shipping; + +import jakarta.validation.constraints.*; + +/** GENERATED — wire DTO for Shipment. Do not hand-edit; regenerated from metadata. */ +public record ShipmentDto( + @NotNull Long id, + @NotNull ShipmentStatus status, + ShipmentMode mode +) { + public enum ShipmentStatus { PENDING, SHIPPED, DELIVERED } + public enum ShipmentMode { AIR, SEA } + + /** A fluent builder, so a caller can set a subset of the components. */ + public static Builder builder() { return new Builder(); } + + /** Builds a {@link ShipmentDto}; an unset component stays null. */ + public static final class Builder { + private Long id; + private ShipmentStatus status; + private ShipmentMode mode; + + private Builder() {} + + public Builder id(Long v) { this.id = v; return this; } + public Builder status(ShipmentStatus v) { this.status = v; return this; } + public Builder mode(ShipmentMode v) { this.mode = v; return this; } + + public ShipmentDto build() { return new ShipmentDto(id, status, mode); } + } +} 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 new file mode 100644 index 000000000..6b095b6f3 --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java @@ -0,0 +1,34 @@ +package acme.shipping; + +import jakarta.validation.constraints.*; + +/** GENERATED — value object ShipmentNote. Do not hand-edit; regenerated from metadata. */ +public record ShipmentNote( + @NotNull @Size(min = 1) String text, + ShipmentNoteKind kind, + @NotNull Status status, + @NotNull Status stage +) { + public enum ShipmentNoteKind { INFO, ALERT } + public enum Status { PENDING, SHIPPED, DELIVERED } + + /** A fluent builder, so a caller can set a subset of the components. */ + public static Builder builder() { return new Builder(); } + + /** Builds a {@link ShipmentNote}; an unset component stays null. */ + public static final class Builder { + private String text; + private ShipmentNoteKind kind; + private Status status; + private Status stage; + + private Builder() {} + + public Builder text(String v) { this.text = v; return this; } + public Builder kind(ShipmentNoteKind v) { this.kind = v; return this; } + public Builder status(Status v) { this.status = v; return this; } + public Builder stage(Status v) { this.stage = v; return this; } + + public ShipmentNote build() { return new ShipmentNote(text, kind, status, stage); } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java new file mode 100644 index 000000000..e20cbd04b --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java @@ -0,0 +1,59 @@ +package acme.shipping; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.generator.spring.runtime.PatchValidationException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** GENERATED — presence-tracked partial-update (PATCH) shape for Shipment. Do not hand-edit; regenerated from metadata. */ +public final class ShipmentPatch { + + private final Map assigned; + + private ShipmentPatch(Map assigned) { this.assigned = assigned; } + + public boolean hasStatus() { return assigned.containsKey("status"); } + public ShipmentDto.ShipmentStatus status() { return (ShipmentDto.ShipmentStatus) assigned.get("status"); } + public boolean hasMode() { return assigned.containsKey("mode"); } + public ShipmentDto.ShipmentMode mode() { return (ShipmentDto.ShipmentMode) assigned.get("mode"); } + + /** The field names ASSIGNED by this patch (present in the body), in order. */ + public Set assignedFields() { return assigned.keySet(); } + + /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */ + public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); } + + /** + * Build from a JSON body. A non-object body is rejected; an ABSENT key is + * untouched; a present null CLEARS a non-@required field and is a validation + * error on a @required one; a present value binds via Jackson exactly as + * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred). + */ + public static ShipmentPatch fromJson(JsonNode body, ObjectMapper mapper) { + if (!body.isObject()) throw new PatchValidationException("(request body)"); + Map assigned = new LinkedHashMap<>(); + bind(body, mapper, assigned, "status", new TypeReference() {}, true); + bind(body, mapper, assigned, "mode", new TypeReference() {}, false); + return new ShipmentPatch(assigned); + } + + private static void bind(JsonNode body, ObjectMapper mapper, Map assigned, + String field, TypeReference type, boolean required) { + if (!body.has(field)) return; // absent → untouched + JsonNode node = body.get(field); + if (node.isNull()) { + if (required) throw new PatchValidationException(field); + assigned.put(field, null); // explicit null → clear + return; + } + try { + assigned.put(field, mapper.treeToValue(node, type)); + } catch (JsonProcessingException e) { + throw new PatchValidationException(field); + } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java new file mode 100644 index 000000000..bdfadca7f --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java @@ -0,0 +1,11 @@ +package acme.shipping; + +import jakarta.validation.constraints.*; + +/** GENERATED — wire DTO for ShipmentSummary. Do not hand-edit; regenerated from metadata. */ +public record ShipmentSummaryDto( + @NotNull Long id, + @NotNull Status status +) { + public enum Status { PENDING, SHIPPED, DELIVERED } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java new file mode 100644 index 000000000..a7726a101 --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java @@ -0,0 +1,56 @@ +package acme.shipping; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.generator.spring.runtime.PatchValidationException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** GENERATED — presence-tracked partial-update (PATCH) shape for ShipmentSummary. Do not hand-edit; regenerated from metadata. */ +public final class ShipmentSummaryPatch { + + private final Map assigned; + + private ShipmentSummaryPatch(Map assigned) { this.assigned = assigned; } + + public boolean hasStatus() { return assigned.containsKey("status"); } + public ShipmentSummaryDto.Status status() { return (ShipmentSummaryDto.Status) assigned.get("status"); } + + /** The field names ASSIGNED by this patch (present in the body), in order. */ + public Set assignedFields() { return assigned.keySet(); } + + /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */ + public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); } + + /** + * Build from a JSON body. A non-object body is rejected; an ABSENT key is + * untouched; a present null CLEARS a non-@required field and is a validation + * error on a @required one; a present value binds via Jackson exactly as + * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred). + */ + public static ShipmentSummaryPatch fromJson(JsonNode body, ObjectMapper mapper) { + if (!body.isObject()) throw new PatchValidationException("(request body)"); + Map assigned = new LinkedHashMap<>(); + bind(body, mapper, assigned, "status", new TypeReference() {}, true); + return new ShipmentSummaryPatch(assigned); + } + + private static void bind(JsonNode body, ObjectMapper mapper, Map assigned, + String field, TypeReference type, boolean required) { + if (!body.has(field)) return; // absent → untouched + JsonNode node = body.get(field); + if (node.isNull()) { + if (required) throw new PatchValidationException(field); + assigned.put(field, null); // explicit null → clear + return; + } + try { + assigned.put(field, mapper.treeToValue(node, type)); + } catch (JsonProcessingException e) { + throw new PatchValidationException(field); + } + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java new file mode 100644 index 000000000..728376546 --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java @@ -0,0 +1,80 @@ +// GENERATED — DO NOT EDIT — response parser for template.prompt `acme::shipping::Note` +package acme.shipping.prompts; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.render.extract.Format; +import com.metaobjects.render.extract.ExtractMap; + +/** Parser for LLM responses matching the `Note` template.prompt. */ +public final class NoteParser { + + private static final ObjectMapper MAPPER = new ObjectMapper().findAndRegisterModules(); + + private NoteParser() { /* no instances */ } + + /** + * Parse an LLM response into a typed {@link acme.shipping.ShipmentNote}. + * + * @throws JsonProcessingException when the input is not valid JSON for the response schema. + */ + public static acme.shipping.ShipmentNote parse(String text) throws JsonProcessingException { + return MAPPER.readValue(text, acme.shipping.ShipmentNote.class); + } + + /** Payload FQN this parser extracts — resolved against the supplied loader at runtime. */ + public static final String PAYLOAD_FQN = "acme::shipping::ShipmentNote"; + + /** + * Tolerant best-effort extraction delegating to the runtime + * {@link com.metaobjects.object.extract.MetaObjectExtractor}; never throws. + * Fully populates nested-object and array-of-object components (unlike the + * self-contained {@code extractLenient(String)} overload). Resolves this payload's + * {@code MetaObject} by {@link #PAYLOAD_FQN} from {@code loader}. + */ + public static com.metaobjects.render.extract.ExtractionResult extractLenient(com.metaobjects.loader.MetaDataLoader loader, String text) { + return extractLenient(loader, text, com.metaobjects.render.extract.ExtractOptions.defaults()); + } + + public static com.metaobjects.render.extract.ExtractionResult extractLenient(com.metaobjects.loader.MetaDataLoader loader, String text, com.metaobjects.render.extract.ExtractOptions opts) { + com.metaobjects.object.MetaObject mo = loader.getMetaObjectByName(PAYLOAD_FQN); + com.metaobjects.render.extract.ExtractionResult raw = + com.metaobjects.object.extract.MetaObjectExtractor.extract(mo, text, Format.JSON, opts); + // The assembled graph is a ValueObject (a Map) with nested + // ValueObjects / List — map it into the value objects' records. + @SuppressWarnings("unchecked") + java.util.Map d = (java.util.Map) raw.data(); + return new com.metaobjects.render.extract.ExtractionResult<>(fromShipmentNote(d), raw.report()); + } + + /** Map an assembled ValueObject (Map) into a typed {@link acme.shipping.ShipmentNote}. Generated; null-tolerant. */ + private static acme.shipping.ShipmentNote fromShipmentNote(java.util.Map d) { + if (d == null) return null; + return new acme.shipping.ShipmentNote( + ExtractMap.asString(d, "text"), + java.util.Optional.ofNullable(ExtractMap.asString(d, "kind")).map(acme.shipping.ShipmentNote.ShipmentNoteKind::valueOf).orElse(null), + java.util.Optional.ofNullable(ExtractMap.asString(d, "status")).map(acme.shipping.ShipmentNote.Status::valueOf).orElse(null), + java.util.Optional.ofNullable(ExtractMap.asString(d, "stage")).map(acme.shipping.ShipmentNote.Status::valueOf).orElse(null) + ); + } + + /** Null-tolerant cast of an assembled value to a Map (a ValueObject IS a Map). */ + @SuppressWarnings("unchecked") + private static java.util.Map asMap(Object v) { + return (v instanceof java.util.Map) ? (java.util.Map) v : null; + } + + /** Map each element of an assembled List via {@code fn}; null/absent -> null; non-Map elements skipped. */ + private static java.util.List mapObjectList( + java.util.Map d, String key, + java.util.function.Function, T> fn) { + Object v = d == null ? null : d.get(key); + if (!(v instanceof java.util.List list)) return null; + java.util.List out = new java.util.ArrayList<>(list.size()); + for (Object elem : list) { + java.util.Map m = asMap(elem); + if (m != null) out.add(fn.apply(m)); + } + return out; + } +} diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/input/meta.json b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/input/meta.json new file mode 100644 index 000000000..53afc71ad --- /dev/null +++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/input/meta.json @@ -0,0 +1,46 @@ +{ + "metadata.root": { + "package": "acme::shipping", + "children": [ + { "object.entity": { "name": "Shipment", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "status", "@required": true, "@values": ["PENDING", "SHIPPED", "DELIVERED"] } }, + { "field.enum": { "name": "mode", "@values": ["AIR", "SEA"] } }, + { "source.rdb": { "@table": "shipments" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "Parcel", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "phase", "extends": "acme::shipping::Shipment.status" } }, + { "source.rdb": { "@table": "parcels" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "Crate", "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "stage", "extends": "acme::shipping::Parcel.phase" } }, + { "source.rdb": { "@table": "crates" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, + { "object.projection": { "name": "ShipmentSummary", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_shipment_summary" } }, + { "field.long": { "name": "id", "extends": "acme::shipping::Shipment.id" } }, + { "field.enum": { "name": "status", "extends": "acme::shipping::Shipment.status" } }, + { "identity.primary": { "name": "pk", "extends": "acme::shipping::Shipment.pk" } } + ] } }, + { "object.value": { "name": "ShipmentNote", "children": [ + { "field.string": { "name": "text", "@required": true } }, + { "field.enum": { "name": "kind", "@values": ["INFO", "ALERT"] } }, + { "field.enum": { "name": "status", "extends": "acme::shipping::Shipment.status" } }, + { "field.enum": { "name": "stage", "extends": "acme::shipping::Parcel.phase" } } + ] } }, + { "template.prompt": { + "name": "Note", + "@payloadRef": "ShipmentNote", + "@responseRef": "ShipmentNote", + "@textRef": "shipping/note", + "@format": "text", + "@responseFormat": "json" + } } + ] + } +} 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 ce9b2ee8c..91ee95df7 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 @@ -160,7 +160,9 @@ private Map renderKotlin(MetaDataLoader loader, String projectNa ? com.metaobjects.generator.kotlin.apidocs.DocsPaths.Layout.PACKAGE : com.metaobjects.generator.kotlin.apidocs.DocsPaths.Layout.FLAT; com.metaobjects.generator.kotlin.apidocs.KotlinApiModel model = - new com.metaobjects.generator.kotlin.apidocs.KotlinApiModelBuilder().build(loader, projectName); + new com.metaobjects.generator.kotlin.apidocs.KotlinApiModelBuilder( + com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(getGlobals())) + .build(loader, projectName); com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsRenderer renderer = new com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsRenderer(); diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java b/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java index 6a0374521..f8d86f6fe 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java +++ b/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java @@ -16,6 +16,8 @@ package com.metaobjects.field; import com.metaobjects.DataTypes; +import com.metaobjects.MetaData; +import com.metaobjects.MetaRoot; import com.metaobjects.attr.BooleanAttribute; import com.metaobjects.attr.StringAttribute; import com.metaobjects.registry.MetaDataRegistry; @@ -23,7 +25,9 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; import java.util.Set; import java.util.regex.Pattern; @@ -309,4 +313,64 @@ public static boolean validateEnumValues(Object value) { } return true; } + + // ----------------------------------------------------------------------- + // FR-019 — single shared-enum-declaration authority (ADR-0026) + // ----------------------------------------------------------------------- + // + // ONE predicate decides "what is this enum field's type" for every JVM codegen site + // (Java/Spring and Kotlin) AND the #246 loader validator (ValidationPhase.sharedEnumSuper, + // immediate-super-only): a shared enum DECLARATION is an abstract field.enum that is a + // direct child of the metadata root. Resolving a field walks its extends chain and, at + // each node, in order: (a) the node IS a declaration -> that is the answer (nearest wins); + // (b) the node declares its OWN @values or OWN @intValueMap -> NONE (it starts a new, + // unshared vocabulary); (c) otherwise continue to its super. A non-enum node, the end of + // the chain, or a cycle answers NONE. There is no condition on the hosting object's + // subtype (entity / projection / value all qualify) and no hop limit. + + /** + * True iff {@code node} is itself a shared-enum DECLARATION: an abstract {@code field.enum} + * that is a direct child of the metadata root ({@link MetaRoot}) — a sibling of + * {@code object.entity}, not a field nested inside an object. ADR-0039: both facts + * (own {@code @isAbstract}, and parentage) are declaration-layer and read own-only — neither + * is ever inherited through {@code extends}. + */ + public static boolean isSharedEnumDeclaration(MetaField node) { + if (!(node instanceof EnumField)) return false; + return isOwnAbstract(node) && node.getParent() instanceof MetaRoot; + } + + private static boolean isOwnAbstract(MetaData node) { + return node.hasMetaAttr(MetaData.ATTR_IS_ABSTRACT, false) + && Boolean.TRUE.equals(node.getMetaAttr(MetaData.ATTR_IS_ABSTRACT, false).getValue()); + } + + /** True iff {@code node} declares its OWN {@code @values} or OWN {@code @intValueMap} — + * the rule-(b) "starts a new vocabulary" stop condition. Own-only (ADR-0039): an inherited + * {@code @values}/{@code @intValueMap} does not stop the walk at this node. */ + private static boolean declaresOwnValuesOrIntMap(MetaField node) { + return node.hasMetaAttr(ATTR_VALUES, false) || node.hasMetaAttr(ATTR_INT_VALUE_MAP, false); + } + + /** + * Resolve the shared-enum declaration {@code field}'s {@code extends} chain reaches, per the + * FR-019 rule above, or {@code null} for an inline field (the field keeps its per-object + * generated type exactly as before this rule existed). Walks {@code field} itself first (a + * field extending a chain that, two hops up, redeclares its own {@code @values} answers NONE + * even when a further hop would reach a declaration), then each super in turn. Cycle-guarded + * by identity. + */ + public static MetaField resolveSharedEnumDeclaration(MetaField field) { + if (!(field instanceof EnumField)) return null; + Set> seen = Collections.newSetFromMap(new IdentityHashMap<>()); + MetaField node = field; + while (node != null) { + if (!seen.add(node)) return null; // cycle + if (!(node instanceof EnumField)) return null; // non-enum node ends the chain + if (isSharedEnumDeclaration(node)) return node; // (a) nearest declaration wins + if (declaresOwnValuesOrIntMap(node)) return null; // (b) starts its own vocabulary + node = node.getSuperField(); // (c) continue + } + return null; // end of chain + } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index 56a036fc4..ede515449 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -823,18 +823,26 @@ private static void validateEnumNode(MetaData node) { /** * The shared-enum super of {@code node}, or null when it has none. * - *

      "Shared" (FR-019 / #246) means the immediate super is abstract AND declared at - * metadata-root — its parent is the {@link MetaRoot}, not an object. Such a declaration + *

      "Shared" (FR-019 / #246) means the immediate super is itself a shared-enum + * DECLARATION — {@link EnumField#isSharedEnumDeclaration}: abstract AND declared at + * metadata-root (its parent is the {@link MetaRoot}, not an object). Such a declaration * is materialized ONCE per port as a single named type, so anything a consuming field * re-declares that belongs to the shared TYPE's contract (its {@code @values} member * set, or the {@code @intValueMap} integer backing of that set) is a conflict. * - *

      Immediate-super-only, matching codegen's {@code Fr019SharedEnum.resolveSharedEnumDecl} - * so the validator and the shared-enum collapse agree on what "shared" means. + *

      Immediate-super-only — this check does NOT walk multiple hops the way codegen's + * {@code EnumField.resolveSharedEnumDeclaration} does when resolving a field's generated + * type. The relationship is real, not coincidental: both call the SAME + * {@link EnumField#isSharedEnumDeclaration} predicate to decide "is this node a shared + * declaration" — this method applies it once, at the immediate super, because #246 is only + * a conflict when THIS node's own {@code @values}/{@code @intValueMap} collide with a + * declaration one hop away; a redeclaration two-or-more hops down the chain starts its own + * (non-shared) vocabulary instead of conflicting with anything, which is exactly what + * {@code resolveSharedEnumDeclaration}'s rule (b) also answers NONE for. */ private static MetaData sharedEnumSuper(MetaData node) { MetaData sup = node.getSuperData(); - return (sup != null && isAbstract(sup) && sup.getParent() instanceof MetaRoot) ? sup : null; + return (sup instanceof MetaField supField && EnumField.isSharedEnumDeclaration(supField)) ? sup : null; } /** diff --git a/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java b/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java index 5b87ffbd4..9a2db3354 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java +++ b/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java @@ -22,8 +22,38 @@ public final class PayloadAccessors { /** The {@code has} prefix every derived boolean accessor carries. */ public static final String HAS_PREFIX = "has"; + /** + * The positional accessor injected into every map-shaped ELEMENT of a collection, so a list + * separator needs no hand-computed {@code last} flag: + * {@code {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}}. The leading {@code -} cannot + * collide with a field name and is still an ordinary Mustache tag name, so the template stays + * valid Mustache. A scalar element has nowhere to hold a key and carries none. An authored key + * of the same name wins, as for {@code has}. + */ + public static final String LAST_ACCESSOR = "-last"; + private PayloadAccessors() { /* no instances */ } + /** Marks each object element of an already-derived list with its {@code -last} position. */ + @SuppressWarnings("unchecked") + private static java.util.List withPositionalAccessors(java.util.List elements) { + int last = elements.size() - 1; + for (int i = 0; i < elements.size(); i++) { + Object el = elements.get(i); + if (el instanceof TypedView view) { + view.last = i == last; + } else if (el instanceof java.util.Map m) { + ((java.util.Map) m).putIfAbsent(LAST_ACCESSOR, i == last); + } + } + return elements; + } + + /** True when {@code name} is {@code -last} and the scope is a collection element. */ + public static boolean isPositionalAccessor(boolean inElement, String name) { + return inElement && LAST_ACCESSOR.equals(name); + } + /** * The boolean-accessor method/section name for a payload field: * {@code "has" + capitalize(fieldName)} (e.g. {@code abilities} → @@ -71,8 +101,9 @@ public static Boolean accessorValue(Object value) { /** * A view over {@code payload} carrying its derived {@code has} accessors, - * recursively — for map-shaped payloads and Java records (viewed as the map of their - * components). + * recursively — for map-shaped payloads, Java records (viewed as the map of their + * components) and any other typed object, which covers getter-based beans and Kotlin data + * classes (overlaid by a {@code TypedView}: the object's own members, plus the derived names). * *

      Without it, the SAME payload data renders differently depending on the shape it * arrived in, which is the divergence the shared {@code render-derived-has-accessor} fixture @@ -149,14 +180,108 @@ private static Object withDerivedAccessors(Object payload, int depth) { if (payload instanceof java.util.Collection coll) { java.util.List out = new java.util.ArrayList<>(coll.size()); for (Object item : coll) out.add(withDerivedAccessors(item, depth + 1)); - return out; + return withPositionalAccessors(out); } if (payload.getClass().isArray() && !payload.getClass().getComponentType().isPrimitive()) { int n = java.lang.reflect.Array.getLength(payload); java.util.List out = new java.util.ArrayList<>(n); for (int i = 0; i < n; i++) out.add(withDerivedAccessors(java.lang.reflect.Array.get(payload, i), depth + 1)); - return out; + return withPositionalAccessors(out); } + if (isViewable(payload)) return new TypedView(payload, depth); return payload; } + + /** + * A value the engine treats specially by its TYPE — a scalar, an iterable, an array, a + * lambda — or a platform value is handed over untouched; only an application object is + * overlaid. + */ + private static boolean isViewable(Object payload) { + if (payload.getClass().isArray()) return false; + if (payload instanceof Enum || payload instanceof CharSequence || payload instanceof Number + || payload instanceof Boolean || payload instanceof Character + || payload instanceof Iterable || payload instanceof java.util.Iterator + || payload instanceof java.util.function.Function + || payload instanceof java.util.concurrent.Callable) return false; + String n = payload.getClass().getName(); + return !(n.startsWith("java.") || n.startsWith("javax.") || n.startsWith("jdk.") || n.startsWith("kotlin.")); + } + + private static final Object MISSING = new Object(); + + // The engine's own member lookup, so a typed object resolves exactly the names it always + // did: a method by its exact name, getX / isX, and a non-private field. + private static final com.github.mustachejava.reflect.ReflectionObjectHandler NATIVE = + new com.github.mustachejava.reflect.ReflectionObjectHandler(); + + // One resolved lookup per (class, name). Class shapes are immutable, so no eviction. + private static final java.util.concurrent.ConcurrentHashMap, + java.util.concurrent.ConcurrentHashMap> LOOKUPS = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** The value of {@code target}'s own member {@code name}, or {@link #MISSING} when it has none. */ + private static Object ownMember(Object target, String name) { + java.util.List scope = java.util.Collections.singletonList(target); + com.github.mustachejava.util.Wrapper lookup = LOOKUPS + .computeIfAbsent(target.getClass(), c -> new java.util.concurrent.ConcurrentHashMap<>()) + .computeIfAbsent(name, n -> NATIVE.find(n, scope)); + if (lookup instanceof com.github.mustachejava.reflect.MissingWrapper) return MISSING; + try { + return lookup.call(scope); + } catch (com.github.mustachejava.util.GuardException e) { + return MISSING; + } + } + + /** + * The derived-accessor overlay of a typed object. To the engine it is a map that holds every + * name the object itself resolves — read from the object only when the template asks for it — + * plus the derived {@code has} and {@code -last}; a name the object has always wins. + * Its string form is the object's own. + */ + private static final class TypedView extends java.util.AbstractMap { + private final Object target; + private final int depth; + private Boolean last; + + TypedView(Object target, int depth) { + this.target = target; + this.depth = depth; + } + + private Object resolve(Object key) { + if (!(key instanceof String name) || name.indexOf('.') >= 0) return MISSING; + Object own = ownMember(target, name); + if (own != MISSING) return withDerivedAccessors(own, depth + 1); + if (LAST_ACCESSOR.equals(name)) return last != null ? last : MISSING; + if (!name.startsWith(HAS_PREFIX) || name.length() == HAS_PREFIX.length()) return MISSING; + String rest = name.substring(HAS_PREFIX.length()); + for (String field : new String[] { Character.toLowerCase(rest.charAt(0)) + rest.substring(1), rest }) { + if (!hasAccessorName(field).equals(name)) continue; + Object value = ownMember(target, field); + if (value == MISSING) continue; + Boolean derived = accessorValue(value); + if (derived != null) return derived; + } + return MISSING; + } + + @Override public boolean containsKey(Object key) { return resolve(key) != MISSING; } + + @Override public Object get(Object key) { + Object value = resolve(key); + return value == MISSING ? null : value; + } + + @Override public java.util.Set> entrySet() { + return java.util.Collections.emptySet(); + } + + @Override public String toString() { return target.toString(); } + + @Override public boolean equals(Object o) { return o == this; } + + @Override public int hashCode() { return System.identityHashCode(this); } + } } diff --git a/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java b/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java index 45dd0eba9..738701df0 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java +++ b/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java @@ -12,22 +12,43 @@ * array-of-object). {@code fields} null → a scalar field (string / number / * boolean / scalar array). * + *

      {@code isArray} is the field's EFFECTIVE array-ness (a resolving read when built from real + * metadata). It is distinct from {@code fields != null}: an array of scalars is + * {@code isArray} with no {@code fields}; a single nested object has {@code fields} and is not + * {@code isArray}. Together they decide whether {@code -last} is available inside a section over + * this field (see {@link Verify}). + * *

      Mirrors {@code server/csharp/MetaObjects.Render/Verify.cs} * {@code PayloadField} record. */ -public record PayloadField(String name, List fields) { +public record PayloadField(String name, List fields, boolean isArray) { public PayloadField { Objects.requireNonNull(name, "name"); } - /** Convenience factory: a scalar field with no nested children. */ + /** A non-array field: the two-component form this record had before {@code isArray}. */ + public PayloadField(String name, List fields) { + this(name, fields, false); + } + + /** Convenience factory: a single (non-array) scalar field. */ public static PayloadField scalar(String name) { - return new PayloadField(name, null); + return new PayloadField(name, null, false); } - /** Convenience factory: a context-pushing object/array-of-object field. */ + /** Convenience factory: a scalar field, an array of scalars when {@code isArray}. */ + public static PayloadField scalar(String name, boolean isArray) { + return new PayloadField(name, null, isArray); + } + + /** Convenience factory: a single (non-array) nested-object field. */ public static PayloadField object(String name, List children) { - return new PayloadField(name, List.copyOf(children)); + return new PayloadField(name, List.copyOf(children), false); + } + + /** Convenience factory: a nested-object field, an array of objects when {@code isArray}. */ + public static PayloadField object(String name, List children, boolean isArray) { + return new PayloadField(name, List.copyOf(children), isArray); } } diff --git a/server/java/render/src/main/java/com/metaobjects/render/Verify.java b/server/java/render/src/main/java/com/metaobjects/render/Verify.java index ba1c9abd6..1e403fcd5 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/Verify.java +++ b/server/java/render/src/main/java/com/metaobjects/render/Verify.java @@ -98,7 +98,7 @@ public static List check( staticTexts.add(templateText); WalkContext ctx = new WalkContext(provider, root, errors, referencedAtRoot, staticTexts); - walk(tokenize(templateText), List.of(root), List.of(), ctx); + walk(tokenize(templateText), List.of(root), List.of(), ctx, false); if (opts.requiredSlots() != null) { for (String slot : opts.requiredSlots()) { @@ -139,7 +139,8 @@ private static void walk( List tokens, List> stack, List seen, - WalkContext ctx + WalkContext ctx, + boolean inElement ) { boolean atRoot = stack.size() == 1 && stack.get(0) == ctx.root(); for (Tok tok : tokens) { @@ -147,25 +148,32 @@ private static void walk( case VarTok v -> { if (".".equals(v.value())) break; // implicit iterator — always valid if (atRoot) ctx.referencedAtRoot().add(v.value().split("\\.")[0]); - if (resolve(stack, v.value()) == null && !isBooleanAccessor(stack, v.value())) { + if (resolve(stack, v.value()) == null && !isBooleanAccessor(stack, v.value()) + && !PayloadAccessors.isPositionalAccessor(inElement, v.value())) { ctx.errors().add(new VerifyError(ERR_VAR_NOT_ON_PAYLOAD, v.value())); } } case SectionTok s -> { if (".".equals(s.value())) { - walk(s.children(), stack, seen, ctx); + walk(s.children(), stack, seen, ctx, inElement); break; } if (atRoot) ctx.referencedAtRoot().add(s.value().split("\\.")[0]); PayloadField field = resolve(stack, s.value()); if (field == null) { + // A positional {{^-last}} gate is a BOOLEAN on the current + // collection element: it pushes nothing, so the body walks in the SAME scope. + if (PayloadAccessors.isPositionalAccessor(inElement, s.value())) { + walk(s.children(), stack, seen, ctx, inElement); + break; + } // An auto-derived boolean accessor ({{#hasAbilities}}) is a // presence GATE over an optional/collection field reachable on // the current scope — not a payload slot of its own. It keeps // the current context (no push); its body still resolves against // the enclosing scope. if (isBooleanAccessor(stack, s.value())) { - walk(s.children(), stack, seen, ctx); + walk(s.children(), stack, seen, ctx, inElement); break; } // Unresolved section head is itself drift; skip the body @@ -176,12 +184,21 @@ private static void walk( // `#` over a container pushes its element fields; `^` (and `#` // over a scalar, used as a conditional) keep the current context. boolean push = !s.inverted() && field.fields() != null; + // `-last` is available in the body only when the INNERMOST enclosing ARRAY + // section iterates OBJECT elements: a `#` over an array field sets the flag + // fresh (true for array-of-objects, false for array-of-scalars, which carry + // no element key even inside an outer object array). Any other section + // (scalar conditional, single nested object, `^`) is not an iteration, so it + // inherits the enclosing scope's flag unchanged. + boolean childInElement = !s.inverted() && field.isArray() + ? field.fields() != null + : inElement; if (push) { List> next = new ArrayList<>(stack); next.add(field.fields()); - walk(s.children(), Collections.unmodifiableList(next), seen, ctx); + walk(s.children(), Collections.unmodifiableList(next), seen, ctx, childInElement); } else { - walk(s.children(), stack, seen, ctx); + walk(s.children(), stack, seen, ctx, childInElement); } } case PartialTok p -> { @@ -195,7 +212,7 @@ private static void walk( ctx.staticTexts().add(text); List nextSeen = new ArrayList<>(seen); nextSeen.add(p.value()); - walk(tokenize(text), stack, Collections.unmodifiableList(nextSeen), ctx); + walk(tokenize(text), stack, Collections.unmodifiableList(nextSeen), ctx, inElement); } } } diff --git a/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java new file mode 100644 index 000000000..da6b8b3f1 --- /dev/null +++ b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java @@ -0,0 +1,258 @@ +package com.metaobjects.render; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +/** + * A typed payload must render byte-identically to the equal map payload — {@code -last} and + * {@code has} come from one mechanism whatever shape the payload arrives in. The oracle is + * the shared render-list-separator fixture, whose expected.txt every port renders identically. + */ +public class TypedPayloadRenderTest { + + // ---- Records ------------------------------------------------------------------------- + public record RItem(String name) {} + public record RTag(String label) {} + public record RGroup(List tags) {} + public record ROwner(String nick) {} + public record REntry(String name, String note, ROwner owner) {} + public record RPayload(List items, List solo, List none, List groups, + List entries, List> authored) {} + + // ---- Getter-based beans (also the shape of a Kotlin data class: getX() per property) ----- + public static class BItem { + private final String name; + public BItem(String name) { this.name = name; } + public String getName() { return name; } + } + public static class BTag { + private final String label; + public BTag(String label) { this.label = label; } + public String getLabel() { return label; } + } + public static class BGroup { + private final List tags; + public BGroup(List tags) { this.tags = tags; } + public List getTags() { return tags; } + } + public static class BOwner { + private final String nick; + public BOwner(String nick) { this.nick = nick; } + public String getNick() { return nick; } + } + public static class BEntry { + private final String name; + private final String note; + private final BOwner owner; + public BEntry(String name, String note, BOwner owner) { this.name = name; this.note = note; this.owner = owner; } + public String getName() { return name; } + public String getNote() { return note; } + public BOwner getOwner() { return owner; } + } + public static class BPayload { + public List getItems() { return List.of(new BItem("a"), new BItem("b"), new BItem("c")); } + public List getSolo() { return List.of(new BItem("only")); } + public List getNone() { return List.of(); } + public List getGroups() { + return List.of(new BGroup(List.of(new BTag("x"), new BTag("y"))), new BGroup(List.of(new BTag("z")))); + } + public List getEntries() { + return List.of(new BEntry("a", "x", new BOwner("ann")), new BEntry("b", null, new BOwner("bob")), + new BEntry("c", "z", new BOwner("cy"))); + } + public List> getAuthored() { return authored(); } + } + + private static List> authored() { + List> out = new ArrayList<>(); + for (String n : List.of("p", "q")) { + Map m = new LinkedHashMap<>(); + m.put("name", n); + m.put("-last", true); + out.add(m); + } + return out; + } + + private static Path fixtureDir() { + Path p = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + while (p != null && !Files.exists(p.resolve("fixtures/render-conformance"))) p = p.getParent(); + return p.resolve("fixtures/render-conformance/render-list-separator"); + } + + private static String read(String file) throws IOException { + return Files.readString(fixtureDir().resolve(file), StandardCharsets.UTF_8); + } + + private static String render(String template, Object payload) { + return new Renderer().render(new RenderRequest( + template, null, payload, new InMemoryProvider(Map.of()), "text", null, null)); + } + + @Test + public void recordPayloadRendersTheSharedFixtureByteIdentically() throws IOException { + RPayload payload = new RPayload( + List.of(new RItem("a"), new RItem("b"), new RItem("c")), + List.of(new RItem("only")), List.of(), + List.of(new RGroup(List.of(new RTag("x"), new RTag("y"))), new RGroup(List.of(new RTag("z")))), + List.of(new REntry("a", "x", new ROwner("ann")), new REntry("b", null, new ROwner("bob")), + new REntry("c", "z", new ROwner("cy"))), + authored()); + assertEquals(read("expected.txt"), render(read("template.mustache"), payload)); + } + + @Test + public void beanPayloadRendersTheSharedFixtureByteIdentically() throws IOException { + assertEquals(read("expected.txt"), render(read("template.mustache"), new BPayload())); + } + + @Test + public void hasFieldGateWorksOnBeanElementsLikeOnMaps() { + String tpl = "{{#items}}{{name}}:{{#hasNote}}y{{/hasNote}}{{^hasNote}}n{{/hasNote}} {{/items}}"; + Map viaMaps = Map.of("items", List.of( + Map.of("name", "a", "note", "x"), Map.of("name", "b", "note", " "))); + Map viaBeans = Map.of("items", List.of( + new BEntry("a", "x", null), new BEntry("b", " ", null))); + assertEquals("a:y b:n ", render(tpl, viaMaps)); + assertEquals(render(tpl, viaMaps), render(tpl, viaBeans)); + } + + @Test + public void getClassIsNotExposedOnABean() { + assertEquals("[]", "[" + render("{{class}}{{getClass}}", new BItem("a")) + "]"); + } + + // ---- The overlay adds names; it never takes any away ----------------------------------- + + /** A value with its own string form. */ + public static final class Money { + private final long cents; + private final String currency; + public Money(long cents, String currency) { this.cents = cents; this.currency = currency; } + public long getCents() { return cents; } + public String getCurrency() { return currency; } + @Override public String toString() { return currency + " " + cents + "c"; } + } + + /** Every read of {@link Line#getExploding()}, counted before it throws. */ + static final java.util.concurrent.atomic.AtomicInteger EXPLODING_READS = new java.util.concurrent.atomic.AtomicInteger(); + + public static class Line { + String sku; // package-private field, no getter + private final Money price; + Line(String sku, Money price) { this.sku = sku; this.price = price; } + public String name() { return "name:" + sku; } // fluent accessor + public Money getPrice() { return price; } + public String getExploding() { + EXPLODING_READS.incrementAndGet(); + throw new IllegalStateException("read a member the template never names"); + } + } + + public static class Order { + public String getId() { return "o-1"; } + public Money getTotal() { return new Money(1250, "USD"); } + public List getLines() { + return List.of(new Line("a", new Money(500, "USD")), new Line("b", new Money(750, "USD"))); + } + public String summary() { return "order " + getId(); } // method resolved by exact name + @Override public String toString() { return "Order[" + getId() + "]"; } + } + + public static class Basket { + public Money getTotal() { return new Money(1250, "USD"); } + public List getLines() { return new Order().getLines(); } + public List getOrders() { return List.of(new Order()); } + } + + /** The engine with no MetaObjects view at all: what a typed payload rendered before. */ + private static String renderWithBareEngine(String template, Object payload) throws IOException { + java.io.StringWriter out = new java.io.StringWriter(); + new com.github.mustachejava.DefaultMustacheFactory() + .compile(new java.io.StringReader(template), "bare").execute(out, payload).flush(); + return out.toString(); + } + + @Test + public void methodFluentAccessorAndNonPrivateFieldStillResolve() { + assertEquals("order o-1|a=name:a;b=name:b;", + render("{{summary}}|{{#lines}}{{sku}}={{name}};{{/lines}}", new Order())); + } + + @Test + public void aValueRendersThroughItsOwnStringForm() { + assertEquals("USD 1250c|USD 500c;USD 750c;|Order[o-1]", + render("{{total}}|{{#lines}}{{price}};{{/lines}}|{{#orders}}{{.}}{{/orders}}", new Basket())); + } + + @Test + public void aMemberTheTemplateNeverNamesIsNeverRead() { + // Line.getExploding() counts each read and then throws. The template does not name it. + // The count is what proves laziness: a view that read every member up front and + // swallowed the failure would still render "a,b". + EXPLODING_READS.set(0); + assertEquals("a,b", render("{{#lines}}{{sku}}{{^-last}},{{/-last}}{{/lines}}", new Order())); + assertEquals(0, EXPLODING_READS.get()); + } + + @Test + public void aTemplateWithoutDerivedNamesRendersExactlyAsTheBareEngineDoes() throws IOException { + String template = "{{id}} {{summary}} {{total}} {{total.cents}} {{toString}}" + + "{{#lines}} [{{sku}} {{name}} {{price}} {{price.currency}} {{id}}]{{/lines}}" + + "{{#total}} {{currency}}{{/total}}{{^missing}} none{{/missing}}"; + String expected = renderWithBareEngine(template, new Order()); + assertEquals( + "o-1 order o-1 USD 1250c 1250 Order[o-1] [a name:a USD 500c USD o-1] [b name:b USD 750c USD o-1] USD none", + expected); + assertEquals(expected, render(template, new Order())); + } + + // ---- A primitive array is the engine's own list, in every payload shape ------------------ + + public record RScores(int[] scores) {} + + public static class BScores { + private final int[] scores; + public BScores(int... scores) { this.scores = scores; } + public int[] getScores() { return scores; } + } + + /** + * The oracle is the Mustache spec: a section over a list renders its body once per element, + * with {@code {{.}}} the element; an inverted section renders for an empty list. A view that + * wrapped the array rendered the body once, with the array's identity string. + */ + @Test + public void aSectionOverAPrimitiveArrayRendersPerElementInEveryPayloadShape() { + String template = "{{#scores}}{{.}},{{/scores}}"; + assertEquals("1,2,3,", render(template, Map.of("scores", new int[] {1, 2, 3}))); + assertEquals("1,2,3,", render(template, new RScores(new int[] {1, 2, 3}))); + assertEquals("1,2,3,", render(template, new BScores(1, 2, 3))); + } + + @Test + public void anInvertedSectionOverAnEmptyPrimitiveArrayRendersItsBody() { + String template = "{{^scores}}none{{/scores}}"; + assertEquals("none", render(template, Map.of("scores", new int[0]))); + assertEquals("none", render(template, new RScores(new int[0]))); + assertEquals("none", render(template, new BScores())); + } + + @Test + public void derivedNamesSitOnTopOfATypedObjectsOwnMembers() { + assertEquals("name:a + name:b has-id no-missing", + render("{{#hasLines}}{{#lines}}{{name}}{{^-last}} + {{/-last}}{{/lines}}{{/hasLines}}" + + "{{#hasId}} has-id{{/hasId}}{{^hasMissing}} no-missing{{/hasMissing}}", new Order())); + } +} diff --git a/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java b/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java index 739e33f0a..09e59143a 100644 --- a/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java +++ b/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java @@ -154,10 +154,11 @@ private static List parseFields(JsonNode array) { private static PayloadField parseField(JsonNode el) { String name = el.get("name").asText(); + boolean isArray = el.path("isArray").asBoolean(false); if (el.has("fields") && el.get("fields").isArray()) { - return new PayloadField(name, parseFields(el.get("fields"))); + return new PayloadField(name, parseFields(el.get("fields")), isArray); } - return PayloadField.scalar(name); + return PayloadField.scalar(name, isArray); } private static List stringArray(JsonNode array) { diff --git a/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java b/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java index 6b3883b3b..a312e5430 100644 --- a/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java +++ b/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java @@ -234,4 +234,50 @@ public void acceptedAccessorNameMatchesSharedRule() { assertEquals(1, drift.size()); assertEquals("ERR_VAR_NOT_ON_PAYLOAD", drift.get(0).code()); } + + // ---- -last availability: the innermost enclosing ARRAY section must iterate objects ---- + + private static final List POSITIONAL_PAYLOAD = List.of( + PayloadField.object("posts", List.of( + PayloadField.scalar("title"), + PayloadField.scalar("note"), + PayloadField.object("author", List.of(PayloadField.scalar("name"))), + PayloadField.scalar("labels", true)), true)); + + @Test + public void lastIsAcceptedInsideAnObjectListSection() { + assertTrue(Verify.check("{{#posts}}{{title}}{{^-last}}, {{/-last}}{{/posts}}", + POSITIONAL_PAYLOAD, VerifyOptions.empty()).isEmpty()); + } + + @Test + public void lastIsRejectedInsideAScalarListNestedInAnObjectList() { + var errors = Verify.check("{{#posts}}{{#labels}}{{.}}{{^-last}}, {{/-last}}{{/labels}}{{/posts}}", + POSITIONAL_PAYLOAD, VerifyOptions.empty()); + assertEquals(1, errors.size()); + assertEquals("ERR_VAR_NOT_ON_PAYLOAD", errors.get(0).code()); + assertEquals("-last", errors.get(0).path()); + } + + @Test + public void scalarConditionalAndSingleObjectSectionsAreTransparentToLast() { + assertTrue(Verify.check( + "{{#posts}}{{#note}}{{note}}{{^-last}}; {{/-last}}{{/note}}{{#author}}{{name}}{{^-last}}, {{/-last}}{{/author}}{{/posts}}", + POSITIONAL_PAYLOAD, VerifyOptions.empty()).isEmpty()); + } + + @Test + public void twoComponentConstructorIsANonArrayField() { + PayloadField scalar = new PayloadField("name", null); + PayloadField nested = new PayloadField("owner", List.of(new PayloadField("nick", null))); + assertEquals(PayloadField.scalar("name"), scalar); + assertEquals(PayloadField.object("owner", List.of(PayloadField.scalar("nick"))), nested); + assertTrue(Verify.check("{{name}}{{#owner}}{{nick}}{{/owner}}", + List.of(scalar, nested), VerifyOptions.empty()).isEmpty()); + // A non-array section does not iterate, so it offers no -last of its own. + var errors = Verify.check("{{#owner}}{{nick}}{{^-last}},{{/-last}}{{/owner}}", + List.of(nested), VerifyOptions.empty()); + assertEquals(1, errors.size()); + assertEquals("-last", errors.get(0).path()); + } } diff --git a/server/java/render/src/test/resources/snapshots/render-list-separator.txt b/server/java/render/src/test/resources/snapshots/render-list-separator.txt new file mode 100644 index 000000000..c786b4029 --- /dev/null +++ b/server/java/render/src/test/resources/snapshots/render-list-separator.txt @@ -0,0 +1,7 @@ +items: a, b, c +solo: only +none: [] +nested: [x|y];[z] +conditional: a(x); bc(z) +object: ann, bob, cy +authored: pq diff --git a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py index fb1801336..8dd24b7b9 100644 --- a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py +++ b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py @@ -21,12 +21,15 @@ """ from __future__ import annotations +import warnings from dataclasses import dataclass -from metaobjects.meta.core.field.meta_field import MetaField +from metaobjects.meta.core.field.meta_field import ( + MetaField, + resolve_shared_enum_declaration, +) from metaobjects.meta.core.field import field_constants as fc from metaobjects.meta.meta_data import MetaData -from metaobjects.shared.base_types import TYPE_METADATA from metaobjects.shared.separators import PACKAGE_SEP from metaobjects.codegen import type_map from metaobjects.codegen.config import GenConfig @@ -48,21 +51,17 @@ def _pascal(name: str) -> str: def resolve_shared_enum_decl(field: MetaField) -> MetaData | None: - """The package-level abstract ``field.enum`` *field* resolves to via ``extends``, or ``None`` - for an inline enum. Qualifies only when the immediate super is (a) a ``field.enum``, - (b) abstract, AND (c) a direct child of the metadata root — a sibling of ``object.entity``, - not an abstract field nested inside an object.""" - if field.sub_type != fc.FIELD_SUBTYPE_ENUM: - return None - sup = field.super_data - if sup is None or sup.sub_type != fc.FIELD_SUBTYPE_ENUM: - return None - if not getattr(sup, "is_abstract", False): - return None - parent = sup.parent - if parent is None or parent.type != TYPE_METADATA: - return None - return sup + """Deprecated name of + :func:`metaobjects.meta.core.field.meta_field.resolve_shared_enum_declaration`, + kept so a generator ejected before the resolver moved to the metadata layer + still imports. Same answer, including through a multi-hop ``extends``.""" + warnings.warn( + "resolve_shared_enum_decl is deprecated; call " + "metaobjects.meta.core.field.meta_field.resolve_shared_enum_declaration", + DeprecationWarning, + stacklevel=2, + ) + return resolve_shared_enum_declaration(field) def is_provided(decl: MetaData) -> bool: @@ -91,11 +90,16 @@ def _meta_package_of(decl: MetaData) -> str: def shared_enum_for_field(field: MetaField) -> SharedEnum | None: - """The shared-enum descriptor *field* resolves to, or ``None`` for inline enums.""" - decl = resolve_shared_enum_decl(field) + """The shared-enum descriptor *field* resolves to, or ``None`` for inline enums. + + The member set is read off the DECLARATION *decl* (a resolving ``attrs()`` + read, never off *field*) so materialization cannot depend on which consuming + field a generator happens to visit first — every field resolving to the same + shared declaration must describe that declaration identically.""" + decl = resolve_shared_enum_declaration(field) if decl is None: return None - values = type_map.effective_enum_values(field) + values = type_map.effective_enum_values(decl) if not values: return None return SharedEnum( diff --git a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py index bcd3d5013..0dbbb5afb 100644 --- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py +++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py @@ -136,21 +136,31 @@ def _derive_payload_field_tree( target = _resolve_nested_object_ref(root, ref, field_pkg) if target is not None and target.sub_type == OBJECT_SUBTYPE_VALUE: children = _derive_payload_field_tree(root, target, next_seen) - fields.append(PayloadField(f.name, children)) + # ADR-0039 resolving read: an array of this VO (vs a single + # nested instance) gates `-last` availability in `verify`. + fields.append( + PayloadField(f.name, children, is_array=f.resolved_is_array()) + ) continue - fields.append(PayloadField(f.name)) + fields.append(PayloadField(f.name, is_array=f.resolved_is_array())) return fields def _field_tree_literal(fields: list[PayloadField]) -> str: """Emit a ``list[PayloadField]`` as a deterministic Python literal: - ``[PayloadField("name"), PayloadField("nested", [PayloadField("x")])]``.""" + ``[PayloadField("name"), PayloadField("nested", [PayloadField("x")])]``. + + The array flag is emitted only when true, so a helper with no array field + stays byte-identical to before ``is_array`` existed.""" parts: list[str] = [] for f in fields: + args = [_py_str(f.name)] if f.fields is not None: - parts.append(f"PayloadField({_py_str(f.name)}, {_field_tree_literal(f.fields)})") - else: - parts.append(f"PayloadField({_py_str(f.name)})") + args.append(_field_tree_literal(f.fields)) + elif f.is_array: + args.append("None") # fill the positional `fields` slot before the kwarg + kw = ", is_array=True" if f.is_array else "" + parts.append(f"PayloadField({', '.join(args)}{kw})") return "[" + ", ".join(parts) + "]" diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index 00ed8d161..6869e78e4 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -49,6 +49,7 @@ FIELD_SUBTYPE_URI, FIELD_SUBTYPE_UUID, ) +from ..meta.core.field.meta_field import is_shared_enum_declaration from ..meta.persistence.db.db_constants import ( DB_COLUMN_TYPE_JSONB, DB_COLUMN_TYPE_UUID, @@ -74,7 +75,6 @@ TYPE_IDENTITY, TYPE_INDEX, TYPE_LAYOUT, - TYPE_METADATA, TYPE_OBJECT, TYPE_ORIGIN, TYPE_RELATIONSHIP, @@ -604,32 +604,6 @@ def _validate_attr_schema( _ENUM_MEMBER_RE = re.compile(ENUM_MEMBER_PATTERN) -def _shared_enum_super(node: MetaData) -> MetaData | None: - """The shared-enum super of ``node``, or ``None`` when it has none. - - "Shared" (FR-019 / #246) means the immediate super is abstract AND declared at - metadata-root — its parent is the ``metadata.root`` node, not an object. Such a - declaration is materialized ONCE per port as a single named type, so anything a - consuming field re-declares that belongs to the shared TYPE's contract (its - ``@values`` member set, or the ``@intValueMap`` integer backing of that set) is a - conflict. A concrete super, or a non-root abstract super (e.g. one nested inside - an object), is legal and not flagged. - - Immediate-super-only, matching codegen's ``resolve_shared_enum_decl`` - (``codegen/generators/fr019_shared_enum.py``) so the validator and the - shared-enum collapse agree on what "shared" means. - """ - sup = node.super_data - if ( - sup is not None - and sup.is_abstract - and sup.parent is not None - and sup.parent.type == TYPE_METADATA - ): - return sup - return None - - def _validate_enum_values( root: MetaData, errors: list[MetaError], @@ -709,8 +683,13 @@ def _validate_enum_values( # #246: own @values AND extends a shared package-level abstract enum — # one shared enum type has one member set, so the own @values would be # silently dropped by the shared-enum codegen collapse. Mirrors the TS - # reference (attr-schema-validate.ts). - if _shared_enum_super(node) is not None: + # reference (attr-schema-validate.ts). IMMEDIATE super only — the single + # authority is ``is_shared_enum_declaration`` + # (``meta/core/field/meta_field.py``), the same predicate + # ``resolve_shared_enum_declaration`` uses at every step of its walk, so a + # mid-chain redeclaration further up still loads (only a value/map owned + # directly under a shared declaration conflicts). + if node.super_data is not None and is_shared_enum_declaration(node.super_data): errors.append( MetaError( f"{label} declares its own '@{FIELD_ATTR_VALUES}' but extends " @@ -779,9 +758,10 @@ def _validate_enum_int_value_map(node: MetaData, errors: list[MetaError]) -> Non # shared enum is materialized once as a single type, so a per-field map would # give one logical type N storage encodings (and, where a port emits per-TYPE # codec artifacts, two same-named declarations). Same remedy as the @values - # half: declare it on the shared declaration and inherit it. - shared_super = _shared_enum_super(node) - if shared_super is not None: + # half: declare it on the shared declaration and inherit it. IMMEDIATE super + # only — same ``is_shared_enum_declaration`` authority as the @values check + # above. + if node.super_data is not None and is_shared_enum_declaration(node.super_data): errors.append( MetaError( f"{label} declares its own '@{FIELD_ATTR_INT_VALUE_MAP}' but extends " diff --git a/server/python/src/metaobjects/meta/core/field/meta_field.py b/server/python/src/metaobjects/meta/core/field/meta_field.py index 0b00c3b3f..8f210c23e 100644 --- a/server/python/src/metaobjects/meta/core/field/meta_field.py +++ b/server/python/src/metaobjects/meta/core/field/meta_field.py @@ -2,7 +2,7 @@ from __future__ import annotations from ....datatype import DataType -from ....shared.base_types import TYPE_ORIGIN +from ....shared.base_types import TYPE_FIELD, TYPE_METADATA, TYPE_ORIGIN from ...meta_data import MetaData from . import field_constants as fc @@ -102,3 +102,69 @@ def set_value( obj.set(key, value) return setattr(obj, key, value) + + +def is_shared_enum_declaration(node: MetaData) -> bool: + """A "shared enum declaration" (FR-019 / #246): a ``field.enum`` that is + abstract AND a direct child of the metadata root — a sibling of + ``object.entity``, not a field nested inside an object. This is the ONE + predicate the loader validator and every codegen enum-naming/materializing + site call to decide what "shared" means (single authority in the meta layer, + not duplicated in codegen or the loader). See + :func:`resolve_shared_enum_declaration` for the full resolution rule.""" + if node.type != TYPE_FIELD or node.sub_type != fc.FIELD_SUBTYPE_ENUM: + return False + parent = node.parent + return bool(node.is_abstract and parent is not None and parent.type == TYPE_METADATA) + + +def _declares_own_enum_vocabulary(node: MetaData) -> bool: + """ADR-0039 sanctioned own read: whether *node* itself declares an OWN + ``@values`` or OWN ``@intValueMap`` — i.e. starts a new enum vocabulary rather + than inheriting one. This is the deliberate own/resolving split in + :func:`resolve_shared_enum_declaration`'s rule (b): the walk must see whether + THIS node introduces its own member set, which an effective/resolving read + would mask by reporting the inherited value instead.""" + return ( + node.attr(fc.FIELD_ATTR_VALUES) is not None + or node.attr(fc.FIELD_ATTR_INT_VALUE_MAP) is not None + ) + + +def resolve_shared_enum_declaration(field: MetaData) -> MetaData | None: + """The shared enum DECLARATION *field* resolves to via its ``extends`` chain, + or ``None`` when it resolves to none (the common inline case — a per-object + ``field.enum`` with its own ``@values``, or a chain that never reaches a + shared declaration). + + Walks from *field* itself up through ``extends``. At each node, in order: + + (a) the node IS a shared declaration (:func:`is_shared_enum_declaration`) — + that node wins. Nearest wins: a chained root abstract (``Money extends + Currency``) answers ``Money``, never continuing on to ``Currency``. + (b) the node declares its OWN ``@values``/``@intValueMap`` + (:func:`_declares_own_enum_vocabulary`) — the answer is ``None``. The node + starts a new vocabulary, so nothing further up the chain counts, even a + mid-chain redeclaration the loader's own #246 check still allows to load. + (c) otherwise, continue to the node's super. + + A non-enum node, the end of the chain, or a cycle answers ``None``. There is + no condition on the hosting object's subtype (entity, projection, value + object all qualify) and no hop limit. + + THE single authority both the loader's #246 validator (which calls + :func:`is_shared_enum_declaration` on the field's IMMEDIATE super only) and + every codegen enum-naming/materializing site call — so they cannot drift + (ADR-0039).""" + if field.sub_type != fc.FIELD_SUBTYPE_ENUM: + return None + seen: set[int] = set() + node: MetaData | None = field + while node is not None and node.sub_type == fc.FIELD_SUBTYPE_ENUM and id(node) not in seen: + seen.add(id(node)) + if is_shared_enum_declaration(node): + return node + if _declares_own_enum_vocabulary(node): + return None + node = node.super_data + return None diff --git a/server/python/src/metaobjects/render/payload_accessors.py b/server/python/src/metaobjects/render/payload_accessors.py index 5ec3275f8..64d3ea79e 100644 --- a/server/python/src/metaobjects/render/payload_accessors.py +++ b/server/python/src/metaobjects/render/payload_accessors.py @@ -27,11 +27,20 @@ "accessor_value", "with_derived_accessors", "is_boolean_accessor", + "LAST_ACCESSOR", + "is_positional_accessor", ] #: The ``has`` prefix every derived boolean accessor carries. HAS_PREFIX = "has" +#: Positional accessor injected into every mapping ELEMENT of a collection, so a list +#: separator needs no hand-computed ``last`` flag: ``{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}``. +#: The leading ``-`` cannot collide with a field name and is still an ordinary Mustache tag +#: name, so the template stays valid Mustache. Scalar elements carry none (nowhere to hold a +#: key). An authored key of the same name wins, as for ``has``. +LAST_ACCESSOR = "-last" + _MAX_DEPTH = 32 @@ -121,7 +130,14 @@ def with_derived_accessors(payload: Any, depth: int = 0) -> Any: if isinstance(payload, (str, bytes)): return payload if isinstance(payload, Sequence): - return [with_derived_accessors(v, depth + 1) for v in payload] + last = len(payload) - 1 + items = [] + for i, v in enumerate(payload): + el = with_derived_accessors(v, depth + 1) + if isinstance(el, dict): + el.setdefault(LAST_ACCESSOR, i == last) + items.append(el) + return items return payload @@ -142,3 +158,13 @@ def is_boolean_accessor(stack: list[list[Any]], name: str) -> bool: if name == has_accessor_name(f.name): return True return False + + +def is_positional_accessor(in_element: bool, name: str) -> bool: + """True when ``name`` is ``-last`` and *in_element* says it is available here. + + ``in_element`` is the caller's own precomputed flag (see ``render/verify.py``'s + walk): true only when the nearest enclosing ARRAY section iterates object + elements — an array of scalars carries no element to hold the key on, so it + is unavailable there even nested inside an outer object array.""" + return in_element and name == LAST_ACCESSOR diff --git a/server/python/src/metaobjects/render/verify.py b/server/python/src/metaobjects/render/verify.py index 3889f469c..bb3264640 100644 --- a/server/python/src/metaobjects/render/verify.py +++ b/server/python/src/metaobjects/render/verify.py @@ -23,7 +23,7 @@ from dataclasses import dataclass from typing import Protocol -from .payload_accessors import is_boolean_accessor +from .payload_accessors import is_boolean_accessor, is_positional_accessor #: A ``{{var}}`` references a field the (contextual) payload does not declare. ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD" @@ -47,10 +47,18 @@ class PayloadField: ``fields`` present → a context-pushing field (object / array-of-object); ``fields is None`` → a scalar (string/number/boolean/scalar-array). + + ``is_array`` is the field's EFFECTIVE ``isArray`` (resolving read, when built + from real metadata) — distinct from ``fields is not None``: an array of + SCALARS has ``is_array=True`` and ``fields=None``; a single nested object has + ``is_array=False`` and ``fields`` present. The combination of the two decides + whether ``-last`` is available inside a ``{{#x}}`` section over this field + (see the walk in :func:`verify`). """ name: str fields: list[PayloadField] | None = None + is_array: bool = False @dataclass(frozen=True) @@ -208,7 +216,10 @@ def verify( static_texts: list[str] = [template_text] def walk( - tokens: list[_Token], stack: list[list[PayloadField]], seen: list[str] + tokens: list[_Token], + stack: list[list[PayloadField]], + seen: list[str], + in_element: bool = False, ) -> None: at_root = len(stack) == 1 and stack[0] is root for tok in tokens: @@ -217,34 +228,51 @@ def walk( continue # implicit iterator — always valid if at_root: referenced_at_root.add(tok.value.split(".")[0]) - if _resolve(stack, tok.value) is None and not is_boolean_accessor( - stack, tok.value + if ( + _resolve(stack, tok.value) is None + and not is_boolean_accessor(stack, tok.value) + and not is_positional_accessor(in_element, tok.value) ): errors.append(VerifyError(ERR_VAR_NOT_ON_PAYLOAD, tok.value)) elif isinstance(tok, _Section): if tok.value == ".": - walk(tok.children, stack, seen) + walk(tok.children, stack, seen, in_element) continue if at_root: referenced_at_root.add(tok.value.split(".")[0]) field = _resolve(stack, tok.value) if field is None: + # A positional `-last` gate is a BOOLEAN on the current element: + # it pushes nothing, so the body walks in the SAME scope. + if is_positional_accessor(in_element, tok.value): + walk(tok.children, stack, seen, in_element) + continue # A derived `has` gate is a BOOLEAN over the current context: # it resolves nothing and pushes nothing, so walk the body in the SAME # scope — what `{{#hasAbilities}}{{#abilities}}…` depends on. if is_boolean_accessor(stack, tok.value): - walk(tok.children, stack, seen) + walk(tok.children, stack, seen, in_element) continue # Unresolved section head is itself drift; skip the body (its # context is unknowable, walking it would cascade false errors). errors.append(VerifyError(ERR_VAR_NOT_ON_PAYLOAD, tok.value)) continue - # `#` over a container pushes its element fields; `^` (and `#` over a - # scalar, used as a conditional) keep the current context. - if not tok.inverted and field.fields is not None: - walk(tok.children, [*stack, field.fields], seen) + # `^` (inverted) never pushes and never changes whether `-last` is + # available — it always inherits the enclosing flag unchanged. + if tok.inverted: + walk(tok.children, stack, seen, in_element) + continue + # `#`: an ARRAY section sets the flag explicitly — true only when + # its elements are objects (there is an element shape to carry + # `-last` on); an array of scalars rules `-last` OUT here, even + # when an outer object array enclosed this section. A non-array + # section (object or scalar conditional) changes nothing of its + # own — it inherits whatever the enclosing context already had. + next_in_element = field.fields is not None if field.is_array else in_element + if field.fields is not None: + walk(tok.children, [*stack, field.fields], seen, next_in_element) else: - walk(tok.children, stack, seen) + walk(tok.children, stack, seen, next_in_element) else: # _Partial if provider is None: continue # can't resolve without a provider @@ -255,7 +283,7 @@ def walk( errors.append(VerifyError(ERR_PARTIAL_UNRESOLVED, tok.value)) continue static_texts.append(body) - walk(_tokenize(body), stack, [*seen, tok.value]) + walk(_tokenize(body), stack, [*seen, tok.value], in_element) walk(_tokenize(template_text), [root], []) diff --git a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py index 9ac224ce5..f1f037d83 100644 --- a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py +++ b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py @@ -10,6 +10,8 @@ materialized; consuming models import it from the configured module. A referenced ``@provided`` enum with no configured module is a codegen-time error naming the enum. """ +import importlib +import sys from pathlib import Path import pytest @@ -19,16 +21,28 @@ import json from metaobjects import InMemoryStringSource, MetaDataFormat, MetaDataLoader +from metaobjects.meta.core.field import field_constants as fc +from metaobjects.meta.core.field.meta_field import resolve_shared_enum_declaration from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.codegen.config import GenConfig from metaobjects.codegen.generator import GenContext -from metaobjects.codegen.generators.entity_model import EntityModelGenerator +from metaobjects.codegen.generators.entity_model import EntityModelGenerator, entity_model +from metaobjects.codegen.generators.fr019_shared_enum import ( + is_provided, + materialized_shared_enums, + resolve_shared_enum_decl, +) +from metaobjects.codegen.runner import run_gen _FIXTURE_DIR = ( Path(__file__).resolve().parents[4] / "fixtures/codegen-conformance/shared-provided-enum/input" ) +_EXPECTED_ENUM_TYPES = ( + Path(__file__).resolve().parents[4] + / "fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json" +) def _emit(config: GenConfig) -> dict[str, str]: @@ -56,7 +70,10 @@ def test_shared_enum_is_materialized_once_and_referenced_by_both_entities() -> N src = files[name] assert "priority: Priority" in src, f"{name} must type priority as the shared enum" assert "from .enums import Priority" in src, f"{name} must import the materialized enum" - assert "Literal" not in src, f"{name} must not inline the shared enum as a Literal" + # Scoped to the `priority` field itself — the extended fixture also gives + # Order its own inline enum (`status`), so the file as a whole legitimately + # contains a Literal for that unrelated field. + assert "priority: Literal" not in src, f"{name} must not inline priority as a Literal" def test_provided_enum_is_not_materialized_and_imported_from_the_configured_module() -> None: @@ -97,3 +114,181 @@ def test_shared_enum_members_are_uppercase_with_wire_value_preserved() -> None: assert 'STATISTICAL = "statistical"' in enums assert 'SEARCH_WEB = "search_web"' in enums assert 'statistical = "statistical"' not in enums # member is not the lowercase value + + +def test_multi_hop_enum_extends_still_resolves_to_the_shared_enum() -> None: + """``Task.urgency`` extends ``Ticket.priority`` which extends the shared abstract ``Priority``: + the shared declaration is found through the intermediate concrete field.""" + files = _emit(GenConfig(out_dir="", provided_enum_namespace="acme_ext.enums")) + task = files["Task.py"] + assert "urgency: Priority" in task + assert "from .enums import Priority" in task + # Scoped to `urgency` — the extended fixture also gives Task its own + # inline/mid-chain-broken enums (`phase`, `weight`), which legitimately + # inline as Literal elsewhere in the same file. + assert "urgency: Literal" not in task + + +def test_decision_table_matches_fixture_exactly() -> None: + """The decision-table fixture (``expected-enum-types.json``) pins, for EVERY + object-hosted ``field.enum`` in every object — entities, the ``TicketSummary`` + projection, the ``TicketBadge`` value object — exactly what + ``resolve_shared_enum_declaration`` (the one function every generator calls) + answers. Exact equality both ways: no missing key, no extra key.""" + root = MetaDataLoader.from_directory(_FIXTURE_DIR).root + objects = [o for o in root.own_children() if isinstance(o, MetaObject)] + + actual: dict[str, dict[str, object]] = {} + for obj in objects: + for f in obj.fields(): + if f.sub_type != fc.FIELD_SUBTYPE_ENUM: + continue + decl = resolve_shared_enum_declaration(f) + actual[f"{obj.resolution_key()}.{f.name}"] = { + "shared": decl.resolution_key() if decl is not None else None, + "provided": is_provided(decl) if decl is not None else False, + } + + expected = json.loads(_EXPECTED_ENUM_TYPES.read_text()) + assert actual == expected["fields"] + + materialized = sorted(e.name for e in materialized_shared_enums(objects)) + assert materialized == expected["materialized"] + + +def test_deprecated_resolver_name_still_answers_the_decision_table() -> None: + """A generator ejected before the resolver moved to the metadata layer calls + ``fr019_shared_enum.resolve_shared_enum_decl``. That name must still import and + must answer what the decision-table fixture pins for every field, including the + multi-hop ones (the removed one-hop predicate answered ``None`` for + ``Task.importance``), while warning that it is deprecated.""" + root = MetaDataLoader.from_directory(_FIXTURE_DIR).root + expected = json.loads(_EXPECTED_ENUM_TYPES.read_text())["fields"] + + actual: dict[str, str | None] = {} + for obj in (o for o in root.own_children() if isinstance(o, MetaObject)): + for f in obj.fields(): + if f.sub_type != fc.FIELD_SUBTYPE_ENUM: + continue + with pytest.warns(DeprecationWarning, match="resolve_shared_enum_declaration"): + decl = resolve_shared_enum_decl(f) + actual[f"{obj.resolution_key()}.{f.name}"] = ( + decl.resolution_key() if decl is not None else None + ) + + assert actual == {key: entry["shared"] for key, entry in expected.items()} + assert actual["acme::shop::Task.importance"] == "acme::shop::Priority" + + +def test_generated_output_for_multi_hop_provided_and_negative_cases( + tmp_path: Path, +) -> None: + """Executes the real generated output (via ``run_gen`` + import, not string + guessing) for every scenario the decision table above names explicitly.""" + root = MetaDataLoader.from_directory(_FIXTURE_DIR).root + out_dir = tmp_path / "generated" + config = GenConfig(out_dir=str(out_dir), provided_enum_namespace="acme_ext.enums") + run_gen(config, root, generators=[entity_model()]) + + # A stub for the @provided module (ADR-0001: it never lives in metadata, and + # genuinely does not exist here) so Refund.py's cross-module import resolves + # for real instead of being skipped. + stub_pkg = tmp_path / "acme_ext" + stub_pkg.mkdir() + (stub_pkg / "__init__.py").write_text("") + (stub_pkg / "enums.py").write_text( + 'from enum import Enum\n\n\nclass Currency(str, Enum):\n' + ' USD = "USD"\n EUR = "EUR"\n GBP = "GBP"\n' + ) + + sys.path.insert(0, str(tmp_path)) + try: + enums_mod = importlib.import_module("generated.enums") + assert not hasattr(enums_mod, "Currency"), "a @provided enum must never be materialized" + assert set(enums_mod.Priority.__members__) == {"LOW", "MEDIUM", "HIGH"} + assert set(enums_mod.Money.__members__) == {"USD", "EUR", "GBP"} + + # Importing executes the real generated modules — proves the shared-enum + # cross-module references actually resolve, not merely render as + # plausible text. + task = importlib.import_module("generated.Task") + subtask = importlib.import_module("generated.Subtask") + ticket_summary = importlib.import_module("generated.TicketSummary") + ticket_badge = importlib.import_module("generated.TicketBadge") + refund = importlib.import_module("generated.Refund") + assert task.Task is not None + assert subtask.Subtask is not None + assert ticket_summary.TicketSummary is not None + assert ticket_badge.TicketBadge is not None + assert refund.Refund is not None + + task_src = (out_dir / "Task.py").read_text() + subtask_src = (out_dir / "Subtask.py").read_text() + ticket_summary_src = (out_dir / "TicketSummary.py").read_text() + ticket_badge_src = (out_dir / "TicketBadge.py").read_text() + refund_src = (out_dir / "Refund.py").read_text() + enums_src = (out_dir / "enums.py").read_text() + + # Multi-hop consumers (Task.importance -> Ticket.severity -> Priority; + # Subtask.rank three hops; a projection; a value object) reference the + # shared type, with no per-object Literal. + assert "importance: Priority" in task_src and "importance: Literal" not in task_src + assert "rank: Priority" in subtask_src and "rank: Literal" not in subtask_src + assert "level: Priority" in ticket_summary_src and "level: Literal" not in ticket_summary_src + assert "badge: Priority" in ticket_badge_src and "badge: Literal" not in ticket_badge_src + for src in (task_src, subtask_src, ticket_summary_src, ticket_badge_src): + assert "from .enums import Priority" in src + + # Priority emitted exactly once across the whole model. + assert enums_src.count("class Priority(str, Enum):") == 1 + + # Refund: two fields whose immediate supers are same-named fields of + # different objects (ticketTier -> Ticket.tier -> Priority; invoiceTier -> + # Invoice.tier -> Money), plus the materialized Money the unit field + # resolves to, plus the @provided Currency that is never materialized. + assert "ticketTier: Priority" in refund_src and "ticketTier: Literal" not in refund_src + assert "invoiceTier: Money" in refund_src and "invoiceTier: Literal" not in refund_src + assert "unit: Money" in refund_src and "unit: Literal" not in refund_src + assert "from .enums import Money" in refund_src + assert "tender: Currency" in refund_src + assert "from acme_ext.enums import Currency" in refund_src + assert "class Currency" not in enums_src + + # Negatives: an own @values/@intValueMap breaks the chain, so these keep + # an inline per-object Literal enum instead of referencing Priority. + for src, field_name in ( + (task_src, "phase"), + (task_src, "weight"), + (subtask_src, "phase"), + (subtask_src, "weight"), + (refund_src, "orderStatus"), + ): + assert f"{field_name}: Literal[" in src, f"{field_name} must keep its inline enum" + finally: + sys.path.remove(str(tmp_path)) + for name in [ + m for m in sys.modules if m.split(".")[0] in ("generated", "acme_ext") + ]: + del sys.modules[name] + + +def test_narrowed_run_on_only_the_projection_still_materializes_the_shared_enum( + tmp_path: Path, +) -> None: + """A run narrowed to ONLY ``TicketSummary`` (a projection, via + ``entity_filter``) must still emit ``Priority`` — must-generate turns on + whether any SURVIVING consumer references the shared type, never on the + hosting object's subtype.""" + root = MetaDataLoader.from_directory(_FIXTURE_DIR).root + out_dir = tmp_path / "generated_narrow" + config = GenConfig(out_dir=str(out_dir), provided_enum_namespace="acme_ext.enums") + run_gen(config, root, generators=[entity_model()], entity_filter=["TicketSummary"]) + + assert (out_dir / "enums.py").exists(), "narrowing to TicketSummary must still emit enums.py" + enums_src = (out_dir / "enums.py").read_text() + assert enums_src.count("class Priority(str, Enum):") == 1 + assert "class Money" not in enums_src, "Money has no consumer in the narrowed run" + + ticket_summary_src = (out_dir / "TicketSummary.py").read_text() + assert "level: Priority" in ticket_summary_src + assert "from .enums import Priority" in ticket_summary_src diff --git a/server/python/tests/render/test_render_model_payload.py b/server/python/tests/render/test_render_model_payload.py index 5ea769996..770effb7f 100644 --- a/server/python/tests/render/test_render_model_payload.py +++ b/server/python/tests/render/test_render_model_payload.py @@ -6,12 +6,19 @@ """ from __future__ import annotations +import json from enum import Enum +from pathlib import Path from pydantic import BaseModel from metaobjects.render.renderer import InMemoryProvider, RenderRequest, render +_LIST_SEPARATOR_FIXTURE = ( + Path(__file__).resolve().parents[4] + / "fixtures/render-conformance/render-list-separator" +) + class _Tier(str, Enum): GOLD = "gold" @@ -51,3 +58,72 @@ def test_a_model_and_its_dump_render_identically() -> None: npc = _Npc(name="Igor", tier=_Tier.GOLD, bio="old", abilities=[_Ability(name="lurk")]) template = "{{name}} {{tier}} {{bio}} {{#abilities}}{{name}}{{/abilities}}" assert _render(template, npc) == _render(template, npc.model_dump(mode="json")) + + +# --- Typed payload elements (shared fixture parity) --------------------------- + + +class _ListItem(BaseModel): + name: str + + +class _Tag(BaseModel): + label: str + + +class _Group(BaseModel): + tags: list[_Tag] + + +class _Owner(BaseModel): + nick: str + + +class _Entry(BaseModel): + name: str + note: str | None = None + owner: _Owner + + +class _ListSeparatorPayload(BaseModel): + """Mirrors ``fixtures/render-conformance/render-list-separator/payload.json``. + + ``authored``'s elements carry a literal ``-last`` key, which is not a valid + Python identifier — item 5's documented escape hatch keeps those as plain + dict elements; every other list is modeled as the Pydantic equivalent of the + DOCUMENTED Python payload shape (a generated value-object model).""" + + items: list[_ListItem] + solo: list[_ListItem] + none: list[_ListItem] + groups: list[_Group] + entries: list[_Entry] + authored: list[dict] + + +def test_typed_payload_renders_the_shared_fixture_byte_identically_to_its_dict_twin() -> None: + """``-last`` (and, transitively, ``has``) must work for BOTH documented + Python payload shapes — a generated Pydantic ``BaseModel`` and a plain + ``dict`` — and a typed payload equal to a dict payload must render + byte-identically. Proven against the shared render-conformance fixture + itself, not a one-off template, so this pins the real cross-port contract.""" + raw = json.loads((_LIST_SEPARATOR_FIXTURE / "payload.json").read_text()) + expected = (_LIST_SEPARATOR_FIXTURE / "expected.txt").read_text() + template = (_LIST_SEPARATOR_FIXTURE / "template.mustache").read_text() + + typed_payload = _ListSeparatorPayload.model_validate(raw) + + rendered_typed = _render(template, typed_payload) + rendered_dict = _render(template, raw) + assert rendered_typed == expected + assert rendered_dict == expected + assert rendered_typed == rendered_dict + + +def test_has_field_gate_on_a_typed_element_inside_a_model_list() -> None: + """``has`` through the same ``with_derived_accessors`` mechanism as + ``-last``, exercised on an ELEMENT of a model-typed list (``_Npc.abilities``), + not just the top-level model the existing has test above covers.""" + template = "{{#abilities}}{{#hasName}}yes{{/hasName}}{{^hasName}}no{{/hasName}}{{/abilities}}" + npc = _Npc(name="Igor", tier=_Tier.GOLD, abilities=[_Ability(name="lurk")]) + assert _render(template, npc) == "yes" diff --git a/server/python/tests/render/test_verify_conformance.py b/server/python/tests/render/test_verify_conformance.py index 922024c89..9bd350633 100644 --- a/server/python/tests/render/test_verify_conformance.py +++ b/server/python/tests/render/test_verify_conformance.py @@ -53,12 +53,20 @@ def _corpus_root() -> Path: def _parse_fields(raw: Any) -> list[PayloadField]: - """Parse the payload.json field-tree (recursive) into PayloadField[].""" + """Parse the payload.json field-tree (recursive) into PayloadField[]. + + ``isArray`` (absent = false) carries the field's array-ness, which gates + ``-last`` availability (an array of scalars never carries it, even nested + inside an outer object array).""" fields: list[PayloadField] = [] for node in raw: sub = node.get("fields") fields.append( - PayloadField(node["name"], _parse_fields(sub) if sub is not None else None) + PayloadField( + node["name"], + _parse_fields(sub) if sub is not None else None, + is_array=bool(node.get("isArray", False)), + ) ) return fields diff --git a/server/typescript/packages/cli/src/lib/payload-field-tree.ts b/server/typescript/packages/cli/src/lib/payload-field-tree.ts index 2c0d8d1a3..44495372a 100644 --- a/server/typescript/packages/cli/src/lib/payload-field-tree.ts +++ b/server/typescript/packages/cli/src/lib/payload-field-tree.ts @@ -7,9 +7,9 @@ import { type MetaData, - TYPE_FIELD, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, + isMetaField, resolveObjectRef, } from "@metaobjectsdev/metadata"; import type { PayloadField } from "@metaobjectsdev/render"; @@ -44,17 +44,21 @@ export function derivePayloadFieldTree( const voPkg = vo.package ?? vo.fileDefaultPackage ?? ""; const nextSeen = new Set(seen).add(voName); const fields: PayloadField[] = []; - for (const f of vo.children().filter((c) => c.type === TYPE_FIELD)) { + for (const f of vo.children().filter(isMetaField)) { + // ADR-0039: effective array-ness — `isArray` may be inherited via extends. Carried only + // when true, so verify can tell a list of objects from a single object and a list of + // scalars from a scalar. + const arrayness = f.resolvedIsArray() ? { isArray: true } : {}; if (f.subType === FIELD_SUBTYPE_OBJECT) { // ADR-0039: effective attr — @objectRef may be inherited via extends. const ref = f.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string") { const fieldPkg = f.parent?.package ?? f.parent?.fileDefaultPackage ?? voPkg; - fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen) }); + fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen), ...arrayness }); continue; } } - fields.push({ name: f.name }); + fields.push({ name: f.name, ...arrayness }); } return fields; } diff --git a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts index 2414de528..071da6f84 100644 --- a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts +++ b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts @@ -51,7 +51,36 @@ describe("derivePayloadFieldTree — mirrors the payload-codegen VO walk", () => expect(tree).toEqual([ { name: "displayName" }, { name: "postCount" }, - { name: "posts", fields: [{ name: "title" }] }, + { name: "posts", fields: [{ name: "title" }], isArray: true }, + ]); + }); + + // verify tells a list of objects from a single object, and a list of scalars from a scalar, + // by the node's array-ness — the field's EFFECTIVE isArray, so one inherited through + // `extends` counts exactly like one declared on the field. + test("array-ness is carried for object lists and scalar lists, including when inherited", async () => { + const root = await loadRoot([ + { "field.string": { name: "Labels", abstract: true, isArray: true } }, + { "object.value": { name: "Tag", children: [{ "field.string": { name: "name" } }] } }, + { + "object.value": { + name: "Card", + children: [ + { "field.string": { name: "title" } }, + { "field.string": { name: "aliases", isArray: true } }, + { "field.string": { name: "labels", extends: "Labels" } }, + { "field.object": { name: "owner", "@objectRef": "Tag" } }, + { "field.object": { name: "tags", isArray: true, "@objectRef": "Tag" } }, + ], + }, + }, + ]); + expect(derivePayloadFieldTree(root, "Card", "acme::ai")).toEqual([ + { name: "title" }, + { name: "aliases", isArray: true }, + { name: "labels", isArray: true }, + { name: "owner", fields: [{ name: "name" }] }, + { name: "tags", fields: [{ name: "name" }], isArray: true }, ]); }); diff --git a/server/typescript/packages/codegen-ts/src/enum-shared.ts b/server/typescript/packages/codegen-ts/src/enum-shared.ts index 25f0cf632..0908bd8f2 100644 --- a/server/typescript/packages/codegen-ts/src/enum-shared.ts +++ b/server/typescript/packages/codegen-ts/src/enum-shared.ts @@ -1,7 +1,7 @@ // FR-019 — shared + externally-provided enum resolution. // // A reusable enum is a ROOT/package-level abstract `field.enum` (a sibling of -// object.entity) that concrete entity fields `extends`. Per ADR-0026 such a +// object.entity) that object fields reach through `extends`. Per ADR-0026 such a // declaration is materialized ONCE per port and referenced, instead of being // redeclared inline in every consuming entity file. // @@ -12,15 +12,17 @@ // reference an existing hand-written/third-party type, resolved via per-port // codegen config (the namespace/module never lives in metadata — ADR-0001). // +// WHICH declaration a field resolves to is decided in the metadata layer +// (`resolveSharedEnumDeclaration`, ADR-0026 Amendment 1) — the same function the +// loader's #246 check uses. This module adds only the TypeScript naming and the +// `@provided` read. +// // The common inline case (a concrete `field.enum` with @values declared directly, -// no root-extends) is UNCHANGED — it stays a per-entity `` union. +// no shared declaration on its extends chain) is UNCHANGED — it stays a per-entity +// `` union. import type { MetaField, MetaRoot } from "@metaobjectsdev/metadata"; -import { - FIELD_SUBTYPE_ENUM, - FIELD_ATTR_PROVIDED, - TYPE_METADATA, -} from "@metaobjectsdev/metadata"; +import { FIELD_ATTR_PROVIDED, resolveSharedEnumDeclaration } from "@metaobjectsdev/metadata"; import { toPascalCase } from "./naming.js"; import { enumValues } from "./enum-meta.js"; @@ -28,39 +30,26 @@ import { enumValues } from "./enum-meta.js"; export interface SharedEnum { /** The materialized type name — the abstract declaration's PascalCase name (cross-port identity). */ readonly name: string; - /** Member symbols (the @values SSOT). */ + /** The declaration's package-qualified key (`::`). */ + readonly declaration: string; + /** Member symbols (the @values SSOT), read from the declaration. */ readonly values: string[]; /** True when the declaration carries `@provided: true` (reference, don't materialize). */ readonly provided: boolean; } -/** - * The root-level abstract `field.enum` a concrete enum field resolves to via - * `extends`, or undefined when the field is an inline enum (no root-abstract - * super). A super qualifies only when it is (a) an abstract field, AND (b) a - * direct child of the metadata root (a package-level declaration) — NOT an - * abstract field nested inside another object. - */ -export function resolveSharedEnumDecl(field: MetaField): MetaField | undefined { - if (field.subType !== FIELD_SUBTYPE_ENUM) return undefined; - const sup = field.resolveSuper(); - if (sup === undefined) return undefined; - if (!sup.isAbstract) return undefined; - // The declaration must sit at the package/root level (sibling of object.entity), - // i.e. its parent node is the metadata root. - const parent = sup.parent; - if (parent === undefined || parent.type !== TYPE_METADATA) return undefined; - return sup; -} - /** The shared-enum descriptor a field resolves to, or undefined for inline enums. */ export function sharedEnumForField(field: MetaField): SharedEnum | undefined { - const decl = resolveSharedEnumDecl(field); + const decl = resolveSharedEnumDeclaration(field); if (decl === undefined) return undefined; - const values = enumValues(field); + // The member set is the DECLARATION's (resolving, so `Money extends Currency` reads + // the members it inherits) — never a consuming field's, so what is materialized + // cannot depend on which consumer is visited first. + const values = enumValues(decl); if (values === undefined) return undefined; return { name: toPascalCase(decl.name), + declaration: decl.resolutionKey(), values, // ADR-0039 sanctioned own: @provided is a declaration-layer provenance marker // ("THIS type is supplied by hand-written/third-party code"), like `abstract` — @@ -74,9 +63,10 @@ export function sharedEnumForField(field: MetaField): SharedEnum | undefined { /** * All shared (root-level abstract) enum declarations in the loaded root that are - * actually CONSUMED by at least one concrete entity field — keyed by the - * materialized type name. A declaration nobody extends is not materialized (no - * dangling type). Deterministic order: first-consumption order across entities. + * actually CONSUMED by at least one object field (entity, projection or value + * object, through any number of `extends` hops) — keyed by the materialized type + * name. A declaration nobody extends is not materialized (no dangling type). + * Deterministic order: first-consumption order across objects. * * `select` (FR-023 §11.1 item 2), when given, narrows which CONSUMING entities * count toward "is this enum used" — an entity for which `select(entity. diff --git a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts index 846520ac1..519f1ad4c 100644 --- a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts +++ b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts @@ -71,6 +71,7 @@ export function buildEnrichedPayloadTree( owner, type: neutralTypeStr(f), required: isFieldRequired(f), + ...(f.resolvedIsArray() ? { isArray: true } : {}), }; if (f.subType === FIELD_SUBTYPE_OBJECT) { const ref = f.attr(FIELD_ATTR_OBJECT_REF); diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 552f44bdd..1e0c25ea3 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -234,7 +234,7 @@ export { renderBarrel } from "./templates/barrel.js"; export type { BarrelEntry } from "./templates/barrel.js"; export { renderFilterType } from "./templates/filter-type.js"; export { renderFilterAllowlist, renderSortAllowlist } from "./templates/filter-allowlist.js"; -export { renderEntityConstants, resourcePath } from "./templates/entity-constants.js"; +export { renderEntityConstants, renderValueObjectConstants, resourcePath } from "./templates/entity-constants.js"; export { renderQueriesFile } from "./templates/queries-file.js"; // #348 — which CRUD verbs a generated routes file mounts. Public because an OWNED // routes generator (ADR-0034) composes the same render call and needs the same option. diff --git a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts index f11d1c717..2574532d5 100644 --- a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts +++ b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts @@ -92,6 +92,32 @@ function renderFieldEntry(f: UiFieldDescriptor): string { return ` ${f.name}: {\n ${entries.join(",\n ")},\n }`; } +/** + * The field-rules constant of an `object.value`: `export const = { $entity, : { name, + * label, view, rules, ... } } as const`. The same per-field shape an entity's constant carries, + * minus `$table` and `$path` — a value object has no source and no route — so a caller reads a + * limit as `..rules.maxLength.value` instead of reaching through the Zod schema's + * getters. Emitted for the value subtype only (see `renderValueObjectFile`). + */ +export function renderValueObjectConstants(obj: MetaObject, emittedName?: string): Code { + const descriptor = buildEntityUiDescriptor(obj); + // The emitted name (collision-qualified, ADR-0044) matches the value object's interface. + const name = emittedName ?? descriptor.entity; + const body = joinCode( + [code` $entity: ${JSON.stringify(descriptor.entity)}`, ...descriptor.fields.map((f) => code`${renderFieldEntry(f)}`)], + { on: ",\n" }, + ); + return code` +/** + * Metadata constants for the value object ${name}: each field's name, label, view and the + * validation rules derived from its validator children (e.g. \`${name}..rules.maxLength.value\`). + */ +export const ${name} = { +${body}, +} as const; +`; +} + export function renderEntityConstants( obj: MetaObject, /** diff --git a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts index 66218ef4b..c2e8cfb2f 100644 --- a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts +++ b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts @@ -94,15 +94,18 @@ export type ${entity.name}Update = Partial<${entity.name}Insert>; /** * The string-literal-union type-alias name for a `field.enum` field — the SINGLE * source of truth for enum-union naming (reused by the entity inferred-types - * emitter AND the payload-VO emitter so both agree byte-for-byte). + * emitter, the payload-VO emitter and the extractor so all agree byte-for-byte). * - * - If the field extends an abstract field.enum (super), use the super field's - * PascalCase name (so multiple fields sharing one abstract enum collapse to a - * single alias). - * - Otherwise use `` for inline enums, where `` is the - * owning object's name (entity OR payload value-object). + * - A field that resolves to a shared enum declaration (through any number of + * `extends` hops) takes that declaration's PascalCase name — the one type every + * consumer of the declaration references. + * - Otherwise the enum is inline. One that extends another field keeps that + * immediate super's PascalCase name; one with no super is ``, + * where `` is the owning object's name (entity OR payload value-object). */ export function enumUnionAliasName(ownerName: string, field: MetaField): string { + const shared = sharedEnumForField(field); + if (shared !== undefined) return shared.name; const superField = field.resolveSuper(); return superField !== undefined ? toPascalCase(superField.name) diff --git a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts index 78976671d..f4cd62a0e 100644 --- a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts +++ b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts @@ -27,7 +27,6 @@ import { type MetaData, - TYPE_FIELD, TYPE_TEMPLATE, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, @@ -42,6 +41,7 @@ import { TEMPLATE_ATTR_SUBJECT_REF, TEMPLATE_ATTR_HTML_BODY_REF, TEMPLATE_ATTR_TEXT_BODY_REF, + isMetaField, resolveObjectRef, } from "@metaobjectsdev/metadata"; import { @@ -108,16 +108,19 @@ function derivePayloadFieldTree( const voPkg = vo.package ?? vo.fileDefaultPackage ?? ""; const nextSeen = new Set(seen).add(voName); const fields: PayloadField[] = []; - for (const f of vo.children().filter((c) => c.type === TYPE_FIELD)) { + for (const f of vo.children().filter(isMetaField)) { + // ADR-0039: effective array-ness — `isArray` may be inherited via extends. Carried only + // when true, so a helper for a payload with no list field is byte-identical. + const arrayness = f.resolvedIsArray() ? { isArray: true } : {}; if (f.subType === FIELD_SUBTYPE_OBJECT) { const ref = f.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string") { const fieldPkg = f.parent?.package ?? f.parent?.fileDefaultPackage ?? voPkg; - fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen) }); + fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen), ...arrayness }); continue; } } - fields.push({ name: f.name }); + fields.push({ name: f.name, ...arrayness }); } return fields; } diff --git a/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts b/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts index d149126e6..555ac85e4 100644 --- a/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts @@ -1,8 +1,8 @@ // Value-object file composer — emits a streamlined "value-only" module for -// metaobjects with no writable source.rdb child. Output is just the TS -// interface + the Zod schema (and enum type aliases when applicable); no -// Drizzle table, no Infer*Model aliases, no filter allowlists, no constants -// object. +// metaobjects with no writable source.rdb child. Output is the TS interface + +// the Zod schema (and enum type aliases when applicable); no Drizzle table, no +// Infer*Model aliases, no filter allowlists. An `object.value` also gets a +// field-rules constant (`..rules`), without `$table` or `$path`. // // Two objects land here that are more than a value: a TPH subtype (it shares the // base's table) and a SOURCELESS ENTITY (identity, no source: a record in a store @@ -15,6 +15,7 @@ import { joinCode, type Code } from "ts-poet"; import type { MetaObject } from "@metaobjectsdev/metadata"; +import { OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata"; import type { RenderContext } from "../render-context.js"; import { renderValueObjectInterface, renderEnumTypeAliases } from "./inferred-types.js"; import { @@ -24,12 +25,13 @@ import { renderTphSubtypeReadSchema, tphDiscriminatorPin, } from "./zod-validators.js"; -import { renderEntityConstants } from "./entity-constants.js"; +import { renderEntityConstants, renderValueObjectConstants } from "./entity-constants.js"; import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js"; import { renderFilterType } from "./filter-type.js"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants.js"; import { namesRef, namesConstArg } from "../names.js"; import { isSourcelessEntity } from "../source-detect.js"; +import { isAbstract } from "../instance-artifacts.js"; export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: RenderContext): string { const enumAliases = renderEnumTypeAliases(obj, ctx); @@ -83,6 +85,14 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren // object stays insert-only (no PATCH semantics — an UpdateSchema would mislead). tphSubtype || sourceless ? renderZodValidators(obj, ctx) : renderInsertSchemaOnly(obj, ctx), ...(tphConstants !== null ? [tphConstants] : []), + // An `object.value` gets a field-rules constant, so a caller reads `..rules` + // rather than the Zod schema's getters. It has no table or route, so the constant carries + // no `$table`/`$path`. Only the value subtype: an abstract object, a TPH subtype, a + // sourceless entity and an entity emitted to a contract-only target also land in this + // composer, and none of them takes it. + ...(obj.subType === OBJECT_SUBTYPE_VALUE && !isAbstract(obj) + ? [renderValueObjectConstants(obj, ctx ? ctx.valueObjectEmittedName(obj) : undefined)] + : []), ...(tphFilterAllowlist !== null ? [tphFilterAllowlist] : []), ...(tphSortAllowlist !== null ? [tphSortAllowlist] : []), ...(tphFilterType !== null ? [tphFilterType] : []), diff --git a/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts b/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts index bc1f7390d..e967ec54a 100644 --- a/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts @@ -133,8 +133,8 @@ describe("entityFile() — emitAbstractShapes knob", () => { }, }); - async function runWith(emitAbstractShapes: boolean) { - const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(MODEL)]); + async function runWith(emitAbstractShapes: boolean, model: string = MODEL) { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(model)]); if (errors.length > 0) throw new Error(errors.map((e: { message: string }) => e.message).join("\n")); const entities = root.objects(); const renderContext = makeRenderContext({ @@ -149,17 +149,68 @@ describe("entityFile() — emitAbstractShapes knob", () => { renderContext, warn: () => {}, }; - return (await entityFile().generate(ctx)).map((f) => f.path); + return entityFile().generate(ctx); } test("default (on): abstract entity emits its shape file", async () => { - const paths = await runWith(true); + const paths = (await runWith(true)).map((f) => f.path); expect(paths).toContain("Base.ts"); expect(paths).toContain("Widget.ts"); }); + // Generated-output contract: the field-rules constant belongs to `object.value` only. An + // abstract object's shape file goes through the same composer and must not carry one. + test("abstract entity's shape file carries no value-object field-rules constant", async () => { + const base = (await runWith(true)).find((f) => f.path === "Base.ts"); + expect(base).toBeDefined(); + expect(base!.content).toContain("export interface Base"); + expect(base!.content).not.toContain("export const Base ="); + expect(base!.content).not.toContain("$entity:"); + }); + + // Generated-output contract, as the CHANGELOG states it: an ABSTRACT `object.value` is a + // shape to extend, never a value a caller holds, so its module exports no field-rules + // constant. The concrete value object that extends it exports one, and that constant lists + // the inherited fields with their limits, because those are the limits of the concrete type. + test("abstract value object exports no field-rules constant; its concrete subtype lists the inherited fields", async () => { + const model = JSON.stringify({ + "metadata.root": { + package: "acme", + children: [ + { + "object.value": { + name: "BaseAddress", + abstract: true, + children: [{ "field.string": { name: "line1", "@maxLength": 80 } }], + }, + }, + { + "object.value": { + name: "ShippingAddress", + extends: "BaseAddress", + children: [{ "field.string": { name: "zip", "@maxLength": 10 } }], + }, + }, + ], + }, + }); + const files = await runWith(true, model); + const base = files.find((f) => f.path === "BaseAddress.ts"); + expect(base).toBeDefined(); + expect(base!.content).toContain("export interface BaseAddress"); + expect(base!.content).not.toContain("export const BaseAddress ="); + expect(base!.content).not.toContain("$entity:"); + + const concrete = files.find((f) => f.path === "ShippingAddress.ts"); + expect(concrete).toBeDefined(); + const constant = concrete!.content.slice(concrete!.content.indexOf("export const ShippingAddress = {")); + expect(constant.startsWith("export const ShippingAddress = {")).toBe(true); + expect(constant).toMatch(/line1: \{[^}]*rules: \{[^}]*maxLength: \{ value: 80,/s); + expect(constant).toMatch(/zip: \{[^}]*rules: \{[^}]*maxLength: \{ value: 10,/s); + }); + test("off: abstract entity emits no file; concrete still emits", async () => { - const paths = await runWith(false); + const paths = (await runWith(false)).map((f) => f.path); expect(paths).not.toContain("Base.ts"); expect(paths).toContain("Widget.ts"); }); diff --git a/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts b/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts index 7526a1327..3c4a2f990 100644 --- a/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts +++ b/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts @@ -1,6 +1,7 @@ // Cross-port FR-019 conformance (TS — the reference). Loads the shared fixture -// `fixtures/codegen-conformance/shared-provided-enum/input/meta.json` and asserts the two -// FR-019 behaviors (ADR-0026): +// `fixtures/codegen-conformance/shared-provided-enum/input/meta.json`, checks every enum +// field's resolution against the port-neutral decision table beside it +// (`expected-enum-types.json`), and asserts the FR-019 behaviors on generated output (ADR-0026): // • Shared materialization — a package-level abstract `field.enum` (`Priority`) extended by // TWO entities is materialized ONCE in `enums.ts`; both entities reference it. // • `@provided` — a package-level abstract `field.enum` (`Currency`, `@provided: true`) is @@ -16,7 +17,9 @@ import { runGen, defineConfig } from "../../src/index.js"; import { barrel } from "../../src/generators/barrel.js"; import { entityFile } from "../../src/generators/entity-file.js"; import { queriesFile } from "../../src/generators/queries-file.js"; -import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { MetaDataLoader, InMemoryStringSource, FIELD_SUBTYPE_ENUM, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata"; +import { sharedEnumForField, materializedSharedEnums } from "../../src/enum-shared.js"; +import { renderSharedEnumsFile } from "../../src/templates/enums-file.js"; function findRepoRoot(start: string): string { let dir = start; @@ -38,7 +41,20 @@ async function loadSharedFixture(): Promise { return res.root; } -async function gen(root: MetaRoot, providedEnumModule?: string): Promise> { +function loadDecisionTable(): { + fields: Record; + materialized: string[]; +} { + const file = join(findRepoRoot(import.meta.dir), "fixtures", "codegen-conformance", "shared-provided-enum", "expected-enum-types.json"); + return JSON.parse(readFileSync(file, "utf-8")); +} + +/** `only`, when given, narrows the run to the objects it accepts (a generator `filter`). */ +async function gen( + root: MetaRoot, + providedEnumModule?: string, + only?: (entity: MetaObject) => boolean, +): Promise> { const tmp = mkdtempSync(join(tmpdir(), "fr019-conf-")); try { await runGen({ @@ -47,7 +63,8 @@ async function gen(root: MetaRoot, providedEnumModule?: string): Promise { expect(inv).not.toContain('from "@acme/ext-enums"'); }); + // The DECISION TABLE (`expected-enum-types.json`) is the port-neutral oracle: for every + // object-hosted enum field in the shared input, which shared declaration its type resolves + // to (or none), and whether that declaration is `@provided`. Every port computes the table + // from the function its generators call and must match it exactly, in both directions, so a + // case cannot be added to the input without all five ports answering it. + test("every enum field resolves to the shared declaration the decision table names", async () => { + const root = await loadSharedFixture(); + const expected = loadDecisionTable(); + const actual: Record = {}; + for (const obj of root.objects()) { + for (const field of obj.fields()) { + if (field.subType !== FIELD_SUBTYPE_ENUM) continue; + const shared = sharedEnumForField(field); + actual[`${obj.resolutionKey()}.${field.name}`] = { + shared: shared?.declaration ?? null, + provided: shared?.provided ?? false, + }; + } + } + expect(actual).toEqual(expected.fields); + expect(materializedSharedEnums(root).map((e) => e.name).sort()).toEqual(expected.materialized); + }); + + // MULTI-HOP, on generated output. Each consumer below reaches the shared `Priority` through + // one or more intermediate fields whose NAME differs from the declaration's (`severity`, + // `importance`), so naming the type after the immediate super would emit `Severity` / + // `Importance` instead. The hosts are two entities and a value object. + test("multi-hop consumers reference the shared type; it is materialized exactly once", async () => { + const files = await gen(await loadSharedFixture(), "@acme/ext-enums"); + const enums = files["enums.ts"]!; + expect(enums.split("export type Priority =").length - 1).toBe(1); + expect(enums.split("export type Money =").length - 1).toBe(1); + + const task = files["Task.ts"]!; + expect(task).toContain('export { type Priority, PriorityEnum } from "./enums";'); + expect(task).toContain("urgency: PriorityEnum.optional()"); + expect(task).toContain("importance: PriorityEnum.optional()"); + expect(task).not.toContain("export type Priority ="); + expect(task).not.toContain("TaskUrgency"); + expect(task).not.toContain("export type Importance"); + expect(task).not.toContain("ImportanceEnum"); + + const subtask = files["Subtask.ts"]!; + expect(subtask).toContain('export { type Priority, PriorityEnum } from "./enums";'); + expect(subtask).toContain("rank: PriorityEnum.optional()"); + expect(subtask).not.toContain("export type Priority ="); + expect(subtask).not.toContain("export type Importance"); + expect(subtask).not.toContain("export type Rank"); + expect(subtask).not.toContain("RankEnum"); + + const badge = files["TicketBadge.ts"]!; + expect(badge).toContain('import { type Priority, PriorityEnum } from "./enums";'); + expect(badge).toContain("badge?: Priority | undefined;"); + expect(badge).toContain("badge: PriorityEnum.optional()"); + expect(badge).not.toContain("export type Severity"); + expect(badge).not.toContain("SeverityEnum"); + expect(badge).not.toContain("TicketBadgeBadge"); + }); + + // The projection over a view. Its read schema validates with the declaration's members and + // declares no named enum of its own, so there is no per-projection enum type to drift from + // the shared one (in TypeScript the row's field type is the same literal union structurally). + test("a multi-hop projection field takes the shared declaration's members and no enum of its own", async () => { + const files = await gen(await loadSharedFixture(), "@acme/ext-enums"); + const summary = files["TicketSummary.ts"]!; + expect(summary).toBeDefined(); + expect(summary).toContain('level: z.enum(["LOW", "MEDIUM", "HIGH"])'); + expect(summary).not.toContain("TicketSummaryLevel"); + expect(summary).not.toContain("export type Level"); + expect(summary).not.toContain("export type Severity"); + }); + + // Two fields of ONE object whose immediate supers are same-named fields (`tier`) of different + // objects, resolving to DIFFERENT shared declarations. Naming by the immediate super gives both + // the name `Tier`, and the per-file dedupe then drops one of them. + test("same-named immediate supers resolving to different shared types stay distinct", async () => { + const files = await gen(await loadSharedFixture(), "@acme/ext-enums"); + const refund = files["Refund.ts"]!; + expect(refund).toBeDefined(); + expect(refund).not.toContain("export type Tier"); + expect(refund).not.toContain("TierEnum"); + expect(refund).toContain("ticketTier: PriorityEnum.optional()"); + expect(refund).toContain("invoiceTier: MoneyEnum.optional()"); + // `unit` reaches the materialized `Money`; `tender` reaches the @provided `Currency`, + // which is referenced from the configured module and never materialized. + expect(refund).toContain("unit: MoneyEnum.optional()"); + expect(refund).toContain('export { type Currency } from "@acme/ext-enums";'); + expect(refund).toContain('tender: z.enum(["USD", "EUR", "GBP"]).optional()'); + expect(files["enums.ts"]!).not.toContain("Currency"); + }); + + // NEGATIVES. A field that redeclares `@values` or `@intValueMap` part-way down the chain + // starts a new vocabulary, and so does everything extending it; an enum that never reaches a + // shared declaration stays inline. Each keeps a locally declared union and takes nothing + // from the shared module for that field. + test("a mid-chain redeclaration and a chain with no shared declaration stay inline", async () => { + const files = await gen(await loadSharedFixture(), "@acme/ext-enums"); + const task = files["Task.ts"]!; + // `phase` redeclares @values: its own members, not the shared Priority's. + expect(task).toContain('export type Tier = "OPEN" | "DONE";'); + // `weight` redeclares @intValueMap over the inherited members: its own inline union. + expect(task).toContain('export type Severity = "LOW" | "MEDIUM" | "HIGH";'); + const subtask = files["Subtask.ts"]!; + expect(subtask).toContain('export type Phase = "OPEN" | "DONE";'); + expect(subtask).toContain('export type Weight = "LOW" | "MEDIUM" | "HIGH";'); + const refund = files["Refund.ts"]!; + expect(refund).toContain('export type Status = "NEW" | "PAID";'); + }); + + // MUST-GENERATE. When the run is scoped to one object whose only enum reaches `Priority` + // through `Ticket.severity`, the shared module must still carry `Priority` — otherwise a + // consumer references a type nothing generated (the failure #259 reported). Scoping to the + // projection, and to the value object, each leaves `Priority` as the one materialized enum. + for (const only of ["TicketSummary", "TicketBadge"]) { + test(`a run scoped to ${only} alone still materializes the shared enum`, async () => { + const root = await loadSharedFixture(); + const enums = renderSharedEnumsFile(root, { select: (fqn) => fqn === `acme::shop::${only}` }); + expect(enums).not.toBeNull(); + expect(enums!).toContain('export type Priority = "LOW" | "MEDIUM" | "HIGH";'); + expect(enums!).not.toContain("Money"); + }); + } + + // A generator `filter` narrows which entity files are written, not which enums exist. + test("an entity-file run filtered to the projection still writes the shared enums module", async () => { + const files = await gen(await loadSharedFixture(), "@acme/ext-enums", (e) => e.name === "TicketSummary"); + expect(Object.keys(files).sort()).toEqual(["TicketSummary.ts", "enums.ts"]); + expect(files["enums.ts"]!).toContain('export type Priority = "LOW" | "MEDIUM" | "HIGH";'); + }); + test("a @provided enum with no providedEnumModule config is a codegen-time error naming the enum", async () => { let err: unknown; try { diff --git a/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts index 88a3f65d9..9fc6489b4 100644 --- a/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts @@ -296,6 +296,18 @@ describe("renderEntityFile — source-aware dispatch", () => { expect(out).not.toContain("InferSelectModel"); }); + // Generated-output contract: the field-rules constant (`export const = { $entity, … }`) + // belongs to `object.value` only. A table-backed entity reaches the same plain-shape composer + // on a contract-only target, and a constant there would name the entity "the value object" + // while carrying no `$table`/`$path`. + test("write-through entity: emits no value-object field-rules constant", async () => { + const { root, entity } = await loadVanillaFixture(); + const out = renderEntityFile(entity, contractCtx(root)); + expect(out).toContain(`export interface ${entity.name}`); + expect(out).not.toContain(`export const ${entity.name} =`); + expect(out).not.toContain("$entity:"); + }); + test("no runtime-ts allowlists in a contract target", async () => { const { root, projection } = await loadProjectionFixture(); const out = renderEntityFile(projection, contractCtx(root)); diff --git a/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts b/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts index d147f59f4..7d8a08a36 100644 --- a/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts @@ -63,8 +63,14 @@ describe("renderValueObjectFile", () => { expect(out).not.toContain("FilterAllowlist"); expect(out).not.toContain("SortAllowlist"); expect(out).not.toContain("$table:"); - expect(out).not.toContain("$entity:"); + expect(out).not.toContain("$path:"); expect(out).not.toContain("UpdateSchema"); + + // A value object DOES get a field-rules constant (no $table/$path — it has no source or route), + // so a caller reads `SampleOutput..rules` instead of the Zod schema's getters. + expect(out).toContain("export const SampleOutput = {"); + expect(out).toContain('$entity: "SampleOutput"'); + expect(out).toMatch(/takeMarkdown: \{[^}]*rules: \{ required: "Take Markdown is required" \}/s); }); test("field.object with a FULLY-QUALIFIED @objectRef strips to the bare type + sibling module", () => { @@ -97,4 +103,14 @@ describe("renderValueObjectFile", () => { expect(out).not.toMatch(/url\?:/); expect(out).not.toMatch(/snippet\?:/); }); + + test("a value object's constant carries a field's @maxLength as rules.maxLength.value", () => { + const vo = metaObject(OBJECT_SUBTYPE_VALUE, "Address"); + const line1 = metaField(FIELD_SUBTYPE_STRING, "line1"); + line1.setAttr("maxLength", 80); + vo.addChild(line1); + const out = renderValueObjectFile(vo); + expect(out).toContain("export const Address = {"); + expect(out).toContain('maxLength: { value: 80, message: "Must be 80 characters or fewer" }'); + }); }); diff --git a/server/typescript/packages/metadata/src/attr-schema-validate.ts b/server/typescript/packages/metadata/src/attr-schema-validate.ts index 23f06f02a..051277ee9 100644 --- a/server/typescript/packages/metadata/src/attr-schema-validate.ts +++ b/server/typescript/packages/metadata/src/attr-schema-validate.ts @@ -45,7 +45,7 @@ import type { MetaData } from "./shared/meta-data.js"; import { ParseError } from "./errors.js"; import type { AttrSchema, TypeRegistry } from "./registry.js"; import { childRuleMatches } from "./registry.js"; -import { TYPE_FIELD, TYPE_METADATA } from "./shared/base-types.js"; +import { TYPE_FIELD } from "./shared/base-types.js"; import { ATTR_SUBTYPE_PROPERTIES } from "./core/attr/attr-constants.js"; // #337: turns "unknown X" into "retired in V — here is the migration". Changes no load // outcome; retired vocabulary still fails with the same code. @@ -58,6 +58,7 @@ import { FIELD_ATTR_INT_VALUE_MAP, ENUM_MEMBER_PATTERN, } from "./core/field/field-constants.js"; +import { isSharedEnumDeclaration } from "./core/field/shared-enum.js"; import { FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_VALUES, @@ -337,18 +338,13 @@ function validateNode( // only own @values need checking here (mirrors the own-attrs-only policy of // Checks 2+3 above — inherited attrs were validated on the declaring node). if (node.type === TYPE_FIELD && node.subType === FIELD_SUBTYPE_ENUM) { - // #246: the shared-enum super, if any — a root-level abstract field.enum (one - // whose parent is the metadata root, not an object). FR-019 materializes such a - // declaration ONCE per port as a single named type, so anything a consuming - // field re-declares that is part of the shared TYPE's contract is a conflict. - // Immediate-super-only, matching codegen's resolveSharedEnumDecl (enum-shared.ts) - // so the validator and the collapse agree on what "shared" means. - const sharedSuper = - node.superData !== undefined && - node.superData.isAbstract && - node.superData.parent?.type === TYPE_METADATA - ? node.superData - : undefined; + // #246: the shared-enum super, if any. FR-019 materializes a shared declaration + // ONCE per port as a single named type, so anything a field directly under it + // re-declares that is part of the shared TYPE's contract is a conflict. The + // predicate is the one codegen's resolver walks with (core/field/shared-enum.ts). + // Only the IMMEDIATE super is checked: a redeclaration further down the chain is + // legal, and the resolver answers it with a per-object inline type. + const sharedSuper = isSharedEnumDeclaration(node.superData) ? node.superData : undefined; // ADR-0039: own — validates the @values DECLARED on this node; a concrete enum // extending an abstract one inherits already-validated @values (own-attrs-only). diff --git a/server/typescript/packages/metadata/src/core/field/shared-enum.ts b/server/typescript/packages/metadata/src/core/field/shared-enum.ts new file mode 100644 index 000000000..b1226550c --- /dev/null +++ b/server/typescript/packages/metadata/src/core/field/shared-enum.ts @@ -0,0 +1,53 @@ +// Shared enum declarations — the single authority for "which type does this enum +// field have" (FR-019, ADR-0026 Amendment 1). The loader's #246 check and every +// codegen site that names, references, imports or materializes an enum type call +// these two functions; neither keeps a predicate of its own. + +import { TYPE_FIELD, TYPE_METADATA } from "../../shared/base-types.js"; +import type { MetaData } from "../../shared/meta-data.js"; +import { FIELD_ATTR_INT_VALUE_MAP, FIELD_ATTR_VALUES, FIELD_SUBTYPE_ENUM } from "./field-constants.js"; +import type { MetaField } from "./meta-field.js"; + +function isEnumField(node: MetaData | undefined): node is MetaField { + return node !== undefined && node.type === TYPE_FIELD && node.subType === FIELD_SUBTYPE_ENUM; +} + +/** + * True when `node` is a shared enum declaration: an abstract `field.enum` that is a + * direct child of the metadata root (a package-level sibling of `object.entity`). An + * abstract enum nested inside an object is not one. + */ +export function isSharedEnumDeclaration(node: MetaData | undefined): node is MetaField { + return isEnumField(node) && isRootAbstract(node); +} + +function isRootAbstract(field: MetaField): boolean { + return field.isAbstract && field.parent?.type === TYPE_METADATA; +} + +/** + * The shared enum declaration an enum field's type resolves to, or undefined when the + * field keeps a per-object inline type. + * + * Walks from the field itself up its `extends` chain. At each node, in order: a shared + * declaration answers (the nearest one wins, so `Money extends Currency` answers + * `Money`); a node that declares its own `@values` or `@intValueMap` starts a new + * vocabulary and answers undefined; otherwise the walk continues at the super. A + * non-enum node, the end of the chain or a cycle answers undefined. The hosting + * object's subtype and the number of hops play no part. + */ +export function resolveSharedEnumDeclaration(field: MetaData | undefined): MetaField | undefined { + const seen = new Set(); + let node = field; + while (isEnumField(node) && !seen.has(node)) { + if (isRootAbstract(node)) return node; + // ADR-0039 own (declared-here layer): the question is whether THIS node redeclares + // the vocabulary. A resolving read would see the inherited @values on every hop and + // stop the walk at the first one. + const own = node.ownAttrs(); + if (own.has(FIELD_ATTR_VALUES) || own.has(FIELD_ATTR_INT_VALUE_MAP)) return undefined; + seen.add(node); + node = node.superData; + } + return undefined; +} diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index afcf69d0d..952a2e885 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -122,6 +122,9 @@ export { // Core node classes export { MetaObject } from "./core/object/meta-object.js"; export { MetaField } from "./core/field/meta-field.js"; +// The one authority for "which type does this enum field have" — the loader's #246 +// check and every codegen site share it (ADR-0026 Amendment 1). +export { isSharedEnumDeclaration, resolveSharedEnumDeclaration } from "./core/field/shared-enum.js"; export { MetaAttr } from "./core/attr/meta-attr.js"; // Runtime object model — backing objects + FQN→factory binding (Phase A). diff --git a/server/typescript/packages/render/src/payload-accessors.ts b/server/typescript/packages/render/src/payload-accessors.ts index 3f6baa195..3a6e3e5c2 100644 --- a/server/typescript/packages/render/src/payload-accessors.ts +++ b/server/typescript/packages/render/src/payload-accessors.ts @@ -27,6 +27,16 @@ export function hasAccessorName(fieldName: string): string { return HAS_PREFIX + capitalize(fieldName); } +/** + * The positional accessor every OBJECT element of a collection carries, so a list separator + * needs no hand-computed `last` flag in the payload: + * `{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}`. A boolean on the element; the leading + * `-` cannot collide with a field name, and it is still an ordinary Mustache tag name, so the + * template stays valid Mustache. A scalar element has nowhere to hold a key and carries none. + * An authored member of the same name wins, as it does for `has`. + */ +export const LAST_ACCESSOR = "-last"; + /** Capitalize the first character, leaving an already-uppercase one untouched. */ export function capitalize(s: string): string { if (s.length === 0) return s; @@ -60,38 +70,97 @@ export function accessorValue(value: unknown): boolean | undefined { } /** - * A view over `payload` carrying its derived `has` accessors, recursively. + * A view over `payload` carrying its derived accessors, recursively: `has` on every + * object, and `-last` on every object that is an element of a collection. * * NON-MUTATING — the caller's payload is never touched, because a render must not - * be able to change the object it was handed. An AUTHORED key always wins: if a + * be able to change the object it was handed. An AUTHORED member always wins: if a * payload genuinely carries `hasFoo`, that value is kept rather than shadowed by a * derived one. * * Recursion follows Mustache's own scoping: every nested object and every array * ELEMENT becomes a context in its own right, so a section over `abilities` sees * the accessors of the ability it is currently iterating. + * + * The payload's shape does not change the result. A plain object is copied with its + * accessors added; anything with its own prototype (a class instance, a Date) is read + * THROUGH a view instead, so its getters, methods and string form stay exactly its own. */ export function withDerivedAccessors(payload: T, depth = 0): T { + return derive(payload, depth, undefined) as T; +} + +/** `last` is set only for an element of a collection: its position in that collection. */ +function derive(payload: unknown, depth: number, last: boolean | undefined): unknown { if (depth > 32) return payload; // pathological graph; render is not a validator if (Array.isArray(payload)) { - return payload.map((v) => withDerivedAccessors(v, depth + 1)) as unknown as T; + const end = payload.length - 1; + return payload.map((v, i) => derive(v, depth + 1, i === end)); } if (payload === null || typeof payload !== "object") return payload; - // PLAIN objects only. Rebuilding from Object.entries() would flatten anything with - // its own prototype — a Date stringifies to "[object Object]" and a class instance - // loses its getters — and the other ports only rebuild map-shaped values, so - // rebuilding more here would be a divergence as well as a regression. + // Only a PLAIN object is rebuilt. Rebuilding from Object.entries() would flatten + // anything with its own prototype — a Date stringifies to "[object Object]" and a + // class instance loses its getters — so those are viewed, never copied. const proto = Object.getPrototypeOf(payload); - if (proto !== Object.prototype && proto !== null) return payload; + if (proto !== Object.prototype && proto !== null) return typedView(payload, depth, last); const src = payload as Record; const out: Record = {}; - for (const [k, v] of Object.entries(src)) out[k] = withDerivedAccessors(v, depth + 1); + for (const [k, v] of Object.entries(src)) out[k] = derive(v, depth + 1, undefined); for (const [k, v] of Object.entries(src)) { const name = hasAccessorName(k); if (Object.prototype.hasOwnProperty.call(src, name)) continue; // authored wins const derived = accessorValue(v); if (derived !== undefined) out[name] = derived; } - return out as unknown as T; + if (last !== undefined && !Object.prototype.hasOwnProperty.call(src, LAST_ACCESSOR)) { + out[LAST_ACCESSOR] = last; + } + return out; +} + +/** The first character lowercased — the inverse of `capitalize` for a camelCase field name. */ +function decapitalize(s: string): string { + return s.length === 0 ? s : s.charAt(0).toLowerCase() + s.slice(1); +} + +/** + * The derived-accessor view of an object that has its own prototype. Every member the + * object has resolves to the object's own — a getter runs against the object itself, a + * method stays bound to it — so the Mustache engine sees the names and values it always + * did. Only a name the object does NOT have can resolve to a derived accessor. + * + * The proxy's target is an empty stand-in, not the object: a frozen payload would + * otherwise forbid returning the derived view of one of its nested values. + */ +function typedView(target: object, depth: number, last: boolean | undefined): object { + const members = new Map(); + const member = (key: PropertyKey): unknown => { + if (members.has(key)) return members.get(key); + const raw: unknown = Reflect.get(target, key, target); + const value = + typeof raw === "function" + ? (...args: unknown[]) => derive(Reflect.apply(raw, target, args), depth + 1, undefined) + : derive(raw, depth + 1, undefined); + members.set(key, value); + return value; + }; + const derived = (key: PropertyKey): boolean | undefined => { + if (typeof key !== "string") return undefined; + if (key === LAST_ACCESSOR) return last; + if (!key.startsWith(HAS_PREFIX)) return undefined; + const rest = key.slice(HAS_PREFIX.length); + for (const field of [decapitalize(rest), rest]) { + if (!(field in target) || hasAccessorName(field) !== key) continue; + const raw: unknown = Reflect.get(target, field, target); + if (typeof raw === "function") continue; + const value = accessorValue(raw); + if (value !== undefined) return value; + } + return undefined; + }; + return new Proxy(Object.create(null) as object, { + has: (_stand, key) => key in target || derived(key) !== undefined, + get: (_stand, key) => (key in target ? member(key) : derived(key)), + }); } diff --git a/server/typescript/packages/render/src/verify.ts b/server/typescript/packages/render/src/verify.ts index f086d809b..ffebdf907 100644 --- a/server/typescript/packages/render/src/verify.ts +++ b/server/typescript/packages/render/src/verify.ts @@ -11,7 +11,7 @@ import Mustache from "mustache"; import type { Provider } from "./provider.js"; -import { HAS_PREFIX, hasAccessorName } from "./payload-accessors.js"; +import { HAS_PREFIX, LAST_ACCESSOR, hasAccessorName } from "./payload-accessors.js"; /** A `{{var}}` references a field the (contextual) payload does not declare. */ export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD"; @@ -25,11 +25,14 @@ export const ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING"; /** * A plain field-tree node mirroring an `object.value` view-object's field walk. * `fields` present → a context-pushing field (object / array-of-object); absent - * → a scalar (string/number/boolean/scalar-array). + * → a scalar (string/number/boolean/scalar-array). `isArray` is the field's + * effective array-ness: with `fields` it is a list of objects, without it a list + * of scalars. */ export interface PayloadField { name: string; fields?: PayloadField[]; + isArray?: boolean; } export interface VerifyError { @@ -84,6 +87,17 @@ function isBooleanAccessor(stack: ResolveStack, name: return false; } +/** + * True when `name` is the positional accessor `-last` and it is available in the current scope. + * `positional` is true exactly when the innermost enclosing ARRAY section iterates object + * elements — the engine puts `-last` on each of those (payload-accessors.ts). An array of + * scalars makes it unavailable even inside an outer object list: a scalar element carries no + * key, so the lookup would bind to the OUTER element's position and render wrong. + */ +function isPositionalAccessor(positional: boolean, name: string): boolean { + return positional && name === LAST_ACCESSOR; +} + function find(fields: F[], name: string): F | undefined { return fields.find((f) => f.name === name); } @@ -172,7 +186,7 @@ export function verify( // (no second resolution pass). const staticTexts: string[] = [templateText]; - function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[]): void { + function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[], positional = false): void { const atRoot = stack.length === 1 && stack[0] === root; for (const tok of tokens) { const type = tok[0] as string; @@ -184,7 +198,7 @@ export function verify( // {{{x}}} (spec); mustache.js emits "&" for it too if (value === ".") break; // implicit iterator — always valid if (atRoot) referencedAtRoot.add(value.split(".")[0]!); - if (!resolve(stack, value) && !isBooleanAccessor(stack, value)) + if (!resolve(stack, value) && !isBooleanAccessor(stack, value) && !isPositionalAccessor(positional, value)) errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value }); break; } @@ -193,17 +207,23 @@ export function verify( // {{^x}}…{{/x}} const sub = Array.isArray(tok[4]) ? (tok[4] as Token[]) : []; if (value === ".") { - walk(sub, stack, seen); + walk(sub, stack, seen, positional); break; } if (atRoot) referencedAtRoot.add(value.split(".")[0]!); const field = resolve(stack, value); if (!field) { + // A positional `-last` gate is a BOOLEAN on the current element: it pushes + // nothing, so the body walks in the SAME scope. + if (isPositionalAccessor(positional, value)) { + walk(sub, stack, seen, positional); + break; + } // A derived `has` gate is a BOOLEAN over the current context, so it // resolves nothing and pushes nothing — walk the body in the SAME scope, // which is what `{{#hasAbilities}}{{#abilities}}…` depends on. if (isBooleanAccessor(stack, value)) { - walk(sub, stack, seen); + walk(sub, stack, seen, positional); break; } // Unresolved section head is itself drift; skip the body (its @@ -214,7 +234,12 @@ export function verify( // `#` over a container pushes its element fields; `^` (and `#` over a // scalar, used as a conditional) keep the current context. const push = type === "#" && field.fields !== undefined; - walk(sub, push ? [...stack, field.fields!] : stack, seen); + // Only a `#` over an ARRAY iterates, so only it moves the positional scope: object + // elements carry `-last`, scalar elements do not. A section over a non-array field + // (a scalar conditional, a single nested object) and every `^` section are + // transparent — the enclosing element's position is still the one in reach. + const iterates = type === "#" && field.isArray === true; + walk(sub, push ? [...stack, field.fields!] : stack, seen, iterates ? field.fields !== undefined : positional); break; } case ">": { @@ -227,7 +252,7 @@ export function verify( break; } staticTexts.push(text); - walk(parse(text), stack, [...seen, value]); + walk(parse(text), stack, [...seen, value], positional); break; } default: diff --git a/server/typescript/packages/render/test/payload-accessors.test.ts b/server/typescript/packages/render/test/payload-accessors.test.ts index de7f22ada..0a572fdbb 100644 --- a/server/typescript/packages/render/test/payload-accessors.test.ts +++ b/server/typescript/packages/render/test/payload-accessors.test.ts @@ -18,6 +18,8 @@ // looked at this shape. import { test, expect, describe } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { render } from "../src/render.js"; import { verify } from "../src/verify.js"; import { hasAccessorName, accessorValue, withDerivedAccessors } from "../src/payload-accessors.js"; @@ -134,8 +136,8 @@ describe("verify — accepts exactly what render resolves", () => { // // The first draft rebuilt ANY non-array object from Object.entries(), which flattened // everything carrying its own prototype: a Date stringified to "[object Object]" and a -// class instance lost its getters. Rendering is not allowed to reshape the payload; only -// plain map-shaped values get derived keys, which is also what the other four ports do. +// class instance lost its getters. Rendering is not allowed to reshape the payload: only +// a plain object is copied, and anything with its own prototype is read through a view. describe("only PLAIN objects are rebuilt", () => { test("a Date still renders as a Date", () => { const out = r("{{when}}", { when: new Date("2020-01-02T03:04:05Z") }); @@ -161,3 +163,140 @@ describe("only PLAIN objects are rebuilt", () => { expect(r("{{#inner}}{{#hasXs}}Y{{/hasXs}}{{/inner}}", { inner: { xs: [1] } })).toBe("Y"); }); }); + +// ── A typed payload renders exactly like the equal plain-object payload ───────── +// +// The oracle is the shared `render-list-separator` fixture: its `expected.txt` is what every +// port must produce from its `payload.json`. Here the SAME data arrives as class instances, +// the shape a caller holds when the payload is built by constructors rather than literals. +// Before the derived accessors reached typed elements, a list of instances rendered a +// trailing separator (`a, b, c, `) because no element carried `-last`. +describe("a typed payload renders like the equal plain-object payload", () => { + const dir = join(import.meta.dir, "../../../../../fixtures/render-conformance/render-list-separator"); + const template = readFileSync(join(dir, "template.mustache"), "utf8"); + const expected = readFileSync(join(dir, "expected.txt"), "utf8"); + interface Fixture { + items: { name: string }[]; + solo: { name: string }[]; + none: { name: string }[]; + groups: { tags: { label: string }[] }[]; + entries: { name: string; note?: string; owner: { nick: string } }[]; + authored: { name: string }[]; + } + const json = JSON.parse(readFileSync(join(dir, "payload.json"), "utf8")) as Fixture; + + class Named { + constructor(readonly name: string) {} + } + class Tag { + constructor(readonly label: string) {} + } + class Group { + constructor(readonly tags: Tag[]) {} + } + // A private field behind a getter: the getter only works when it runs against the + // instance itself, which is what a view over the instance has to preserve. + class Owner { + readonly #nick: string; + constructor(nick: string) { + this.#nick = nick; + } + get nick(): string { + return this.#nick; + } + } + class Entry { + constructor( + readonly name: string, + readonly owner: Owner, + readonly note?: string, + ) {} + } + // An AUTHORED `-last` member: it must win over the derived position. + class Pinned { + readonly "-last" = true; + constructor(readonly name: string) {} + } + class Payload { + readonly items = json.items.map((i) => new Named(i.name)); + readonly solo = json.solo.map((i) => new Named(i.name)); + readonly none = json.none.map((i) => new Named(i.name)); + readonly groups = json.groups.map((g) => new Group(g.tags.map((t) => new Tag(t.label)))); + readonly entries = json.entries.map((e) => new Entry(e.name, new Owner(e.owner.nick), e.note)); + readonly authored = json.authored.map((a) => new Pinned(a.name)); + } + + test("the list-separator fixture renders its expected output from class instances", () => { + expect(r(template, json)).toBe(expected); + expect(r(template, new Payload())).toBe(expected); + }); + + test("a frozen typed payload renders the same", () => { + const frozen = Object.freeze(new Payload()); + for (const item of frozen.items) Object.freeze(item); + expect(r(template, frozen)).toBe(expected); + }); + + test("has is derived for a class instance exactly as for the equal plain object", () => { + class Hero { + readonly abilities = ["fly"]; + readonly bio = " "; + readonly level = 3; + get nickname(): string { + return "Ace"; + } + } + const tpl = + "{{#hasAbilities}}A{{/hasAbilities}}{{^hasBio}}no-bio{{/hasBio}}{{#hasNickname}}N{{/hasNickname}}" + + "[{{#hasLevel}}L{{/hasLevel}}]"; + const plain = { abilities: ["fly"], bio: " ", level: 3, nickname: "Ace" }; + expect(r(tpl, plain)).toBe("Ano-bioN[]"); + expect(r(tpl, new Hero())).toBe(r(tpl, plain)); + }); + + test("an authored has member wins on a class instance", () => { + class Quiet { + readonly abilities = ["fly"]; + readonly hasAbilities = false; + } + expect(r("{{#hasAbilities}}A{{/hasAbilities}}{{^hasAbilities}}none{{/hasAbilities}}", new Quiet())).toBe("none"); + }); + + test("a value nested in a class instance keeps its own string form", () => { + class Stamped { + readonly when = new Date("2020-01-02T03:04:05Z"); + } + expect(r("{{when}}", new Stamped())).toBe(r("{{when}}", { when: new Date("2020-01-02T03:04:05Z") })); + }); + + test("a method on a class instance still resolves as a computed value", () => { + class Person { + constructor( + readonly first: string, + readonly last: string, + ) {} + full(): string { + return `${this.first} ${this.last}`; + } + } + expect(r("{{full}}", new Person("Ada", "Lovelace"))).toBe("Ada Lovelace"); + }); + + // The count is what proves laziness: a view that read every member up front and swallowed + // the failure would still render "a,b". + test("a member the template never names is never read", () => { + let reads = 0; + class Line { + constructor(readonly sku: string) {} + get exploding(): string { + reads += 1; + throw new Error("read a member the template never names"); + } + } + class Order { + readonly lines = [new Line("a"), new Line("b")]; + } + expect(r("{{#lines}}{{sku}}{{^-last}},{{/-last}}{{/lines}}", new Order())).toBe("a,b"); + expect(reads).toBe(0); + }); +}); diff --git a/server/typescript/packages/render/test/verify-conformance.test.ts b/server/typescript/packages/render/test/verify-conformance.test.ts index 676454bc8..d4e351f18 100644 --- a/server/typescript/packages/render/test/verify-conformance.test.ts +++ b/server/typescript/packages/render/test/verify-conformance.test.ts @@ -16,7 +16,9 @@ import { // Verify-conformance corpus: the cross-language template-drift oracle (FR-004 // Plan #3 C). Each fixture dir under fixtures/verify-conformance/ holds // payload.json the payload FIELD-TREE — PayloadField[], the declared -// shape (NOT render data, unlike render-conformance) +// shape (NOT render data, unlike render-conformance); a node +// may carry "isArray": true (a list of objects with `fields`, +// a list of scalars without) // template.mustache the template text whose {{vars}} are drift-checked // partials/*.mustache optional partial bodies, referenced as `partials/` // options.json optional { requiredSlots?, requiredTags?: string[]; provider?: "with" | "without" } diff --git a/spec/conformance-tests.md b/spec/conformance-tests.md index 3c98adff4..53c57a464 100644 --- a/spec/conformance-tests.md +++ b/spec/conformance-tests.md @@ -239,14 +239,18 @@ Each fixture is a directory under `fixtures/verify-conformance/`: - **`payload.json`** is a `PayloadField[]` — the recursive field tree a port derives from an `object.value` view-object. A node *with* a `fields` array is a - context-pushing field (object / array-of-object); a node *without* is a scalar: + context-pushing field (object / array-of-object); a node *without* is a scalar. + A node may carry `"isArray": true`, the field's effective array-ness: with + `fields` it is a list of objects, without it a list of scalars. Absent means not + a list. It decides where the positional accessor `-last` is available: ```json [ { "name": "displayName" }, - { "name": "posts", "fields": [ + { "name": "posts", "isArray": true, "fields": [ { "name": "title" }, - { "name": "tags", "fields": [{ "name": "name" }] } + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } ] } ] ``` diff --git a/spec/decisions/ADR-0026-shared-and-provided-named-types.md b/spec/decisions/ADR-0026-shared-and-provided-named-types.md index 0f998a5fc..d09deca7c 100644 --- a/spec/decisions/ADR-0026-shared-and-provided-named-types.md +++ b/spec/decisions/ADR-0026-shared-and-provided-named-types.md @@ -128,3 +128,83 @@ codegen + fixtures effort for a need Option 1 already serves. - [ADR-0001](ADR-0001-cross-language-type-binding.md) — bind metadata→native types at build time, per port (no FQN in metadata). - [enum datatype design](../../docs/superpowers/specs/2026-05-23-enum-datatype-design.md) — D6 abstract-enum reuse; int-backed values + display labels remain deferred. - [FR-019](../../docs/superpowers/specs/2026-06-06-fr-019-shared-and-provided-enums-design.md) — implementation (enum shared-materialization + `@provided`, with value-object `@provided` as a noted follow-up). + +## Amendment 1 (2026-10-10) — type identity through an `extends` chain + +§2 says a field that `extends` a shared declaration references its one type, and says nothing +about a field that reaches the declaration through ANOTHER field. Each port filled that gap on +its own, and more than once per port: the loader's #246 check, the type namer and the +materializer each carried a private notion of "the super". Four defects came from two of those +answers disagreeing: a projection whose enum type did not generate (#259), a chained root +abstract named after the wrong declaration, an own `@values` silently dropped (#246), and a +two-hop field that got its own inlined copy of a shared enum. + +**The rule.** A *shared enum declaration* is a `field.enum` that is abstract AND a direct child +of the metadata root. To resolve an enum field's type, start at the field itself and walk its +`extends` chain. At each node, in this order: + +1. the node is a shared enum declaration: that declaration is the answer (the nearest wins, so + `Money extends Currency` answers `Money`); +2. the node declares its OWN `@values` or its OWN `@intValueMap`: there is no shared type. The + node starts a new vocabulary, and the field keeps a per-object inline type; +3. otherwise continue at the node's super. + +A non-enum node, the end of the chain or a cycle answers "no shared type". The hosting object's +subtype plays no part, and there is no hop limit: an entity, projection or value-object field +that reaches a shared declaration through any number of hops references that one type. + +**One authority per port.** The walk lives in each port's metadata layer +(`resolveSharedEnumDeclaration` / `isSharedEnumDeclaration`, named idiomatically), and both the +loader and every codegen site call it. The #246 check asks the predicate about the IMMEDIATE +super only, which is unchanged: own `@values` or `@intValueMap` directly under a shared +declaration is `ERR_ENUM_EXTENDS_VALUES_CONFLICT`, and a redeclaration further down the chain +loads and is answered by step 2. The two JVM generators share the Java function, so they cannot +disagree. + +**Materialization.** The member set and the int map are read from the DECLARATION (a resolving +read, so a chained declaration materializes the members it inherits), never from whichever +consuming field is visited first. The shared type is emitted whenever any generated consumer +references it, including when the only consumer is a multi-hop projection or value-object +field. Where a port has several generators that reference the type, ONE of them owns the file, +so generators writing to separate output directories never produce two copies of one class: in +Java that is `SpringDtoGenerator`, which writes every shared enum the model uses, and the +value-object and output-parser generators write it only under `emitSharedEnums=true`, for a +build that runs them without it. A `@provided` declaration is never generated, and the reference +to it is resolved inside the same type resolver every generator calls, from the same +`providedEnumNamespace` / `providedEnumPackages` configuration, so no generator can name a +class in the declaration's own package. + +**This supersedes #259's remedy.** #259 reported a type that did not generate, and was fixed by +giving a Kotlin projection its own copy of the enum. The projection now references the shared +type, and the shared type always generates. A field whose chain ends at an inline, +object-hosted enum keeps each port's inline naming and output byte for byte. Sharing an inline +enum across objects stays out of scope. + +**What changes in each port.** One case is common to all five: a field that reaches a shared +declaration through two or more hops, with nothing redeclared on the way, references the shared +type. Before, it generated an inline union named for the field it extends (TypeScript), a nested +`` enum (C#), a nested enum that reused the shared declaration's name inside the +DTO (Java), an `` enum file that is no longer written (Kotlin), or an inline +`Literal[...]` (Python). + +Java alone changes in two further ways, because Java alone had these behaviors: + +- *Value objects.* The value-object generator nested a copy of any enum a field extended, a + shared or `@provided` declaration included, so one declaration became two Java types that do + not convert (`TicketPayload.Priority` beside the top-level `Priority`). A value-object field + that reaches a shared or `@provided` declaration, in one hop or many, now references it. +- *A redeclaration under a shared declaration.* Java names an inline enum that has a super for + its TOP-MOST super. For a field answered by step 2 whose top-most super is a shared + declaration, that name is the shared type's own, nested with different members and shadowing + it inside the DTO. That one case is named `` instead (`TaskDto.Priority` + becomes `TaskDto.TaskPhase`). It stays inline. + +Every other inline enum keeps its name: Java's top-most-super name where no shared declaration +is on the chain (`ShipmentSummaryDto.Status`), pinned by a golden test against the previous +generator's output. + +**Gate.** `fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json` is a +port-neutral decision table: for every object-hosted enum field of the shared input, the +declaration it resolves to (or none), whether that declaration is `@provided`, and which +declarations are materialized. All five ports compute it from the function their generators +call and must match it in both directions.