diff --git a/CLAUDE.md b/CLAUDE.md index 270501c7c..7c3835f3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -552,5 +552,4 @@ See `spec/roadmap.md` for current and planned library work. (Consumer-adoption v - [TECHNICAL] Field-type → Drizzle-column-type mapping table (needed for complete TS codegen coverage). - [TECHNICAL] ObjectManagerDB further modernization. FR-003 Plan 4 (2026-05-27) closed the three engine-debt anti-patterns. The Spring Boot 3 starter + OMDB autoconfiguration + virtual-thread audit shipped 2026-05-30 (`metaobjects-spring-boot-starter`). **jOOQ migration is a closed non-goal**: jOOQ's OSS edition excludes Oracle/SQL Server/DB2 (commercial license required), which would paywall OMDB's commercial-DB drivers in a public OSS project, and jOOQ generates code *from* a schema — the inverse of MetaObjects' metadata-is-the-spine model. -- [TECHNICAL] Payload `origin.*` resolution in `codegen-spring` (Day-1 deferral — see `server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md`). Kotlin's `KotlinPayloadGenerator` is the cross-port reference. - [TECHNICAL] WARN envelope-shape assertion on cross-port `expected-warnings.json` (closed 2026-05-27 — runners now assert envelope shape on warnings; legacy string-list path retired). diff --git a/agent-context/skills/metaobjects-prompts/SKILL.md b/agent-context/skills/metaobjects-prompts/SKILL.md index 40eeb876d..514735dc1 100644 --- a/agent-context/skills/metaobjects-prompts/SKILL.md +++ b/agent-context/skills/metaobjects-prompts/SKILL.md @@ -50,27 +50,22 @@ LLM tool-call envelope with no renderable text body (the body IS the `@format` attrs above). The vocabulary exists today; MCP exposure of declared prompts/tools is roadmap, not shipped — don't promise it. -## The payload is an `object.value` projection - -The payload is **not** an entity — it's an `object.value` whose every field carries -an `origin.*` child saying where its value comes from. Three origin subtypes: - -| Origin | Behavior | -|---|---| -| `origin.passthrough @from "Entity.field"` | payload property matches the source field | -| `origin.aggregate @agg ` | `count`→long, `avg`→double, others match source | -| `origin.collection @via "Parent.rel"` | a list of a nested payload, assembled from a relationship | - -These are the **payload-assembly** origins — the vocabulary this skill covers. -**Projection** read models (`object.projection` over an entity) carry a fuller origin -vocabulary — the `@agg` predicate quantifiers `any`/`all`, the `collect` array rollup, -plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (an argmax-style -pick) — those live in the `metaobjects-authoring` skill and -`docs/features/source-kinds.md`, not here: don't reach for them on a payload VO. - -Declaring the payload as a projection is what makes payload bloat visible: adding a -field to the prompt is a diff on the `object.value`, and a renamed source field -breaks the build instead of silently degrading the prompt. +## The payload is an `object.value` you declare + +The payload is **not** an entity — it's an `object.value` whose DECLARED fields ARE +the prompt's typed shape. Every port's payload codegen is +**declared-type-authoritative (#270)**: a field's generated type comes only from its +declared `field.` + `isArray` + `@objectRef`, and a nested payload is a +declared `field.object @objectRef` to another `object.value` (`isArray: true` for a +list). The caller supplies the field values at render time. An `origin.*` child on a +payload field is IGNORED for typing — never author assembly origins (`aggregate` / +`collection` / `computed` / `first`) on a payload VO. Derivation belongs to +**projection** read models (`object.projection` over an entity), covered by the +`metaobjects-authoring` skill and `docs/features/source-kinds.md`, not here. + +Declaring the payload shape is what makes payload bloat visible: adding a field to +the prompt is a diff on the `object.value`, and `verify` catches template/payload +drift at build time instead of letting a prompt silently degrade. ```json { @@ -81,12 +76,10 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "WelcomePayload", "children": [ - { "field.string": { "name": "displayName", - "children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } }, - { "field.long": { "name": "postCount", - "children": [ { "origin.aggregate": { "@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }, + { "field.string": { "name": "displayName" } }, + { "field.long": { "name": "postCount" } }, { "field.object": { "name": "posts", "@objectRef": "PostSummary", - "children": [ { "origin.collection": { "@via": "Author.posts" } } ] } } + "isArray": true } } ] } }, @@ -94,8 +87,7 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "PostSummary", "children": [ - { "field.string": { "name": "title", - "children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } } + { "field.string": { "name": "title" } } ] } }, diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index b6403ef3a..f956da972 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -19,8 +19,8 @@ This buys four guarantees: 4. **Cross-language conformance** — a Python eval renders exactly what the Java production server sends. -The vocabulary is `template.*` (the renderable unit) + `origin.*` (the projection -fields that build the payload VO). Mustache is the chosen template engine — it has +The vocabulary is `template.*` (the renderable unit) over a declared +`object.value` payload shape. Mustache is the chosen template engine — it has the only published cross-language spec + conformance suite. ## Two template subtypes @@ -41,21 +41,23 @@ Both carry the same generic attributes: | `@owner` | no | Governance attribute | | `@since` | no | Governance attribute | -## Payload origins +## Payload fields are declared -A payload is an `object.value` view-object whose fields each declare an `origin.*` -child. Three origin subtypes: +A payload is an `object.value` view-object whose fields DECLARE the payload's +shape — a prompt's payload is a typed projection you author, so payload bloat +shows up as a diff. Every port's payload codegen is **declared-type-authoritative +(#270)**: a field's generated type comes only from its declared `field.` ++ `isArray` + `@objectRef`, and a nested payload is a declared `field.object +@objectRef` to another `object.value` (`isArray: true` for a list). An `origin.*` +child on a payload field is **ignored for typing** — it never changes the +generated type, nullability, or the nested-payload set. The caller supplies the +field values at render time. -| Origin | Behavior | -|---|---| -| `origin.passthrough @from "Entity.field"` | Payload property type matches the source field | -| `origin.aggregate @agg ` | `count` → `Long`; `avg` → `Double`; others match source field | -| `origin.collection @via "Parent.rel"` | `List` — assembled from a relationship | - -These are the payload-assembly origins. **Projection** read models (`object.projection` over an -entity) carry a fuller origin vocabulary — the `@agg` quantifiers `any` / `all` and the `collect` -array rollup, plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (#195) — see -[source-kinds.md](source-kinds.md). +Derivation and assembly belong to **projection** read models (`object.projection` +over an entity), which carry the origin vocabulary — `origin.passthrough`, +`origin.aggregate` (incl. the `any` / `all` quantifiers and the `collect` array +rollup), `origin.computed` (a closed `@expr` grammar) and `origin.first` (#195) — +see [source-kinds.md](source-kinds.md). ## Authoring @@ -73,13 +75,10 @@ their post count + the first 3 post titles. "object.value": { "name": "WelcomePayload", "children": [ - { "field.string": { "name": "displayName", - "children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } }, - { "field.long": { "name": "postCount", - "children": [ { "origin.aggregate": { - "@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }, + { "field.string": { "name": "displayName" } }, + { "field.long": { "name": "postCount" } }, { "field.object": { "name": "posts", "@objectRef": "PostSummary", - "children": [ { "origin.collection": { "@via": "Author.posts" } } ] } } + "isArray": true } } ] } }, @@ -87,8 +86,7 @@ their post count + the first 3 post titles. "object.value": { "name": "PostSummary", "children": [ - { "field.string": { "name": "title", - "children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } } + { "field.string": { "name": "title" } } ] } }, @@ -117,28 +115,18 @@ metadata: children: - field.string: name: displayName - children: - - origin.passthrough: { from: Author.name } - field.long: name: postCount - children: - - origin.aggregate: - agg: count - of: Post.id - via: Author.posts - field.object: name: posts objectRef: PostSummary - children: - - origin.collection: { via: Author.posts } + isArray: true - object.value: name: PostSummary children: - field.string: name: title - children: - - origin.passthrough: { from: Post.title } - template.prompt: name: WelcomePrompt @@ -214,8 +202,8 @@ const out: string = await render({ `metaobjects-render` ships `Renderer` + `Provider` (Classpath, Filesystem, InMemory) + `Verify`. `SpringPayloadGenerator` (in `metaobjects-codegen-spring`) -emits a Java 21 `record` payload per template, resolving all three origin -subtypes (matches the Kotlin reference). Host code may also pass a +emits a Java 21 `record` payload per template, typing every component from its +declared field (#270; matches the Kotlin reference). Host code may also pass a `Map` to the renderer if it doesn't want the generated type. ```java @@ -224,7 +212,7 @@ import com.metaobjects.render.*; Provider provider = new FilesystemProvider(Path.of("./prompts")); String out = Renderer.render(RenderRequest.builder() .ref("lobby/welcome") - .payload(new WelcomePayload("Ada", 12L, List.of(new PostSummary("Hello")))) + .payload(new WelcomePromptPayload("Ada", 12L, List.of(new PostSummaryPayload("Hello")))) .provider(provider) .format("xml") .build()); @@ -235,17 +223,18 @@ String out = Renderer.render(RenderRequest.builder() public record WelcomePromptPayload( String displayName, Long postCount, - java.util.List posts + java.util.List posts ) {} -public record PostSummary(String title) {} +// generated/acme/blog/prompts/PostSummaryPayload.java +public record PostSummaryPayload(String title) {} ``` ### Kotlin `metaobjects-metadata-ktx` wraps `Renderer` in an idiomatic Kotlin builder. `KotlinPayloadGenerator` (in `codegen-kotlin`) emits a `@Serializable` payload data -class per template, resolving all three origin subtypes. +class per template, typing every property from its declared field (#270). ```kotlin import com.metaobjects.metadata.ktx.render @@ -254,10 +243,10 @@ import java.nio.file.Path val out = render { ref = "lobby/welcome" - payload = WelcomePayload( + payload = WelcomePromptPayload( displayName = "Ada", postCount = 12, - posts = listOf(PostSummary("Hello")), + posts = listOf(PostSummaryPayload("Hello")), ) provider = FilesystemProvider(Path.of("./prompts")) format = "xml" @@ -265,44 +254,55 @@ val out = render { ``` ```kotlin -// generated/acme/blog/WelcomePromptPayload.kt +// generated/acme/blog/prompts/WelcomePromptPayload.kt @Serializable -data class WelcomePayload( +data class WelcomePromptPayload( val displayName: String, val postCount: Long, - val posts: List, + val posts: List, ) +// generated/acme/blog/prompts/PostSummaryPayload.kt @Serializable -data class PostSummary(val title: String) +data class PostSummaryPayload(val title: String) ``` ### C# `MetaObjects.Render` ships the render engine + verify. `MetaObjects.Codegen` -ships payload-VO codegen. +ships payload-VO codegen for **`template.output`** parse targets — the strict +record is named after the **value object**, not the template (for this model: +`record WelcomePayload` / `record PostSummary`), with `required` init-only +properties named verbatim after the metadata fields (`displayName`, `postCount`, +`posts`). Nothing is emitted for a `template.prompt`; its render payload is a +plain object/array graph you supply: ```csharp using MetaObjects.Render; var provider = new FilesystemProvider("./prompts"); -var payload = new WelcomePayload( - DisplayName: "Ada", - PostCount: 12, - Posts: new[] { new PostSummary("Hello") }); - -string output = Renderer.Render(new RenderRequest( - Ref: "lobby/welcome", - Payload: payload, - Provider: provider, - Format: "xml")); +var payload = new Dictionary +{ + ["displayName"] = "Ada", + ["postCount"] = 12, + ["posts"] = new[] { new Dictionary { ["title"] = "Hello" } }, +}; + +string output = Renderer.Render(new RenderRequest +{ + Ref = "lobby/welcome", + Payload = payload, + Provider = provider, + Format = "xml", +}); ``` ### Python `metaobjects.render` ships the Mustache engine + `Verify`. The Python loader recognizes `template.*` + `origin.*`. Payload-VO codegen **is** emitted (the -`payload` generator emits a Pydantic `BaseModel` per template, origin-aware — see +`payload` generator emits a Pydantic `BaseModel` per template, typed from the +declared fields (#270) — see [Output parsing (FR-006)](#output-parsing-fr-006)), so a consumer can render from the generated payload type or from a plain `dict`. @@ -491,13 +491,14 @@ The following conformance fixtures gate this feature's behavior across ports: - [`fixtures/conformance/error-template-prompt-missing-payload-ref/`](../../fixtures/conformance/error-template-prompt-missing-payload-ref/) — `template.prompt` requires `@payloadRef` - [`fixtures/conformance/error-template-required-slot-missing/`](../../fixtures/conformance/error-template-required-slot-missing/) — required slot declarations are checked -**Payload origins (`origin.*`)** +**Origins (`origin.*`) — loader vocabulary** (declares derivation lineage; ignored +for payload typing per #270) - [`fixtures/conformance/origin-passthrough-simple/`](../../fixtures/conformance/origin-passthrough-simple/) — `origin.passthrough` cross-entity field reference - [`fixtures/conformance/origin-aggregate-count/`](../../fixtures/conformance/origin-aggregate-count/) — `origin.aggregate @agg=count` - [`fixtures/conformance/origin-aggregate-sum/`](../../fixtures/conformance/origin-aggregate-sum/) — `origin.aggregate @agg=sum` - [`fixtures/conformance/origin-multi-level-via/`](../../fixtures/conformance/origin-multi-level-via/) — dotted-path `@via` traversal across hops -- [`fixtures/conformance/origin-collection-simple/`](../../fixtures/conformance/origin-collection-simple/) — `origin.collection` for repeated-row payloads +- [`fixtures/conformance/origin-collection-simple/`](../../fixtures/conformance/origin-collection-simple/) — `origin.collection` loads on a repeated-row shape - [`fixtures/conformance/error-origin-bad-via-path/`](../../fixtures/conformance/error-origin-bad-via-path/) — unresolvable `@via` rejected - [`fixtures/conformance/error-origin-bad-aggregate-fn/`](../../fixtures/conformance/error-origin-bad-aggregate-fn/) — unknown `@agg` rejected - [`fixtures/conformance/error-origin-passthrough-type-mismatch/`](../../fixtures/conformance/error-origin-passthrough-type-mismatch/) — a `passthrough` field whose `field.` differs from its `@from` source fails with `ERR_PASSTHROUGH_TYPE_MISMATCH` diff --git a/docs/ports/python.md b/docs/ports/python.md index 7d71f1049..482e7770b 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -262,8 +262,10 @@ Two generators ship together for the full prompt+parse story: - `payload_vo_generator` emits one `_payload.py` per declared `template.*` (prompt / output / toolcall) — a Pydantic v2 `Payload` - `BaseModel` resolving all three origin subtypes (`passthrough` / `aggregate` / - `collection`). Mirrors the Kotlin reference shape. + `BaseModel` typed from the DECLARED fields only (#270 — any `origin.*` child a + payload field carries is ignored for typing; a nested payload is a declared + `field.object @objectRef` to another `object.value`). Mirrors the Kotlin + reference shape. - `output_parser_generator` emits one `_output_parser.py` per `template.output`, importing the payload class from the sibling payload module. @@ -326,8 +328,8 @@ design is at [ADR-0010](../../spec/decisions/ADR-0010-template-output-parser-cod the feature reference is at [`features/templates-and-payloads.md`](../features/templates-and-payloads.md#output-parsing-fr-006). -**Per-file dedupe note.** When `origin.collection` references the same nested -target across two templates, each template's payload file contains its own +**Per-file dedupe note.** When two templates' payloads reference the same nested +`field.object @objectRef` target, each template's payload file contains its own copy of the nested class (per-file, not per-run dedupe). This differs from Kotlin's cross-run dedupe (KotlinPoet → one class per `.kt` file). The Python choice keeps each generated payload module self-contained — see the @@ -351,7 +353,7 @@ import lines are stable. | Source kinds (table / view / storedProc) | Loader-level yes; codegen for non-`table` kinds is in progress | | `field.currency` / `field.enum` / `field.object` + `@storage` | Loader-level yes; codegen for `field.object` `flattened` storage is in progress | | Templates + render (FR-004) | Yes (`metaobjects.render`) | -| Payload-VO codegen | Yes (`payload_vo_generator` — Pydantic v2 `BaseModel` per template, origin-aware) | +| Payload-VO codegen | Yes (`payload_vo_generator` — Pydantic v2 `BaseModel` per template, declared-type-authoritative per #270) | | Output parser codegen (FR-006) | Yes (`output_parser_generator` — Pydantic throw-only; imports the payload class from the sibling payload module) | | Declarative template-codegen | Yes — `metaobjects gen --template-spec` (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with C#) | | Migrations | TS-only by design (ADR-0015) — no Python `migrate` command; consume the canonical `schema.postgres.sql` | diff --git a/docs/superpowers/plans/2026-08-06-projection-payload-vocab-batch.md b/docs/superpowers/plans/2026-08-06-projection-payload-vocab-batch.md index d47447313..f2cf33414 100644 --- a/docs/superpowers/plans/2026-08-06-projection-payload-vocab-batch.md +++ b/docs/superpowers/plans/2026-08-06-projection-payload-vocab-batch.md @@ -8,11 +8,14 @@ ## STATUS — update as you go (edit this file, commit the checkbox flips with the work) -- [ ] Phase 0 — setup, premise recon -- [ ] Unit A (#270) — Python: payload typing declared-authoritative -- [ ] Unit A (#270) — Kotlin: payload typing declared-authoritative -- [ ] Unit A (#270) — docs closure (CLAUDE.md open question, KNOWN_GAPS, roadmap) -- [ ] Unit A — independent review + merge to `main` + local-ci green +- [x] Phase 0 — setup, premise recon — **DONE 2026-08-06** +- [x] Unit A (#270) — Python: payload typing declared-authoritative — **DONE** (`c5ad0802`) +- [x] Unit A (#270) — Kotlin: payload typing declared-authoritative — **DONE** (`c5ad0802`) +- [x] Unit A (#270) — **Java: payload typing declared-authoritative — SCOPE ADDITION, see A.0 NOTE** (`2d67696d`) +- [x] Unit A (#270) — Java honors declared `@isArray` on plain scalars (`34705ce9`) +- [x] Unit A (#270) — docs closure (CLAUDE.md open question, KNOWN_GAPS, roadmap) + doc-truth wave (`608f4217`) +- [x] Unit A — independent review (review → adjudication → 2 fix rounds → final review → re-review): **SHIP** +- [ ] Unit A — merge to `main` + local-ci green - [ ] Release 1 — `0.20.16` / `7.20.16` coordinated PATCH (checkpoint with the maintainer first) - [ ] Unit B — doctrine docs amendment (ADR-0007 / FR-024 §7) — docs-only - [x] Unit C — Gate 0 adopter scan — **CLEARED 2026-08-06** (see C.0) @@ -79,7 +82,11 @@ Read the rulings in `spec/roadmap.md` (grep `"#210 — \[RULED"`, `"#212 — \[R ## Unit A — #270: payload typing is declared-type-authoritative (Kotlin + Python) -**Ruling:** TS / C# / Java payload emitters are origin-blind and **correct**. Kotlin and Python derive payload field types from `origin.*`; on `origin.collection` they discard the field's declared `@objectRef` and substitute the `@via` relationship's target entity — a declared curated VO silently becomes the full entity, defeating the payload-bloat contract. `@agg count` is hardwired to a long type regardless of a declared `field.int`. Delete the origin dispatch **including** the `origin.collection` edge in each port's ADR-0044 name-map closure and the extract tier that shares the name map (#228) — lockstep per port. Nullability falls back to declared `@required`. +> **NOTE (2026-08-06, executed) — THIS RULING'S PREMISE WAS FALSIFIED FOR JAVA.** `SpringPayloadGenerator` was **also** origin-aware (imports at L12–16, the contract in its Javadoc at L63–75, dispatch at L453–461, `@agg count`→`Long` at L531–532, the `@via` walk at L552, plus a `CollectionOrigin` edge in the #228-shared name-map closure at L255–272 with **no** subtype filter). Only **TS and C#** were genuinely origin-blind. Phase 0's recon missed it because this environment's `grep` wrapper passes `-I` (skip binary) and the file contained **raw NUL bytes** in string literals, so the search silently returned nothing — see the tooling note below. **Maintainer ruled: fix Java too**, since #210 makes payloads projections and projections legitimately carry assembly origins, so the dispatch would fire on the shape that becomes the norm. Java converged in `2d67696d`; the NUL bytes became `"\0"` escapes in the same commit. **Also falsified: "nullability falls back to declared `@required`"** — only TS and Python read `@required` at all; Kotlin, Java and C# never did. The accurate contract is *"nullability is never derived from origin semantics."* +> +> **Tooling note for every future unit:** `grep` in this environment is a shell function shadowing `/usr/bin/grep` that skips binary-looking files **silently** (no output, exit 1). A file with a raw NUL byte is invisible to it. Use `git grep -- ` or `command grep` for load-bearing claims, and treat empty output from a command that must print something as a broken command, never a negative result. The one remaining NUL-bearing file, `server/typescript/packages/metadata/src/constraint-merge.ts` L72, is fixed in Unit C §7b. + +**Ruling (as written 2026-08-05, corrected by the NOTE above):** TS / C# / Java payload emitters are origin-blind and **correct**. Kotlin and Python derive payload field types from `origin.*`; on `origin.collection` they discard the field's declared `@objectRef` and substitute the `@via` relationship's target entity — a declared curated VO silently becomes the full entity, defeating the payload-bloat contract. `@agg count` is hardwired to a long type regardless of a declared `field.int`. Delete the origin dispatch **including** the `origin.collection` edge in each port's ADR-0044 name-map closure and the extract tier that shares the name map (#228) — lockstep per port. Nullability falls back to declared `@required`. ### A.0 Premise recon (stop if any fails) diff --git a/docs/superpowers/specs/2026-05-25-codegen-kotlin-design.md b/docs/superpowers/specs/2026-05-25-codegen-kotlin-design.md index dd04e9740..ff8163a7b 100644 --- a/docs/superpowers/specs/2026-05-25-codegen-kotlin-design.md +++ b/docs/superpowers/specs/2026-05-25-codegen-kotlin-design.md @@ -141,9 +141,12 @@ data class WelcomePromptPayload( - Class name = `Payload` - Package = entity-package + `.prompts` (kept separate from entity namespace) -- For `origin.collection` children → `List` with `NestedPayload` recursively generated -- For `origin.aggregate count/sum/avg/min/max` → numeric type from the agg semantics -- For `origin.passthrough` → typed prop matching the source field +- **Typing is declared-type-authoritative (#270):** a payload field's type comes + only from its declared `field.` + `isArray` + `@objectRef`; an + `origin.*` child is ignored for typing (the caller supplies the values at render + time). This superseded the original origin-based typing rules (`collection` → + `List`, `aggregate` → agg-derived numeric, `passthrough` → source + type) — see [`docs/features/templates-and-payloads.md`](../../features/templates-and-payloads.md). - Shared payload-VO referenced by multiple templates → generated once, imported by all ### 4.4 `KotlinValidatorGenerator` — runtime startup validation diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md index 40eeb876d..514735dc1 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -50,27 +50,22 @@ LLM tool-call envelope with no renderable text body (the body IS the `@format` attrs above). The vocabulary exists today; MCP exposure of declared prompts/tools is roadmap, not shipped — don't promise it. -## The payload is an `object.value` projection - -The payload is **not** an entity — it's an `object.value` whose every field carries -an `origin.*` child saying where its value comes from. Three origin subtypes: - -| Origin | Behavior | -|---|---| -| `origin.passthrough @from "Entity.field"` | payload property matches the source field | -| `origin.aggregate @agg ` | `count`→long, `avg`→double, others match source | -| `origin.collection @via "Parent.rel"` | a list of a nested payload, assembled from a relationship | - -These are the **payload-assembly** origins — the vocabulary this skill covers. -**Projection** read models (`object.projection` over an entity) carry a fuller origin -vocabulary — the `@agg` predicate quantifiers `any`/`all`, the `collect` array rollup, -plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (an argmax-style -pick) — those live in the `metaobjects-authoring` skill and -`docs/features/source-kinds.md`, not here: don't reach for them on a payload VO. - -Declaring the payload as a projection is what makes payload bloat visible: adding a -field to the prompt is a diff on the `object.value`, and a renamed source field -breaks the build instead of silently degrading the prompt. +## The payload is an `object.value` you declare + +The payload is **not** an entity — it's an `object.value` whose DECLARED fields ARE +the prompt's typed shape. Every port's payload codegen is +**declared-type-authoritative (#270)**: a field's generated type comes only from its +declared `field.` + `isArray` + `@objectRef`, and a nested payload is a +declared `field.object @objectRef` to another `object.value` (`isArray: true` for a +list). The caller supplies the field values at render time. An `origin.*` child on a +payload field is IGNORED for typing — never author assembly origins (`aggregate` / +`collection` / `computed` / `first`) on a payload VO. Derivation belongs to +**projection** read models (`object.projection` over an entity), covered by the +`metaobjects-authoring` skill and `docs/features/source-kinds.md`, not here. + +Declaring the payload shape is what makes payload bloat visible: adding a field to +the prompt is a diff on the `object.value`, and `verify` catches template/payload +drift at build time instead of letting a prompt silently degrade. ```json { @@ -81,12 +76,10 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "WelcomePayload", "children": [ - { "field.string": { "name": "displayName", - "children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } }, - { "field.long": { "name": "postCount", - "children": [ { "origin.aggregate": { "@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }, + { "field.string": { "name": "displayName" } }, + { "field.long": { "name": "postCount" } }, { "field.object": { "name": "posts", "@objectRef": "PostSummary", - "children": [ { "origin.collection": { "@via": "Author.posts" } } ] } } + "isArray": true } } ] } }, @@ -94,8 +87,7 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "PostSummary", "children": [ - { "field.string": { "name": "title", - "children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } } + { "field.string": { "name": "title" } } ] } }, diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md index 40eeb876d..514735dc1 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -50,27 +50,22 @@ LLM tool-call envelope with no renderable text body (the body IS the `@format` attrs above). The vocabulary exists today; MCP exposure of declared prompts/tools is roadmap, not shipped — don't promise it. -## The payload is an `object.value` projection - -The payload is **not** an entity — it's an `object.value` whose every field carries -an `origin.*` child saying where its value comes from. Three origin subtypes: - -| Origin | Behavior | -|---|---| -| `origin.passthrough @from "Entity.field"` | payload property matches the source field | -| `origin.aggregate @agg ` | `count`→long, `avg`→double, others match source | -| `origin.collection @via "Parent.rel"` | a list of a nested payload, assembled from a relationship | - -These are the **payload-assembly** origins — the vocabulary this skill covers. -**Projection** read models (`object.projection` over an entity) carry a fuller origin -vocabulary — the `@agg` predicate quantifiers `any`/`all`, the `collect` array rollup, -plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (an argmax-style -pick) — those live in the `metaobjects-authoring` skill and -`docs/features/source-kinds.md`, not here: don't reach for them on a payload VO. - -Declaring the payload as a projection is what makes payload bloat visible: adding a -field to the prompt is a diff on the `object.value`, and a renamed source field -breaks the build instead of silently degrading the prompt. +## The payload is an `object.value` you declare + +The payload is **not** an entity — it's an `object.value` whose DECLARED fields ARE +the prompt's typed shape. Every port's payload codegen is +**declared-type-authoritative (#270)**: a field's generated type comes only from its +declared `field.` + `isArray` + `@objectRef`, and a nested payload is a +declared `field.object @objectRef` to another `object.value` (`isArray: true` for a +list). The caller supplies the field values at render time. An `origin.*` child on a +payload field is IGNORED for typing — never author assembly origins (`aggregate` / +`collection` / `computed` / `first`) on a payload VO. Derivation belongs to +**projection** read models (`object.projection` over an entity), covered by the +`metaobjects-authoring` skill and `docs/features/source-kinds.md`, not here. + +Declaring the payload shape is what makes payload bloat visible: adding a field to +the prompt is a diff on the `object.value`, and `verify` catches template/payload +drift at build time instead of letting a prompt silently degrade. ```json { @@ -81,12 +76,10 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "WelcomePayload", "children": [ - { "field.string": { "name": "displayName", - "children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } }, - { "field.long": { "name": "postCount", - "children": [ { "origin.aggregate": { "@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }, + { "field.string": { "name": "displayName" } }, + { "field.long": { "name": "postCount" } }, { "field.object": { "name": "posts", "@objectRef": "PostSummary", - "children": [ { "origin.collection": { "@via": "Author.posts" } } ] } } + "isArray": true } } ] } }, @@ -94,8 +87,7 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "PostSummary", "children": [ - { "field.string": { "name": "title", - "children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } } + { "field.string": { "name": "title" } } ] } }, diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md index 40eeb876d..514735dc1 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -50,27 +50,22 @@ LLM tool-call envelope with no renderable text body (the body IS the `@format` attrs above). The vocabulary exists today; MCP exposure of declared prompts/tools is roadmap, not shipped — don't promise it. -## The payload is an `object.value` projection - -The payload is **not** an entity — it's an `object.value` whose every field carries -an `origin.*` child saying where its value comes from. Three origin subtypes: - -| Origin | Behavior | -|---|---| -| `origin.passthrough @from "Entity.field"` | payload property matches the source field | -| `origin.aggregate @agg ` | `count`→long, `avg`→double, others match source | -| `origin.collection @via "Parent.rel"` | a list of a nested payload, assembled from a relationship | - -These are the **payload-assembly** origins — the vocabulary this skill covers. -**Projection** read models (`object.projection` over an entity) carry a fuller origin -vocabulary — the `@agg` predicate quantifiers `any`/`all`, the `collect` array rollup, -plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (an argmax-style -pick) — those live in the `metaobjects-authoring` skill and -`docs/features/source-kinds.md`, not here: don't reach for them on a payload VO. - -Declaring the payload as a projection is what makes payload bloat visible: adding a -field to the prompt is a diff on the `object.value`, and a renamed source field -breaks the build instead of silently degrading the prompt. +## The payload is an `object.value` you declare + +The payload is **not** an entity — it's an `object.value` whose DECLARED fields ARE +the prompt's typed shape. Every port's payload codegen is +**declared-type-authoritative (#270)**: a field's generated type comes only from its +declared `field.` + `isArray` + `@objectRef`, and a nested payload is a +declared `field.object @objectRef` to another `object.value` (`isArray: true` for a +list). The caller supplies the field values at render time. An `origin.*` child on a +payload field is IGNORED for typing — never author assembly origins (`aggregate` / +`collection` / `computed` / `first`) on a payload VO. Derivation belongs to +**projection** read models (`object.projection` over an entity), covered by the +`metaobjects-authoring` skill and `docs/features/source-kinds.md`, not here. + +Declaring the payload shape is what makes payload bloat visible: adding a field to +the prompt is a diff on the `object.value`, and `verify` catches template/payload +drift at build time instead of letting a prompt silently degrade. ```json { @@ -81,12 +76,10 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "WelcomePayload", "children": [ - { "field.string": { "name": "displayName", - "children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } }, - { "field.long": { "name": "postCount", - "children": [ { "origin.aggregate": { "@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }, + { "field.string": { "name": "displayName" } }, + { "field.long": { "name": "postCount" } }, { "field.object": { "name": "posts", "@objectRef": "PostSummary", - "children": [ { "origin.collection": { "@via": "Author.posts" } } ] } } + "isArray": true } } ] } }, @@ -94,8 +87,7 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "PostSummary", "children": [ - { "field.string": { "name": "title", - "children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } } + { "field.string": { "name": "title" } } ] } }, diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md index 40eeb876d..514735dc1 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -50,27 +50,22 @@ LLM tool-call envelope with no renderable text body (the body IS the `@format` attrs above). The vocabulary exists today; MCP exposure of declared prompts/tools is roadmap, not shipped — don't promise it. -## The payload is an `object.value` projection - -The payload is **not** an entity — it's an `object.value` whose every field carries -an `origin.*` child saying where its value comes from. Three origin subtypes: - -| Origin | Behavior | -|---|---| -| `origin.passthrough @from "Entity.field"` | payload property matches the source field | -| `origin.aggregate @agg ` | `count`→long, `avg`→double, others match source | -| `origin.collection @via "Parent.rel"` | a list of a nested payload, assembled from a relationship | - -These are the **payload-assembly** origins — the vocabulary this skill covers. -**Projection** read models (`object.projection` over an entity) carry a fuller origin -vocabulary — the `@agg` predicate quantifiers `any`/`all`, the `collect` array rollup, -plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (an argmax-style -pick) — those live in the `metaobjects-authoring` skill and -`docs/features/source-kinds.md`, not here: don't reach for them on a payload VO. - -Declaring the payload as a projection is what makes payload bloat visible: adding a -field to the prompt is a diff on the `object.value`, and a renamed source field -breaks the build instead of silently degrading the prompt. +## The payload is an `object.value` you declare + +The payload is **not** an entity — it's an `object.value` whose DECLARED fields ARE +the prompt's typed shape. Every port's payload codegen is +**declared-type-authoritative (#270)**: a field's generated type comes only from its +declared `field.` + `isArray` + `@objectRef`, and a nested payload is a +declared `field.object @objectRef` to another `object.value` (`isArray: true` for a +list). The caller supplies the field values at render time. An `origin.*` child on a +payload field is IGNORED for typing — never author assembly origins (`aggregate` / +`collection` / `computed` / `first`) on a payload VO. Derivation belongs to +**projection** read models (`object.projection` over an entity), covered by the +`metaobjects-authoring` skill and `docs/features/source-kinds.md`, not here. + +Declaring the payload shape is what makes payload bloat visible: adding a field to +the prompt is a diff on the `object.value`, and `verify` catches template/payload +drift at build time instead of letting a prompt silently degrade. ```json { @@ -81,12 +76,10 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "WelcomePayload", "children": [ - { "field.string": { "name": "displayName", - "children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } }, - { "field.long": { "name": "postCount", - "children": [ { "origin.aggregate": { "@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }, + { "field.string": { "name": "displayName" } }, + { "field.long": { "name": "postCount" } }, { "field.object": { "name": "posts", "@objectRef": "PostSummary", - "children": [ { "origin.collection": { "@via": "Author.posts" } } ] } } + "isArray": true } } ] } }, @@ -94,8 +87,7 @@ breaks the build instead of silently degrading the prompt. "object.value": { "name": "PostSummary", "children": [ - { "field.string": { "name": "title", - "children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } } + { "field.string": { "name": "title" } } ] } }, diff --git a/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs index 2d88f3b47..62a68fd7c 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs @@ -156,4 +156,61 @@ public void Collision_within_one_payloadRefs_closure_emits_two_distinct_qualifie Assert.Contains("public required AcmeBetaNote fromBeta { get; init; }", file.Content); Assert.DoesNotContain("public sealed record Note", file.Content); } + + // #270 reference-emitter pin — C# is one of the two genuinely origin-blind REFERENCE + // payload emitters the Kotlin / Python / Java ports converged on, but nothing gated + // that: this pins that an `origin.*` child on a payload VO field is IGNORED for + // typing — the declared `field.` + `isArray` + `@objectRef` win — so a + // future change cannot drift this reference into origin dispatch (the drift that + // let three ports diverge in the first place). Test-only: product code untouched. + [Fact] + public void Origin_children_are_ignored_for_typing_declared_type_wins() + { + const string m = """ + { "metadata.root": { "package": "acme::ai", "children": [ + { "object.entity": { "name": "Source", "children": [ + { "field.string": { "name": "displayName" } } + ]}}, + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "posts", "@objectRef": "Post", "@cardinality": "many" } } + ]}}, + { "object.entity": { "name": "Post", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "internalNotes" } } + ]}}, + { "object.value": { "name": "Highlight", "children": [ + { "field.string": { "name": "snippet" } } + ]}}, + { "object.value": { "name": "Digest", "children": [ + { "field.int": { "name": "alias", "children": [ + { "origin.passthrough": { "@from": "Source.displayName", "@convert": true } } + ]}}, + { "field.string": { "name": "summary", "children": [ + { "origin.collection": { "@via": "Author.posts" } } + ]}}, + { "field.object": { "name": "posts", "@objectRef": "Highlight", "isArray": true, "children": [ + { "origin.collection": { "@via": "Author.posts" } } + ]}} + ]}}, + { "template.output": { "name": "DigestDoc", + "@payloadRef": "Digest", "@textRef": "ai/digest", "@format": "json" } } + ]}} + """; + var files = new PayloadGenerator().Generate(Ctx(Load(m))).ToList(); + var file = Assert.Single(files); + Assert.Equal("Digest.payload.cs", file.Path); + // Declared `field.int` wins over the (`@convert`-acknowledged) string passthrough. + Assert.Contains("public required int alias { get; init; }", file.Content); + Assert.DoesNotContain("string alias", file.Content); + // Declared `field.string` wins over origin.collection — no list, no via-target type. + Assert.Contains("public required string summary { get; init; }", file.Content); + // Declared `field.object @objectRef` + isArray wins over the disagreeing @via walk. + Assert.Contains("public required IReadOnlyList posts { get; init; }", file.Content); + Assert.Contains("public sealed record Highlight", file.Content); + Assert.Contains("public required string snippet { get; init; }", file.Content); + // The ignored @via entity never enters the closure. + Assert.DoesNotContain("record Post", file.Content); + Assert.DoesNotContain("internalNotes", file.Content); + } } diff --git a/server/java/codegen-kotlin/KNOWN_GAPS.md b/server/java/codegen-kotlin/KNOWN_GAPS.md index 82f180edd..ad5dc187d 100644 --- a/server/java/codegen-kotlin/KNOWN_GAPS.md +++ b/server/java/codegen-kotlin/KNOWN_GAPS.md @@ -99,34 +99,22 @@ flagging needs metadata-level expression (an `@updateRequired` attr or similar) that hasn't been settled cross-port; a follow-up FR can add the typed split once the partial-update story converges. -## `EnumField` on payload VOs emitted as `String` - -**Status:** Day-1 fallback on the payload-VO codepath only. Entities and Exposed columns already emit typed enum classes. - -[`KotlinTypeMapper.kt:126`](src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt#L126) -maps `EnumField` to `STRING` in `kotlinTypeName(...)`, which is the type -function `KotlinPayloadGenerator` calls when building each payload-VO -data-class property. The resulting payload class therefore exposes the -enum as a `String` — the wire-stable shape for kotlinx.serialization JSON -output. The deferral comment at lines 123-125 explains the rationale and -points at the cross-port enum-design spec -[`docs/superpowers/specs/2026-05-23-enum-datatype-design.md`](../../../docs/superpowers/specs/2026-05-23-enum-datatype-design.md). - -**What's NOT gappy** (corrects a common misread): - -- Entity data classes already emit typed enum classes — - [`KotlinEntityGenerator.kt:79`](src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt#L79) - emits a top-level `@Serializable enum class` per `field.enum` before the - data class, and the field is typed as that enum. -- Exposed table columns already emit typed `enumerationByName(...)` — - [`KotlinExposedTableGenerator.kt:229`](src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt#L229) - uses the generated enum class via `KotlinTypeMapper.enumTypeName(field, entity)`. - -So the only path still using `String` is the payload-VO. Migrating it to -the typed enum class is a small generator change — gated on the cross-port -enum-design spec settling the wire-vs-Kotlin representation question (does -the JSON wire stay string-valued? Use `@SerialName` per member? Use an -int-backed `@values` form?). +## `EnumField` on payload VOs emitted as `String` (RESOLVED) + +**Status:** RESOLVED — the payload-VO codepath now emits the typed enum class too, +so this former Day-1 gap is closed on every path. + +`KotlinPayloadGenerator.resolveFieldType`'s `field.enum` arm types the STRICT +payload property as the generated enum class +(`KotlinTypeMapper.enumTypeName(field, owner)`; single → ``, array → +`List<>`) and emits the enum file per run via `KotlinEnumEmitter`. The +lenient `Extracted` mirror deliberately stays `String` / `List` +(the extract mapper bridges `String` → enum via `valueOf`). Entity data classes +([`KotlinEntityGenerator.kt`](src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt)) +and Exposed columns +([`KotlinExposedTableGenerator.kt`](src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt)) +were typed all along. Kept as a resolved entry (rather than deleted) because +external notes referenced this section by title. ## Composite-FK relationships not emitted @@ -155,3 +143,15 @@ specified the URL grammar for nested-jsonb sort. Materializing nested jsonb on read needs an `@Contextual` kotlinx.serialization round-trip plus a column-type wiring; both ship as a unit when a real consumer needs the path. + +## `KotlinGenUtil.splitDottedRef` has zero in-repo callers + +**Status:** deliberately kept, not dead code — recorded here so it is not later +rediscovered as live. + +#270 (payload typing is declared-type-authoritative) deleted the payload +generator's `origin.*` dotted-ref navigation, which was the last in-repo caller +of [`KotlinGenUtil.splitDottedRef`](src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt). +The helper stays because `KotlinGenUtil` is deliberately `public` for adopters +subclassing a generator (see its class KDoc) — removing a public helper is an +API break out of proportion to the cleanup. Prune it in a future MAJOR. diff --git a/server/java/codegen-kotlin/README.md b/server/java/codegen-kotlin/README.md index 628421203..167a496b7 100644 --- a/server/java/codegen-kotlin/README.md +++ b/server/java/codegen-kotlin/README.md @@ -67,17 +67,19 @@ fun AuthorTable.postsQuery(authorId: Long): Query = so consumers can write `AuthorTable.postsQuery(author.id).toList()` (or chain `.orderBy(...)` / `.limit(...)` first). One helper fn per to-many composition; the file is skipped entirely for entities with no to-many relationships. -## FR-004 payload origins - -`KotlinPayloadGenerator` resolves all three origin subtypes on payload-VO fields: - -| Origin | Behavior | -|---|---| -| `origin.passthrough @from "Entity.field"` | payload property type = source field's type | -| `origin.aggregate @agg count` | `Long` (regardless of `@of`) | -| `origin.aggregate @agg avg` | `Double` | -| `origin.aggregate @agg sum\|min\|max` | matches source field's type | -| `origin.collection @via "Parent.rel"` | `List`; nested payload class generated recursively + deduplicated | +## FR-004 payload codegen + +`KotlinPayloadGenerator` emits a `@Serializable` payload data class per +`template.*`, typing every property from its **declared field only** (#270 — +declared-type-authoritative): a property's type comes from the field's +`field.` + `isArray`, and a nested payload is a declared `field.object +@objectRef` to another `object.value` (`isArray: true` → a `List<…>`). The +caller supplies the field values at render time; an `origin.*` child on a +payload field is ignored for typing (derivation/assembly origins live on +`object.projection` read models, not payload VOs). Nested payload classes are +generated recursively and deduplicated per run. See +[`docs/features/templates-and-payloads.md`](../../../docs/features/templates-and-payloads.md) +for the cross-port contract and a worked example. ## Wiring in your `pom.xml` 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 03795a4d0..c55f69450 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 @@ -10,7 +10,6 @@ import com.metaobjects.generator.GeneratorException import com.metaobjects.loader.MetaDataLoader import com.metaobjects.`object`.MetaObject import com.metaobjects.origin.AggregateOrigin -import com.metaobjects.origin.CollectionOrigin import com.metaobjects.origin.MetaOrigin import com.metaobjects.source.RdbSource import com.metaobjects.template.MetaTemplate @@ -321,7 +320,7 @@ public object KotlinGenUtil { // ADR-0042 — resolve @payloadRef under the loader's own package-local contract. val vo = resolveValueObjectRef(loader, payloadRef, tmpl.getPackage()) ?: continue val nestedPkg = KotlinNaming.promptsPackage(PackageMapping.splitFqn(tmpl.name).first) - collectNestedClosure(vo, loader, nestedPkg, voOutPkg, orderedFqns, mutableSetOf(vo.name)) + collectNestedClosure(vo, nestedPkg, voOutPkg, orderedFqns, mutableSetOf(vo.name)) } // Group by (output package, bare short name). val byPkgShort = LinkedHashMap>() @@ -359,52 +358,40 @@ public object KotlinGenUtil { } /** - * ADR-0044 pass 1 — walk [vo]'s transitive nested-payload closure (plain - * `field.object @objectRef` + `origin.collection @via` edges), assigning each - * not-yet-seen target VO to [outPkg] (first reaching template wins) and recording it - * in [orderedFqns]. [seen] is seeded with the primary VO's FQN and is the cycle guard. + * ADR-0044 pass 1 — walk [vo]'s transitive nested-payload closure (declared + * `field.object @objectRef` edges ONLY, #270), assigning each not-yet-seen target VO + * to [outPkg] (first reaching template wins) and recording it in [orderedFqns]. + * [seen] is seeded with the primary VO's FQN and is the cycle guard. */ private fun collectNestedClosure( vo: MetaObject, - loader: MetaDataLoader, outPkg: String, voOutPkg: MutableMap, orderedFqns: MutableList, seen: MutableSet, ) { for (field in vo.metaFields) { - val target = nestedTargetOf(field, loader) ?: continue + val target = nestedTargetOf(field) ?: continue val fqn = target.name if (!seen.add(fqn)) continue if (!voOutPkg.containsKey(fqn)) { voOutPkg[fqn] = outPkg orderedFqns.add(fqn) } - collectNestedClosure(target, loader, outPkg, voOutPkg, orderedFqns, seen) + collectNestedClosure(target, outPkg, voOutPkg, orderedFqns, seen) } } /** * The nested-payload target VO a [field] contributes to the closure, or `null` when it - * contributes no nested class. Passthrough / aggregate / computed / first origins yield - * scalar types (no nested class). NOTE: the `origin.collection @via` and `field.objectRef` - * navigation here uses [resolveObjectByShortOrFqn] / the loader-bound `objectRef` — the - * origin-navigation ref kind (#244's domain), intentionally NOT the ADR-0042 @payloadRef - * resolver (which is only for the template's own @payloadRef). + * contributes no nested class. The ONLY closure edge is a declared + * `field.object @objectRef` whose target is an `object.value` (#270 — an `origin.*` + * child never contributes an edge; a non-object field contributes nothing). NOTE: the + * `field.objectRef` navigation uses the loader-bound `objectRef` — the field-navigation + * ref kind (#244's domain), intentionally NOT the ADR-0042 @payloadRef resolver (which + * is only for the template's own @payloadRef). */ - private fun nestedTargetOf(field: MetaField<*>, loader: MetaDataLoader): MetaObject? { - val origin = field.children.filterIsInstance().firstOrNull() - if (origin is CollectionOrigin) { - val via = origin.via ?: return null - val (parentName, relName) = splitDottedRef(via) ?: return null - val parent = resolveObjectByShortOrFqn(loader, parentName) ?: return null - val rel = parent.relationships - .firstOrNull { it.name == relName || it.name.substringAfterLast("::") == relName } - ?: return null - val targetRef = rel.objectRef ?: return null - return resolveObjectByShortOrFqn(loader, targetRef) - } - if (origin != null) return null // passthrough / aggregate / computed / first -> scalar + private fun nestedTargetOf(field: MetaField<*>): MetaObject? { if (field is ObjectField) { val target = try { field.objectRef } catch (e: RuntimeException) { null } ?: return null if (target.subType != MetaObject.SUBTYPE_VALUE) return null diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt index 8782620c6..035304e09 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt @@ -7,21 +7,11 @@ import com.metaobjects.generator.GeneratorIOWriter import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase import com.metaobjects.loader.MetaDataLoader import com.metaobjects.`object`.MetaObject -import com.metaobjects.origin.AggregateOrigin -import com.metaobjects.origin.CollectionOrigin -import com.metaobjects.origin.ComputedOrigin -import com.metaobjects.origin.FirstOrigin -import com.metaobjects.origin.MetaOrigin -import com.metaobjects.origin.PassthroughOrigin -import com.metaobjects.relationship.MetaRelationship import com.metaobjects.template.MetaTemplate -import com.squareup.kotlinpoet.BOOLEAN import com.squareup.kotlinpoet.ClassName -import com.squareup.kotlinpoet.DOUBLE import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.LONG import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec @@ -38,23 +28,25 @@ import java.nio.file.Paths * *

Output package = `.prompts`; class name = `Payload`. * - *

Origin-aware: each field on the payload VO may carry an `origin.*` child that - * declares how the value is derived. The property's TypeName is resolved as: + *

DECLARED-TYPE-AUTHORITATIVE (#270): a payload field's property type comes ONLY from + * its declared `field.` + `isArray` + `@objectRef` — never from any `origin.*` + * child it carries (an origin child is IGNORED for typing; the field types exactly as if + * it were absent). Nullability is never derived from origin semantics (no more + * "computed/first are nullable"); this port does not read `@required` either — every + * emitted property is unconditionally non-null (only the TS and Python emitters + * consult `@required` for optionality). A prompt's payload is a typed projection the + * author DECLARES, so payload bloat shows up as a diff — matching the origin-blind + * TS / C# reference emitters (Java converged alongside this port, #270 fix round 1). + * The property's TypeName is resolved as: *

    - *
  • {@code origin.passthrough} (@from "Entity.field") — type of the referenced source field.
  • - *
  • {@code origin.aggregate} (@agg count) — {@code Long}; (@agg avg) — {@code Double}; - * (@agg sum/min/max) — type of the referenced `@of` field; (@agg any/all) — {@code Boolean} - * (a predicate quantifier, #195); (@agg collect) — {@code List} where T is the `@of` - * element type (an array rollup, #195).
  • - *
  • {@code origin.collection} (@via "Parent.rel") — {@code List}, and the - * nested payload class is recursively emitted alongside (deduped per execute() run).
  • - *
  • {@code origin.computed} (@expr ...) — the field's own declared subType, NULLABLE - * (expression nullability is conservative, #195).
  • - *
  • {@code origin.first} (@of "Entity.field" @orderBy [...]) — the `@of` source column's type, - * NULLABLE (an empty related set → null, #195).
  • - *
  • No origin child — fall back to {@link KotlinTypeMapper#payloadTypeName(MetaField)} - * (parsed JSON value for a `field.string @dbColumnType=jsonb` open bag; otherwise the - * same mapping as {@code kotlinTypeName}).
  • + *
  • {@code field.enum} — the generated enum class (single, or {@code List}).
  • + *
  • {@code field.object @objectRef} to an `object.value` — the nested + * `Payload` (single, or {@code List} when isArray), + * recursively emitted alongside (deduped per execute() run). This declared edge is + * the ONLY nested-payload closure edge.
  • + *
  • Otherwise — {@link KotlinTypeMapper#payloadTypeName(MetaField)} (parsed JSON value + * for a `field.string @dbColumnType=jsonb` open bag; otherwise the same mapping as + * {@code kotlinTypeName}), wrapped {@code List<...>} when isArray.
  • *
*/ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { @@ -72,9 +64,9 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { override fun execute(loader: MetaDataLoader) { parseArgs() val outRoot = Paths.get(outDir.absolutePath) - // Dedupe nested payload classes emitted via origin.collection across all - // templates in this run. Key = FQN of the source view-object (or entity) the - // nested payload was generated from. + // Dedupe nested payload classes emitted via declared `field.object @objectRef` + // edges across all templates in this run. Key = FQN of the source value-object + // the nested payload was generated from. val emittedNestedFqns = mutableSetOf() // Run-level dedupe of emitted enum-class files by enum FQN. A `field.enum` payload field is // typed as its generated enum class (reusing the entity enum scheme); two fields sharing an @@ -123,9 +115,9 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { /** * Emit a single @Serializable data class for [voObject] into [outPkg].[className], - * resolving each field's TypeName via [resolveFieldType]. When a field has an - * `origin.collection`, recursively emits its nested payload class first (per-run - * deduped via [emittedNestedFqns]). + * resolving each field's TypeName via [resolveFieldType]. When a field is a declared + * `field.object @objectRef` to an `object.value`, recursively emits its nested + * payload class first (per-run deduped via [emittedNestedFqns]). */ protected open fun emitPayloadClass( outPkg: String, @@ -161,10 +153,13 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { } /** - * Resolve the Kotlin TypeName of a single payload-VO field, honoring any - * `origin.*` child. Falls back to [KotlinTypeMapper.payloadTypeName] when no - * origin is present (parsed JSON value for a `field.string @dbColumnType=jsonb` open - * bag, otherwise identical to `kotlinTypeName`). + * Resolve the Kotlin TypeName of a single payload-VO field from its DECLARATION + * only (#270): `field.` + `isArray` + `@objectRef`. Any `origin.*` child + * the field carries is IGNORED — the field types exactly as if the origin child + * were absent (matching the origin-blind TS / C# reference emitters). Falls + * back to [KotlinTypeMapper.payloadTypeName] (parsed JSON value for a + * `field.string @dbColumnType=jsonb` open bag, otherwise identical to + * `kotlinTypeName`). */ protected open fun resolveFieldType( field: MetaField<*>, @@ -176,28 +171,6 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { emittedEnumFqns: MutableSet, nameMap: Map, ): TypeName { - // ADR-0039/ADR-0029: origin.* NEVER inherits — a derived field's origin is - // declared-here, so read OWN children (field.children), not resolving. - val origin = field.children.filterIsInstance().firstOrNull() - - if (origin != null) { - return when (origin) { - is PassthroughOrigin -> resolvePassthroughType(origin, loader, field) - is AggregateOrigin -> resolveAggregateType(origin, loader, field) - is CollectionOrigin -> resolveCollectionType( - origin, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, field, nameMap - ) - // #195 origin.computed: a row-level value; its type is the field's own declared - // subType (validation pins the inferred root type == field subType). Conservative - // nullable — an expression's null-ness is expression-dependent. - is ComputedOrigin -> KotlinTypeMapper.payloadTypeName(field).copy(nullable = true) - // #195 origin.first: the @of source column's type, NULLABLE (an empty related set - // after @filter selects no row → null). - is FirstOrigin -> resolveFirstType(origin, loader, field) - else -> KotlinTypeMapper.payloadTypeName(field) - } - } - // field.enum (incl. array-of-enum): type the strict payload field as the generated enum // class (the same `` / shared-super scheme the entity generator // uses), and emit that enum file (deduped per run). Single → ``; array → `List<>`. @@ -215,9 +188,9 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { } } - // Naked `field.object @objectRef` (no origin child): emit the nested payload class - // and return its type (single, or List when isArray). Mirrors the - // Spring port's resolveObjectFieldType — needed so a nested-object payload compiles. + // Declared `field.object @objectRef`: emit the nested payload class and return + // its type (single, or List when isArray). Mirrors the Spring + // port's resolveObjectFieldType — needed so a nested-object payload compiles. if (field is ObjectField) { return resolveObjectFieldType(field, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, nameMap) } @@ -233,11 +206,22 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { } /** - * Naked `field.object @objectRef`: recursively emit `Payload` for the - * referenced value-object (deduped per run) and return that type — or - * `List` when `isArray: true`. Falls back to the scalar type mapping when - * the ref can't be resolved or the target is not an `object.value` (defensive — loader - * validation normally gates these). + * Declared `field.object @objectRef`: recursively emit `Payload` for + * the referenced value-object (deduped per run) and return that type — or + * `List` when `isArray: true`. + * + * BOTH "fallback" branches THROW, they do not degrade: `fallbackType()` is + * [KotlinTypeMapper.payloadTypeName], whose type mapping has NO `ObjectField` arm — an + * object field reaching it hits the `else -> throw IllegalArgumentException` arm and + * crashes the generator. That covers (a) an unresolvable ref (a dangling ref IS + * loader-gated first, `ERR_UNRESOLVED_OBJECT_REF`) and (b) a resolved target that is + * not an `object.value` — the latter is this port's own PRE-EXISTING gate, NOT a + * loader-enforced contract (no port's loader constrains a nested `@objectRef` target's + * subtype today; the TS/C# reference emitters and Python resolve and emit whatever the + * ref names). Note #270 WIDENED what reaches (b): an `ObjectField` carrying an origin + * child used to be consumed by the deleted origin dispatch; an entity-targeting one now + * throws here. The legal-target-set ruling (and whether this stays a throw) is #210's + * loader-validation call — do not copy this behavior to other ports meanwhile. */ private fun resolveObjectFieldType( field: ObjectField, @@ -282,141 +266,6 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { } } - /** - * `origin.passthrough @from "Entity.field"`: resolve to the source field's - * Kotlin TypeName. Falls back to the payload field's own type if the dotted - * ref can't be resolved (defensive — the loader's ValidationPhase already - * gates @from being present and well-formed). - */ - private fun resolvePassthroughType( - origin: PassthroughOrigin, - loader: MetaDataLoader, - fallbackField: MetaField<*>, - ): TypeName { - val from = origin.from ?: return KotlinTypeMapper.payloadTypeName(fallbackField) - val sourceField = resolveDottedFieldRef(loader, from) - ?: return KotlinTypeMapper.payloadTypeName(fallbackField) - return KotlinTypeMapper.payloadTypeName(sourceField) - } - - /** - * `origin.aggregate @agg X [@of "Entity.field"]`: type rule — - * - count → Long - * - avg → Double - * - sum/min/max → type of the `@of` field - * - any/all → Boolean (a predicate quantifier; empty set → false/true, never null — #195) - * - collect → List where T is the `@of` element type (array rollup; empty set → [] — #195) - */ - private fun resolveAggregateType( - origin: AggregateOrigin, - loader: MetaDataLoader, - fallbackField: MetaField<*>, - ): TypeName { - return when (origin.agg) { - MetaOrigin.AGG_COUNT -> LONG - MetaOrigin.AGG_AVG -> DOUBLE - // #195 boolean rollup — a quantifier over the related row-set. Always Boolean, - // COALESCE-guaranteed non-null (the payload emits fields non-null by default). - MetaOrigin.AGG_ANY, MetaOrigin.AGG_ALL -> BOOLEAN - // #195 array rollup — List, non-null (empty set → []). The @of names - // the collected scalar column; payloadTypeName gives its element type. - MetaOrigin.AGG_COLLECT -> { - val of = origin.of ?: return KotlinTypeMapper.payloadTypeName(fallbackField) - val sourceField = resolveDottedFieldRef(loader, of) - ?: return KotlinTypeMapper.payloadTypeName(fallbackField) - ClassName("kotlin.collections", "List") - .parameterizedBy(KotlinTypeMapper.payloadTypeName(sourceField)) - } - MetaOrigin.AGG_SUM, MetaOrigin.AGG_MIN, MetaOrigin.AGG_MAX -> { - val of = origin.of ?: return KotlinTypeMapper.payloadTypeName(fallbackField) - val sourceField = resolveDottedFieldRef(loader, of) - ?: return KotlinTypeMapper.payloadTypeName(fallbackField) - KotlinTypeMapper.payloadTypeName(sourceField) - } - else -> KotlinTypeMapper.payloadTypeName(fallbackField) - } - } - - /** - * `origin.first @of "Entity.field" @orderBy [...]`: type = the `@of` source column's Kotlin - * type, made NULLABLE — an empty related set (after `@filter`) selects no row, so the projected - * value can be null (#195). Falls back to the payload field's own (nullable) type when `@of` - * can't be resolved (defensive — the loader's ValidationPhase gates `@of` presence/shape). - */ - private fun resolveFirstType( - origin: FirstOrigin, - loader: MetaDataLoader, - fallbackField: MetaField<*>, - ): TypeName { - val of = origin.of ?: return KotlinTypeMapper.payloadTypeName(fallbackField).copy(nullable = true) - val sourceField = resolveDottedFieldRef(loader, of) - ?: return KotlinTypeMapper.payloadTypeName(fallbackField).copy(nullable = true) - return KotlinTypeMapper.payloadTypeName(sourceField).copy(nullable = true) - } - - /** - * `origin.collection @via "Parent.relName"`: walk Parent's relationship `relName` - * to its `@objectRef` target entity, recursively emit a nested payload class - * (`Payload`) into [nestedPkg], and return `List`. - * Dedupe across the whole run via [emittedNestedFqns]. - */ - private fun resolveCollectionType( - origin: CollectionOrigin, - loader: MetaDataLoader, - nestedPkg: String, - outRoot: Path, - emittedNestedFqns: MutableSet, - emittedEnumFqns: MutableSet, - fallbackField: MetaField<*>, - nameMap: Map, - ): TypeName { - val fallbackType = { KotlinTypeMapper.payloadTypeName(fallbackField) } - val via = origin.via ?: return fallbackType() - val (parentName, relName) = KotlinGenUtil.splitDottedRef(via) ?: return fallbackType() - val parent = KotlinGenUtil.resolveObjectByShortOrFqn(loader, parentName) ?: return fallbackType() - // ADR-0039: relationships are inheritable — RESOLVE via parent.relationships; - // parent.children (own-only) would miss a relationship inherited via extends. - val relationship = parent.relationships - .firstOrNull { it.name == relName || it.name.substringAfterLast("::") == relName } - ?: return fallbackType() - val targetRef = relationship.objectRef ?: return fallbackType() - val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, targetRef) ?: return fallbackType() - // ADR-0044 — collision-scoped class name (see resolveObjectFieldType). - val nestedClassName = nameMap[target.name] - ?: (PackageMapping.splitFqn(target.name).second + "Payload") - - if (emittedNestedFqns.add(target.name)) { - emitPayloadClass( - outPkg = nestedPkg, - className = nestedClassName, - kdoc = "GENERATED — nested payload for collection target `${target.name}`.\n", - voObject = target, - loader = loader, - outRoot = outRoot, - emittedNestedFqns = emittedNestedFqns, - emittedEnumFqns = emittedEnumFqns, - nameMap = nameMap, - ) - } - - val listType = ClassName("kotlin.collections", "List") - return listType.parameterizedBy(ClassName(nestedPkg, nestedClassName)) - } - - /** - * Resolve a dotted `"Entity.field"` ref to the MetaField on Entity (by short - * name OR FQN match). Returns null when either half can't be resolved. - */ - private fun resolveDottedFieldRef(loader: MetaDataLoader, dottedRef: String): MetaField<*>? { - val (entityName, fieldName) = KotlinGenUtil.splitDottedRef(dottedRef) ?: return null - val obj = KotlinGenUtil.resolveObjectByShortOrFqn(loader, entityName) ?: return null - // Fields on a MetaObject are typically stored under their short name, but - // be defensive against an FQN-stored field-name (matches relationship lookup). - return obj.metaFields.firstOrNull { - it.name == fieldName || it.name.substringAfterLast("::") == fieldName - } - } - // === MultiFileDirectGeneratorBase abstract-method stubs ==================== override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ } override fun ?> getSingleWriter( 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 dc4fa0e75..f37fd18ba 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 @@ -1,12 +1,18 @@ package com.metaobjects.generator.kotlin +import com.metaobjects.loader.InMemoryStringSource +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.template.MetaTemplate import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse /** - * Unit tests for [KotlinGenUtil] helpers. Currently covers [KotlinGenUtil.camelToSnake] + * Unit tests for [KotlinGenUtil] helpers. Covers [KotlinGenUtil.camelToSnake] * — the column-name normaliser used by [KotlinExposedTableGenerator] so generated - * Exposed columns match the snake_case convention nearly every Postgres schema uses. + * Exposed columns match the snake_case convention nearly every Postgres schema uses — + * and the #270 / ADR-0044 name-map closure gates on [KotlinGenUtil.computePayloadNameMap] + * (the closure walks ONLY declared `field.object @objectRef` -> `object.value` edges). */ class KotlinGenUtilTest { @@ -51,4 +57,119 @@ class KotlinGenUtilTest { // No camel boundaries → no underscores inserted; only lowercased pass-through. assertEquals("already_snake", KotlinGenUtil.camelToSnake("already_snake")) } + + // ----------------------------------------------------------------------- + // #270 / ADR-0044 — name-map closure gates on computePayloadNameMap. + // The closure walks ONLY declared `field.object @objectRef` -> object.value + // edges: an origin child on a field.object does NOT remove its declared + // edge (positive), and a field carrying ONLY origin.collection contributes + // nothing (negative). Both directions gate the nestedTargetOf edit that + // retired the `origin.collection @via` closure edge. + // ----------------------------------------------------------------------- + + private val alphaNoteFixture = """{ + "metadata.root": { "package": "acme::alpha", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "alphaText" } } + ] } } + ] } + }""".trimIndent() + + private val betaNoteFixture = """{ + "metadata.root": { "package": "acme::beta", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "betaText" } } + ] } } + ] } + }""".trimIndent() + + private fun loadPackages(name: String, vararg fixtures: String): MetaDataLoader { + val loader = MetaDataLoader.createManual(false, name) + loader.init() + loader.load(fixtures.mapIndexed { i, fx -> InMemoryStringSource(fx, "$name-src$i") }) + loader.register() + return loader + } + + private fun payloadNameMap(loader: MetaDataLoader): Map { + // ADR-0039: root-scan discipline — resolving children accessor (mirrors + // KotlinPayloadGenerator.execute's template scan). + val templates = loader.root.getChildren(MetaTemplate::class.java, true).sortedBy { it.name } + return KotlinGenUtil.computePayloadNameMap(templates, loader) + } + + @Test fun `origin-carrying object field stays in the name-map closure (issue-270 positive gate)`() { + // fromAlpha DECLARES acme::alpha::Note AND carries an origin.collection whose + // @via walks to the Post entity; fromBeta declares acme::beta::Note plainly. + // Both same-short-named Notes must be package-qualified — if the origin child + // dropped the declared edge from the closure, the collision would go + // undetected and both would fall back to a clobbered bare NotePayload. + val appFixture = """{ + "metadata.root": { "package": "acme::app", "children": [ + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "posts", + "@objectRef": "Post", "@cardinality": "many" } } + ] } }, + { "object.entity": { "name": "Post", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "internalNotes" } } + ] } }, + { "object.value": { "name": "Digest", "children": [ + { "field.object": { "name": "fromAlpha", + "@objectRef": "acme::alpha::Note", "children": [ + { "origin.collection": { "@via": "Author.posts" } } + ] } }, + { "field.object": { "name": "fromBeta", + "@objectRef": "acme::beta::Note" } } + ] } }, + { "template.output": { "name": "DigestDoc", + "@payloadRef": "Digest", "@textRef": "app/digest", "@format": "json" } } + ] } + }""".trimIndent() + + val loader = loadPackages("kgu-namemap-pos", alphaNoteFixture, betaNoteFixture, appFixture) + val nameMap = payloadNameMap(loader) + + assertEquals("AcmeAlphaNotePayload", nameMap["acme::alpha::Note"], + "origin-carrying declared edge must stay in the closure and qualify; map=$nameMap") + assertEquals("AcmeBetaNotePayload", nameMap["acme::beta::Note"], + "the plain declared edge must qualify against the colliding alpha Note; map=$nameMap") + assertFalse(nameMap.containsKey("acme::app::Post"), + "the ignored @via entity must NOT enter the closure; map=$nameMap") + } + + @Test fun `origin-collection-only field contributes nothing to the name-map (issue-270 negative gate)`() { + // The `posts` field carries ONLY origin.collection; its @via walks to + // acme::beta::Note, which shares a bare short name with the DECLARED + // acme::alpha::Note. Were the retired collection edge still in the closure, + // the two would collide and both would qualify. Instead: the declared Note + // stays BARE and the @via target never enters the map. + val appFixture = """{ + "metadata.root": { "package": "acme::app", "children": [ + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "notes", + "@objectRef": "acme::beta::Note", "@cardinality": "many" } } + ] } }, + { "object.value": { "name": "Digest", "children": [ + { "field.object": { "name": "fromAlpha", + "@objectRef": "acme::alpha::Note" } }, + { "field.string": { "name": "posts", "children": [ + { "origin.collection": { "@via": "Author.notes" } } + ] } } + ] } }, + { "template.output": { "name": "DigestDoc", + "@payloadRef": "Digest", "@textRef": "app/digest", "@format": "json" } } + ] } + }""".trimIndent() + + val loader = loadPackages("kgu-namemap-neg", alphaNoteFixture, betaNoteFixture, appFixture) + val nameMap = payloadNameMap(loader) + + assertEquals("NotePayload", nameMap["acme::alpha::Note"], + "no collision without the origin edge — the declared Note keeps its bare name; map=$nameMap") + assertFalse(nameMap.containsKey("acme::beta::Note"), + "an origin.collection-only field must contribute nothing to the closure; map=$nameMap") + } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt index 5d875d9e0..62751debf 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt @@ -80,20 +80,24 @@ class KotlinPayloadGeneratorTest { } // ----------------------------------------------------------------------- - // origin.* coverage — FR-004 payload-VO field-value provenance + // origin.* coverage — #270: payload typing is DECLARED-TYPE-AUTHORITATIVE. + // A field carrying any `origin.*` child types exactly as if the origin + // child were absent (matching the origin-blind TS / C# reference emitters). // ----------------------------------------------------------------------- - @Test fun originPassthroughResolvesSourceFieldType() { - // PayloadVo.title carries `origin.passthrough @from "Source.title"`. - // Expected: emitted property uses Source.title's type (String). + @Test fun originPassthroughIgnoredDeclaredTypeWins() { + // #270 — PayloadVo.title is DECLARED `field.int` but carries + // `origin.passthrough @from "Source.title"` to a STRING source + // (`@convert: true` acknowledges the deliberate type change, #185). + // Expected: emitted property uses the DECLARED type (Int), never the source's. val fx = """{ "metadata.root": { "package": "acme::demo", "children": [ { "object.entity": { "name": "Source", "children": [ { "field.string": { "name": "title" } } ] } }, { "object.value": { "name": "ArticleSummary", "children": [ - { "field.string": { "name": "title", "children": [ - { "origin.passthrough": { "@from": "Source.title" } } + { "field.int": { "name": "title", "children": [ + { "origin.passthrough": { "@from": "Source.title", "@convert": true } } ] } } ] } }, { "template.prompt": { "name": "Article", @@ -111,15 +115,17 @@ class KotlinPayloadGeneratorTest { assertTrue(Files.exists(emitted), "expected $emitted; files=${Files.walk(outDir).toList()}") val src = Files.readString(emitted) - assertTrue("val title: String" in src, src) + assertTrue("val title: Int" in src, src) + assertTrue("val title: String" !in src, src) } finally { outDir.toFile().deleteRecursively() } } - @Test fun originAggregateCountEmitsLong() { - // PayloadVo.postCount has `origin.aggregate @agg count @of "Post.id" @via "Author.posts"`. - // Expected: emitted property is Long regardless of the underlying field subtype. + @Test fun originAggregateCountIgnoredDeclaredTypeWins() { + // #270 — PayloadVo.postCount is DECLARED `field.int` but carries + // `origin.aggregate @agg count`. Expected: the DECLARED type (Int) — + // no more hardwired Long. val fx = """{ "metadata.root": { "package": "acme::demo", "children": [ { "object.entity": { "name": "Author", "children": [ @@ -151,15 +157,17 @@ class KotlinPayloadGeneratorTest { assertTrue(Files.exists(emitted), "expected $emitted; files=${Files.walk(outDir).toList()}") val src = Files.readString(emitted) - assertTrue("val postCount: Long" in src, src) + assertTrue("val postCount: Int" in src, src) + assertTrue("val postCount: Long" !in src, src) } finally { outDir.toFile().deleteRecursively() } } - @Test fun originAggregateAvgEmitsDouble() { - // PayloadVo.avgScore has `origin.aggregate @agg avg @of "Post.score"`. - // Expected: emitted property is Double regardless of `@of` field's type. + @Test fun originAggregateAvgIgnoredDeclaredTypeWins() { + // #270 — PayloadVo.avgScore is DECLARED `field.float` but carries + // `origin.aggregate @agg avg`. Expected: the DECLARED type (Float) — + // no more hardwired Double. val fx = """{ "metadata.root": { "package": "acme::demo", "children": [ { "object.entity": { "name": "Author", "children": [ @@ -172,7 +180,7 @@ class KotlinPayloadGeneratorTest { { "field.long": { "name": "score" } } ] } }, { "object.value": { "name": "AuthorSummary", "children": [ - { "field.double": { "name": "avgScore", "children": [ + { "field.float": { "name": "avgScore", "children": [ { "origin.aggregate": { "@agg": "avg", "@of": "Post.score", "@via": "Author.posts" } } ] } } @@ -192,18 +200,19 @@ class KotlinPayloadGeneratorTest { assertTrue(Files.exists(emitted), "expected $emitted; files=${Files.walk(outDir).toList()}") val src = Files.readString(emitted) - assertTrue("val avgScore: Double" in src, src) + assertTrue("val avgScore: Float" in src, src) + assertTrue("val avgScore: Double" !in src, src) } finally { outDir.toFile().deleteRecursively() } } - @Test fun originCollectionEmitsListOfNestedPayload() { - // PayloadVo.posts has `origin.collection @via "Author.posts"`. - // Expected: - // - parent payload emits `val posts: List` - // - a separate file `PostPayload.kt` is also emitted in the same prompts/ package - // - PostPayload contains Post's primitive fields + @Test fun originCollectionIgnoredNoNestedPayloadEmitted() { + // #270 — PayloadVo.posts is DECLARED `field.string` but carries + // `origin.collection @via "Author.posts"`. Expected: + // - the DECLARED scalar type (`val posts: String`) — no List + // - NO PostPayload.kt is emitted (a non-object field contributes no + // nested payload class; the @via target is never reached) val fx = """{ "metadata.root": { "package": "acme::demo", "children": [ { "object.entity": { "name": "Author", "children": [ @@ -235,35 +244,97 @@ class KotlinPayloadGeneratorTest { val nestedFile = outDir.resolve("acme/demo/prompts/PostPayload.kt") assertTrue(Files.exists(parentFile), "expected $parentFile; files=${Files.walk(outDir).toList()}") - assertTrue(Files.exists(nestedFile), - "expected $nestedFile; files=${Files.walk(outDir).toList()}") + assertTrue(Files.notExists(nestedFile), + "PostPayload.kt must NOT be emitted (origin.collection is ignored); " + + "files=${Files.walk(outDir).toList()}") val parentSrc = Files.readString(parentFile) - assertTrue("val posts: List" in parentSrc, parentSrc) + assertTrue("val posts: String" in parentSrc, parentSrc) + assertTrue("List" !in parentSrc, parentSrc) + } finally { + outDir.toFile().deleteRecursively() + } + } - val nestedSrc = Files.readString(nestedFile) - assertTrue("data class PostPayload" in nestedSrc, nestedSrc) - assertTrue("val id: Long" in nestedSrc, nestedSrc) - assertTrue("val title: String" in nestedSrc, nestedSrc) - assertTrue("package acme.demo.prompts" in nestedSrc, nestedSrc) + @Test fun `disagreeing origin-collection is ignored — declared curated objectRef wins (issue-270)`() { + // #270 load-bearing disagreement test — the payload field DECLARES a curated + // value-object (`field.object @objectRef: Highlight, isArray: true`) AND carries an + // `origin.collection @via "Author.posts"` walking to a DIFFERENT, fuller entity (Post). + // Expected: + // (a) `val posts: List` — the DECLARATION wins; + // (b) HighlightPayload.kt (the curated VO) is emitted, PostPayload.kt is NOT — + // the silent payload-bloat leak the prompt pillar exists to prevent. + val fx = """{ + "metadata.root": { "package": "acme::demo", "children": [ + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "posts", + "@objectRef": "Post", "@cardinality": "many" } } + ] } }, + { "object.entity": { "name": "Post", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "title" } }, + { "field.string": { "name": "body" } }, + { "field.string": { "name": "internalNotes" } } + ] } }, + { "object.value": { "name": "Highlight", "children": [ + { "field.string": { "name": "snippet" } } + ] } }, + { "object.value": { "name": "AuthorDigest", "children": [ + { "field.object": { "name": "posts", "@objectRef": "Highlight", + "isArray": true, "children": [ + { "origin.collection": { "@via": "Author.posts" } } + ] } } + ] } }, + { "template.prompt": { "name": "AuthorDigestView", + "@payloadRef": "AuthorDigest", "@textRef": "demo/digest" } } + ] } + }""".trimIndent() + + val outDir = Files.createTempDirectory("kpay-disagree-") + try { + val gen = KotlinPayloadGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loadString("test-disagree", fx)) + + val parentFile = outDir.resolve("acme/demo/prompts/AuthorDigestViewPayload.kt") + val curatedFile = outDir.resolve("acme/demo/prompts/HighlightPayload.kt") + val entityFile = outDir.resolve("acme/demo/prompts/PostPayload.kt") + assertTrue(Files.exists(parentFile), + "expected $parentFile; files=${Files.walk(outDir).toList()}") + assertTrue(Files.exists(curatedFile), + "expected curated $curatedFile; files=${Files.walk(outDir).toList()}") + assertTrue(Files.notExists(entityFile), + "PostPayload.kt (the @via entity) must NOT be emitted; " + + "files=${Files.walk(outDir).toList()}") + + val parentSrc = Files.readString(parentFile) + // (a) DECLARED wins: @objectRef + isArray, not the @via walk. + assertTrue("val posts: List" in parentSrc, parentSrc) + assertTrue("PostPayload" !in parentSrc, parentSrc) + + val curatedSrc = Files.readString(curatedFile) + // (b) the closure emits the curated VO's shape, not the fuller entity's. + assertTrue("data class HighlightPayload" in curatedSrc, curatedSrc) + assertTrue("val snippet: String" in curatedSrc, curatedSrc) + assertTrue("internalNotes" !in curatedSrc, curatedSrc) } finally { outDir.toFile().deleteRecursively() } } - @Test fun `issue-195 new origins resolve native payload types (any-all Boolean, collect List, first nullable, computed nullable)`() { - // The four #195 projection read-model capabilities, hosted on a payload VO. The payload - // generator's origin dispatch must type each derived field from its origin (NOT the raw - // declared subType), matching the TS/other-port native-typing contract: - // - origin.aggregate @agg:any|all → Boolean (a predicate quantifier over @filter; non-null) - // - origin.aggregate @agg:collect → List where T = the @of element type (non-null) - // - origin.first → the @of source type, NULLABLE (empty set → null) - // - origin.computed → the field's own declared subType, NULLABLE (conservative) + @Test fun `issue-195 origins hosted on a payload VO are ignored — declared types win (issue-270)`() { + // #270 — the four #195 origins, hosted on a payload VO, are IGNORED for typing: + // every field types from its DECLARED `field.` + `isArray`, non-null + // (the payload emitter's declared-path default — nullability never comes from + // origin semantics: no more "first is nullable" / "computed is conservatively + // nullable"). // - // Shapes obey the #195 loader validation: any/all carry @filter + @via and FORBID @of; - // collect is isArray with a subtype-matching @of; first's field is non-@required; computed's - // @expr references the host VO's own field (`bio`). The Kotlin dispatch keys on @agg / the - // origin subtype, never on @filter/@orderBy. + // Shapes obey the #195 loader validation: any/all carry @filter + @via and FORBID @of + // (and must be field.boolean); collect is isArray with a subtype-matching @of; computed's + // @expr references the host VO's own field (`bio`). Note any/all/collect are cases where + // validation FORCES declared == derived, so their assertions are unchanged from the + // pre-#270 origin-dispatch era. val fx = """{ "metadata.root": { "package": "acme::demo", "children": [ { "object.entity": { "name": "Author", "children": [ @@ -312,20 +383,23 @@ class KotlinPayloadGeneratorTest { "expected $emitted; files=${Files.walk(outDir).toList()}") val src = Files.readString(emitted) - // any / all → Boolean (non-null) + // any / all → declared field.boolean (non-null; declared==derived by validation) assertTrue("val hasAnyPost: Boolean" in src && "val hasAnyPost: Boolean?" !in src, - "origin.aggregate @agg:any must be non-null Boolean; saw:\n$src") + "declared field.boolean must emit non-null Boolean; saw:\n$src") assertTrue("val allPosts: Boolean" in src && "val allPosts: Boolean?" !in src, - "origin.aggregate @agg:all must be non-null Boolean; saw:\n$src") - // collect → List (non-null), element = @of (Post.category) type + "declared field.boolean must emit non-null Boolean; saw:\n$src") + // collect → declared field.string isArray → List (non-null; + // declared==derived by validation) assertTrue("val categories: List" in src && "val categories: List?" !in src, - "origin.aggregate @agg:collect must be non-null List; saw:\n$src") - // first → the @of source type (String), NULLABLE - assertTrue("val latestCategory: String?" in src, - "origin.first must be nullable @of source type (String?); saw:\n$src") - // computed → declared subType (Boolean), NULLABLE (conservative) - assertTrue("val hasBio: Boolean?" in src, - "origin.computed must be nullable declared subType (Boolean?); saw:\n$src") + "declared field.string isArray must emit non-null List; saw:\n$src") + // first → declared field.string, NON-NULL (#270: origin.first no longer + // forces nullability) + assertTrue("val latestCategory: String" in src && "val latestCategory: String?" !in src, + "declared field.string must emit non-null String (origin.first ignored); saw:\n$src") + // computed → declared field.boolean, NON-NULL (#270: origin.computed no + // longer forces nullability) + assertTrue("val hasBio: Boolean" in src && "val hasBio: Boolean?" !in src, + "declared field.boolean must emit non-null Boolean (origin.computed ignored); saw:\n$src") } finally { outDir.toFile().deleteRecursively() } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java index 58853c336..ff9c0301a 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java @@ -45,8 +45,8 @@ * {@link #payloadFields(MetaData, MetaDataLoader)} resolves the template's * {@code @payloadRef} value-object and maps each of its fields via * {@link SpringPayloadGenerator#resolveFieldType} (the same per-field type - * resolution the payload generator uses, incl. origin.passthrough / aggregate / - * collection and enums). Optionality mirrors the payload generator's nullable + * resolution the payload generator uses — declared-type-authoritative per #270, + * incl. nested {@code field.object} refs and enums). Optionality mirrors the payload generator's nullable * rule: a field is optional iff the generator would emit a {@code hasXxx()} * helper for it ({@link SpringPayloadGenerator#hasHelperBody(String, String)} * returns non-null) — i.e. String / List / reference types are optional, bare @@ -86,8 +86,8 @@ public static List dtoFields(MetaObject entity) { * {@link SpringPayloadGenerator#appliesTo(MetaData, MetaDataLoader)} first). * *

The payload generator's {@code resolveFieldType} may recursively emit - * nested payload records to disk as a side effect (origin.collection / - * field.object arms). Since this is a docs-derivation path, those writes are + * nested payload records to disk as a side effect (the declared + * {@code field.object} arm). Since this is a docs-derivation path, those writes are * directed to a throwaway temp directory so the real output tree is never * touched.

*/ diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md index 24437bcee..a23407718 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md @@ -163,3 +163,21 @@ persistence-conformance roundtrip column first) and the Kotlin `field.string @dbColumnType=jsonb` open-bag PATCH (needs a kotlinx `parseToJsonElement` bridge). TPH entities with VO columns also remain out of scope (the TPH union skips `ObjectField`). + +## `SpringPayloadGenerator.resolveObjectByShortOrFqn` has zero in-repo callers + +**Status:** deliberately kept, not dead code — recorded here so it is not later +rediscovered as live. + +#270 (payload typing is declared-type-authoritative) deleted the payload +generator's `origin.*` type-dispatch and dotted-ref navigation, which held the +last in-repo callers of the `protected static` `resolveObjectByShortOrFqn` +(and its private `shortName` support). The helper stays because `protected` +members of this deliberately-extensible generator are adopter subclass API — +removing one is an API break out of proportion to the cleanup. Same +keep-and-record policy as `KotlinGenUtil.splitDottedRef` in the sibling +`codegen-kotlin` module's `KNOWN_GAPS.md`. Prune in a future MAJOR. (The other +stranded origin helpers — `firstOriginChild`, `resolveDottedFieldRef`, +`splitDottedRef` — were deleted: the private ones are not adopter-facing, and +the `MetaOrigin` inspector had zero callers anywhere, the review's own +delete-unless-genuinely-used-elsewhere rule.) diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java index f63659001..91added11 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java @@ -9,11 +9,6 @@ import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.object.MetaObject; -import com.metaobjects.origin.AggregateOrigin; -import com.metaobjects.origin.CollectionOrigin; -import com.metaobjects.origin.MetaOrigin; -import com.metaobjects.origin.PassthroughOrigin; -import com.metaobjects.relationship.MetaRelationship; import com.metaobjects.template.MetaTemplate; import java.io.IOException; @@ -60,34 +55,30 @@ * lands in {@code acme.ai.prompts.NpcResponseOutputPayload}), and the * bare {@code prompts} package when no metadata package is set. * - *

Origin-aware: each field on the payload VO may carry an {@code origin.*} - * child that declares how the value is derived. The record component's Java - * type is resolved as: + *

DECLARED-TYPE-AUTHORITATIVE (#270): a payload field's record-component type + * comes ONLY from its declared {@code field.} + {@code isArray} + + * {@code @objectRef} — never from any {@code origin.*} child it carries (an + * origin child is IGNORED for typing; the field types exactly as if it were + * absent). A prompt's payload is a typed projection the author DECLARES, so + * payload bloat shows up as a diff — matching the origin-blind TS / C# emitters + * and the converged Kotlin / Python ports. The component type is resolved as: *

    - *
  • {@code origin.passthrough} ({@code @from "Entity.field"}) — type of the - * referenced source field.
  • - *
  • {@code origin.aggregate} ({@code @agg count}) — {@code Long}; - * ({@code @agg avg}) — {@code Double}; - * ({@code @agg sum|min|max}) — type of the referenced {@code @of} field.
  • - *
  • {@code origin.collection} ({@code @via "Parent.rel"}) — - * {@code List}, and the nested payload class is recursively - * emitted alongside (deduped per {@link #execute(MetaDataLoader)} run).
  • - *
  • {@code field.object} with {@code @objectRef} (no {@code origin.*} child) — + *
  • {@code field.enum} — the generated nested enum type (single, or + * {@code java.util.List}).
  • + *
  • {@code field.object} with {@code @objectRef} to an {@code object.value} — * recursively emit {@code Payload} (per-run deduped) and * return that type, or {@code java.util.List} when the field - * is {@code isArray: true}. Mirrors Kotlin's plain {@code ObjectField} - * arm.
  • - *
  • No origin child and not a {@code field.object} — fall back to - * {@link SpringTypeMapper#javaTypeName(MetaField)}.
  • + * is {@code isArray: true}. This declared edge is the ONLY nested-payload + * closure edge. Mirrors Kotlin's {@code ObjectField} arm. + *
  • Otherwise — fall back to {@link SpringTypeMapper#javaTypeName(MetaField)}.
  • *
* *

Class-naming convention: the emitted record name is * {@code Payload}. Templates declared in * {@code camelCase} (e.g. {@code adjudicationUser}) are capitalised before * appending {@code Payload}, matching Kotlin/C#/TS/Python and Java's own - * PascalCase class-naming convention. Nested payload class names from - * {@code origin.collection} / {@code field.object} arms are capitalised the - * same way. + * PascalCase class-naming convention. Nested payload class names from the + * {@code field.object} arm are capitalised the same way. * *

Skips and defensive cases: *

    @@ -121,9 +112,10 @@ public void execute(MetaDataLoader loader) { parseArgs(); Path outRoot = Paths.get(outDir.getAbsolutePath()); - // Dedupe nested payload classes emitted via origin.collection across - // all templates in this run. Key = FQN of the source value-object the - // nested payload was generated from. Mirrors KotlinPayloadGenerator. + // Dedupe nested payload classes emitted via declared `field.object + // @objectRef` edges across all templates in this run. Key = FQN of the + // source value-object the nested payload was generated from. Mirrors + // KotlinPayloadGenerator. Set emittedNestedFqns = new HashSet<>(); // Stable name order — matches the other ports' deterministic emission. @@ -178,7 +170,7 @@ public static Map computePayloadNameMap(List templ // Group by (output package, bare short name). Map> byPkgShort = new LinkedHashMap<>(); for (String fqn : orderedFqns) { - String key = voOutPkg.get(fqn) + "" + SpringNaming.splitFqn(fqn)[1]; + String key = voOutPkg.get(fqn) + "\0" + SpringNaming.splitFqn(fqn)[1]; byPkgShort.computeIfAbsent(key, k -> new ArrayList<>()).add(fqn); } Map nameMap = new LinkedHashMap<>(); @@ -200,7 +192,7 @@ public static Map computePayloadNameMap(List templ List sortedFqns = new ArrayList<>(nameMap.keySet()); Collections.sort(sortedFqns); for (String fqn : sortedFqns) { - String pkgName = voOutPkg.get(fqn) + "" + nameMap.get(fqn); + String pkgName = voOutPkg.get(fqn) + "\0" + nameMap.get(fqn); String prev = ownerByPkgName.putIfAbsent(pkgName, fqn); if (prev != null && !prev.equals(fqn)) { throw new GeneratorException(ERR_PAYLOAD_NAME_COLLISION @@ -213,11 +205,11 @@ public static Map computePayloadNameMap(List templ } /** - * ADR-0044 pass 1 — walk {@code vo}'s transitive nested-payload closure (plain - * {@code field.object @objectRef} + {@code origin.collection @via} edges), - * assigning each not-yet-seen target VO to {@code outPkg} (first reaching - * template wins) and recording it in {@code orderedFqns}. {@code seen} is seeded - * with the primary VO's FQN and doubles as the cycle guard. + * ADR-0044 pass 1 — walk {@code vo}'s transitive nested-payload closure (declared + * {@code field.object @objectRef} edges ONLY, #270), assigning each not-yet-seen + * target VO to {@code outPkg} (first reaching template wins) and recording it in + * {@code orderedFqns}. {@code seen} is seeded with the primary VO's FQN and + * doubles as the cycle guard. * *

    public static (promoted from {@code protected} instance, #228) — see * {@link #computePayloadNameMap}. @@ -243,35 +235,19 @@ public static void collectNestedClosure(MetaObject vo, /** * The nested-payload target VO a {@code field} contributes to the closure, or - * {@code null} when it contributes no nested record. Mirrors the resolution in - * {@link #resolveObjectFieldType} (plain {@code field.object @objectRef}) and - * {@link #resolveCollectionType} ({@code origin.collection @via}) EXACTLY, so the - * closure walk and the emission walk agree on the target set. Passthrough / - * aggregate origins yield scalar types (no nested record). + * {@code null} when it contributes no nested record. The ONLY closure edge is a + * declared {@code field.object @objectRef} whose target is an + * {@code object.value} (#270 — an {@code origin.*} child never contributes an + * edge; a non-object field contributes nothing). Mirrors the resolution in + * {@link #resolveObjectFieldType} EXACTLY, so the closure walk and the emission + * walk agree on the target set. * *

    public static (promoted from {@code protected} instance, #228) — see - * {@link #computePayloadNameMap}. + * {@link #computePayloadNameMap}. {@code loader} is retained for the #228 public + * signature; the declared edge resolves through the loader-bound + * {@link ObjectField#getObjectRef()} and no longer reads it directly. */ public static MetaObject nestedTargetOf(MetaField field, MetaDataLoader loader) { - MetaOrigin origin = firstOriginChild(field); - if (origin instanceof CollectionOrigin co) { - String via = co.getVia(); - if (via == null) return null; - String[] split = splitDottedRef(via); - if (split == null) return null; - MetaObject parent = resolveObjectByShortOrFqn(loader, split[0]); - if (parent == null) return null; - for (MetaData child : parent.getChildren()) { - if (!(child instanceof MetaRelationship rel)) continue; - if (rel.getName().equals(split[1]) || shortName(rel.getName()).equals(split[1])) { - String targetRef = rel.getObjectRef(); - if (targetRef == null) return null; - return resolveObjectByShortOrFqn(loader, targetRef); - } - } - return null; - } - if (origin != null) return null; // passthrough / aggregate -> scalar if (field instanceof ObjectField of) { MetaObject target = of.getObjectRef(); if (target == null || !MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) return null; @@ -343,9 +319,9 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot, /** * Emit a single Java record for {@code voObject} into * {@code .}, resolving each component's type via - * {@link #resolveFieldType}. When a field has an {@code origin.collection}, - * recursively emits its nested payload record first (per-run deduped via - * {@code emittedNestedFqns}). + * {@link #resolveFieldType}. When a field is a declared {@code field.object + * @objectRef} to an {@code object.value}, recursively emits its nested payload + * record first (per-run deduped via {@code emittedNestedFqns}). */ protected void emitPayloadRecord(String outPkg, String recordName, @@ -438,10 +414,14 @@ protected void emitPayloadRecord(String outPkg, } /** - * Resolve the Java type expression of a single payload-VO field. Precedence: - * (1) {@code origin.*} child wins if present; (2) otherwise a - * {@code field.object} routes through the nested-payload emission arm; (3) - * otherwise the scalar fallback via {@link SpringTypeMapper#javaTypeName}. + * Resolve the Java type expression of a single payload-VO field from its + * DECLARATION only (#270): {@code field.} + {@code isArray} + + * {@code @objectRef}. Any {@code origin.*} child the field carries is IGNORED — + * the field types exactly as if the origin child were absent (matching the + * origin-blind TS / C# emitters and the converged Kotlin / Python ports). + * Precedence: (1) {@code field.enum} types as the generated nested enum; + * (2) a {@code field.object} routes through the nested-payload emission arm; + * (3) otherwise the scalar fallback via {@link SpringTypeMapper#javaTypeName}. */ public String resolveFieldType(MetaField field, MetaObject owner, @@ -450,16 +430,6 @@ public String resolveFieldType(MetaField field, Path outRoot, Set emittedNestedFqns, Map nameMap) { - MetaOrigin origin = firstOriginChild(field); - if (origin instanceof PassthroughOrigin pt) { - return resolvePassthroughType(pt, loader, field); - } - if (origin instanceof AggregateOrigin ag) { - return resolveAggregateType(ag, loader, field); - } - if (origin instanceof CollectionOrigin co) { - return resolveCollectionType(co, loader, nestedPkg, outRoot, emittedNestedFqns, field, nameMap); - } // field.enum (scalar or array): type the STRICT payload component as the generated // Java enum nested in this record. The enum name is unqualified here because the record // references its own nested type; the sibling parser/mapper qualifies it as @@ -472,14 +442,25 @@ public String resolveFieldType(MetaField field, if (field instanceof ObjectField of) { return resolveObjectFieldType(of, loader, nestedPkg, outRoot, emittedNestedFqns, nameMap); } - return SpringTypeMapper.javaTypeName(field); + // Scalar array (`isArray: true` on a non-object, non-enum field): the declared + // contract is field. + @isArray, so wrap the element type as + // java.util.List — matching Kotlin's scalar-array arm and Python's + // type_map list[...] wrap. Without this, javaTypeName returns the bare element + // type and the declared array-ness is silently dropped (#270 fix round 2). + String scalarType = SpringTypeMapper.javaTypeName(field); + // ADR-0039: resolving array-ness (isArrayType() is the effective flag; isArray() + // is the own-only native flag). + if (field.isArrayType()) { + return "java.util.List<" + scalarType + ">"; + } + return scalarType; } /** - * Naked {@code field.object @objectRef}: recursively emit + * Declared {@code field.object @objectRef}: recursively emit * {@code Payload} for the referenced VO, and * return that type — or {@code java.util.List} when - * {@code isArray: true}. Mirrors Kotlin's plain-{@code ObjectField} arm in + * {@code isArray: true}. Mirrors Kotlin's {@code ObjectField} arm in * {@code KotlinPayloadGenerator.resolveFieldType}. */ protected String resolveObjectFieldType(ObjectField field, @@ -501,94 +482,8 @@ protected String resolveObjectFieldType(ObjectField field, } /** - * {@code origin.passthrough @from "Entity.field"}: resolve to the source - * field's Java type. Falls back to the payload field's own type if the - * dotted ref can't be resolved (defensive — the loader's ValidationPhase - * already gates {@code @from} being present and well-formed). - */ - protected String resolvePassthroughType(PassthroughOrigin origin, - MetaDataLoader loader, - MetaField fallbackField) { - String from = origin.getFrom(); - if (from == null) return SpringTypeMapper.javaTypeName(fallbackField); - MetaField sourceField = resolveDottedFieldRef(loader, from); - if (sourceField == null) return SpringTypeMapper.javaTypeName(fallbackField); - return SpringTypeMapper.javaTypeName(sourceField); - } - - /** - * {@code origin.aggregate @agg X @of "Entity.field"}: type rule — - *

      - *
    • count → {@code Long}
    • - *
    • avg → {@code Double}
    • - *
    • sum/min/max → type of the {@code @of} field
    • - *
    - */ - protected String resolveAggregateType(AggregateOrigin origin, - MetaDataLoader loader, - MetaField fallbackField) { - String agg = origin.getAgg(); - if (MetaOrigin.AGG_COUNT.equals(agg)) return "Long"; - if (MetaOrigin.AGG_AVG.equals(agg)) return "Double"; - if (MetaOrigin.AGG_SUM.equals(agg) - || MetaOrigin.AGG_MIN.equals(agg) - || MetaOrigin.AGG_MAX.equals(agg)) { - String of = origin.getOf(); - if (of == null) return SpringTypeMapper.javaTypeName(fallbackField); - MetaField sourceField = resolveDottedFieldRef(loader, of); - if (sourceField == null) return SpringTypeMapper.javaTypeName(fallbackField); - return SpringTypeMapper.javaTypeName(sourceField); - } - return SpringTypeMapper.javaTypeName(fallbackField); - } - - /** - * {@code origin.collection @via "Parent.relName"}: walk Parent's relationship - * {@code relName} to its {@code @objectRef} target value-object, recursively - * emit a nested payload record ({@code Payload}) into - * {@code nestedPkg}, and return {@code List}. Dedupe across - * the whole run via {@code emittedNestedFqns}. - */ - protected String resolveCollectionType(CollectionOrigin origin, - MetaDataLoader loader, - String nestedPkg, - Path outRoot, - Set emittedNestedFqns, - MetaField fallbackField, - Map nameMap) { - String via = origin.getVia(); - if (via == null) return SpringTypeMapper.javaTypeName(fallbackField); - String[] split = splitDottedRef(via); - if (split == null) return SpringTypeMapper.javaTypeName(fallbackField); - String parentName = split[0]; - String relName = split[1]; - - MetaObject parent = resolveObjectByShortOrFqn(loader, parentName); - if (parent == null) return SpringTypeMapper.javaTypeName(fallbackField); - - MetaRelationship relationship = null; - for (MetaData child : parent.getChildren()) { - if (!(child instanceof MetaRelationship rel)) continue; - String relShort = shortName(rel.getName()); - if (rel.getName().equals(relName) || relShort.equals(relName)) { - relationship = rel; - break; - } - } - if (relationship == null) return SpringTypeMapper.javaTypeName(fallbackField); - - String targetRef = relationship.getObjectRef(); - if (targetRef == null) return SpringTypeMapper.javaTypeName(fallbackField); - - MetaObject target = resolveObjectByShortOrFqn(loader, targetRef); - if (target == null) return SpringTypeMapper.javaTypeName(fallbackField); - return emitNestedAndReturnType(target, loader, nestedPkg, outRoot, emittedNestedFqns, true, nameMap); - } - - /** - * Shared nested-payload emit path used by {@link #resolveCollectionType} - * (always a list) and {@link #resolveObjectFieldType} (single or list, - * depending on {@code asList}). Emits the record at most once per run via + * Nested-payload emit path used by {@link #resolveObjectFieldType} (single or + * list, depending on {@code asList}). Emits the record at most once per run via * the {@code emittedNestedFqns} dedupe set, and returns the type expression * to use as the parent field's component type. Returns * {@code java.util.List} (fully-qualified to sidestep any @@ -624,9 +519,9 @@ protected String emitNestedAndReturnType(MetaObject target, } // ------------------------------------------------------------------------- - // Local helpers (intentionally not in SpringNaming — origin/relationship - // resolution is payload-specific. If a second generator needs them, lift - // them up the same way KotlinGenUtil holds its share.) + // Local helpers (intentionally not in SpringNaming — payload-specific. If a + // second generator needs them, lift them up the same way KotlinGenUtil holds + // its share.) // ------------------------------------------------------------------------- /** @@ -650,36 +545,19 @@ private static List collectEnumDecls(MetaObject vo) { return decls; } - /** First {@link MetaOrigin} child of {@code field}, or {@code null} when absent. */ - protected static MetaOrigin firstOriginChild(MetaField field) { - for (MetaData child : field.getChildren()) { - if (child instanceof MetaOrigin o) return o; - } - return null; - } - - /** - * Resolve a dotted {@code "Entity.field"} ref to the {@link MetaField} on - * Entity (by short name OR FQN match). Returns {@code null} when either - * half can't be resolved. - */ - private static MetaField resolveDottedFieldRef(MetaDataLoader loader, String dottedRef) { - String[] split = splitDottedRef(dottedRef); - if (split == null) return null; - MetaObject obj = resolveObjectByShortOrFqn(loader, split[0]); - if (obj == null) return null; - String fieldName = split[1]; - for (MetaField field : obj.getMetaFields()) { - if (field.getName().equals(fieldName) || shortName(field.getName()).equals(fieldName)) { - return field; - } - } - return null; - } - /** * Resolve a {@link MetaObject} (entity OR value) by exact FQN or by short * name. Returns {@code null} when neither matches. + * + *

    #270 stranded this helper — its last in-repo callers were the deleted + * {@code origin.*} dotted-ref resolvers — but it is KEPT: {@code protected} + * members of this deliberately-extensible generator are adopter subclass API, + * and removing one is an API break out of proportion to the cleanup (the same + * keep-and-record policy as Kotlin's {@code KotlinGenUtil.splitDottedRef}). + * Recorded in this module's {@code KNOWN_GAPS.md}; prune in a future MAJOR. + * NOTE: this is the bare-tail/first-match resolver — never use it for + * {@code @payloadRef} (that ref kind is ADR-0042 package-local; see + * {@link #resolveValueObject}). */ protected static MetaObject resolveObjectByShortOrFqn(MetaDataLoader loader, String ref) { for (MetaObject obj : loader.getMetaObjects()) { @@ -690,35 +568,24 @@ protected static MetaObject resolveObjectByShortOrFqn(MetaDataLoader loader, Str return null; } + /** Trailing segment after the last {@code ::}, or the whole input when no separator. */ + private static String shortName(String fqn) { + int idx = fqn.lastIndexOf("::"); + return idx < 0 ? fqn : fqn.substring(idx + 2); + } + /** * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities) * under the ADR-0042 package-local contract (#228): a bare ref resolves in * {@code referrerPkg} first, else root-level; an FQN ref matches exactly. Distinct - * from {@link #resolveObjectByShortOrFqn} (used only by the {@code origin.@from}/ - * {@code @of}/{@code @via} dotted-ref walk above, a different ref kind out of this - * fix's scope) — {@code @payloadRef} is the one every port's canonical resolver - * gates, matching the loader's own {@code ValidationPhase} validation of the same ref. + * from the bare-tail {@link #resolveObjectByShortOrFqn} — {@code @payloadRef} is + * the one every port's canonical resolver gates, matching the loader's own + * {@code ValidationPhase} validation of the same ref. */ public static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) { return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg); } - /** - * Split {@code "A.b"} into {@code ["A", "b"]}; {@code null} if the ref - * isn't a single-dot ref (no dot, leading dot, or trailing dot). - */ - private static String[] splitDottedRef(String ref) { - int dot = ref.indexOf('.'); - if (dot <= 0 || dot >= ref.length() - 1) return null; - return new String[] { ref.substring(0, dot), ref.substring(dot + 1) }; - } - - /** Trailing segment after the last {@code ::}, or the whole input when no separator. */ - private static String shortName(String fqn) { - int idx = fqn.lastIndexOf("::"); - return idx < 0 ? fqn : fqn.substring(idx + 2); - } - /** * Decide whether a record component gets a {@code hasFoo()} instance-method * helper, and return the method body if so. The rules mirror what 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 5df091467..6978ece0d 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 @@ -54,11 +54,13 @@ private SpringTypeMapper() { /* no instances */ } * fully-qualified type expression (e.g. {@code "Long"}, * {@code "java.time.Instant"}, {@code "java.util.UUID"}). * - *

    The returned string is inserted verbatim into the generated record - * component declaration. It never includes generic parameters — a future - * {@code List} (i.e. {@code isArray=true}) arm will need a - * separate code path because Java records don't allow varargs-style - * component declarations.

    + *

    The returned string is the ELEMENT type, inserted verbatim into the + * generated record component declaration. Array-ness ({@code isArray=true}) + * wrapping in {@code java.util.List} is the CALLER's concern — + * {@code SpringDtoGenerator.componentType} for DTO records and + * {@code SpringPayloadGenerator.resolveFieldType} for payload records both + * wrap; a Java record accepts a {@code List} component fine (the + * earlier varargs rationale here was wrong).

    * *

    Currency: returns {@code "Long"} — the wire/storage contract is * integer minor units (cents for USD, yen for JPY). Float arithmetic for diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringPayloadGeneratorTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringPayloadGeneratorTest.java index a8847d866..a10a8b1ab 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringPayloadGeneratorTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringPayloadGeneratorTest.java @@ -25,9 +25,9 @@ * Tests for {@link SpringPayloadGenerator}. Pins the per-template * payload-record contract: one Java record per {@code template.*} * (prompt / output / toolcall), named {@code Payload}, - * in {@code .prompts}, with components mirroring the - * {@code @payloadRef} value-object's scalar fields, honouring - * {@code origin.*} children. FR-006 / ADR-0010. + * in {@code .prompts}, with components typed from the + * {@code @payloadRef} value-object's DECLARED fields only — any + * {@code origin.*} child is IGNORED for typing (#270). FR-006 / ADR-0010. */ public class SpringPayloadGeneratorTest extends SharedRegistryTestBase { @@ -197,14 +197,18 @@ public void skipsTemplatesWithUnresolvedPayloadRef() throws Exception { } // ---------------------------------------------------------------------- - // origin.* coverage — FR-004 payload-VO field-value provenance - // (mirrors KotlinPayloadGeneratorTest) + // origin.* coverage — #270: payload typing is DECLARED-TYPE-AUTHORITATIVE. + // A field carrying any `origin.*` child types exactly as if the origin + // child were absent (matching the origin-blind TS / C# emitters and the + // converged Kotlin / Python ports; mirrors KotlinPayloadGeneratorTest). // ---------------------------------------------------------------------- @Test - public void originPassthroughResolvesSourceFieldType() throws Exception { - // PayloadVo.title carries `origin.passthrough @from "Source.title"`. - // Expected: emitted component uses Source.title's type (String). + public void originPassthroughIgnoredDeclaredTypeWins() throws Exception { + // #270 — PayloadVo.title is DECLARED `field.int` but carries + // `origin.passthrough @from "Source.title"` to a STRING source + // (`@convert: true` acknowledges the deliberate type change, #185). + // Expected: the DECLARED type (Integer), never the source's. String fixture = """ { "metadata.root": { "package": "acme::demo", "children": [ @@ -212,8 +216,8 @@ public void originPassthroughResolvesSourceFieldType() throws Exception { { "field.string": { "name": "title" } } ] } }, { "object.value": { "name": "ArticleSummary", "children": [ - { "field.string": { "name": "title", "children": [ - { "origin.passthrough": { "@from": "Source.title" } } + { "field.int": { "name": "title", "children": [ + { "origin.passthrough": { "@from": "Source.title", "@convert": true } } ] } } ] } }, { "template.prompt": { "name": "Article", @@ -234,14 +238,17 @@ public void originPassthroughResolvesSourceFieldType() throws Exception { Path emitted = outDir.resolve("acme/demo/prompts/ArticlePayload.java"); assertTrue("expected " + emitted, Files.exists(emitted)); String src = Files.readString(emitted); - assertTrue("expected `String title` from passthrough; saw:\n" + src, + assertTrue("expected DECLARED `Integer title` (passthrough ignored); saw:\n" + src, + src.contains("Integer title")); + assertFalse("must NOT take the @from source's String type; saw:\n" + src, src.contains("String title")); } @Test - public void originAggregateCountEmitsLong() throws Exception { - // PayloadVo.postCount has `origin.aggregate @agg count @of "Post.id" @via "Author.posts"`. - // Expected: emitted component is Long regardless of payload field's own subtype. + public void originAggregateCountIgnoredDeclaredTypeWins() throws Exception { + // #270 — PayloadVo.postCount is DECLARED `field.int` but carries + // `origin.aggregate @agg count`. Expected: the DECLARED type (Integer) — + // no more hardwired Long. String fixture = """ { "metadata.root": { "package": "acme::demo", "children": [ @@ -277,14 +284,17 @@ public void originAggregateCountEmitsLong() throws Exception { Path emitted = outDir.resolve("acme/demo/prompts/AuthorStatsPayload.java"); assertTrue("expected " + emitted, Files.exists(emitted)); String src = Files.readString(emitted); - assertTrue("expected `Long postCount` (count → Long); saw:\n" + src, + assertTrue("expected DECLARED `Integer postCount` (count no longer hardwires Long); saw:\n" + src, + src.contains("Integer postCount")); + assertFalse("must NOT hardwire Long; saw:\n" + src, src.contains("Long postCount")); } @Test - public void originAggregateAvgEmitsDouble() throws Exception { - // PayloadVo.avgScore has `origin.aggregate @agg avg @of "Post.score"`. - // Expected: emitted component is Double regardless of @of field's type. + public void originAggregateAvgIgnoredDeclaredTypeWins() throws Exception { + // #270 — PayloadVo.avgScore is DECLARED `field.float` but carries + // `origin.aggregate @agg avg`. Expected: the DECLARED type (Float) — + // no more hardwired Double. String fixture = """ { "metadata.root": { "package": "acme::demo", "children": [ @@ -298,7 +308,7 @@ public void originAggregateAvgEmitsDouble() throws Exception { { "field.long": { "name": "score" } } ] } }, { "object.value": { "name": "AuthorSummary", "children": [ - { "field.double": { "name": "avgScore", "children": [ + { "field.float": { "name": "avgScore", "children": [ { "origin.aggregate": { "@agg": "avg", "@of": "Post.score", "@via": "Author.posts" } } ] } } @@ -321,14 +331,16 @@ public void originAggregateAvgEmitsDouble() throws Exception { Path emitted = outDir.resolve("acme/demo/prompts/AuthorStatsPayload.java"); assertTrue("expected " + emitted, Files.exists(emitted)); String src = Files.readString(emitted); - assertTrue("expected `Double avgScore` (avg → Double); saw:\n" + src, + assertTrue("expected DECLARED `Float avgScore` (avg no longer hardwires Double); saw:\n" + src, + src.contains("Float avgScore")); + assertFalse("must NOT hardwire Double; saw:\n" + src, src.contains("Double avgScore")); } @Test - public void originAggregateSumEmitsSourceFieldType() throws Exception { - // PayloadVo.totalScore has `origin.aggregate @agg sum @of "Post.score"` where Post.score is field.long. - // Expected: emitted component takes the @of field's type (Long). + public void originAggregateSumIgnoredDeclaredTypeWins() throws Exception { + // #270 — PayloadVo.totalScore is DECLARED `field.int`; the `@agg sum` + // whose `@of` is a field.long no longer overrides it. String fixture = """ { "metadata.root": { "package": "acme::demo", "children": [ @@ -365,19 +377,19 @@ public void originAggregateSumEmitsSourceFieldType() throws Exception { Path emitted = outDir.resolve("acme/demo/prompts/AuthorStatsPayload.java"); assertTrue("expected " + emitted, Files.exists(emitted)); String src = Files.readString(emitted); - // payload field's own subtype is `field.int` (Integer) — sum/min/max must - // override it with the @of field's type (Long here). - assertTrue("expected `Long totalScore` (sum → @of field type); saw:\n" + src, + assertTrue("expected DECLARED `Integer totalScore` (sum no longer takes @of type); saw:\n" + src, + src.contains("Integer totalScore")); + assertFalse("must NOT take the @of field's Long type; saw:\n" + src, src.contains("Long totalScore")); } @Test - public void originCollectionEmitsListOfNestedPayload() throws Exception { - // PayloadVo.posts has `origin.collection @via "Author.posts"`. - // Expected: - // - parent payload emits `java.util.List posts` - // - a separate file PostPayload.java is also emitted in the same prompts/ package - // - PostPayload contains Post's primitive fields + public void originCollectionIgnoredNoNestedPayloadEmitted() throws Exception { + // #270 — PayloadVo.posts is DECLARED `field.string` but carries + // `origin.collection @via "Author.posts"`. Expected: + // - the DECLARED scalar type (`String posts`) — no List + // - NO PostPayload.java is emitted (a non-object field contributes no + // nested payload record; the @via target is never reached) String fixture = """ { "metadata.root": { "package": "acme::demo", "children": [ @@ -413,27 +425,21 @@ public void originCollectionEmitsListOfNestedPayload() throws Exception { Path parentFile = outDir.resolve("acme/demo/prompts/AuthorViewPayload.java"); Path nestedFile = outDir.resolve("acme/demo/prompts/PostPayload.java"); assertTrue("expected " + parentFile, Files.exists(parentFile)); - assertTrue("expected nested " + nestedFile, Files.exists(nestedFile)); + assertFalse("PostPayload.java must NOT be emitted (origin.collection is ignored)", + Files.exists(nestedFile)); String parentSrc = Files.readString(parentFile); - assertTrue("expected `java.util.List posts` on parent; saw:\n" + parentSrc, - parentSrc.contains("java.util.List posts")); - - String nestedSrc = Files.readString(nestedFile); - assertTrue("expected `public record PostPayload(`; saw:\n" + nestedSrc, - nestedSrc.contains("public record PostPayload(")); - assertTrue("expected `Long id` in nested; saw:\n" + nestedSrc, - nestedSrc.contains("Long id")); - assertTrue("expected `String title` in nested; saw:\n" + nestedSrc, - nestedSrc.contains("String title")); - assertTrue("expected `package acme.demo.prompts;` on nested; saw:\n" + nestedSrc, - nestedSrc.contains("package acme.demo.prompts;")); + assertTrue("expected DECLARED `String posts`; saw:\n" + parentSrc, + parentSrc.contains("String posts")); + assertFalse("must NOT type as List; saw:\n" + parentSrc, + parentSrc.contains("java.util.List")); } @Test - public void originCollectionDedupesNestedPayloadAcrossMultipleTemplates() throws Exception { - // Two templates reference the same collection target — the nested - // PostPayload should be emitted exactly once. + public void originCollectionIgnoredAcrossMultipleTemplates() throws Exception { + // #270 — two templates whose payload fields carry the same + // `origin.collection` both emit the declared scalar; NO nested + // PostPayload record exists anywhere in the run. String fixture = """ { "metadata.root": { "package": "acme::demo", "children": [ @@ -463,9 +469,9 @@ public void originCollectionDedupesNestedPayloadAcrossMultipleTemplates() throws ] } } """; - Path outDir = tempFolder.newFolder("payload-coll-dedupe").toPath(); - Path workspace = tempFolder.newFolder("payload-coll-dedupe-fx").toPath(); - MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "payload-coll-dedupe", fixture); + Path outDir = tempFolder.newFolder("payload-coll-multi").toPath(); + Path workspace = tempFolder.newFolder("payload-coll-multi-fx").toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "payload-coll-multi", fixture); SpringPayloadGenerator gen = new SpringPayloadGenerator(); Map args = new HashMap<>(); @@ -473,28 +479,355 @@ public void originCollectionDedupesNestedPayloadAcrossMultipleTemplates() throws gen.setArgs(args); gen.execute(loader); - // Both top-level payloads are emitted... - assertTrue(Files.exists(outDir.resolve("acme/demo/prompts/DetailViewPayload.java"))); - assertTrue(Files.exists(outDir.resolve("acme/demo/prompts/OverviewPayload.java"))); - // ...and exactly one PostPayload.java exists. - Path nested = outDir.resolve("acme/demo/prompts/PostPayload.java"); - assertTrue("expected single PostPayload.java", Files.exists(nested)); - - // Both parents must reference the SAME nested type by name. + // Both top-level payloads are emitted with the DECLARED scalar... String detailSrc = Files.readString(outDir.resolve("acme/demo/prompts/DetailViewPayload.java")); String overviewSrc = Files.readString(outDir.resolve("acme/demo/prompts/OverviewPayload.java")); - assertTrue("detail must reference List; saw:\n" + detailSrc, - detailSrc.contains("java.util.List posts")); - assertTrue("overview must reference List; saw:\n" + overviewSrc, - overviewSrc.contains("java.util.List posts")); - - // Sanity: prompts dir has exactly the 3 expected payload files. + assertTrue("detail must declare `String posts`; saw:\n" + detailSrc, + detailSrc.contains("String posts")); + assertTrue("overview must declare `String posts`; saw:\n" + overviewSrc, + overviewSrc.contains("String posts")); + // ...and NO PostPayload.java exists. + assertFalse("PostPayload.java must NOT be emitted", + Files.exists(outDir.resolve("acme/demo/prompts/PostPayload.java"))); + + // Sanity: prompts dir has exactly the 2 parent payload files. try (java.util.stream.Stream stream = Files.list(outDir.resolve("acme/demo/prompts"))) { - assertEquals("prompts dir should hold exactly 3 files (2 parents + 1 nested)", - 3L, stream.count()); + assertEquals("prompts dir should hold exactly 2 files (the 2 parents, no nested)", + 2L, stream.count()); } } + @Test + public void scalarArrayFieldEmitsListComponent() throws Exception { + // #270 fix round 2 — the declared contract is field. + @isArray + + // @objectRef. A plain `field.string isArray:true` (no origin child) must emit + // `java.util.List`, matching Kotlin (KotlinPayloadGenerator's scalar-array + // arm) and Python (type_map list[...] wrap). Previously the bare element type was + // emitted and the declared array-ness was silently dropped. + String fixture = """ + { + "metadata.root": { "package": "acme::demo", "children": [ + { "object.value": { "name": "TagList", "children": [ + { "field.string": { "name": "title" } }, + { "field.string": { "name": "tags", "isArray": true } } + ] } }, + { "template.prompt": { "name": "TagView", + "@payloadRef": "TagList", "@textRef": "demo/tags" } } + ] } + } + """; + Path outDir = tempFolder.newFolder("payload-scalar-array").toPath(); + Path workspace = tempFolder.newFolder("payload-scalar-array-fx").toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "payload-scalar-array", fixture); + + SpringPayloadGenerator gen = new SpringPayloadGenerator(); + Map args = new HashMap<>(); + args.put("outputDir", outDir.toString()); + gen.setArgs(args); + gen.execute(loader); + + Path emitted = outDir.resolve("acme/demo/prompts/TagViewPayload.java"); + assertTrue("expected " + emitted, Files.exists(emitted)); + String src = Files.readString(emitted); + assertTrue("declared @isArray scalar must emit `java.util.List tags`; saw:\n" + src, + src.contains("java.util.List tags")); + assertFalse("array-ness must not be dropped to a bare `String tags`; saw:\n" + src, + src.contains(" String tags")); + // The non-array sibling stays bare. + assertTrue("plain `String title` unchanged; saw:\n" + src, src.contains("String title")); + // hasFoo() routing: the List component takes the isEmpty form (not isBlank). + assertTrue("List component gets the isEmpty hasTags() helper; saw:\n" + src, + src.contains("public boolean hasTags()") + && src.contains("return tags != null && !tags.isEmpty();")); + } + + @Test + public void scalarArrayWithDisagreeingOriginCollectionStillEmitsListOfElementType() throws Exception { + // #270 fix round 2 — pins the regression shape specifically: a `field.string + // isArray:true` carrying a disagreeing `origin.collection @via` previously took + // the collection arm (List); post-#270 it must type from the + // DECLARATION as `java.util.List` — never a bare `String` (the interim + // regression) and never the @via entity's payload. + String fixture = """ + { + "metadata.root": { "package": "acme::demo", "children": [ + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "posts", + "@objectRef": "Post", "@cardinality": "many" } } + ] } }, + { "object.entity": { "name": "Post", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "internalNotes" } } + ] } }, + { "object.value": { "name": "TagList", "children": [ + { "field.string": { "name": "tags", "isArray": true, "children": [ + { "origin.collection": { "@via": "Author.posts" } } + ] } } + ] } }, + { "template.prompt": { "name": "TagView", + "@payloadRef": "TagList", "@textRef": "demo/tags" } } + ] } + } + """; + Path outDir = tempFolder.newFolder("payload-scalar-array-origin").toPath(); + Path workspace = tempFolder.newFolder("payload-scalar-array-origin-fx").toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "payload-scalar-array-origin", fixture); + + SpringPayloadGenerator gen = new SpringPayloadGenerator(); + Map args = new HashMap<>(); + args.put("outputDir", outDir.toString()); + gen.setArgs(args); + gen.execute(loader); + + Path emitted = outDir.resolve("acme/demo/prompts/TagViewPayload.java"); + assertTrue("expected " + emitted, Files.exists(emitted)); + String src = Files.readString(emitted); + assertTrue("declared List wins over the ignored origin; saw:\n" + src, + src.contains("java.util.List tags")); + assertFalse("must NOT drop to a bare String; saw:\n" + src, + src.contains(" String tags")); + assertFalse("must NOT take the @via entity's payload type; saw:\n" + src, + src.contains("PostPayload")); + assertFalse("PostPayload.java must NOT be emitted", + Files.exists(outDir.resolve("acme/demo/prompts/PostPayload.java"))); + } + + @Test + public void disagreeingOriginCollectionDeclaredObjectRefWins() throws Exception { + // #270 load-bearing disagreement test — the payload field DECLARES a curated + // value-object (`field.object @objectRef: Highlight, isArray: true`) AND carries + // an `origin.collection @via "Author.posts"` walking to a DIFFERENT, fuller + // entity (Post). Expected: + // (a) `java.util.List posts` — the DECLARATION wins; + // (b) HighlightPayload.java (the curated VO) is emitted, PostPayload.java is + // NOT — the silent payload-bloat leak the prompt pillar exists to prevent. + String fixture = """ + { + "metadata.root": { "package": "acme::demo", "children": [ + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "posts", + "@objectRef": "Post", "@cardinality": "many" } } + ] } }, + { "object.entity": { "name": "Post", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "title" } }, + { "field.string": { "name": "body" } }, + { "field.string": { "name": "internalNotes" } } + ] } }, + { "object.value": { "name": "Highlight", "children": [ + { "field.string": { "name": "snippet" } } + ] } }, + { "object.value": { "name": "AuthorDigest", "children": [ + { "field.object": { "name": "posts", "@objectRef": "Highlight", + "isArray": true, "children": [ + { "origin.collection": { "@via": "Author.posts" } } + ] } } + ] } }, + { "template.prompt": { "name": "AuthorDigestView", + "@payloadRef": "AuthorDigest", "@textRef": "demo/digest" } } + ] } + } + """; + Path outDir = tempFolder.newFolder("payload-disagree").toPath(); + Path workspace = tempFolder.newFolder("payload-disagree-fx").toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "payload-disagree", fixture); + + SpringPayloadGenerator gen = new SpringPayloadGenerator(); + Map args = new HashMap<>(); + args.put("outputDir", outDir.toString()); + gen.setArgs(args); + gen.execute(loader); + + Path parentFile = outDir.resolve("acme/demo/prompts/AuthorDigestViewPayload.java"); + Path curatedFile = outDir.resolve("acme/demo/prompts/HighlightPayload.java"); + Path entityFile = outDir.resolve("acme/demo/prompts/PostPayload.java"); + assertTrue("expected " + parentFile, Files.exists(parentFile)); + assertTrue("expected curated " + curatedFile, Files.exists(curatedFile)); + assertFalse("PostPayload.java (the @via entity) must NOT be emitted", + Files.exists(entityFile)); + + String parentSrc = Files.readString(parentFile); + // (a) DECLARED wins: @objectRef + isArray, not the @via walk. + assertTrue("expected `java.util.List posts`; saw:\n" + parentSrc, + parentSrc.contains("java.util.List posts")); + assertFalse("parent must NOT reference PostPayload; saw:\n" + parentSrc, + parentSrc.contains("PostPayload")); + + String curatedSrc = Files.readString(curatedFile); + // (b) the closure emits the curated VO's shape, not the fuller entity's. + assertTrue("expected `public record HighlightPayload(`; saw:\n" + curatedSrc, + curatedSrc.contains("public record HighlightPayload(")); + assertTrue("expected `String snippet`; saw:\n" + curatedSrc, + curatedSrc.contains("String snippet")); + assertFalse("curated record must NOT carry the entity's fields; saw:\n" + curatedSrc, + curatedSrc.contains("internalNotes")); + } + + /** + * #270 / ADR-0044 name-map gate (positive direction) — a {@code field.object} + * {@code @objectRef} that ALSO carries an origin child still contributes its + * DECLARED edge to the name-map closure. Two same-short-named {@code Note} VOs + * (one reached through the origin-carrying field, one plain) must BOTH receive + * package-qualified names; if the origin-carrying edge were dropped from the + * closure, the collision would go undetected and both would fall back to a + * clobbered bare {@code NotePayload}. + */ + @Test + public void originCarryingObjectFieldStaysInNameMapClosure() throws Exception { + Path fxDir = tempFolder.newFolder("xpkg-origin-pos-fx").toPath(); + Files.writeString(fxDir.resolve("meta.alpha.json"), """ + { + "metadata.root": { "package": "acme::alpha", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "alphaText" } } + ] } } + ] } + } + """); + Files.writeString(fxDir.resolve("meta.beta.json"), """ + { + "metadata.root": { "package": "acme::beta", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "betaText" } } + ] } } + ] } + } + """); + Files.writeString(fxDir.resolve("meta.app.json"), """ + { + "metadata.root": { "package": "acme::app", "children": [ + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "posts", + "@objectRef": "Post", "@cardinality": "many" } } + ] } }, + { "object.entity": { "name": "Post", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "internalNotes" } } + ] } }, + { "object.value": { "name": "Digest", "children": [ + { "field.object": { "name": "fromAlpha", + "@objectRef": "acme::alpha::Note", "children": [ + { "origin.collection": { "@via": "Author.posts" } } + ] } }, + { "field.object": { "name": "fromBeta", + "@objectRef": "acme::beta::Note" } } + ] } }, + { "template.output": { "name": "DigestDoc", + "@payloadRef": "Digest", "@textRef": "app/digest", "@format": "json" } } + ] } + } + """); + + Path outDir = tempFolder.newFolder("xpkg-origin-pos").toPath(); + MetaDataLoader loader = loadMultiFile("xpkg-origin-pos", + fxDir.resolve("meta.alpha.json"), + fxDir.resolve("meta.beta.json"), + fxDir.resolve("meta.app.json")); + + SpringPayloadGenerator gen = new SpringPayloadGenerator(); + Map args = new HashMap<>(); + args.put("outputDir", outDir.toString()); + gen.setArgs(args); + gen.execute(loader); + + Path prompts = outDir.resolve("acme/app/prompts"); + assertTrue("expected AcmeAlphaNotePayload.java (origin-carrying declared edge stays in closure)", + Files.exists(prompts.resolve("AcmeAlphaNotePayload.java"))); + assertTrue("expected AcmeBetaNotePayload.java", + Files.exists(prompts.resolve("AcmeBetaNotePayload.java"))); + assertFalse("must NOT emit a clobbered bare NotePayload.java", + Files.exists(prompts.resolve("NotePayload.java"))); + assertFalse("must NOT emit PostPayload.java (the ignored @via entity)", + Files.exists(prompts.resolve("PostPayload.java"))); + String digest = Files.readString(prompts.resolve("DigestDocPayload.java")); + assertTrue("fromAlpha must type AcmeAlphaNotePayload; saw:\n" + digest, + digest.contains("AcmeAlphaNotePayload fromAlpha")); + assertTrue("fromBeta must type AcmeBetaNotePayload; saw:\n" + digest, + digest.contains("AcmeBetaNotePayload fromBeta")); + } + + /** + * #270 / ADR-0044 name-map gate (negative direction) — a field carrying ONLY + * {@code origin.collection} (a non-object field) contributes NOTHING to the + * name-map closure. The {@code @via} walk reaches {@code acme::beta::Note}, + * which shares a bare short name with the declared {@code acme::alpha::Note}; + * were the retired collection edge still in the closure, the two would collide + * and both would qualify. Instead the declared Note stays BARE. + */ + @Test + public void originCollectionOnlyFieldContributesNothingToNameMap() throws Exception { + Path fxDir = tempFolder.newFolder("xpkg-origin-neg-fx").toPath(); + Files.writeString(fxDir.resolve("meta.alpha.json"), """ + { + "metadata.root": { "package": "acme::alpha", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "alphaText" } } + ] } } + ] } + } + """); + Files.writeString(fxDir.resolve("meta.beta.json"), """ + { + "metadata.root": { "package": "acme::beta", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "betaText" } } + ] } } + ] } + } + """); + Files.writeString(fxDir.resolve("meta.app.json"), """ + { + "metadata.root": { "package": "acme::app", "children": [ + { "object.entity": { "name": "Author", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.aggregation": { "name": "notes", + "@objectRef": "acme::beta::Note", "@cardinality": "many" } } + ] } }, + { "object.value": { "name": "Digest", "children": [ + { "field.object": { "name": "fromAlpha", + "@objectRef": "acme::alpha::Note" } }, + { "field.string": { "name": "posts", "children": [ + { "origin.collection": { "@via": "Author.notes" } } + ] } } + ] } }, + { "template.output": { "name": "DigestDoc", + "@payloadRef": "Digest", "@textRef": "app/digest", "@format": "json" } } + ] } + } + """); + + Path outDir = tempFolder.newFolder("xpkg-origin-neg").toPath(); + MetaDataLoader loader = loadMultiFile("xpkg-origin-neg", + fxDir.resolve("meta.alpha.json"), + fxDir.resolve("meta.beta.json"), + fxDir.resolve("meta.app.json")); + + SpringPayloadGenerator gen = new SpringPayloadGenerator(); + Map args = new HashMap<>(); + args.put("outputDir", outDir.toString()); + gen.setArgs(args); + gen.execute(loader); + + Path prompts = outDir.resolve("acme/app/prompts"); + // The declared Note is UNIQUE in the closure (the origin-only field + // contributes nothing), so it keeps its bare name. + Path bare = prompts.resolve("NotePayload.java"); + assertTrue("expected bare NotePayload.java (no collision without the origin edge)", + Files.exists(bare)); + assertTrue("bare NotePayload must carry the DECLARED alpha shape", + Files.readString(bare).contains("String alphaText")); + assertFalse("must NOT package-qualify (no collision): AcmeAlphaNotePayload.java", + Files.exists(prompts.resolve("AcmeAlphaNotePayload.java"))); + assertFalse("must NOT package-qualify (no collision): AcmeBetaNotePayload.java", + Files.exists(prompts.resolve("AcmeBetaNotePayload.java"))); + String digest = Files.readString(prompts.resolve("DigestDocPayload.java")); + assertTrue("posts must be the DECLARED String scalar; saw:\n" + digest, + digest.contains("String posts")); + } + // ── field.object support (no origin) ────────────────────────────────── @Test @@ -599,8 +932,9 @@ public void fieldObjectIsArrayEmitsListType() throws Exception { @Test public void fieldObjectMixedFieldsAllSurviveIntoParent() throws Exception { // Cover the multi-shape case: scalar + single field.object + isArray - // field.object + a passthrough origin. All four must reach the parent - // record; previous scalarFields() filter dropped the two object refs. + // field.object + a field carrying an (ignored, #270) passthrough origin. + // All four must reach the parent record with their DECLARED types; + // previous scalarFields() filter dropped the two object refs. String fixture = """ { "metadata.root": { "package": "acme::ai", "children": [ diff --git a/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py b/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py index b54c5be88..05276010b 100644 --- a/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py +++ b/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py @@ -10,19 +10,18 @@ and output parsing (matches the Java payload-VO ↔ Java output-parser handoff). Each generated file declares a Pydantic v2 ``BaseModel`` per template. Field -types are origin-aware: a payload-VO field may carry an ``origin.*`` child that -declares how the value is derived, and the field's annotation is resolved as: - -* ``origin.passthrough`` (``@from "Entity.field"``) — type of the source field. -* ``origin.aggregate`` (``@agg count``) — ``int``. - (``@agg avg``) — ``float``. - (``@agg sum``/``min``/``max``) — type of ``@of`` field. -* ``origin.collection`` (``@via "Parent.relName"``) — ``list[Payload]``, - and the nested ``Payload`` is emitted into the SAME file - (so callers ``from .