@@ -3,6 +3,7 @@ package com.metaobjects.generator.kotlin
33import com.metaobjects.field.EnumField
44import com.metaobjects.field.MetaField
55import com.metaobjects.field.ObjectField
6+ import com.metaobjects.generator.GeneratorException
67import com.metaobjects.generator.GeneratorIOWriter
78import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
89import com.metaobjects.loader.MetaDataLoader
@@ -59,6 +60,14 @@ import java.nio.file.Paths
5960 */
6061open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase <MetaObject >() {
6162
63+ companion object {
64+ // ADR-0044 backstop error code — a codegen-time (not loader) error, peer of the
65+ // render tier's ERR_VAR_NOT_ON_PAYLOAD. Declared LOCALLY (as in the TS/C#/Python/
66+ // Java ports) rather than in the shared cross-port ledger; promoted to the ledger
67+ // once the coordinated follow-up lands, so no port reddens on a code it doesn't emit.
68+ const val ERR_PAYLOAD_NAME_COLLISION = " ERR_PAYLOAD_NAME_COLLISION"
69+ }
70+
6271 override fun getFilterClass (): Class <MetaObject > = MetaObject ::class .java
6372
6473 override fun execute (loader : MetaDataLoader ) {
@@ -73,9 +82,142 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
7382 // abstract enum super collapse onto ONE emitted file.
7483 val emittedEnumFqns = mutableSetOf<String >()
7584 // ADR-0039: root-scan discipline — resolving children accessor.
76- for (md in loader.root.getChildren(MetaTemplate ::class .java, true )) {
77- emit(md, loader, outRoot, emittedNestedFqns, emittedEnumFqns)
85+ val templates = loader.root.getChildren(MetaTemplate ::class .java, true )
86+ .sortedBy { it.name }
87+ // ADR-0044 — collision-scoped nested-payload class names, a pure function of the
88+ // loaded templates (order-independent). Keyed by VO FQN.
89+ val nameMap = computePayloadNameMap(templates, loader)
90+ for (md in templates) {
91+ emit(md, loader, outRoot, emittedNestedFqns, emittedEnumFqns, nameMap)
92+ }
93+ }
94+
95+ /* *
96+ * ADR-0044 pass 1/2 — the run's nested-payload name map, keyed by value-object FQN
97+ * (`MetaObject.name`), scoped per OUTPUT PACKAGE. Kotlin is a one-class-per-file
98+ * emitter (KotlinPoet `FileSpec(outPkg, className)`), so its collision domain is the
99+ * output prompts package: two value-objects sharing a bare short name written into
100+ * the same package would clobber one `NotePayload.kt`. A nested VO whose bare short
101+ * name is UNIQUE in its output package emits `<Short>Payload` (byte-identical to
102+ * pre-ADR-0044 output); a COLLISION emits every member under its package-qualified
103+ * derived name (`acme::alpha::Note` -> `AcmeAlphaNotePayload`). A still-colliding
104+ * derived name fails loud with [ERR_PAYLOAD_NAME_COLLISION]. Pure function of the
105+ * templates — never of emission order.
106+ */
107+ protected open fun computePayloadNameMap (
108+ templates : List <MetaTemplate >,
109+ loader : MetaDataLoader ,
110+ ): Map <String , String > {
111+ // FQN -> output package (first reaching template in sorted order wins, matching
112+ // the run-wide dedupe). The primary VO is template-named, so excluded.
113+ val voOutPkg = LinkedHashMap <String , String >()
114+ val orderedFqns = ArrayList <String >()
115+ for (tmpl in templates) {
116+ val payloadRef = tmpl.payloadRef ? : continue
117+ val vo = resolveViewObject(loader, payloadRef) ? : continue
118+ val nestedPkg = KotlinNaming .promptsPackage(PackageMapping .splitFqn(tmpl.name).first)
119+ collectNestedClosure(vo, loader, nestedPkg, voOutPkg, orderedFqns, mutableSetOf (vo.name))
120+ }
121+ // Group by (output package, bare short name).
122+ val byPkgShort = LinkedHashMap <String , MutableList <String >>()
123+ for (fqn in orderedFqns) {
124+ val key = voOutPkg[fqn] + " " + PackageMapping .splitFqn(fqn).second
125+ byPkgShort.getOrPut(key) { ArrayList () }.add(fqn)
126+ }
127+ val nameMap = LinkedHashMap <String , String >()
128+ for (fqns in byPkgShort.values) {
129+ if (fqns.size == 1 ) {
130+ val fqn = fqns[0 ]
131+ nameMap[fqn] = KotlinNaming .payloadName(PackageMapping .splitFqn(fqn).second)
132+ } else {
133+ for (fqn in fqns) {
134+ val (pkg, short) = PackageMapping .splitFqn(fqn)
135+ nameMap[fqn] = KotlinNaming .payloadName(packageQualifiedName(pkg, short))
136+ }
137+ }
138+ }
139+ // Backstop — per output package, two DISTINCT FQNs deriving the same class name.
140+ // Sorted so the named pair (and whether any collision fires) is order-independent.
141+ val ownerByPkgName = HashMap <String , String >()
142+ for (fqn in nameMap.keys.sorted()) {
143+ val pkgName = voOutPkg[fqn] + " " + nameMap[fqn]
144+ val prev = ownerByPkgName.putIfAbsent(pkgName, fqn)
145+ if (prev != null && prev != fqn) {
146+ throw GeneratorException (
147+ " $ERR_PAYLOAD_NAME_COLLISION : payload record name collision: \" ${nameMap[fqn]} \" " +
148+ " derives from both \" $prev \" and \" $fqn \" — rename one value-object or move " +
149+ " it to a package that derives a distinct name"
150+ )
151+ }
152+ }
153+ return nameMap
154+ }
155+
156+ /* *
157+ * ADR-0044 pass 1 — walk [vo]'s transitive nested-payload closure (plain
158+ * `field.object @objectRef` + `origin.collection @via` edges), assigning each
159+ * not-yet-seen target VO to [outPkg] (first reaching template wins) and recording it
160+ * in [orderedFqns]. [seen] is seeded with the primary VO's FQN and is the cycle guard.
161+ */
162+ protected fun collectNestedClosure (
163+ vo : MetaObject ,
164+ loader : MetaDataLoader ,
165+ outPkg : String ,
166+ voOutPkg : MutableMap <String , String >,
167+ orderedFqns : MutableList <String >,
168+ seen : MutableSet <String >,
169+ ) {
170+ for (field in vo.metaFields) {
171+ val target = nestedTargetOf(field, loader) ? : continue
172+ val fqn = target.name
173+ if (! seen.add(fqn)) continue
174+ if (! voOutPkg.containsKey(fqn)) {
175+ voOutPkg[fqn] = outPkg
176+ orderedFqns.add(fqn)
177+ }
178+ collectNestedClosure(target, loader, outPkg, voOutPkg, orderedFqns, seen)
179+ }
180+ }
181+
182+ /* *
183+ * The nested-payload target VO a [field] contributes to the closure, or `null` when
184+ * it contributes no nested class. Mirrors the resolution in [resolveObjectFieldType]
185+ * (plain `field.object @objectRef`) and [resolveCollectionType] (`origin.collection
186+ * @via`) EXACTLY, so the closure walk and the emission walk agree. Passthrough /
187+ * aggregate / computed / first origins yield scalar types (no nested class).
188+ */
189+ protected fun nestedTargetOf (field : MetaField <* >, loader : MetaDataLoader ): MetaObject ? {
190+ val origin = field.children.filterIsInstance<MetaOrigin >().firstOrNull()
191+ if (origin is CollectionOrigin ) {
192+ val via = origin.via ? : return null
193+ val (parentName, relName) = KotlinGenUtil .splitDottedRef(via) ? : return null
194+ val parent = KotlinGenUtil .resolveObjectByShortOrFqn(loader, parentName) ? : return null
195+ val rel = parent.relationships
196+ .firstOrNull { it.name == relName || it.name.substringAfterLast(" ::" ) == relName }
197+ ? : return null
198+ val targetRef = rel.objectRef ? : return null
199+ return KotlinGenUtil .resolveObjectByShortOrFqn(loader, targetRef)
200+ }
201+ if (origin != null ) return null // passthrough / aggregate / computed / first -> scalar
202+ if (field is ObjectField ) {
203+ val target = try { field.objectRef } catch (e: RuntimeException ) { null } ? : return null
204+ if (target.subType != MetaObject .SUBTYPE_VALUE ) return null
205+ return target
78206 }
207+ return null
208+ }
209+
210+ /* *
211+ * ADR-0044 — PascalCase each dotted segment of [kotlinPkg] (already `::`->`.`
212+ * converted by [PackageMapping.splitFqn]), concatenate, append the bare [shortName]
213+ * (`"acme.alpha"` + `"Note"` -> `"AcmeAlphaNote"`). A root-level (empty-package) node
214+ * keeps its bare short name.
215+ */
216+ protected fun packageQualifiedName (kotlinPkg : String , shortName : String ): String {
217+ if (kotlinPkg.isEmpty()) return shortName
218+ return kotlinPkg.split(" ." )
219+ .filter { it.isNotEmpty() }
220+ .joinToString(" " ) { it.replaceFirstChar { c -> c.uppercaseChar() } } + shortName
79221 }
80222
81223 protected open fun emit (
@@ -84,6 +226,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
84226 outRoot : Path ,
85227 emittedNestedFqns : MutableSet <String >,
86228 emittedEnumFqns : MutableSet <String >,
229+ nameMap : Map <String , String >,
87230 ) {
88231 val payloadRef = template.payloadRef ? : return
89232 val payloadVo = resolveViewObject(loader, payloadRef) ? : return
@@ -101,6 +244,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
101244 outRoot = outRoot,
102245 emittedNestedFqns = emittedNestedFqns,
103246 emittedEnumFqns = emittedEnumFqns,
247+ nameMap = nameMap,
104248 )
105249 }
106250
@@ -119,6 +263,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
119263 outRoot : Path ,
120264 emittedNestedFqns : MutableSet <String >,
121265 emittedEnumFqns : MutableSet <String >,
266+ nameMap : Map <String , String >,
122267 ) {
123268 val serializable = ClassName (" kotlinx.serialization" , " Serializable" )
124269
@@ -129,7 +274,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
129274
130275 val ctorBuilder = FunSpec .constructorBuilder()
131276 for (field in voObject.metaFields) {
132- val type = resolveFieldType(field, voObject, loader, outPkg, outRoot, emittedNestedFqns, emittedEnumFqns)
277+ val type = resolveFieldType(field, voObject, loader, outPkg, outRoot, emittedNestedFqns, emittedEnumFqns, nameMap )
133278 ctorBuilder.addParameter(ParameterSpec .builder(field.name, type).build())
134279 typeBuilder.addProperty(
135280 PropertySpec .builder(field.name, type).initializer(field.name).build()
@@ -156,6 +301,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
156301 outRoot : Path ,
157302 emittedNestedFqns : MutableSet <String >,
158303 emittedEnumFqns : MutableSet <String >,
304+ nameMap : Map <String , String >,
159305 ): TypeName {
160306 // ADR-0039/ADR-0029: origin.* NEVER inherits — a derived field's origin is
161307 // declared-here, so read OWN children (field.children), not resolving.
@@ -166,7 +312,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
166312 is PassthroughOrigin -> resolvePassthroughType(origin, loader, field)
167313 is AggregateOrigin -> resolveAggregateType(origin, loader, field)
168314 is CollectionOrigin -> resolveCollectionType(
169- origin, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, field
315+ origin, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, field, nameMap
170316 )
171317 // #195 origin.computed: a row-level value; its type is the field's own declared
172318 // subType (validation pins the inferred root type == field subType). Conservative
@@ -200,7 +346,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
200346 // and return its type (single, or List<TargetPayload> when isArray). Mirrors the
201347 // Spring port's resolveObjectFieldType — needed so a nested-object payload compiles.
202348 if (field is ObjectField ) {
203- return resolveObjectFieldType(field, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns)
349+ return resolveObjectFieldType(field, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, nameMap )
204350 }
205351
206352 // Scalar array (`isArray: true` on a non-object field): model as List<ElementType> in the
@@ -227,6 +373,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
227373 outRoot : Path ,
228374 emittedNestedFqns : MutableSet <String >,
229375 emittedEnumFqns : MutableSet <String >,
376+ nameMap : Map <String , String >,
230377 ): TypeName {
231378 val fallbackType = { KotlinTypeMapper .payloadTypeName(field) }
232379 val target = try {
@@ -236,7 +383,10 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
236383 } ? : return fallbackType()
237384 if (target.subType != MetaObject .SUBTYPE_VALUE ) return fallbackType()
238385
239- val nestedClassName = PackageMapping .splitFqn(target.name).second + " Payload"
386+ // ADR-0044 — class name (declaration, file, reference) from the collision-scoped
387+ // name map (bare when unique in the output package, package-qualified on collision).
388+ val nestedClassName = nameMap[target.name]
389+ ? : (PackageMapping .splitFqn(target.name).second + " Payload" )
240390 if (emittedNestedFqns.add(target.name)) {
241391 emitPayloadClass(
242392 outPkg = nestedPkg,
@@ -247,6 +397,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
247397 outRoot = outRoot,
248398 emittedNestedFqns = emittedNestedFqns,
249399 emittedEnumFqns = emittedEnumFqns,
400+ nameMap = nameMap,
250401 )
251402 }
252403
@@ -344,6 +495,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
344495 emittedNestedFqns : MutableSet <String >,
345496 emittedEnumFqns : MutableSet <String >,
346497 fallbackField : MetaField <* >,
498+ nameMap : Map <String , String >,
347499 ): TypeName {
348500 val fallbackType = { KotlinTypeMapper .payloadTypeName(fallbackField) }
349501 val via = origin.via ? : return fallbackType()
@@ -356,7 +508,9 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
356508 ? : return fallbackType()
357509 val targetRef = relationship.objectRef ? : return fallbackType()
358510 val target = KotlinGenUtil .resolveObjectByShortOrFqn(loader, targetRef) ? : return fallbackType()
359- val nestedClassName = PackageMapping .splitFqn(target.name).second + " Payload"
511+ // ADR-0044 — collision-scoped class name (see resolveObjectFieldType).
512+ val nestedClassName = nameMap[target.name]
513+ ? : (PackageMapping .splitFqn(target.name).second + " Payload" )
360514
361515 if (emittedNestedFqns.add(target.name)) {
362516 emitPayloadClass(
@@ -368,6 +522,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
368522 outRoot = outRoot,
369523 emittedNestedFqns = emittedNestedFqns,
370524 emittedEnumFqns = emittedEnumFqns,
525+ nameMap = nameMap,
371526 )
372527 }
373528
0 commit comments