From c5ad080292204e6897c3733d4ce1f59d9fde9ca9 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 6 Aug 2026 22:01:38 -0400 Subject: [PATCH 1/6] fix(codegen): payload typing is declared-type-authoritative in Kotlin + Python (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt pillar's contract is that a prompt's payload is a typed projection the author DECLARES, so payload bloat shows up as a diff. The Kotlin and Python payload-VO generators broke that contract by deriving a field's type from its origin.* child: origin.collection discarded a declared curated @objectRef and substituted the @via relationship's full target entity (silent payload bloat, invisible in a diff); @agg count hardwired Long/int over the declared subtype; passthrough/computed/first overrode declared types and nullability. TS, C# and Java were already origin-blind — the correct reference behavior. Both ports now type a payload field ONLY from its declared field. + isArray + @objectRef, take nullability ONLY from the declared @required, and walk the nested-payload closure ONLY over declared field.object @objectRef edges — a field carrying any origin.* child types exactly as if the child were absent, and a non-object field with origin.collection contributes no nested class. The origin.collection edge is deleted in lockstep from the ADR-0044 name-map closure both ports share with their extract tier (#228). Kotlin: KotlinPayloadGenerator drops the origin dispatch and its five private resolvers; KotlinGenUtil.nestedTargetOf keeps only the declared @objectRef -> object.value edge. Python: payload_vo_generator drops _find_origin_child, the three origin resolvers, and the origin closure edge in _nested_target_of, plus their now-orphaned dotted-ref helpers. New disagreement tests in both ports pin declared-wins when a curated @objectRef and a fuller origin.collection @via disagree. The payload-with-origins snapshot (declared == derived by construction) is byte-identical, as is all other Kotlin snapshot output. Docs: the CLAUDE.md open-questions bullet on codegen-spring payload origin resolution is closed as moot (origin-blindness is now the contract, and the KNOWN_GAPS entry it cited no longer exists); roadmap marks #270 shipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE --- CLAUDE.md | 1 - .../generator/kotlin/KotlinGenUtil.kt | 41 +-- .../kotlin/KotlinPayloadGenerator.kt | 231 ++---------- .../kotlin/KotlinPayloadGeneratorTest.kt | 180 +++++++--- .../generators/payload_vo_generator.py | 331 ++++-------------- .../codegen/test_payload_vo_generator.py | 149 +++++--- spec/roadmap.md | 2 +- 7 files changed, 336 insertions(+), 599 deletions(-) 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/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..ab87f6358 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,22 @@ 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 likewise comes only from the declaration, never from + * origin semantics. 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# / Java payload + * emitters. 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 +61,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 +112,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 +150,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# / Java payload 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 +168,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 +185,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,8 +203,8 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() { } /** - * Naked `field.object @objectRef`: recursively emit `Payload` for the - * referenced value-object (deduped per run) and return that type — or + * Declared `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). @@ -282,141 +252,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/KotlinPayloadGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt index 5d875d9e0..aa33e084b 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# / Java 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/python/src/metaobjects/codegen/generators/payload_vo_generator.py b/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py index b54c5be88..b7195f943 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 .