From fc4caf01248564fa0de3cb68b7ecd4eb7889bc10 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 10 Oct 2026 22:48:28 -0400 Subject: [PATCH 1/7] feat: multi-hop shared-enum dedup, -first/-last list accessors, value-object rules constant - Shared-enum resolution walks the extends chain (TypeScript, C#, Java, Python), so an enum field that extends a field which extends the shared abstract references the shared type instead of inlining its own. C# EffectiveEnumValues reads through every hop. Kotlin keeps its per-object projection enum (#259). - Templates: every map-shaped collection element carries derived -first/-last booleans, so a list separator is {{^-last}}, {{/-last}} with no payload flag. All five ports render it identically; verify --templates accepts it inside a container section and flags it elsewhere. New render-list-separator and verify-positional-accessor-* fixtures. - TypeScript: a value object's module exports a field-rules constant, like an entity's ..rules, without the table and path keys. --- CHANGELOG.md | 33 +++++++++++++++++ docs/CONFORMANCE.md | 20 +++++------ docs/features/templates-and-payloads.md | 22 ++++++++++++ .../.metaobjects/.gen-state/.hashes.json | 6 ++-- .../src/generated/InstructorProfile.ts | 35 +++++++++++++++++++ .../generated/ProgramDescriptionPayload.ts | 35 +++++++++++++++++++ .../src/generated/SyllabusSection.ts | 33 +++++++++++++++++ .../.metaobjects/.gen-state/.hashes.json | 2 +- .../generated/ts/SubscriberBlurbPayload.ts | 15 ++++++++ .../shared-provided-enum/input/meta.json | 6 ++++ .../render-list-separator/expected.txt | 6 ++++ .../render-list-separator/meta.json | 1 + .../render-list-separator/payload.json | 10 ++++++ .../render-list-separator/template.mustache | 6 ++++ .../expected-drift.json | 1 + .../payload.json | 8 +++++ .../template.mustache | 1 + .../expected-drift.json | 1 + .../payload.json | 8 +++++ .../template.mustache | 1 + .../expected-drift.json | 1 + .../payload.json | 8 +++++ .../template.mustache | 1 + .../Fr019SharedProvidedConformanceTests.cs | 10 ++++++ .../Generators/Fr019SharedEnum.cs | 22 ++++++++---- .../MetaObjects.Render/PayloadAccessors.cs | 23 ++++++++++++ server/csharp/MetaObjects.Render/Verify.cs | 22 ++++++++---- server/csharp/MetaObjects/Meta/MetaField.cs | 2 +- .../generator/spring/Fr019SharedEnum.java | 20 +++++++---- .../Fr019SharedProvidedConformanceTest.java | 10 ++++++ .../metaobjects/render/PayloadAccessors.java | 34 ++++++++++++++++-- .../java/com/metaobjects/render/Verify.java | 24 ++++++++----- .../snapshots/render-list-separator.txt | 6 ++++ .../codegen/generators/fr019_shared_enum.py | 25 +++++++------ .../metaobjects/render/payload_accessors.py | 28 ++++++++++++++- .../python/src/metaobjects/render/verify.py | 28 ++++++++++----- .../test_fr019_shared_provided_conformance.py | 10 ++++++ .../packages/codegen-ts/src/enum-shared.ts | 22 +++++++----- .../packages/codegen-ts/src/index.ts | 2 +- .../src/templates/entity-constants.ts | 25 +++++++++++++ .../src/templates/value-object-file.ts | 6 +++- .../fr019-shared-provided-conformance.test.ts | 14 ++++++++ .../test/templates/value-object-file.test.ts | 18 +++++++++- .../packages/render/src/payload-accessors.ts | 24 ++++++++++++- .../typescript/packages/render/src/verify.ts | 31 ++++++++++++---- 45 files changed, 580 insertions(+), 86 deletions(-) create mode 100644 fixtures/render-conformance/render-list-separator/expected.txt create mode 100644 fixtures/render-conformance/render-list-separator/meta.json create mode 100644 fixtures/render-conformance/render-list-separator/payload.json create mode 100644 fixtures/render-conformance/render-list-separator/template.mustache create mode 100644 fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-clean/payload.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache create mode 100644 fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache create mode 100644 fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache create mode 100644 server/java/render/src/test/resources/snapshots/render-list-separator.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 514b4743a..a9d32a0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,28 @@ 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: `-first` / `-last` list accessors, so a separator needs no payload flag (every + port).** `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c`. Every map-shaped + element of a collection now carries two derived booleans, `-first` and `-last`; the names start + with `-`, which no field name can, and they are ordinary Mustache tag names, so a template that + uses them is still valid Mustache. An authored key of the same name wins, a nested list keeps + its own position, and a template that uses neither renders byte-for-byte as before. + `meta verify --templates` accepts them inside a section over a container field and reports them + anywhere else as `ERR_VAR_NOT_ON_PAYLOAD`, including inside a list of scalars, where an element + cannot carry a key. Gated by the new `render-list-separator` render fixture and three + `verify-positional-accessor-*` verify fixtures, run by TypeScript, C#, Java, Kotlin and Python. + 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. A value object's 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. `meta gen` adds the constant to existing value-object modules; three-way merge preserves + hand edits. A TPH subtype and a sourceless entity are unchanged. An ejected `entity` generator + needs `meta eject entity --force` to pick it up. + - **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 +512,17 @@ until you regenerate. ### Fixed +- **Shared-enum dedup now survives a multi-hop `extends` (TypeScript, C#, Java, Python).** An enum + field that extends another field which itself extends 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 `enums.ts` one. The resolver now walks the `extends` + chain and takes the nearest root-level abstract enum, so the field references the shared type, + and the chain's `@values` are read through every hop (C# read one). An adopter's generated code + changes only where such a field exists: the duplicate enum disappears and the field references + the shared one. Kotlin is unchanged: it names a projection's enum per object on purpose (#259), + and keeps doing so. Gated by a `Task` entity added to the shared `shared-provided-enum` + fixture. + - **`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..ee3666db8 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/) | 34 | ✓ | ✓ | 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) | @@ -284,18 +284,18 @@ 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 `-first` / `-last` list-separator accessors. -### `fixtures/verify-conformance/` (31) +### `fixtures/verify-conformance/` (34) -All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md) +All 34 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md) (template drift section — `Renderer.verify`). ### `fixtures/persistence-conformance/` (52 — 46 query + 6 migration) @@ -522,8 +522,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 34 ++ 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/templates-and-payloads.md b/docs/features/templates-and-payloads.md index 01214c397..f8b5b3fc7 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -209,6 +209,27 @@ 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 — `-first` / `-last`.** Every map-shaped element of a collection carries two + derived booleans, so `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c` with + no `last` flag in the payload. `{{#-first}}…{{/-first}}` is the matching lead-in. The names + start with `-`, which no field name can, and they are ordinary Mustache tag names, so the + template is still valid Mustache and any Mustache tool parses it. An authored key 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. + +`meta verify --templates` accepts `-first` / `-last` inside a section over a container field and +reports anything else as `ERR_VAR_NOT_ON_PAYLOAD`: at the top level, and inside a list of +scalars, where an element has nowhere to carry a key (give the list an object element instead). +Output of a template that uses neither is byte-for-byte what it was. + ## What each port generates ### TypeScript @@ -680,6 +701,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/) — `-first` / `-last` positional accessors: `{{^-last}}, {{/-last}}` separators, lead-in, nested lists, single-element and empty lists - [`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/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json index edb060783..4746a34bd 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", @@ -15,7 +15,7 @@ "src/generated/ProgramDescriptionOutput.output.ts": "f13b7b2108bf8507b6aeb1e819edd06eb2d689ed259dc04784d19d62bee9dfd2", "src/generated/ProgramDescriptionOutput.render.ts": "77691e7f4c7dd8658ad2842fb5ea68a4b79e5c5ec69bb7221c618db303f2eb9f", "src/generated/ProgramDescriptionPayload.form.tsx": "a9dff101453450117aff3029b2c5259e7d0866667aaab722ba890beaaa20f95d", - "src/generated/ProgramDescriptionPayload.ts": "8df741a3271c6e6e8317041dd088f6b5de5f0f2e95c1b637528992c578e487d7", + "src/generated/ProgramDescriptionPayload.ts": "18161f91b74c419c062763d21dc5c7395bcb7208ea5adb0661ea91545b12200c", "src/generated/ProgramSummary.queries.ts": "743e8ce74eee85bb821cf3d909957fae630085fb1f5a7567dd6bfdcae4364910", "src/generated/ProgramSummary.routes.ts": "995e893a6b21e65d7bc031761c18bb7bb0c790abb31cb72546bf95573c980099", "src/generated/ProgramSummary.ts": "181f48ed374dfdf2f5b3213e0064833f071fd7029ed9357a6eef764616c2429c", @@ -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/ProgramDescriptionPayload.ts b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts index 751abd467..c2cb80d89 100644 --- a/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts +++ b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts @@ -10,3 +10,38 @@ export interface ProgramDescriptionPayload { lessonCount?: number | undefined; } export const ProgramDescriptionPayloadInsertSchema = z.object({}); +/** + * Metadata constants for the value object ProgramDescriptionPayload: each field's name, label, view and the + * validation rules derived from its validator children (e.g. `ProgramDescriptionPayload..rules.maxLength.value`). + */ +export const ProgramDescriptionPayload = { + $entity: "ProgramDescriptionPayload", + title: { + name: "title", + label: "Title", + view: "text", + htmlType: "text", + rules: { + required: "Title is required", + maxLength: { value: 200, message: "Must be 200 characters or fewer" }, + }, + }, + summary: { + name: "summary", + label: "Summary", + view: "text", + htmlType: "text", + }, + authorName: { + name: "authorName", + label: "Author Name", + view: "text", + htmlType: "text", + }, + lessonCount: { + name: "lessonCount", + label: "Lesson Count", + view: "number", + htmlType: "number", + }, +} 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/shared-provided-enum/input/meta.json b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json index eb87f21f3..fd6912743 100644 --- a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json +++ b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json @@ -18,6 +18,12 @@ { "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" } }, + { "source.rdb": { "@table": "tasks" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } }, { "object.entity": { "name": "Invoice", "children": [ { "field.long": { "name": "id" } }, { "field.enum": { "name": "money", "extends": "Money" } }, 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..4072c5c26 --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/expected.txt @@ -0,0 +1,6 @@ +items: a, b, c +lead: (first) a b c +solo: only +none: [] +nested: [x|y];[z] +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..d64d7499c --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/meta.json @@ -0,0 +1 @@ +{ "format": "text", "note": "Positional -first/-last accessors on collection elements: separator via {{^-last}}, lead-in via {{#-first}}, nested lists keep their own position, single-element and empty lists, 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..1277589c1 --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/payload.json @@ -0,0 +1,10 @@ +{ + "items": [{ "name": "a" }, { "name": "b" }, { "name": "c" }], + "solo": [{ "name": "only" }], + "none": [], + "groups": [ + { "tags": [{ "label": "x" }, { "label": "y" }] }, + { "tags": [{ "label": "z" }] } + ], + "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..8c4981b30 --- /dev/null +++ b/fixtures/render-conformance/render-list-separator/template.mustache @@ -0,0 +1,6 @@ +items: {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}} +lead:{{#items}}{{#-first}} (first){{/-first}} {{name}}{{/items}} +solo: {{#solo}}{{name}}{{^-last}}, {{/-last}}{{/solo}} +none: [{{#none}}{{name}}{{^-last}}, {{/-last}}{{/none}}] +nested: {{#groups}}[{{#tags}}{{label}}{{^-last}}|{{/-last}}{{/tags}}]{{^-last}};{{/-last}}{{/groups}} +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..15ef2afa5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json @@ -0,0 +1,8 @@ +[ + { "name": "displayName" }, + { "name": "posts", "fields": [ + { "name": "title" }, + { "name": "tags", "fields": [{ "name": "name" }] } + ] }, + { "name": "labels" } +] 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..f2b6e32e8 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache @@ -0,0 +1 @@ +{{#posts}}{{title}}{{^-last}}, {{/-last}}{{#-first}}!{{/-first}}{{#tags}}{{name}}{{^-last}}|{{/-last}}{{/tags}}{{/posts}} 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..15ef2afa5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json @@ -0,0 +1,8 @@ +[ + { "name": "displayName" }, + { "name": "posts", "fields": [ + { "name": "title" }, + { "name": "tags", "fields": [{ "name": "name" }] } + ] }, + { "name": "labels" } +] 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..15ef2afa5 --- /dev/null +++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json @@ -0,0 +1,8 @@ +[ + { "name": "displayName" }, + { "name": "posts", "fields": [ + { "name": "title" }, + { "name": "tags", "fields": [{ "name": "name" }] } + ] }, + { "name": "labels" } +] 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/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs index d86ef9166..4f97fca66 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs @@ -61,6 +61,16 @@ 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. + 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", task.Content); + Assert.Contains("public Priority? Urgency", task.Content); + } + [Fact] public void Provided_enum_is_not_materialized_and_referenced_at_the_configured_namespace() { diff --git a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs index f320a939c..26f038b3b 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs @@ -44,19 +44,27 @@ 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 + /// root-abstract super). The nearest super in the chain that is (a) an abstract enum field, AND /// (b) a direct child of the metadata root (a package-level declaration) — NOT an /// abstract field nested inside another object. /// 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; + // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field that + // extends a concrete (or nested) enum field which itself extends the shared declaration + // still resolves to it. The seen-set guards a malformed cyclic chain. + var seen = new HashSet(ReferenceEqualityComparer.Instance) { field }; + var sup = field.ResolveSuper(); + while (sup is not null && seen.Add(sup)) + { + if (sup.SubType != FIELD_SUBTYPE_ENUM) 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.IsAbstract && sup.Parent is { } parent && parent.Type == BaseTypes.TYPE_METADATA) return sup; + sup = sup.ResolveSuper(); + } + return null; } /// The shared-enum descriptor a field resolves to, or null for inline enums. diff --git a/server/csharp/MetaObjects.Render/PayloadAccessors.cs b/server/csharp/MetaObjects.Render/PayloadAccessors.cs index c1c56b3f6..7b08b12c7 100644 --- a/server/csharp/MetaObjects.Render/PayloadAccessors.cs +++ b/server/csharp/MetaObjects.Render/PayloadAccessors.cs @@ -33,6 +33,23 @@ public static class PayloadAccessors /// public static string HasAccessorName(string fieldName) => HasPrefix + Capitalize(fieldName); + /// + /// Positional accessors 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 FirstAccessor = "-first"; + + /// + public const string LastAccessor = "-last"; + + /// True when is -first/-last and the scope is a collection element. + public static bool IsPositionalAccessor(bool inElement, string name) => + inElement && (name == FirstAccessor || name == LastAccessor); + /// Capitalize the first character, leaving an already-uppercase one untouched. public static string Capitalize(string s) { @@ -122,6 +139,12 @@ public static bool IsBooleanAccessor(List> stack, st { var outList = new List(); foreach (var item in seq) outList.Add(WithDerivedAccessors(item, depth + 1)); + for (int i = 0; i < outList.Count; i++) + { + if (outList[i] is not Dictionary el) continue; + el.TryAdd(FirstAccessor, i == 0); + el.TryAdd(LastAccessor, i == outList.Count - 1); + } return outList; } diff --git a/server/csharp/MetaObjects.Render/Verify.cs b/server/csharp/MetaObjects.Render/Verify.cs index 3856c5358..b28fa58b9 100644 --- a/server/csharp/MetaObjects.Render/Verify.cs +++ b/server/csharp/MetaObjects.Render/Verify.cs @@ -153,7 +153,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 +164,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 `-first` / `-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 @@ -193,9 +201,9 @@ void Walk(IReadOnlyList tokens, List> stack, Li if (push) { var next = new List>(stack) { field.Fields! }; - Walk(s.Children, next, seen); + Walk(s.Children, next, seen, true); } - else Walk(s.Children, stack, seen); + else Walk(s.Children, stack, seen, inElement); break; case PartialTok p: @@ -208,7 +216,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/Meta/MetaField.cs b/server/csharp/MetaObjects/Meta/MetaField.cs index 9426fd383..93aac3fa0 100644 --- a/server/csharp/MetaObjects/Meta/MetaField.cs +++ b/server/csharp/MetaObjects/Meta/MetaField.cs @@ -205,7 +205,7 @@ public long? Scale /// this field nor any super carries @values. /// public IReadOnlyList? EffectiveEnumValues => - EnumValues ?? (ResolveSuper()?.EnumValues); + EnumValues ?? ResolveSuper()?.EffectiveEnumValues; /// /// True if the field is required (NOT NULL). 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..333d81444 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 @@ -97,17 +97,23 @@ 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 {@code extends} chain is walked and the NEAREST super that 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 — wins, so a multi-hop chain through concrete enum + * fields still reaches the shared declaration. */ public static MetaField resolveSharedEnumDecl(MetaField field) { if (!(field instanceof EnumField)) return null; + java.util.Set> seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + seen.add(field); 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; + while (sup != null && seen.add(sup)) { + if (!(sup instanceof EnumField)) return null; + // The declaration must sit at the package/root level (a sibling of object.entity). + if (GeneratorUtil.isAbstract(sup) && sup.getParent() instanceof MetaRoot) return sup; + sup = sup.getSuperField(); + } + return null; } /** The shared-enum descriptor a field resolves to, or {@code null} for inline enums. */ 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..02509f553 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 @@ -81,6 +81,16 @@ public void sharedEnumIsMaterializedOnceAndReferencedByBothEntities() throws Exc } } + @Test + public void multiHopEnumExtendsStillResolvesToTheSharedEnum() throws Exception { + // Task.urgency extends Ticket.priority, which extends the shared abstract Priority. + 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 enum must NOT be redeclared in TaskDto; saw:\n" + task, + task.contains("public enum")); + } + @Test public void providedEnumIsNotMaterializedAndReferencedAtTheConfiguredNamespace() throws Exception { Path out = run(loadShared(), "com.acme.ext"); 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..2183d11bb 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"; + /** + * Positional accessors 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 FIRST_ACCESSOR = "-first"; + public static final String LAST_ACCESSOR = "-last"; + private PayloadAccessors() { /* no instances */ } + /** Adds {@code -first}/{@code -last} to the map-shaped elements of an already-derived list. */ + @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++) { + if (elements.get(i) instanceof java.util.Map m) { + java.util.Map el = (java.util.Map) m; + el.putIfAbsent(FIRST_ACCESSOR, i == 0); + el.putIfAbsent(LAST_ACCESSOR, i == last); + } + } + return elements; + } + + /** True when {@code name} is {@code -first}/{@code -last} and the scope is a collection element. */ + public static boolean isPositionalAccessor(boolean inElement, String name) { + return inElement && (FIRST_ACCESSOR.equals(name) || LAST_ACCESSOR.equals(name)); + } + /** * The boolean-accessor method/section name for a payload field: * {@code "has" + capitalize(fieldName)} (e.g. {@code abilities} → @@ -149,13 +179,13 @@ 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); } return payload; } 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..333d6ae97 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 {{#-first}} / {{^-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 @@ -179,9 +187,9 @@ private static void walk( 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, true); } else { - walk(s.children(), stack, seen, ctx); + walk(s.children(), stack, seen, ctx, inElement); } } case PartialTok p -> { @@ -195,7 +203,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/resources/snapshots/render-list-separator.txt b/server/java/render/src/test/resources/snapshots/render-list-separator.txt new file mode 100644 index 000000000..4072c5c26 --- /dev/null +++ b/server/java/render/src/test/resources/snapshots/render-list-separator.txt @@ -0,0 +1,6 @@ +items: a, b, c +lead: (first) a b c +solo: only +none: [] +nested: [x|y];[z] +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..0e7c85493 100644 --- a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py +++ b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py @@ -49,20 +49,23 @@ 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.""" + for an inline enum. The ``extends`` chain is walked and the NEAREST super that 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 — wins, so a multi-hop + chain through concrete enum fields still reaches the shared declaration.""" if field.sub_type != fc.FIELD_SUBTYPE_ENUM: return None + seen = {id(field)} 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 + while sup is not None and id(sup) not in seen: + if sup.sub_type != fc.FIELD_SUBTYPE_ENUM: + return None + parent = sup.parent + if getattr(sup, "is_abstract", False) and parent is not None and parent.type == TYPE_METADATA: + return sup + seen.add(id(sup)) + sup = sup.super_data + return None def is_provided(decl: MetaData) -> bool: diff --git a/server/python/src/metaobjects/render/payload_accessors.py b/server/python/src/metaobjects/render/payload_accessors.py index 5ec3275f8..c97ac0611 100644 --- a/server/python/src/metaobjects/render/payload_accessors.py +++ b/server/python/src/metaobjects/render/payload_accessors.py @@ -27,11 +27,22 @@ "accessor_value", "with_derived_accessors", "is_boolean_accessor", + "FIRST_ACCESSOR", + "LAST_ACCESSOR", + "is_positional_accessor", ] #: The ``has`` prefix every derived boolean accessor carries. HAS_PREFIX = "has" +#: Positional accessors 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``. +FIRST_ACCESSOR = "-first" +LAST_ACCESSOR = "-last" + _MAX_DEPTH = 32 @@ -121,7 +132,15 @@ 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(FIRST_ACCESSOR, i == 0) + el.setdefault(LAST_ACCESSOR, i == last) + items.append(el) + return items return payload @@ -142,3 +161,10 @@ 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 ``-first`` / ``-last`` and the current scope is an element of a + collection section. Permissive about shape, like ``is_boolean_accessor``: ``in_element`` is + true whenever the innermost context was pushed by a ``{{#x}}`` over a container field.""" + return in_element and name in (FIRST_ACCESSOR, LAST_ACCESSOR) diff --git a/server/python/src/metaobjects/render/verify.py b/server/python/src/metaobjects/render/verify.py index 3889f469c..2443c064c 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" @@ -208,7 +208,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,23 +220,30 @@ 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 `-first` / `-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). @@ -242,9 +252,9 @@ def walk( # `#` 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) + walk(tok.children, [*stack, field.fields], seen, True) else: - walk(tok.children, stack, seen) + walk(tok.children, stack, seen, in_element) else: # _Partial if provider is None: continue # can't resolve without a provider @@ -255,7 +265,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..1bfb2b7b8 100644 --- a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py +++ b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py @@ -97,3 +97,13 @@ 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 + assert "Literal" not in task diff --git a/server/typescript/packages/codegen-ts/src/enum-shared.ts b/server/typescript/packages/codegen-ts/src/enum-shared.ts index 25f0cf632..0a1694f97 100644 --- a/server/typescript/packages/codegen-ts/src/enum-shared.ts +++ b/server/typescript/packages/codegen-ts/src/enum-shared.ts @@ -43,14 +43,20 @@ export interface SharedEnum { */ 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; + // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field + // that extends a concrete (or nested) enum field which itself extends the shared + // declaration still resolves to it. The seen-set guards a malformed cyclic chain. + const seen = new Set([field]); + let sup = field.resolveSuper(); + while (sup !== undefined && !seen.has(sup)) { + if (sup.subType !== FIELD_SUBTYPE_ENUM) return undefined; + // The declaration must sit at the package/root level (sibling of object.entity), + // i.e. its parent node is the metadata root. + if (sup.isAbstract && sup.parent !== undefined && sup.parent.type === TYPE_METADATA) return sup; + seen.add(sup); + sup = sup.resolveSuper(); + } + return undefined; } /** The shared-enum descriptor a field resolves to, or undefined for inline enums. */ 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..e77b9748a 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,31 @@ function renderFieldEntry(f: UiFieldDescriptor): string { return ` ${f.name}: {\n ${entries.join(",\n ")},\n }`; } +/** + * A value object's field-rules constant: `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. + */ +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/value-object-file.ts b/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts index d149126e6..a566ab23b 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 @@ -24,7 +24,7 @@ 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"; @@ -83,6 +83,10 @@ 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] : []), + // A pure value object (no TPH, no sourceless identity) gets a field-rules constant, so a caller + // reads `..rules` rather than the Zod schema's getters. It has no table or route, so + // it takes the constant without `$table`/`$path`. + ...(!tphSubtype && !sourceless ? [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/golden/fr019-shared-provided-conformance.test.ts b/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts index 7526a1327..2623aafa2 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 @@ -109,6 +109,20 @@ describe("FR-019 shared + provided enum conformance (TS)", () => { expect(inv).not.toContain('from "@acme/ext-enums"'); }); + // MULTI-HOP: `Task.urgency extends Ticket.priority`, and `Ticket.priority` itself extends the + // root abstract `Priority`. The shared declaration must be found through the intermediate + // concrete field, so `Task` references `enums.ts` instead of inlining its own `TaskUrgency`. + test("an enum field extending another enum field that extends the shared abstract still dedups", 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); + const task = files["Task.ts"]!; + expect(task).toBeDefined(); + expect(task).toContain('from "./enums"'); + expect(task).toContain("PriorityEnum"); + expect(task).not.toContain("TaskUrgency"); + }); + 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/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/render/src/payload-accessors.ts b/server/typescript/packages/render/src/payload-accessors.ts index 3f6baa195..7b03ab385 100644 --- a/server/typescript/packages/render/src/payload-accessors.ts +++ b/server/typescript/packages/render/src/payload-accessors.ts @@ -27,6 +27,17 @@ export function hasAccessorName(fieldName: string): string { return HAS_PREFIX + capitalize(fieldName); } +/** + * Positional accessors injected into every ELEMENT of a collection, so a list separator needs + * no hand-computed `last` flag in the payload: `{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}`. + * `-first` / `-last` are booleans 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. + * Only MAP-shaped elements carry them (a scalar element has nowhere to hold a key). An authored + * key of the same name wins, as it does for `has`. + */ +export const FIRST_ACCESSOR = "-first"; +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; @@ -74,7 +85,18 @@ export function accessorValue(value: unknown): boolean | undefined { export function withDerivedAccessors(payload: T, depth = 0): T { 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 last = payload.length - 1; + return payload.map((v, i) => { + const el = withDerivedAccessors(v, depth + 1); + if (el === null || typeof el !== "object" || Array.isArray(el)) return el; + const proto = Object.getPrototypeOf(el); + if (proto !== Object.prototype && proto !== null) return el; + const rec = el as Record; + const out: Record = { ...rec }; + if (!Object.prototype.hasOwnProperty.call(rec, FIRST_ACCESSOR)) out[FIRST_ACCESSOR] = i === 0; + if (!Object.prototype.hasOwnProperty.call(rec, LAST_ACCESSOR)) out[LAST_ACCESSOR] = i === last; + return out; + }) as unknown as T; } if (payload === null || typeof payload !== "object") return payload; // PLAIN objects only. Rebuilding from Object.entries() would flatten anything with diff --git a/server/typescript/packages/render/src/verify.ts b/server/typescript/packages/render/src/verify.ts index f086d809b..54a8d7649 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, FIRST_ACCESSOR, 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"; @@ -84,6 +84,17 @@ function isBooleanAccessor(stack: ResolveStack, name: return false; } +/** + * True when `name` is a positional accessor (`-first` / `-last`) and the current scope is an + * element of a collection section — the engine injects both into every map-shaped element + * (payload-accessors.ts). Like `has` it is permissive about shape: `inElement` is true + * whenever the innermost context was pushed by a `{{#x}}` over a container field, which an + * object-typed field also is. + */ +function isPositionalAccessor(inElement: boolean, name: string): boolean { + return inElement && (name === FIRST_ACCESSOR || name === LAST_ACCESSOR); +} + function find(fields: F[], name: string): F | undefined { return fields.find((f) => f.name === name); } @@ -172,7 +183,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[], inElement = false): void { const atRoot = stack.length === 1 && stack[0] === root; for (const tok of tokens) { const type = tok[0] as string; @@ -184,7 +195,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(inElement, value)) errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value }); break; } @@ -193,17 +204,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, inElement); break; } if (atRoot) referencedAtRoot.add(value.split(".")[0]!); const field = resolve(stack, value); if (!field) { + // A positional `-first` / `-last` gate is a BOOLEAN on the current element: it + // pushes nothing, so the body walks in the SAME scope. + if (isPositionalAccessor(inElement, value)) { + walk(sub, stack, seen, inElement); + 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, inElement); break; } // Unresolved section head is itself drift; skip the body (its @@ -214,7 +231,7 @@ 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); + walk(sub, push ? [...stack, field.fields!] : stack, seen, push ? true : inElement); break; } case ">": { @@ -227,7 +244,7 @@ export function verify( break; } staticTexts.push(text); - walk(parse(text), stack, [...seen, value]); + walk(parse(text), stack, [...seen, value], inElement); break; } default: From 20ce9ed30df4c501b617a0d110257d2d360706a6 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 00:08:41 -0400 Subject: [PATCH 2/7] no-mistakes(review): Finish Java render, typed payload views, JVM shared enums Assisted-by: no-mistakes:claude:claude-sonnet-5-5 --- CHANGELOG.md | 85 ++++--- docs/CONFORMANCE.md | 16 +- docs/features/field-types.md | 25 +- docs/features/migrations-and-drift.md | 3 +- docs/features/templates-and-payloads.md | 37 ++- .../.metaobjects/.gen-state/.hashes.json | 2 +- .../generated/ProgramDescriptionPayload.ts | 35 --- fixtures/codegen-conformance/README.md | 13 + .../expected-enum-types.json | 27 ++ .../shared-provided-enum/input/meta.json | 60 ++++- .../render-list-separator/expected.txt | 3 +- .../render-list-separator/meta.json | 2 +- .../render-list-separator/payload.json | 5 + .../render-list-separator/template.mustache | 3 +- .../payload.json | 10 +- .../template.mustache | 2 +- .../expected-drift.json | 1 + .../payload.json | 12 + .../template.mustache | 1 + .../expected-drift.json | 1 + .../payload.json | 12 + .../template.mustache | 1 + .../payload.json | 10 +- .../payload.json | 10 +- .../expected-drift.json | 1 + .../payload.json | 12 + .../template.mustache | 1 + .../Fr019SharedProvidedConformanceTests.cs | 142 ++++++++++- .../Generators/Fr019SharedEnum.cs | 37 +-- .../Generators/RenderHelperGenerator.cs | 18 +- .../MetaObjects.Codegen/PayloadFieldTree.cs | 9 +- .../TypedPayloadTests.cs | 115 +++++++++ .../VerifyConformanceTests.cs | 5 +- .../MetaObjects.Render.Tests/VerifyTests.cs | 4 +- .../MetaObjects.Render/PayloadAccessors.cs | 67 ++--- server/csharp/MetaObjects.Render/Verify.cs | 20 +- .../Core/Field/SharedEnumDeclaration.cs | 66 +++++ .../MetaObjects/Loader/ValidationPasses.cs | 27 +- .../generator/verify/TemplateVerify.java | 7 +- .../generator/kotlin/Fr019SharedEnum.kt | 34 ++- .../generator/kotlin/KotlinEnumEmitter.kt | 7 +- .../kotlin/KotlinRenderHelperGenerator.kt | 11 +- .../generator/kotlin/KotlinTypeMapper.kt | 71 ++---- ...otlinFr019SharedProvidedConformanceTest.kt | 146 +++++++++++ .../kotlin/KotlinProjectionTwoHopEnumTest.kt | 99 +++++--- .../kotlin/KotlinTypedPayloadRenderTest.kt | 68 ++++++ .../generator/spring/Fr019SharedEnum.java | 103 +++++--- .../generator/spring/SpringDtoGenerator.java | 34 +-- .../spring/SpringOutputParserGenerator.java | 25 +- .../spring/SpringRenderHelperGenerator.java | 13 +- .../generator/spring/SpringTypeMapper.java | 40 +-- .../spring/SpringValueObjectGenerator.java | 46 +++- .../Fr019SharedProvidedConformanceTest.java | 231 +++++++++++++++++- .../GeneratedTypedEnumCompileRunTest.java | 19 +- .../java/com/metaobjects/field/EnumField.java | 64 +++++ .../metaobjects/loader/ValidationPhase.java | 18 +- .../metaobjects/render/PayloadAccessors.java | 86 ++++++- .../com/metaobjects/render/PayloadField.java | 26 +- .../java/com/metaobjects/render/Verify.java | 15 +- .../render/TypedPayloadRenderTest.java | 135 ++++++++++ .../render/VerifyConformanceTest.java | 5 +- .../com/metaobjects/render/VerifyTest.java | 31 +++ .../snapshots/render-list-separator.txt | 3 +- .../codegen/generators/fr019_shared_enum.py | 38 +-- .../generators/render_helper_generator.py | 22 +- .../metaobjects/loader/validation_passes.py | 44 +--- .../metaobjects/meta/core/field/meta_field.py | 68 +++++- .../metaobjects/render/payload_accessors.py | 16 +- .../python/src/metaobjects/render/verify.py | 30 ++- .../test_fr019_shared_provided_conformance.py | 166 ++++++++++++- .../tests/render/test_render_model_payload.py | 76 ++++++ .../tests/render/test_verify_conformance.py | 12 +- .../cli/src/lib/payload-field-tree.ts | 12 +- .../cli/test/unit/payload-field-tree.test.ts | 31 ++- .../packages/codegen-ts/src/enum-shared.ts | 60 ++--- .../src/generators/template-payload-tree.ts | 1 + .../src/templates/entity-constants.ts | 9 +- .../src/templates/inferred-types.ts | 15 +- .../codegen-ts/src/templates/render-helper.ts | 11 +- .../src/templates/value-object-file.ts | 21 +- .../test/generators/entity-file.test.ts | 16 +- .../fr019-shared-provided-conformance.test.ts | 157 +++++++++++- .../test/projection/entity-file.test.ts | 12 + .../metadata/src/attr-schema-validate.ts | 22 +- .../metadata/src/core/field/shared-enum.ts | 53 ++++ .../typescript/packages/metadata/src/index.ts | 3 + .../packages/render/src/payload-accessors.ts | 103 +++++--- .../typescript/packages/render/src/verify.ts | 46 ++-- .../render/test/payload-accessors.test.ts | 125 +++++++++- .../render/test/verify-conformance.test.ts | 4 +- spec/conformance-tests.md | 10 +- ...DR-0026-shared-and-provided-named-types.md | 50 ++++ 92 files changed, 2791 insertions(+), 679 deletions(-) create mode 100644 fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache create mode 100644 fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache create mode 100644 fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json create mode 100644 fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache create mode 100644 server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs create mode 100644 server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt create mode 100644 server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java create mode 100644 server/typescript/packages/metadata/src/core/field/shared-enum.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d32a0e1..4ebda3515 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,11 @@ 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 used to get its own enum type; it now + references the shared one, in every port. In Kotlin this includes a projection or value-object + field: its former `` enum file is no longer generated, and code that imported + that type imports the shared enum instead. A model with no such field regenerates unchanged. **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,27 +73,39 @@ 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: `-first` / `-last` list accessors, so a separator needs no payload flag (every - port).** `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c`. Every map-shaped - element of a collection now carries two derived booleans, `-first` and `-last`; the names start - with `-`, which no field name can, and they are ordinary Mustache tag names, so a template that - uses them is still valid Mustache. An authored key of the same name wins, a nested list keeps - its own position, and a template that uses neither renders byte-for-byte as before. - `meta verify --templates` accepts them inside a section over a container field and reports them - anywhere else as `ERR_VAR_NOT_ON_PAYLOAD`, including inside a list of scalars, where an element - cannot carry a key. Gated by the new `render-list-separator` render fixture and three - `verify-positional-accessor-*` verify fixtures, run by TypeScript, C#, Java, Kotlin and Python. - 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"). +- **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. + `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. A value object's 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. `meta gen` adds the constant to existing value-object modules; three-way merge preserves - hand edits. A TPH subtype and a sourceless entity are unchanged. An ejected `entity` generator - needs `meta eject entity --force` to pick it up. + 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 @@ -512,16 +529,24 @@ until you regenerate. ### Fixed -- **Shared-enum dedup now survives a multi-hop `extends` (TypeScript, C#, Java, Python).** An enum - field that extends another field which itself extends 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 `enums.ts` one. The resolver now walks the `extends` - chain and takes the nearest root-level abstract enum, so the field references the shared type, - and the chain's `@values` are read through every hop (C# read one). An adopter's generated code - changes only where such a field exists: the duplicate enum disappears and the field references - the shared one. Kotlin is unchanged: it names a projection's enum per object on purpose (#259), - and keeps doing so. Gated by a `Task` entity added to the shared `shared-provided-enum` - fixture. +- **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`). - **`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: diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index ee3666db8..9081b3e82 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -28,7 +28,7 @@ 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/) | 34 | ✓ | ✓ | 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/) | 16 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | @@ -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 | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -291,12 +291,14 @@ All 16 fixtures → [features/templates-and-payloads.md](features/templates-and- 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, unicode multibyte handling, and the `-first` / `-last` list-separator accessors. +trailing-newline preservation, unicode multibyte handling, and the `-last` list-separator accessor. -### `fixtures/verify-conformance/` (34) +### `fixtures/verify-conformance/` (37) -All 34 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,7 +524,7 @@ 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 34 +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 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/templates-and-payloads.md b/docs/features/templates-and-payloads.md index f8b5b3fc7..c3f6f03b9 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -214,21 +214,36 @@ For the `lobby/welcome` template: 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 — `-first` / `-last`.** Every map-shaped element of a collection carries two - derived booleans, so `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c` with - no `last` flag in the payload. `{{#-first}}…{{/-first}}` is the matching lead-in. The names - start with `-`, which no field name can, and they are ordinary Mustache tag names, so the - template is still valid Mustache and any Mustache tool parses it. An authored key of the same - name wins. A nested list keeps its own position, independent of the list around it. +- **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. -`meta verify --templates` accepts `-first` / `-last` inside a section over a container field and -reports anything else as `ERR_VAR_NOT_ON_PAYLOAD`: at the top level, and inside a list of -scalars, where an element has nowhere to carry a key (give the list an object element instead). -Output of a template that uses neither is byte-for-byte what it was. +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 @@ -701,7 +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/) — `-first` / `-last` positional accessors: `{{^-last}}, {{/-last}}` separators, lead-in, nested lists, single-element and empty lists +- [`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/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json index 4746a34bd..06ad48809 100644 --- a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json +++ b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json @@ -15,7 +15,7 @@ "src/generated/ProgramDescriptionOutput.output.ts": "f13b7b2108bf8507b6aeb1e819edd06eb2d689ed259dc04784d19d62bee9dfd2", "src/generated/ProgramDescriptionOutput.render.ts": "77691e7f4c7dd8658ad2842fb5ea68a4b79e5c5ec69bb7221c618db303f2eb9f", "src/generated/ProgramDescriptionPayload.form.tsx": "a9dff101453450117aff3029b2c5259e7d0866667aaab722ba890beaaa20f95d", - "src/generated/ProgramDescriptionPayload.ts": "18161f91b74c419c062763d21dc5c7395bcb7208ea5adb0661ea91545b12200c", + "src/generated/ProgramDescriptionPayload.ts": "8df741a3271c6e6e8317041dd088f6b5de5f0f2e95c1b637528992c578e487d7", "src/generated/ProgramSummary.queries.ts": "743e8ce74eee85bb821cf3d909957fae630085fb1f5a7567dd6bfdcae4364910", "src/generated/ProgramSummary.routes.ts": "995e893a6b21e65d7bc031761c18bb7bb0c790abb31cb72546bf95573c980099", "src/generated/ProgramSummary.ts": "181f48ed374dfdf2f5b3213e0064833f071fd7029ed9357a6eef764616c2429c", diff --git a/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts index c2cb80d89..751abd467 100644 --- a/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts +++ b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts @@ -10,38 +10,3 @@ export interface ProgramDescriptionPayload { lessonCount?: number | undefined; } export const ProgramDescriptionPayloadInsertSchema = z.object({}); -/** - * Metadata constants for the value object ProgramDescriptionPayload: each field's name, label, view and the - * validation rules derived from its validator children (e.g. `ProgramDescriptionPayload..rules.maxLength.value`). - */ -export const ProgramDescriptionPayload = { - $entity: "ProgramDescriptionPayload", - title: { - name: "title", - label: "Title", - view: "text", - htmlType: "text", - rules: { - required: "Title is required", - maxLength: { value: 200, message: "Must be 200 characters or fewer" }, - }, - }, - summary: { - name: "summary", - label: "Summary", - view: "text", - htmlType: "text", - }, - authorName: { - name: "authorName", - label: "Author Name", - view: "text", - htmlType: "text", - }, - lessonCount: { - name: "lessonCount", - label: "Lesson Count", - view: "number", - htmlType: "number", - }, -} 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..22bf2201d --- /dev/null +++ b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json @@ -0,0 +1,27 @@ +{ + "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 } + }, + "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 fd6912743..029f5b1fa 100644 --- a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json +++ b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json @@ -6,29 +6,63 @@ { "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" } }, - { "source.rdb": { "@table": "tasks" } }, + { "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" } } ] } } ] } diff --git a/fixtures/render-conformance/render-list-separator/expected.txt b/fixtures/render-conformance/render-list-separator/expected.txt index 4072c5c26..c786b4029 100644 --- a/fixtures/render-conformance/render-list-separator/expected.txt +++ b/fixtures/render-conformance/render-list-separator/expected.txt @@ -1,6 +1,7 @@ items: a, b, c -lead: (first) 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 index d64d7499c..0fdd6d37f 100644 --- a/fixtures/render-conformance/render-list-separator/meta.json +++ b/fixtures/render-conformance/render-list-separator/meta.json @@ -1 +1 @@ -{ "format": "text", "note": "Positional -first/-last accessors on collection elements: separator via {{^-last}}, lead-in via {{#-first}}, nested lists keep their own position, single-element and empty lists, authored key wins" } +{ "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 index 1277589c1..4bf973660 100644 --- a/fixtures/render-conformance/render-list-separator/payload.json +++ b/fixtures/render-conformance/render-list-separator/payload.json @@ -6,5 +6,10 @@ { "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 index 8c4981b30..a7d11096f 100644 --- a/fixtures/render-conformance/render-list-separator/template.mustache +++ b/fixtures/render-conformance/render-list-separator/template.mustache @@ -1,6 +1,7 @@ items: {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}} -lead:{{#items}}{{#-first}} (first){{/-first}} {{name}}{{/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/payload.json b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json index 15ef2afa5..ecf481bd5 100644 --- a/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json +++ b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json @@ -1,8 +1,12 @@ [ { "name": "displayName" }, - { "name": "posts", "fields": [ + { "name": "posts", "isArray": true, "fields": [ { "name": "title" }, - { "name": "tags", "fields": [{ "name": "name" }] } + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } ] }, - { "name": "labels" } + { "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 index f2b6e32e8..ab48fe2d2 100644 --- a/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache +++ b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache @@ -1 +1 @@ -{{#posts}}{{title}}{{^-last}}, {{/-last}}{{#-first}}!{{/-first}}{{#tags}}{{name}}{{^-last}}|{{/-last}}{{/tags}}{{/posts}} +{{#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/payload.json b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json index 15ef2afa5..ecf481bd5 100644 --- a/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json +++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json @@ -1,8 +1,12 @@ [ { "name": "displayName" }, - { "name": "posts", "fields": [ + { "name": "posts", "isArray": true, "fields": [ { "name": "title" }, - { "name": "tags", "fields": [{ "name": "name" }] } + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } ] }, - { "name": "labels" } + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } ] diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json index 15ef2afa5..ecf481bd5 100644 --- a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json +++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json @@ -1,8 +1,12 @@ [ { "name": "displayName" }, - { "name": "posts", "fields": [ + { "name": "posts", "isArray": true, "fields": [ { "name": "title" }, - { "name": "tags", "fields": [{ "name": "name" }] } + { "name": "note" }, + { "name": "author", "fields": [{ "name": "name" }] }, + { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } ] }, - { "name": "labels" } + { "name": "owner", "fields": [{ "name": "name" }] }, + { "name": "labels", "isArray": true } ] 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 4f97fca66..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; @@ -64,11 +67,19 @@ 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. + // 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", task.Content); + 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] @@ -90,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 26f038b3b..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,36 +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). The nearest super in the chain that is (a) an abstract enum 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; - // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field that - // extends a concrete (or nested) enum field which itself extends the shared declaration - // still resolves to it. The seen-set guards a malformed cyclic chain. - var seen = new HashSet(ReferenceEqualityComparer.Instance) { field }; - var sup = field.ResolveSuper(); - while (sup is not null && seen.Add(sup)) - { - if (sup.SubType != FIELD_SUBTYPE_ENUM) 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.IsAbstract && sup.Parent is { } parent && parent.Type == BaseTypes.TYPE_METADATA) return sup; - sup = sup.ResolveSuper(); - } - return null; - } + 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.Render.Tests/TypedPayloadTests.cs b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs new file mode 100644 index 000000000..062d45b3d --- /dev/null +++ b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs @@ -0,0 +1,115 @@ +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); + } +} 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 7b08b12c7..021979b91 100644 --- a/server/csharp/MetaObjects.Render/PayloadAccessors.cs +++ b/server/csharp/MetaObjects.Render/PayloadAccessors.cs @@ -34,21 +34,18 @@ public static class PayloadAccessors public static string HasAccessorName(string fieldName) => HasPrefix + Capitalize(fieldName); /// - /// Positional accessors injected into every map-shaped ELEMENT of a collection, so a list - /// separator needs no hand-computed last flag: + /// 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 FirstAccessor = "-first"; - - /// public const string LastAccessor = "-last"; - /// True when is -first/-last and the scope is a collection element. + /// True when is -last and the scope is a collection element. public static bool IsPositionalAccessor(bool inElement, string name) => - inElement && (name == FirstAccessor || name == LastAccessor); + inElement && name == LastAccessor; /// Capitalize the first character, leaving an already-uppercase one untouched. public static string Capitalize(string s) @@ -142,46 +139,50 @@ public static bool IsBooleanAccessor(List> stack, st for (int i = 0; i < outList.Count; i++) { if (outList[i] is not Dictionary el) continue; - el.TryAdd(FirstAccessor, i == 0); el.TryAdd(LastAccessor, i == outList.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. - if (WireNamedProperties(payload.GetType()) is { } props) + // ADR-0056 — a typed payload (a generated value-object POCO, a record, an anonymous + // object). A template names the FIELD (`{{bio}}`), and a generated POCO carries that + // name in [JsonPropertyName] on a PascalCase member, so a typed object is viewed as the + // map of its readable public members keyed by wire name (or the member's own name when + // it declares none — exactly the name reflection lookup resolved before). The view is + // what carries the derived has and -last accessors, so a typed payload renders + // byte-identically to the equal map payload. A scalar-like value (number, enum, date, + // Guid, JsonElement, ...) is never viewed: its string form is its own. + if (IsScalarLike(payload.GetType())) return payload; { var asMap = new Dictionary(StringComparer.Ordinal); - foreach (var (name, prop) in props) asMap[name] = prop.GetValue(payload); + foreach (var (name, read) in MemberReaders(payload.GetType())) asMap.TryAdd(name, read(payload)); return WithDerivedAccessors(asMap, depth); } - - return payload; } - private static readonly System.Collections.Concurrent.ConcurrentDictionary - WireNameCache = new(); + private static bool IsScalarLike(Type t) => + t.IsPrimitive || t.IsEnum || t.Namespace is "System" || t.Namespace?.StartsWith("System.", StringComparison.Ordinal) == true; + + private static readonly System.Collections.Concurrent.ConcurrentDictionary)[]> + MemberCache = new(); /// - /// 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. + /// The readable public instance members of , each keyed by its + /// [JsonPropertyName] (or its own name when it has none). Cached per type. /// - private static (string, System.Reflection.PropertyInfo)[]? WireNamedProperties(Type type) => - WireNameCache.GetOrAdd(type, static t => + private static (string, Func)[] MemberReaders(Type type) => + MemberCache.GetOrAdd(type, static t => { - var props = t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) - .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) - .ToArray(); - string? WireName(System.Reflection.PropertyInfo p) => - (p.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true) - .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name; - if (!props.Any(p => WireName(p) is not null)) return null; - return props.Select(p => (WireName(p) ?? p.Name, p)).ToArray(); + const System.Reflection.BindingFlags Flags = + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance; + string WireName(System.Reflection.MemberInfo m) => + (m.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true) + .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name ?? m.Name; + var readers = new List<(string, Func)>(); + foreach (var p in t.GetProperties(Flags).Where(p => p.CanRead && p.GetIndexParameters().Length == 0)) + readers.Add((WireName(p), o => p.GetValue(o))); + foreach (var f in t.GetFields(Flags)) + readers.Add((WireName(f), o => f.GetValue(o))); + return readers.ToArray(); }); } diff --git a/server/csharp/MetaObjects.Render/Verify.cs b/server/csharp/MetaObjects.Render/Verify.cs index b28fa58b9..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); @@ -175,7 +178,7 @@ void Walk(IReadOnlyList tokens, List> stack, Li var field = Resolve(stack, s.Value); if (field is null) { - // A positional `-first` / `-last` gate is a BOOLEAN on the current + // 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)) { @@ -198,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, true); + Walk(s.Children, next, seen, childInElement); } - else Walk(s.Children, stack, seen, inElement); + else Walk(s.Children, stack, seen, childInElement); break; case PartialTok p: 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/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..851398d44 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,11 +11,13 @@ 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 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. */ object Fr019SharedEnum { @@ -48,18 +48,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 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..ea7c8452b 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 @@ -40,7 +40,12 @@ object KotlinEnumEmitter { ): Boolean { val enumClassName = KotlinTypeMapper.enumTypeName(field, owner) ?: 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 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}, 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). 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? { 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 = EnumField.resolveSharedEnumDeclaration(field) + if (declaration != null) { + val (declPkg, declShort) = PackageMapping.splitFqn(declaration.name) + return ClassName(declPkg, declShort.replaceFirstChar { it.uppercase() }) } val fieldPascal = field.name.replaceFirstChar { it.uppercase() } 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..3d9144220 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,145 @@ 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 { + // Task.importance (two hops) and Subtask.rank (three hops) both reach Priority. + // 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") + for (name in listOf("TicketSummary.kt", "TicketBadge.kt")) { + val f = shop.resolve(name) + if (Files.exists(f)) assertTrue("Priority" in f.readText(), "$name must reference Priority; saw:\n${f.readText()}") + } + } 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() + assertTrue("ticketTier" in refund && "invoiceTier" in refund && "unit" in refund + && "tender" in refund && "orderStatus" in refund, + "Refund.kt must declare all five fields; saw:\n$refund") + assertTrue("com.acme.ext.Currency" in refund, + "Refund.tender must reference the configured external namespace; 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/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/KotlinTypedPayloadRenderTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt new file mode 100644 index 000000000..2ad908367 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt @@ -0,0 +1,68 @@ +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 { + + 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)) + } +} 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 333d81444..4bd76c38e 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,25 +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). - * The {@code extends} chain is walked and the NEAREST super that 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 — wins, so a multi-hop chain through concrete enum - * fields still reaches the shared declaration. + * 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; - java.util.Set> seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); - seen.add(field); - MetaField sup = field.getSuperField(); - while (sup != null && seen.add(sup)) { - if (!(sup instanceof EnumField)) return null; - // The declaration must sit at the package/root level (a sibling of object.entity). - if (GeneratorUtil.isAbstract(sup) && sup.getParent() instanceof MetaRoot) return sup; - sup = sup.getSuperField(); - } - return null; + return EnumField.resolveSharedEnumDeclaration(field); } /** The shared-enum descriptor a field resolves to, or {@code null} for inline enums. */ @@ -151,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); @@ -178,6 +183,35 @@ public static List materializedSharedEnums(MetaDataLoader loader) { return out; } + /** + * Emit each materialized shared enum ONCE as a standalone top-level Java {@code enum} in its + * declaring package — {@code /.java} containing {@code public enum { A, B, C }}. + * Every generator whose output REFERENCES a shared type (the DTO, value-object and output-parser + * generators) calls this, so the type exists whichever of them a run includes; the content is a + * pure function of the declaration, so the repeated write is byte-identical. Provided enums + * emit nothing (referenced externally). + */ + 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: *
      @@ -194,7 +228,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..b05228447 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 @@ -114,7 +114,7 @@ public void execute(MetaDataLoader loader) { 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); + 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 +905,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 +1055,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..62f1ab75b 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,9 @@ protected Class getFilterClass() { public void execute(MetaDataLoader loader) { parseArgs(); Path outRoot = Paths.get(outDir.getAbsolutePath()); + fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( + getArg("providedEnumNamespace"), getArg("providedEnumPackages")); + Fr019SharedEnum.emitMaterialized(loader, outRoot); // ADR-0052: the direction rule lives in FindInbound, never re-derived here. for (MetaTemplate tmpl : FindInbound.inboundTemplates(loader)) { @@ -371,12 +385,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..ad42f5155 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 @@ -22,9 +22,7 @@ import com.metaobjects.identity.MetaIdentity; import com.metaobjects.object.MetaObject; -import java.util.HashSet; import java.util.List; -import java.util.Set; /** * Centralised mapping from {@link MetaField} subtype to the Java type used in @@ -319,37 +317,25 @@ 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. + * C# {@code CSharpNaming.EnumTypeName}): when {@code field} reaches a package-level shared + * enum DECLARATION via {@link EnumField#resolveSharedEnumDeclaration} (the single + * cross-generator authority — possibly multi-hop, short-circuited by an own + * {@code @values}/{@code @intValueMap} along the way), the name is + * {@code Pascal(declaration.shortName)}; otherwise (an inline field — no reachable + * declaration) it is {@code Pascal(owner.shortName) + Pascal(field.name)}. 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 superRoot = resolveSuperRoot(field); - if (superRoot != null) { - return pascal(SpringNaming.splitFqn(superRoot.getName())[1]); + MetaField declaration = EnumField.resolveSharedEnumDeclaration(field); + if (declaration != null) { + return pascal(SpringNaming.splitFqn(declaration.getName())[1]); } return pascal(SpringNaming.splitFqn(owner.getName())[1]) + pascal(field.getName()); } - /** - * 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. - */ - static MetaField resolveSuperRoot(MetaField field) { - MetaField current = field.getSuperField(); - if (current == null) return null; - Set seen = new HashSet<>(); - while (true) { - MetaField next = current.getSuperField(); - if (next == null) break; - if (!seen.add(current.getName())) break; // cycle guard - current = next; - } - return current; - } - /** * Read the EFFECTIVE {@code @values} of an {@link EnumField} (inheriting from an * {@code extends}-super when the field carries no own values), as the verbatim member list. 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..2b19a8c64 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,9 @@ protected Class getFilterClass() { public void execute(MetaDataLoader loader) { parseArgs(); Path outRoot = Paths.get(outDir.getAbsolutePath()); + fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( + getArg("providedEnumNamespace"), getArg("providedEnumPackages")); + Fr019SharedEnum.emitMaterialized(loader, outRoot); for (MetaObject vo : valueShapes(loader)) { emit(vo, outRoot); } @@ -138,7 +153,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 +188,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 02509f553..c5b70eea7 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; @@ -84,11 +94,15 @@ 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 enum must NOT be redeclared in TaskDto; saw:\n" + task, - task.contains("public enum")); + assertFalse("shared Priority must NOT be redeclared in TaskDto; saw:\n" + task, + task.contains("public enum Priority")); } @Test @@ -117,4 +131,217 @@ 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 keep their per-object inline enum. Same for the + // Subtask fields that extend them. + MetaDataLoader loader = loadShared(); + Path out = run(loader, "com.acme.ext"); + + String taskDto = Files.readString(out.resolve("acme/shop/TaskDto.java")); + assertTrue("Task.phase must keep its inline enum; saw:\n" + taskDto, + taskDto.contains("public enum TaskPhase")); + assertTrue("Task.weight must keep its inline enum; saw:\n" + taskDto, + taskDto.contains("public enum TaskWeight")); + + String subtaskDto = Files.readString(out.resolve("acme/shop/SubtaskDto.java")); + assertTrue("Subtask.phase must keep its inline enum; saw:\n" + subtaskDto, + subtaskDto.contains("public enum SubtaskPhase")); + assertTrue("Subtask.weight must keep its inline enum; saw:\n" + subtaskDto, + subtaskDto.contains("public enum SubtaskWeight")); + + String refundDto = Files.readString(out.resolve("acme/shop/RefundDto.java")); + assertTrue("Refund.orderStatus must keep its inline enum; saw:\n" + refundDto, + refundDto.contains("public enum RefundOrderStatus")); + } + + @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..7f6b35e18 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 @@ -127,16 +127,17 @@ public void sharedAbstractEnumEmitsOneTypeNamedForSuperUsedByBothFields() throws generate(loader, gen); - // 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, by the value-object run itself. 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( 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 2183d11bb..3eac8ff0c 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 @@ -23,35 +23,33 @@ public final class PayloadAccessors { public static final String HAS_PREFIX = "has"; /** - * Positional accessors injected into every map-shaped ELEMENT of a collection, so a list + * 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 FIRST_ACCESSOR = "-first"; public static final String LAST_ACCESSOR = "-last"; private PayloadAccessors() { /* no instances */ } - /** Adds {@code -first}/{@code -last} to the map-shaped elements of an already-derived list. */ + /** Adds {@code -last} to the map-shaped elements of an already-derived list. */ @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++) { if (elements.get(i) instanceof java.util.Map m) { java.util.Map el = (java.util.Map) m; - el.putIfAbsent(FIRST_ACCESSOR, i == 0); el.putIfAbsent(LAST_ACCESSOR, i == last); } } return elements; } - /** True when {@code name} is {@code -first}/{@code -last} and the scope is a collection element. */ + /** True when {@code name} is {@code -last} and the scope is a collection element. */ public static boolean isPositionalAccessor(boolean inElement, String name) { - return inElement && (FIRST_ACCESSOR.equals(name) || LAST_ACCESSOR.equals(name)); + return inElement && LAST_ACCESSOR.equals(name); } /** @@ -101,8 +99,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 getter-based beans, which covers Kotlin data classes (viewed as the map of + * their getters and public fields). * *

      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 @@ -187,6 +186,77 @@ private static Object withDerivedAccessors(Object payload, int depth) { for (int i = 0; i < n; i++) out.add(withDerivedAccessors(java.lang.reflect.Array.get(payload, i), depth + 1)); return withPositionalAccessors(out); } + if (isViewableBean(payload)) { + java.util.Map members = new java.util.LinkedHashMap<>(); + for (MemberReader r : readersFor(payload.getClass())) { + try { + members.putIfAbsent(r.name(), r.read(payload)); + } catch (ReflectiveOperationException | RuntimeException e) { + // Left out rather than failing the render, as for a record component. + } + } + return withDerivedAccessors(members, depth); + } return payload; } + + // A getter-based bean's readable members, resolved ONCE per class like the record accessors. + // Names are the ones the Mustache engine already resolved on such an object: the getter's own + // name (getName, isActive), the property name it implies (name, + // active) and public fields. Only getters are read — a fluent method is never invoked + // as a side effect of viewing — and nothing declared by Object (so no getClass) is exposed. + private static final java.util.concurrent.ConcurrentHashMap, MemberReader[]> MEMBERS = + new java.util.concurrent.ConcurrentHashMap<>(); + + private record MemberReader(String name, java.lang.reflect.Method getter, java.lang.reflect.Field field) { + Object read(Object target) throws ReflectiveOperationException { + return getter != null ? getter.invoke(target) : field.get(target); + } + } + + private static MemberReader[] readersFor(Class type) { + return MEMBERS.computeIfAbsent(type, c -> { + java.util.List readers = new java.util.ArrayList<>(); + for (java.lang.reflect.Method m : c.getMethods()) { + if (m.getDeclaringClass() == Object.class || m.getParameterCount() != 0 + || java.lang.reflect.Modifier.isStatic(m.getModifiers()) + || m.getReturnType() == void.class) continue; + String n = m.getName(); + String property = n.startsWith("get") ? n.substring(3) : n.startsWith("is") ? n.substring(2) : null; + if (property == null || property.isEmpty() || !Character.isUpperCase(property.charAt(0))) continue; + try { + m.setAccessible(true); + } catch (RuntimeException e) { + continue; + } + readers.add(new MemberReader(n, m, null)); + readers.add(new MemberReader(decapitalize(property), m, null)); + } + for (java.lang.reflect.Field f : c.getFields()) { + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; + try { + f.setAccessible(true); + } catch (RuntimeException e) { + continue; + } + readers.add(new MemberReader(f.getName(), null, f)); + } + return readers.toArray(new MemberReader[0]); + }); + } + + /** The Java Beans rule: lowercase the first character unless the first two are both upper case. */ + private static String decapitalize(String s) { + if (s.length() > 1 && Character.isUpperCase(s.charAt(0)) && Character.isUpperCase(s.charAt(1))) return s; + return Character.toLowerCase(s.charAt(0)) + s.substring(1); + } + + /** A scalar-like or platform value keeps its own string form; only an application bean is viewed. */ + private static boolean isViewableBean(Object payload) { + if (payload instanceof Enum || payload instanceof CharSequence || payload instanceof Number + || payload instanceof Boolean || payload instanceof Character || payload instanceof Iterable) return false; + String n = payload.getClass().getName(); + return !(n.startsWith("java.") || n.startsWith("javax.") || n.startsWith("jdk.") + || n.startsWith("kotlin.") || n.startsWith("sun.") || n.startsWith("com.sun.")); + } } 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..24c556d9c 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,38 @@ * 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. */ + /** 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 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 context-pushing object/array-of-object field. */ + /** 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 333d6ae97..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 @@ -161,7 +161,7 @@ private static void walk( if (atRoot) ctx.referencedAtRoot().add(s.value().split("\\.")[0]); PayloadField field = resolve(stack, s.value()); if (field == null) { - // A positional {{#-first}} / {{^-last}} gate is a BOOLEAN on the current + // 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); @@ -184,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, true); + walk(s.children(), Collections.unmodifiableList(next), seen, ctx, childInElement); } else { - walk(s.children(), stack, seen, ctx, inElement); + walk(s.children(), stack, seen, ctx, childInElement); } } case PartialTok p -> { 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..c1e6a7237 --- /dev/null +++ b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java @@ -0,0 +1,135 @@ +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")) + "]"); + } +} 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..133501132 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,35 @@ 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()); + } } 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 index 4072c5c26..c786b4029 100644 --- a/server/java/render/src/test/resources/snapshots/render-list-separator.txt +++ b/server/java/render/src/test/resources/snapshots/render-list-separator.txt @@ -1,6 +1,7 @@ items: a, b, c -lead: (first) 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 0e7c85493..f37ec0ad4 100644 --- a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py +++ b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py @@ -23,10 +23,12 @@ 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 @@ -47,27 +49,6 @@ def _pascal(name: str) -> str: return name[:1].upper() + name[1:] if name else name -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. The ``extends`` chain is walked and the NEAREST super that 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 — wins, so a multi-hop - chain through concrete enum fields still reaches the shared declaration.""" - if field.sub_type != fc.FIELD_SUBTYPE_ENUM: - return None - seen = {id(field)} - sup = field.super_data - while sup is not None and id(sup) not in seen: - if sup.sub_type != fc.FIELD_SUBTYPE_ENUM: - return None - parent = sup.parent - if getattr(sup, "is_abstract", False) and parent is not None and parent.type == TYPE_METADATA: - return sup - seen.add(id(sup)) - sup = sup.super_data - return None - - def is_provided(decl: MetaData) -> bool: """``@provided`` truth of an enum DECLARATION. @@ -94,11 +75,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 c97ac0611..64d3ea79e 100644 --- a/server/python/src/metaobjects/render/payload_accessors.py +++ b/server/python/src/metaobjects/render/payload_accessors.py @@ -27,7 +27,6 @@ "accessor_value", "with_derived_accessors", "is_boolean_accessor", - "FIRST_ACCESSOR", "LAST_ACCESSOR", "is_positional_accessor", ] @@ -35,12 +34,11 @@ #: The ``has`` prefix every derived boolean accessor carries. HAS_PREFIX = "has" -#: Positional accessors injected into every mapping ELEMENT of a collection, so a list +#: 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``. -FIRST_ACCESSOR = "-first" LAST_ACCESSOR = "-last" _MAX_DEPTH = 32 @@ -137,7 +135,6 @@ def with_derived_accessors(payload: Any, depth: int = 0) -> Any: for i, v in enumerate(payload): el = with_derived_accessors(v, depth + 1) if isinstance(el, dict): - el.setdefault(FIRST_ACCESSOR, i == 0) el.setdefault(LAST_ACCESSOR, i == last) items.append(el) return items @@ -164,7 +161,10 @@ def is_boolean_accessor(stack: list[list[Any]], name: str) -> bool: def is_positional_accessor(in_element: bool, name: str) -> bool: - """True when ``name`` is ``-first`` / ``-last`` and the current scope is an element of a - collection section. Permissive about shape, like ``is_boolean_accessor``: ``in_element`` is - true whenever the innermost context was pushed by a ``{{#x}}`` over a container field.""" - return in_element and name in (FIRST_ACCESSOR, LAST_ACCESSOR) + """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 2443c064c..bb3264640 100644 --- a/server/python/src/metaobjects/render/verify.py +++ b/server/python/src/metaobjects/render/verify.py @@ -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) @@ -234,7 +242,7 @@ def walk( referenced_at_root.add(tok.value.split(".")[0]) field = _resolve(stack, tok.value) if field is None: - # A positional `-first` / `-last` gate is a BOOLEAN on the current element: + # 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) @@ -249,12 +257,22 @@ def walk( # 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, True) - else: + # `^` (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, next_in_element) else: # _Partial if provider is None: continue # can't resolve without a provider 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 1bfb2b7b8..c4e11480f 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,27 @@ 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, +) +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 +69,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: @@ -106,4 +122,148 @@ def test_multi_hop_enum_extends_still_resolves_to_the_shared_enum() -> None: task = files["Task.py"] assert "urgency: Priority" in task assert "from .enums import Priority" in task - assert "Literal" not 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_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 0a1694f97..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,45 +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; - // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field - // that extends a concrete (or nested) enum field which itself extends the shared - // declaration still resolves to it. The seen-set guards a malformed cyclic chain. - const seen = new Set([field]); - let sup = field.resolveSuper(); - while (sup !== undefined && !seen.has(sup)) { - if (sup.subType !== FIELD_SUBTYPE_ENUM) return undefined; - // The declaration must sit at the package/root level (sibling of object.entity), - // i.e. its parent node is the metadata root. - if (sup.isAbstract && sup.parent !== undefined && sup.parent.type === TYPE_METADATA) return sup; - seen.add(sup); - sup = sup.resolveSuper(); - } - return undefined; -} - /** 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` — @@ -80,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/templates/entity-constants.ts b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts index e77b9748a..2574532d5 100644 --- a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts +++ b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts @@ -93,10 +93,11 @@ function renderFieldEntry(f: UiFieldDescriptor): string { } /** - * A value object's field-rules constant: `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. + * 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); 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 a566ab23b..3f1a1be86 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 { @@ -83,10 +84,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] : []), - // A pure value object (no TPH, no sourceless identity) gets a field-rules constant, so a caller - // reads `..rules` rather than the Zod schema's getters. It has no table or route, so - // it takes the constant without `$table`/`$path`. - ...(!tphSubtype && !sourceless ? [renderValueObjectConstants(obj, ctx ? ctx.valueObjectEmittedName(obj) : undefined)] : []), + // 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 + ? [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..70cb32e64 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 @@ -149,17 +149,27 @@ 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:"); + }); + 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 2623aafa2..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"'); }); - // MULTI-HOP: `Task.urgency extends Ticket.priority`, and `Ticket.priority` itself extends the - // root abstract `Priority`. The shared declaration must be found through the intermediate - // concrete field, so `Task` references `enums.ts` instead of inlining its own `TaskUrgency`. - test("an enum field extending another enum field that extends the shared abstract still dedups", async () => { + // 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).toBeDefined(); - expect(task).toContain('from "./enums"'); - expect(task).toContain("PriorityEnum"); + 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 () => { 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/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 7b03ab385..3a6e3e5c2 100644 --- a/server/typescript/packages/render/src/payload-accessors.ts +++ b/server/typescript/packages/render/src/payload-accessors.ts @@ -28,14 +28,13 @@ export function hasAccessorName(fieldName: string): string { } /** - * Positional accessors injected into every ELEMENT of a collection, so a list separator needs - * no hand-computed `last` flag in the payload: `{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}`. - * `-first` / `-last` are booleans 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. - * Only MAP-shaped elements carry them (a scalar element has nowhere to hold a key). An authored - * key of the same name wins, as it does for `has`. + * 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 FIRST_ACCESSOR = "-first"; export const LAST_ACCESSOR = "-last"; /** Capitalize the first character, leaving an already-uppercase one untouched. */ @@ -71,49 +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)) { - const last = payload.length - 1; - return payload.map((v, i) => { - const el = withDerivedAccessors(v, depth + 1); - if (el === null || typeof el !== "object" || Array.isArray(el)) return el; - const proto = Object.getPrototypeOf(el); - if (proto !== Object.prototype && proto !== null) return el; - const rec = el as Record; - const out: Record = { ...rec }; - if (!Object.prototype.hasOwnProperty.call(rec, FIRST_ACCESSOR)) out[FIRST_ACCESSOR] = i === 0; - if (!Object.prototype.hasOwnProperty.call(rec, LAST_ACCESSOR)) out[LAST_ACCESSOR] = i === last; - return out; - }) 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 54a8d7649..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, FIRST_ACCESSOR, LAST_ACCESSOR, 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 { @@ -85,14 +88,14 @@ function isBooleanAccessor(stack: ResolveStack, name: } /** - * True when `name` is a positional accessor (`-first` / `-last`) and the current scope is an - * element of a collection section — the engine injects both into every map-shaped element - * (payload-accessors.ts). Like `has` it is permissive about shape: `inElement` is true - * whenever the innermost context was pushed by a `{{#x}}` over a container field, which an - * object-typed field also is. + * 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(inElement: boolean, name: string): boolean { - return inElement && (name === FIRST_ACCESSOR || name === LAST_ACCESSOR); +function isPositionalAccessor(positional: boolean, name: string): boolean { + return positional && name === LAST_ACCESSOR; } function find(fields: F[], name: string): F | undefined { @@ -183,7 +186,7 @@ export function verify( // (no second resolution pass). const staticTexts: string[] = [templateText]; - function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[], inElement = false): 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; @@ -195,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) && !isPositionalAccessor(inElement, value)) + if (!resolve(stack, value) && !isBooleanAccessor(stack, value) && !isPositionalAccessor(positional, value)) errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value }); break; } @@ -204,23 +207,23 @@ export function verify( // {{^x}}…{{/x}} const sub = Array.isArray(tok[4]) ? (tok[4] as Token[]) : []; if (value === ".") { - walk(sub, stack, seen, inElement); + walk(sub, stack, seen, positional); break; } if (atRoot) referencedAtRoot.add(value.split(".")[0]!); const field = resolve(stack, value); if (!field) { - // A positional `-first` / `-last` gate is a BOOLEAN on the current element: it - // pushes nothing, so the body walks in the SAME scope. - if (isPositionalAccessor(inElement, value)) { - walk(sub, stack, seen, inElement); + // 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, inElement); + walk(sub, stack, seen, positional); break; } // Unresolved section head is itself drift; skip the body (its @@ -231,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, push ? true : inElement); + // 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 ">": { @@ -244,7 +252,7 @@ export function verify( break; } staticTexts.push(text); - walk(parse(text), stack, [...seen, value], inElement); + 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..f3afc06a6 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,122 @@ 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"); + }); +}); 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..ef621b23f 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,53 @@ 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. + +**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. + +**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. From 34f61b0d5fcbca0a583990a434e798543ee8b98b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 00:38:39 -0400 Subject: [PATCH 3/7] no-mistakes(review): Restore Java inline enum names; overlay typed payload views Assisted-by: no-mistakes:claude:claude-opus-5-5 --- CHANGELOG.md | 50 +++++- .../expected-enum-types.json | 4 +- .../shared-provided-enum/input/meta.json | 4 + .../TypedPayloadTests.cs | 88 ++++++++++ .../MetaObjects.Render/PayloadAccessors.cs | 139 +++++++++++----- server/csharp/MetaObjects.Render/Renderer.cs | 4 +- ...otlinFr019SharedProvidedConformanceTest.kt | 26 +-- .../kotlin/KotlinTypedPayloadRenderTest.kt | 51 ++++++ .../generator/spring/SpringTypeMapper.java | 49 +++++- .../Fr019SharedProvidedConformanceTest.java | 64 +++++-- .../spring/InlineEnumNamesGoldenTest.java | 79 +++++++++ .../expected/acme/shipping/CrateDto.java | 27 +++ .../expected/acme/shipping/CratePatch.java | 56 +++++++ .../expected/acme/shipping/ParcelDto.java | 27 +++ .../expected/acme/shipping/ParcelPatch.java | 56 +++++++ .../expected/acme/shipping/ShipmentDto.java | 31 ++++ .../expected/acme/shipping/ShipmentNote.java | 34 ++++ .../expected/acme/shipping/ShipmentPatch.java | 59 +++++++ .../acme/shipping/ShipmentSummaryDto.java | 11 ++ .../acme/shipping/ShipmentSummaryPatch.java | 56 +++++++ .../acme/shipping/prompts/NoteParser.java | 80 +++++++++ .../golden/inline-enum-names/input/meta.json | 46 +++++ .../metaobjects/render/PayloadAccessors.java | 157 ++++++++++-------- .../com/metaobjects/render/PayloadField.java | 5 + .../render/TypedPayloadRenderTest.java | 82 +++++++++ .../com/metaobjects/render/VerifyTest.java | 15 ++ ...DR-0026-shared-and-provided-named-types.md | 23 +++ 27 files changed, 1176 insertions(+), 147 deletions(-) create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/input/meta.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ebda3515..f5913df64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,10 +54,16 @@ Total hand-written lines did not fall, so do not read it as a total line reducti 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 used to get its own enum type; it now - references the shared one, in every port. In Kotlin this includes a projection or value-object - field: its former `` enum file is no longer generated, and code that imported - that type imports the shared enum instead. A model with no such field regenerates unchanged. + 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. The **Fixed** entry + lists each port's before and after. **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 @@ -81,7 +87,10 @@ generator catalog and the Atlas evaluation are 1.2 work. 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. + bean, a Kotlin data class, a Pydantic model. The same data renders the same bytes. A typed + object is overlaid, never copied: its 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. `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, @@ -548,6 +557,37 @@ until you regenerate. 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. + - 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. + + 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/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json index 22bf2201d..d1652b74b 100644 --- a/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json +++ b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json @@ -21,7 +21,9 @@ "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::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 029f5b1fa..c3bf2f1a0 100644 --- a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json +++ b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json @@ -63,6 +63,10 @@ ] } }, { "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/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs index 062d45b3d..5827e40b6 100644 --- a/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs +++ b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs @@ -112,4 +112,92 @@ public void HasFieldGateWorksOnTypedElementsLikeOnMaps() 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"; + } + + private sealed class Line + { + public string Sku = ""; + public Money Price { get; init; } + public string Label() => "label:" + Sku; + public string Exploding => 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 throws when read. No template below names it. + const string template = "{{#Lines}}{{Sku}}{{^-last}},{{/-last}}{{/Lines}}"; + Assert.Equal("a,b", Render(template, SampleOrder())); + } + + [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/PayloadAccessors.cs b/server/csharp/MetaObjects.Render/PayloadAccessors.cs index 021979b91..088afcd9e 100644 --- a/server/csharp/MetaObjects.Render/PayloadAccessors.cs +++ b/server/csharp/MetaObjects.Render/PayloadAccessors.cs @@ -103,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; @@ -119,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) { @@ -129,60 +135,107 @@ 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)); - for (int i = 0; i < outList.Count; i++) - { - if (outList[i] is not Dictionary el) continue; - el.TryAdd(LastAccessor, i == outList.Count - 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 typed payload (a generated value-object POCO, a record, an anonymous - // object). A template names the FIELD (`{{bio}}`), and a generated POCO carries that - // name in [JsonPropertyName] on a PascalCase member, so a typed object is viewed as the - // map of its readable public members keyed by wire name (or the member's own name when - // it declares none — exactly the name reflection lookup resolved before). The view is - // what carries the derived has and -last accessors, so a typed payload renders - // byte-identically to the equal map payload. A scalar-like value (number, enum, date, - // Guid, JsonElement, ...) is never viewed: its string form is its own. - if (IsScalarLike(payload.GetType())) return payload; + // 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 accessors a map payload gets. + if (WireNamedProperties(payload.GetType()) is { } props) { var asMap = new Dictionary(StringComparer.Ordinal); - foreach (var (name, read) in MemberReaders(payload.GetType())) asMap.TryAdd(name, read(payload)); - return WithDerivedAccessors(asMap, depth); + foreach (var (name, prop) in props) asMap[name] = prop.GetValue(payload); + return Derive(asMap, depth, last); } - } - private static bool IsScalarLike(Type t) => - t.IsPrimitive || t.IsEnum || t.Namespace is "System" || t.Namespace?.StartsWith("System.", StringComparison.Ordinal) == true; + // 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)[]> - MemberCache = new(); + private static readonly System.Collections.Concurrent.ConcurrentDictionary + WireNameCache = new(); /// - /// The readable public instance members of , each keyed by its - /// [JsonPropertyName] (or its own name when it has none). Cached per type. + /// 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 the overlay. /// - private static (string, Func)[] MemberReaders(Type type) => - MemberCache.GetOrAdd(type, static t => + private static (string, System.Reflection.PropertyInfo)[]? WireNamedProperties(Type type) => + WireNameCache.GetOrAdd(type, static t => { - const System.Reflection.BindingFlags Flags = - System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance; - string WireName(System.Reflection.MemberInfo m) => - (m.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true) - .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name ?? m.Name; - var readers = new List<(string, Func)>(); - foreach (var p in t.GetProperties(Flags).Where(p => p.CanRead && p.GetIndexParameters().Length == 0)) - readers.Add((WireName(p), o => p.GetValue(o))); - foreach (var f in t.GetFields(Flags)) - readers.Add((WireName(f), o => f.GetValue(o))); - return readers.ToArray(); + var props = t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .ToArray(); + string? WireName(System.Reflection.PropertyInfo p) => + (p.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true) + .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name; + 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/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 3d9144220..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 @@ -205,7 +205,6 @@ class KotlinFr019SharedProvidedConformanceTest { fun `multi-hop projection and value-object fields reference the shared type`() { val out = generate("com.acme.ext") try { - // Task.importance (two hops) and Subtask.rank (three hops) both reach Priority. // 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. @@ -218,10 +217,12 @@ class KotlinFr019SharedProvidedConformanceTest { 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") - for (name in listOf("TicketSummary.kt", "TicketBadge.kt")) { - val f = shop.resolve(name) - if (Files.exists(f)) assertTrue("Priority" in f.readText(), "$name must reference Priority; saw:\n${f.readText()}") - } + 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() } @@ -232,11 +233,16 @@ class KotlinFr019SharedProvidedConformanceTest { val out = generate("com.acme.ext") try { val refund = out.resolve("acme/shop/Refund.kt").readText() - assertTrue("ticketTier" in refund && "invoiceTier" in refund && "unit" in refund - && "tender" in refund && "orderStatus" in refund, - "Refund.kt must declare all five fields; saw:\n$refund") - assertTrue("com.acme.ext.Currency" in refund, - "Refund.tender must reference the configured external namespace; saw:\n$refund") + // 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() } 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 index 2ad908367..e30914234 100644 --- 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 @@ -65,4 +65,55 @@ class KotlinTypedPayloadRenderTest { 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() = 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), + ) + } + + @Test + fun `a property the template never names is never read`() { + assertEquals("a,b", render("{{#lines}}{{sku}}{{^-last}},{{/-last}}{{/lines}}", order)) + } + + @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-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 ad42f5155..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 @@ -22,7 +22,9 @@ import com.metaobjects.identity.MetaIdentity; import com.metaobjects.object.MetaObject; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** * Centralised mapping from {@link MetaField} subtype to the Java type used in @@ -315,14 +317,23 @@ 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 {@code field} reaches a package-level shared - * enum DECLARATION via {@link EnumField#resolveSharedEnumDeclaration} (the single - * cross-generator authority — possibly multi-hop, short-circuited by an own - * {@code @values}/{@code @intValueMap} along the way), the name is - * {@code Pascal(declaration.shortName)}; otherwise (an inline field — no reachable - * declaration) it is {@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 @@ -333,9 +344,31 @@ public static String enumTypeName(MetaObject owner, MetaField field) { if (declaration != null) { return pascal(SpringNaming.splitFqn(declaration.getName())[1]); } + MetaField superRoot = resolveSuperRoot(field); + if (superRoot != null && !EnumField.isSharedEnumDeclaration(superRoot)) { + return pascal(SpringNaming.splitFqn(superRoot.getName())[1]); + } return pascal(SpringNaming.splitFqn(owner.getName())[1]) + pascal(field.getName()); } + /** + * 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 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(); + if (current == null) return null; + Set seen = new HashSet<>(); + while (true) { + MetaField next = current.getSuperField(); + if (next == null) break; + if (!seen.add(current.getName())) break; // cycle guard + current = next; + } + return current; + } + /** * Read the EFFECTIVE {@code @values} of an {@link EnumField} (inheriting from an * {@code extends}-super when the field carries no own values), as the verbatim member list. 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 c5b70eea7..227177d4e 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 @@ -294,26 +294,66 @@ public void refundFieldsResolveToDistinctSharedDeclarationsOnTheSameObject() thr 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 keep their per-object inline enum. Same for the - // Subtask fields that extend them. + // 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 keep its inline enum; saw:\n" + taskDto, - taskDto.contains("public enum TaskPhase")); - assertTrue("Task.weight must keep its inline enum; saw:\n" + taskDto, - taskDto.contains("public enum TaskWeight")); + 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 keep its inline enum; saw:\n" + subtaskDto, - subtaskDto.contains("public enum SubtaskPhase")); - assertTrue("Subtask.weight must keep its inline enum; saw:\n" + subtaskDto, - subtaskDto.contains("public enum SubtaskWeight")); + 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 inline enum; saw:\n" + refundDto, - refundDto.contains("public enum RefundOrderStatus")); + 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 ")); + assertTrue("the shared Priority must be emitted by the value-object run itself", + Files.readString(voOut.resolve("acme/shop/Priority.java")) + .contains("public enum Priority { LOW, MEDIUM, HIGH }")); + assertFalse("the @provided Currency must never be generated", + Files.exists(voOut.resolve("acme/shop/Currency.java"))); } @Test 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/render/src/main/java/com/metaobjects/render/PayloadAccessors.java b/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java index 3eac8ff0c..7bad3d868 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 @@ -34,14 +34,16 @@ public final class PayloadAccessors { private PayloadAccessors() { /* no instances */ } - /** Adds {@code -last} to the map-shaped elements of an already-derived list. */ + /** 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++) { - if (elements.get(i) instanceof java.util.Map m) { - java.util.Map el = (java.util.Map) m; - el.putIfAbsent(LAST_ACCESSOR, i == last); + 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; @@ -100,8 +102,8 @@ public static Boolean accessorValue(Object value) { /** * A view over {@code payload} carrying its derived {@code has} accessors, * recursively — for map-shaped payloads, Java records (viewed as the map of their - * components) and getter-based beans, which covers Kotlin data classes (viewed as the map of - * their getters and public fields). + * 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 @@ -186,77 +188,98 @@ private static Object withDerivedAccessors(Object payload, int depth) { for (int i = 0; i < n; i++) out.add(withDerivedAccessors(java.lang.reflect.Array.get(payload, i), depth + 1)); return withPositionalAccessors(out); } - if (isViewableBean(payload)) { - java.util.Map members = new java.util.LinkedHashMap<>(); - for (MemberReader r : readersFor(payload.getClass())) { - try { - members.putIfAbsent(r.name(), r.read(payload)); - } catch (ReflectiveOperationException | RuntimeException e) { - // Left out rather than failing the render, as for a record component. - } - } - return withDerivedAccessors(members, depth); - } + if (isViewable(payload)) return new TypedView(payload, depth); return payload; } - // A getter-based bean's readable members, resolved ONCE per class like the record accessors. - // Names are the ones the Mustache engine already resolved on such an object: the getter's own - // name (getName, isActive), the property name it implies (name, - // active) and public fields. Only getters are read — a fluent method is never invoked - // as a side effect of viewing — and nothing declared by Object (so no getClass) is exposed. - private static final java.util.concurrent.ConcurrentHashMap, MemberReader[]> MEMBERS = + /** + * A value the engine treats specially by its TYPE — a scalar, an iterable, a lambda — or a + * platform value is handed over untouched; only an application object is overlaid. + */ + private static boolean isViewable(Object payload) { + 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<>(); - private record MemberReader(String name, java.lang.reflect.Method getter, java.lang.reflect.Field field) { - Object read(Object target) throws ReflectiveOperationException { - return getter != null ? getter.invoke(target) : field.get(target); + /** 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; } } - private static MemberReader[] readersFor(Class type) { - return MEMBERS.computeIfAbsent(type, c -> { - java.util.List readers = new java.util.ArrayList<>(); - for (java.lang.reflect.Method m : c.getMethods()) { - if (m.getDeclaringClass() == Object.class || m.getParameterCount() != 0 - || java.lang.reflect.Modifier.isStatic(m.getModifiers()) - || m.getReturnType() == void.class) continue; - String n = m.getName(); - String property = n.startsWith("get") ? n.substring(3) : n.startsWith("is") ? n.substring(2) : null; - if (property == null || property.isEmpty() || !Character.isUpperCase(property.charAt(0))) continue; - try { - m.setAccessible(true); - } catch (RuntimeException e) { - continue; - } - readers.add(new MemberReader(n, m, null)); - readers.add(new MemberReader(decapitalize(property), m, null)); - } - for (java.lang.reflect.Field f : c.getFields()) { - if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; - try { - f.setAccessible(true); - } catch (RuntimeException e) { - continue; - } - readers.add(new MemberReader(f.getName(), null, f)); + /** + * 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 readers.toArray(new MemberReader[0]); - }); - } + return MISSING; + } - /** The Java Beans rule: lowercase the first character unless the first two are both upper case. */ - private static String decapitalize(String s) { - if (s.length() > 1 && Character.isUpperCase(s.charAt(0)) && Character.isUpperCase(s.charAt(1))) return s; - return Character.toLowerCase(s.charAt(0)) + s.substring(1); - } + @Override public boolean containsKey(Object key) { return resolve(key) != MISSING; } - /** A scalar-like or platform value keeps its own string form; only an application bean is viewed. */ - private static boolean isViewableBean(Object payload) { - if (payload instanceof Enum || payload instanceof CharSequence || payload instanceof Number - || payload instanceof Boolean || payload instanceof Character || payload instanceof Iterable) return false; - String n = payload.getClass().getName(); - return !(n.startsWith("java.") || n.startsWith("javax.") || n.startsWith("jdk.") - || n.startsWith("kotlin.") || n.startsWith("sun.") || n.startsWith("com.sun.")); + @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 24c556d9c..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 @@ -27,6 +27,11 @@ public record PayloadField(String name, List fields, boolean isArr Objects.requireNonNull(name, "name"); } + /** 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, false); 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 index c1e6a7237..9a48c22d2 100644 --- a/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java +++ b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java @@ -132,4 +132,86 @@ public void hasFieldGateWorksOnBeanElementsLikeOnMaps() { 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"; } + } + + 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() { 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() throws when read. The template does not name it. + assertEquals("a,b", render("{{#lines}}{{sku}}{{^-last}},{{/-last}}{{/lines}}", new Order())); + } + + @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())); + } + + @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/VerifyTest.java b/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java index 133501132..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 @@ -265,4 +265,19 @@ public void scalarConditionalAndSingleObjectSectionsAreTransparentToLast() { "{{#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/spec/decisions/ADR-0026-shared-and-provided-named-types.md b/spec/decisions/ADR-0026-shared-and-provided-named-types.md index ef621b23f..3d6191a27 100644 --- a/spec/decisions/ADR-0026-shared-and-provided-named-types.md +++ b/spec/decisions/ADR-0026-shared-and-provided-named-types.md @@ -173,6 +173,29 @@ type, and the shared type always generates. A field whose chain ends at an inlin 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 From 8165ab497c82bc73a7e5bee4169b4df7a127bf75 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 01:03:21 -0400 Subject: [PATCH 4/7] no-mistakes(review): One shared-enum owner, Kotlin provided-enum resolver, C# cycle guard Assisted-by: no-mistakes:claude:claude-opus-5-5 --- CHANGELOG.md | 29 ++++- docs/features/own-your-codegen.md | 10 ++ docs/ports/java.md | 26 ++++ docs/ports/kotlin.md | 26 ++++ .../EnumExtendsCycleTests.cs | 56 +++++++++ server/csharp/MetaObjects/Meta/MetaField.cs | 17 ++- .../generator/kotlin/Fr019SharedEnum.kt | 46 ++++--- .../generator/kotlin/KotlinEntityGenerator.kt | 24 ++-- .../generator/kotlin/KotlinEnumEmitter.kt | 5 +- .../kotlin/KotlinExposedTableGenerator.kt | 14 ++- .../kotlin/KotlinExtractSchemaEmitter.kt | 19 +-- .../generator/kotlin/KotlinGenUtil.kt | 15 ++- .../generator/kotlin/KotlinM2mSupport.kt | 16 ++- .../kotlin/KotlinOutputParserGenerator.kt | 10 +- .../kotlin/KotlinRelationsGenerator.kt | 12 +- .../kotlin/KotlinSpringControllerGenerator.kt | 26 ++-- .../generator/kotlin/KotlinTypeMapper.kt | 14 ++- .../kotlin/apidocs/KotlinApiModelBuilder.kt | 9 +- .../ApiDocsCrossPortConformanceKtTest.kt | 2 +- .../apidocs/KotlinApiDocsAccuracyKtTest.kt | 2 +- .../KotlinApiDocsSourceSelectionKtTest.kt | 2 +- .../apidocs/ProjectionDocsRoutesKtTest.kt | 2 +- .../generator/kotlin/KotlinGenUtilTest.kt | 2 +- .../generator/kotlin/KotlinM2mCodegenTest.kt | 2 +- ...linM2mSupportAbstractMidLevelTargetTest.kt | 4 +- .../KotlinProvidedEnumAcrossGeneratorsTest.kt | 119 ++++++++++++++++++ .../kotlin/KotlinReportRestSurfaceTest.kt | 2 +- .../generator/kotlin/KotlinTypeMapperTest.kt | 4 +- .../generator/kotlin/ReportingInertTest.kt | 2 +- .../generator/spring/Fr019SharedEnum.java | 21 +++- .../generator/spring/SpringDtoGenerator.java | 5 +- .../spring/SpringOutputParserGenerator.java | 4 +- .../spring/SpringValueObjectGenerator.java | 4 +- .../Fr019SharedProvidedConformanceTest.java | 5 +- .../GeneratedTypedEnumCompileRunTest.java | 114 +++++++++++++++-- .../java/com/metaobjects/mojo/DocsMojo.java | 4 +- ...DR-0026-shared-and-provided-named-types.md | 9 +- 37 files changed, 567 insertions(+), 116 deletions(-) create mode 100644 server/csharp/MetaObjects.Conformance.Tests/EnumExtendsCycleTests.cs create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProvidedEnumAcrossGeneratorsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f5913df64..df1bb1ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,13 @@ Total hand-written lines did not fall, so do not read it as a total line reducti 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. The **Fixed** entry - lists each port's before and after. + 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 + ``. The **Fixed** entry lists each port's before and after. **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 @@ -576,11 +581,31 @@ until you regenerate. 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. + 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. + 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 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/ports/java.md b/docs/ports/java.md index d03257fb9..30a8d7b08 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -300,6 +300,32 @@ 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 +``. + ### 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..d822c2ac7 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -254,6 +254,32 @@ 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. + **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/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/Meta/MetaField.cs b/server/csharp/MetaObjects/Meta/MetaField.cs index 93aac3fa0..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()?.EffectiveEnumValues; + 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-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 851398d44..907363120 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 @@ -14,18 +14,34 @@ import com.squareup.kotlinpoet.ClassName * 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 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. + * 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 { + /** 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. */ data class ProvidedEnumConfig(val fallbackNamespace: String?, val packageNamespaces: Map) { companion object { - fun of(fallback: String?, packageMapArg: String?): ProvidedEnumConfig = - ProvidedEnumConfig(fallback?.trim()?.takeIf { it.isNotEmpty() }, parsePackageMap(packageMapArg)) + /** + * 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)), + ) } } @@ -80,15 +96,13 @@ 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. Called only by [KotlinTypeMapper.enumTypeName]. */ - fun providedClassName(field: MetaField<*>, config: ProvidedEnumConfig): ClassName? { - val decl = resolveSharedEnumDecl(field) ?: return null - if (!isProvided(decl)) return null + fun providedClassName(decl: EnumField, config: ProvidedEnumConfig): ClassName { val (_, shortRaw) = PackageMapping.splitFqn(decl.name) val simple = shortRaw.replaceFirstChar { it.uppercase() } val metaPackage = metaPackageOf(decl.name) @@ -97,8 +111,8 @@ object Fr019SharedEnum { 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) 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 ea7c8452b..b58a2ba56 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,8 +37,11 @@ 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 (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 // Read members from the DECLARATION (FR-019: EnumField.resolveSharedEnumDeclaration — the // same authority enumTypeName just resolved), never from whichever consuming field is 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) } } /** 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 +103,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 +200,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 +216,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..a9e20d53e 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." ) @@ -719,9 +722,13 @@ public object KotlinGenUtil { } /** [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..d04e995ad 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,8 +241,8 @@ 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 } 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/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index c207c43b6..5842edb20 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.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.KotlinM2mSupport import com.metaobjects.generator.kotlin.KotlinNaming @@ -106,6 +107,13 @@ open class KotlinSpringControllerGenerator : 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 2e10cdfa9..597434497 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 @@ -187,7 +187,10 @@ object KotlinTypeMapper { * 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). With no reachable declaration the field keeps its per-object + * 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]). * @@ -198,11 +201,16 @@ object KotlinTypeMapper { * 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 - val declaration = EnumField.resolveSharedEnumDeclaration(field) + 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() }) } 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..99f334135 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,10 @@ 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, +) { private companion object { /** Structured formats that additionally get an output-format prompt + a tolerant extractor. */ @@ -329,7 +332,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 +500,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/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/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/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-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 4bd76c38e..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 @@ -184,12 +184,21 @@ public static List materializedSharedEnums(MetaDataLoader loader) { } /** - * Emit each materialized shared enum ONCE as a standalone top-level Java {@code enum} in its - * declaring package — {@code /.java} containing {@code public enum { A, B, C }}. - * Every generator whose output REFERENCES a shared type (the DTO, value-object and output-parser - * generators) calls this, so the type exists whichever of them a run includes; the content is a - * pure function of the declaration, so the repeated write is byte-identical. Provided enums - * emit nothing (referenced externally). + * 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)) { 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 b05228447..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,8 +112,9 @@ 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. + // 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()) { 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 62f1ab75b..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 @@ -140,7 +140,9 @@ public void execute(MetaDataLoader loader) { Path outRoot = Paths.get(outDir.getAbsolutePath()); fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( getArg("providedEnumNamespace"), getArg("providedEnumPackages")); - Fr019SharedEnum.emitMaterialized(loader, outRoot); + 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)) { 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 2b19a8c64..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 @@ -85,7 +85,9 @@ public void execute(MetaDataLoader loader) { Path outRoot = Paths.get(outDir.getAbsolutePath()); fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of( getArg("providedEnumNamespace"), getArg("providedEnumPackages")); - Fr019SharedEnum.emitMaterialized(loader, outRoot); + if (Boolean.parseBoolean(getArg(Fr019SharedEnum.ARG_EMIT_SHARED_ENUMS, "false"))) { + Fr019SharedEnum.emitMaterialized(loader, outRoot); + } for (MetaObject vo : valueShapes(loader)) { emit(vo, outRoot); } 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 227177d4e..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 @@ -349,9 +349,8 @@ public void valueObjectFieldOneHopFromASharedOrProvidedDeclarationReferencesItAn 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 ")); - assertTrue("the shared Priority must be emitted by the value-object run itself", - Files.readString(voOut.resolve("acme/shop/Priority.java")) - .contains("public enum Priority { LOW, MEDIUM, HIGH }")); + 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"))); } 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 7f6b35e18..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,11 +126,13 @@ 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 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, by the value-object run itself. + // shared type is emitted exactly once, as its own file. String src = Files.readString(gen.resolve("acme/orders/TicketPayload.java")); assertFalse("the record must reference the shared enum, not nest one; saw:\n" + src, src.contains("enum ")); @@ -152,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(); @@ -169,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/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/spec/decisions/ADR-0026-shared-and-provided-named-types.md b/spec/decisions/ADR-0026-shared-and-provided-named-types.md index 3d6191a27..d09deca7c 100644 --- a/spec/decisions/ADR-0026-shared-and-provided-named-types.md +++ b/spec/decisions/ADR-0026-shared-and-provided-named-types.md @@ -165,7 +165,14 @@ disagree. 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. +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 From 8e3c0cff19f52c5e397d5d5da4f5a056396a7399 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 08:48:22 -0400 Subject: [PATCH 5/7] no-mistakes(review): Keep deprecated Kotlin helper overloads for ejected generators Assisted-by: no-mistakes:claude:claude-sonnet-5-5 --- CHANGELOG.md | 25 +- docs/ports/kotlin.md | 5 + .../generator/kotlin/Fr019SharedEnum.kt | 50 +- .../generator/kotlin/KotlinEnumEmitter.kt | 17 +- .../kotlin/KotlinExtractSchemaEmitter.kt | 16 + .../generator/kotlin/KotlinGenUtil.kt | 16 + .../generator/kotlin/KotlinM2mSupport.kt | 16 + .../generator/kotlin/KotlinTypeMapper.kt | 12 + .../kotlin/apidocs/KotlinApiModelBuilder.kt | 6 + .../EjectedAtBaseGeneratorsCompatTest.kt | 181 ++ .../KotlinEntityGenerator.kt.txt | 895 +++++++ .../KotlinExposedTableGenerator.kt.txt | 2320 +++++++++++++++++ .../KotlinOutputParserGenerator.kt.txt | 228 ++ .../KotlinRelationsGenerator.kt.txt | 393 +++ .../KotlinRenderHelperGenerator.kt.txt | 392 +++ .../KotlinSpringControllerGenerator.kt.txt | 2248 ++++++++++++++++ .../codegen/generators/fr019_shared_enum.py | 15 + .../test_fr019_shared_provided_conformance.py | 25 + 18 files changed, 6853 insertions(+), 7 deletions(-) create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedAtBaseGeneratorsCompatTest.kt create mode 100644 server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinEntityGenerator.kt.txt create mode 100644 server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinExposedTableGenerator.kt.txt create mode 100644 server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinOutputParserGenerator.kt.txt create mode 100644 server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRelationsGenerator.kt.txt create mode 100644 server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinRenderHelperGenerator.kt.txt create mode 100644 server/java/codegen-kotlin/src/test/resources/ejected-at-base/KotlinSpringControllerGenerator.kt.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index df1bb1ecf..e712ef8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,9 @@ Total hand-written lines did not fall, so do not read it as a total line reducti 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 - ``. The **Fixed** entry lists each port's before and after. + ``. An owned (ejected) Kotlin generator keeps compiling and keeps its previous + `@provided` answer until you re-eject it. The **Fixed** entry lists each port's before and + after, and the re-eject command. **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 @@ -605,6 +607,27 @@ until you regenerate. 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` reports each copy's own changes). 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 diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index d822c2ac7..725ca7b78 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -280,6 +280,11 @@ the enum first, with an error naming the enum, its package and both args. It app 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/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 907363120..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 @@ -28,9 +28,26 @@ object Fr019SharedEnum { /** 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. */ - data class ProvidedEnumConfig(val fallbackNamespace: String?, val packageNamespaces: Map) { + /** + * 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. @@ -42,6 +59,13 @@ object Fr019SharedEnum { 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)) } } @@ -100,11 +124,13 @@ object Fr019SharedEnum { * 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. Called only by [KotlinTypeMapper.enumTypeName]. + * writes. Under [ProvidedEnumConfig.DECLARATION_PACKAGE] the answer is the class in the + * declaration's own package instead, and nothing throws. */ - fun providedClassName(decl: EnumField, config: ProvidedEnumConfig): ClassName { - 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()) { @@ -118,6 +144,20 @@ object Fr019SharedEnum { 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/KotlinEnumEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt index b58a2ba56..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 @@ -40,7 +40,7 @@ object KotlinEnumEmitter { provided: Fr019SharedEnum.ProvidedEnumConfig, ): Boolean { // FR-019: a @provided shared enum is referenced externally, never materialized. - if (Fr019SharedEnum.isProvidedEnumField(field)) return false + 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 // Read members from the DECLARATION (FR-019: EnumField.resolveSharedEnumDeclaration — the @@ -82,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/KotlinExtractSchemaEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt index 385073cae..f32395a59 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt @@ -80,6 +80,22 @@ object KotlinExtractSchemaEmitter { } } + @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, provided: Fr019SharedEnum.ProvidedEnumConfig): String { val (pkg, short) = PackageMapping.splitFqn(vo.name) 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 a9e20d53e..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 @@ -721,6 +721,22 @@ 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, 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 d04e995ad..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 @@ -246,6 +246,22 @@ object KotlinM2mSupport { 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/KotlinTypeMapper.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt index 597434497..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 @@ -224,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 99f334135..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 @@ -63,6 +63,12 @@ class KotlinApiModelBuilder( 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. */ val STRUCTURED_FORMATS = setOf(TemplateConstants.FORMAT_JSON, TemplateConstants.FORMAT_XML) 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/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/python/src/metaobjects/codegen/generators/fr019_shared_enum.py b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py index f37ec0ad4..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,6 +21,7 @@ """ from __future__ import annotations +import warnings from dataclasses import dataclass from metaobjects.meta.core.field.meta_field import ( @@ -49,6 +50,20 @@ def _pascal(name: str) -> str: return name[:1].upper() + name[1:] if name else name +def resolve_shared_enum_decl(field: MetaField) -> MetaData | None: + """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: """``@provided`` truth of an enum DECLARATION. 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 c4e11480f..f1f037d83 100644 --- a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py +++ b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py @@ -30,6 +30,7 @@ from metaobjects.codegen.generators.fr019_shared_enum import ( is_provided, materialized_shared_enums, + resolve_shared_enum_decl, ) from metaobjects.codegen.runner import run_gen @@ -155,6 +156,30 @@ def test_decision_table_matches_fixture_exactly() -> None: 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: From 504d94ff600a81264e0f0cbef33e9c8097a21111 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 09:35:00 -0400 Subject: [PATCH 6/7] no-mistakes(review): Pass arrays through typed view; abstract value-object gate; Java re-eject note Assisted-by: no-mistakes:claude:claude-opus-5-5 --- CHANGELOG.md | 38 ++++++++++++++-- docs/ports/java.md | 29 ++++++++++++ .../kotlin/KotlinTypedPayloadRenderTest.kt | 19 ++++++++ .../metaobjects/render/PayloadAccessors.java | 6 ++- .../render/TypedPayloadRenderTest.java | 31 +++++++++++++ .../src/templates/value-object-file.ts | 3 +- .../test/generators/entity-file.test.ts | 45 ++++++++++++++++++- 7 files changed, 163 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e712ef8ed..ecd58cfca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,8 +69,11 @@ Total hand-written lines did not fall, so do not read it as a total line reducti 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. The **Fixed** entry lists each port's before and - after, and the re-eject command. + `@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 @@ -595,6 +598,35 @@ until you regenerate. 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: @@ -623,7 +655,7 @@ until you regenerate. 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` reports each copy's own changes). For an edited copy, + 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 diff --git a/docs/ports/java.md b/docs/ports/java.md index 30a8d7b08..8af1efadb 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -326,6 +326,35 @@ A `@provided` enum is never generated. All three generators reference it where 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/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 index e30914234..d8dcb88d5 100644 --- 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 @@ -99,6 +99,25 @@ class KotlinTypedPayloadRenderTest { ) } + 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`() { assertEquals("a,b", render("{{#lines}}{{sku}}{{^-last}},{{/-last}}{{/lines}}", order)) 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 7bad3d868..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 @@ -193,10 +193,12 @@ private static Object withDerivedAccessors(Object payload, int depth) { } /** - * A value the engine treats specially by its TYPE — a scalar, an iterable, a lambda — or a - * platform value is handed over untouched; only an application object is overlaid. + * 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 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 index 9a48c22d2..3eb90339f 100644 --- a/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java +++ b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java @@ -208,6 +208,37 @@ public void aTemplateWithoutDerivedNamesRendersExactlyAsTheBareEngineDoes() thro 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", 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 3f1a1be86..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 @@ -31,6 +31,7 @@ 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); @@ -89,7 +90,7 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren // 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 + ...(obj.subType === OBJECT_SUBTYPE_VALUE && !isAbstract(obj) ? [renderValueObjectConstants(obj, ctx ? ctx.valueObjectEmittedName(obj) : undefined)] : []), ...(tphFilterAllowlist !== null ? [tphFilterAllowlist] : []), 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 70cb32e64..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({ @@ -168,6 +168,47 @@ describe("entityFile() — emitAbstractShapes knob", () => { 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)).map((f) => f.path); expect(paths).not.toContain("Base.ts"); From baf290aa638956b384a42547435b75f429774054 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 10:39:57 -0400 Subject: [PATCH 7/7] no-mistakes(test): Count reads in laziness tests; correct typed-payload CHANGELOG wording Assisted-by: no-mistakes:claude:claude-opus-5-5 --- CHANGELOG.md | 11 ++++++++--- .../TypedPayloadTests.cs | 18 ++++++++++++++++-- .../kotlin/KotlinTypedPayloadRenderTest.kt | 15 ++++++++++++++- .../render/TypedPayloadRenderTest.java | 14 ++++++++++++-- .../render/test/payload-accessors.test.ts | 18 ++++++++++++++++++ 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd58cfca..ca94ccbb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,10 +97,15 @@ generator catalog and the Atlas evaluation are 1.2 work. 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. A typed - object is overlaid, never copied: its own members (methods and fields included) resolve as + 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. + 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, diff --git a/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs index 5827e40b6..23a8cff81 100644 --- a/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs +++ b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs @@ -120,12 +120,22 @@ 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 => throw new InvalidOperationException("read a member the template never names"); + public string Exploding + { + get + { + Interlocked.Increment(ref s_explodingReads); + throw new InvalidOperationException("read a member the template never names"); + } + } } private sealed class Order @@ -172,9 +182,13 @@ public void AValueRendersThroughItsOwnStringForm() [Fact] public void AMemberTheTemplateNeverNamesIsNeverRead() { - // Line.Exploding throws when read. No template below names it. + // 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] 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 index d8dcb88d5..c0e31142d 100644 --- 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 @@ -16,6 +16,11 @@ import kotlin.test.assertEquals */ 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) @@ -74,7 +79,11 @@ class KotlinTypedPayloadRenderTest { data class Line(val sku: String, val price: Money) { fun label(): String = "label:$sku" - val exploding: String get() = throw IllegalStateException("read a member the template never names") + 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) { @@ -120,7 +129,11 @@ class KotlinTypedPayloadRenderTest { @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 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 index 3eb90339f..da6b8b3f1 100644 --- a/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java +++ b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java @@ -145,13 +145,19 @@ public static final class Money { @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() { throw new IllegalStateException("read a member the template never names"); } + public String getExploding() { + EXPLODING_READS.incrementAndGet(); + throw new IllegalStateException("read a member the template never names"); + } } public static class Order { @@ -192,8 +198,12 @@ public void aValueRendersThroughItsOwnStringForm() { @Test public void aMemberTheTemplateNeverNamesIsNeverRead() { - // Line.getExploding() throws when read. The template does not name it. + // 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 diff --git a/server/typescript/packages/render/test/payload-accessors.test.ts b/server/typescript/packages/render/test/payload-accessors.test.ts index f3afc06a6..0a572fdbb 100644 --- a/server/typescript/packages/render/test/payload-accessors.test.ts +++ b/server/typescript/packages/render/test/payload-accessors.test.ts @@ -281,4 +281,22 @@ describe("a typed payload renders like the equal plain-object payload", () => { } 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); + }); });