Skip to content

Commit 39e5be9

Browse files
committed
Merge codegen-kotlin Phase G — typed Kotlin enum class generation
field.enum now emits a proper Kotlin enum class instead of String: - KotlinTypeMapper.enumTypeName(field, entity) computes <EntityShort><FieldPascal> (e.g., Player.status → PlayerStatus) - KotlinEntityGenerator walks entity's field.enum children and emits a separate <EntityShort><FieldPascal>.kt with @serializable enum class; entity property uses the typed enum class instead of String - KotlinExposedTableGenerator emits enumerationByName("col", 64, EnumClass::class) instead of varchar Legacy KotlinTypeMapper.kotlinTypeName/exposedColumnSpec API preserved (still returns String/varchar for EnumField) — typed dispatch happens entirely in the generators via the new enumTypeName() helper. 49 module tests green (45 baseline + 4 new enum tests).
2 parents 56094a3 + 6c84b70 commit 39e5be9

6 files changed

Lines changed: 212 additions & 7 deletions

File tree

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.metaobjects.generator.kotlin
22

3+
import com.metaobjects.field.EnumField
34
import com.metaobjects.field.MetaField
45
import com.metaobjects.field.ObjectField
56
import com.metaobjects.generator.GeneratorIOWriter
@@ -54,6 +55,12 @@ class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
5455
}
5556

5657
private fun emit(obj: MetaObject, outRoot: Path, loader: MetaDataLoader) {
58+
// Emit one Kotlin enum class file per `field.enum` child BEFORE the data class
59+
// so the resolved property type (a ClassName) points at a real file.
60+
for (field in obj.metaFields) {
61+
if (field is EnumField) emitEnumFile(obj, field, outRoot)
62+
}
63+
5764
val (pkg, shortName) = PackageMapping.splitFqn(obj.name)
5865
val serializable = ClassName("kotlinx.serialization", "Serializable")
5966

@@ -64,7 +71,7 @@ class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
6471

6572
val ctorBuilder = FunSpec.constructorBuilder()
6673
for (field in obj.metaFields) {
67-
val baseType = resolvePropertyType(field, loader)
74+
val baseType = resolvePropertyType(field, obj, loader)
6875
val nullable = !KotlinGenUtil.isRequiredField(field)
6976
val propType = if (nullable) baseType.copy(nullable = true) else baseType
7077
val propName = field.name
@@ -84,14 +91,54 @@ class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
8491
fileSpec.writeTo(outRoot)
8592
}
8693

94+
/**
95+
* Emit a top-level `@Serializable enum class` for a [field.enum] hung off [owner].
96+
* Members come from the field's required `@values` string-array attr — emitted
97+
* verbatim to preserve case (typically SCREAMING_SNAKE_CASE per spec).
98+
*/
99+
private fun emitEnumFile(owner: MetaObject, field: EnumField, outRoot: Path) {
100+
val enumClassName = KotlinTypeMapper.enumTypeName(field, owner) ?: return
101+
val members = readEnumValues(field)
102+
if (members.isNullOrEmpty()) {
103+
// Defensive: the loader's ValidationPhase already requires @values be
104+
// non-empty. Skip emission rather than produce a syntactically broken file.
105+
return
106+
}
107+
val serializable = ClassName("kotlinx.serialization", "Serializable")
108+
val enumBuilder = TypeSpec.enumBuilder(enumClassName.simpleName)
109+
.addAnnotation(serializable)
110+
.addKdoc("GENERATED — do not hand-edit. Regenerated from metadata.\n")
111+
for (member in members) {
112+
enumBuilder.addEnumConstant(member)
113+
}
114+
FileSpec.builder(enumClassName.packageName, enumClassName.simpleName)
115+
.addType(enumBuilder.build())
116+
.build()
117+
.writeTo(outRoot)
118+
}
119+
120+
/** Read the `@values` string-array attr (own-only); null/empty if absent. */
121+
private fun readEnumValues(field: EnumField): List<String>? {
122+
if (!field.hasMetaAttr(EnumField.ATTR_VALUES, false)) return null
123+
val raw = runCatching { field.getMetaAttr(EnumField.ATTR_VALUES, false).value }.getOrNull()
124+
return when (raw) {
125+
is List<*> -> raw.mapNotNull { it?.toString() }
126+
else -> null
127+
}
128+
}
129+
87130
/**
88131
* Resolve the Kotlin TypeName for a single property. For `field.object` fields,
89132
* the type is a reference to the generated data class of the field's `@objectRef`
90-
* (e.g., `Address` for `field.object @objectRef="Address"`). The `@storage` attr
91-
* is intentionally NOT consulted here — flattened vs jsonb only affects the
92-
* persistence column shape, not the in-memory shape.
133+
* (e.g., `Address` for `field.object @objectRef="Address"`). For `field.enum`
134+
* fields, the type is the typed enum class generated alongside the entity
135+
* (e.g., `PlayerStatus` for `Player.status`). The `@storage` attr is intentionally
136+
* NOT consulted here — flattened vs jsonb only affects the persistence column shape,
137+
* not the in-memory shape.
93138
*/
94-
private fun resolvePropertyType(field: MetaField<*>, loader: MetaDataLoader): TypeName {
139+
private fun resolvePropertyType(field: MetaField<*>, owner: MetaObject, loader: MetaDataLoader): TypeName {
140+
// field.enum → typed enum class generated alongside this entity.
141+
KotlinTypeMapper.enumTypeName(field, owner)?.let { return it }
95142
if (field is ObjectField) {
96143
val ref = readObjectRef(field)
97144
val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) }

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.metaobjects.generator.kotlin
22

33
import com.metaobjects.database.CoreDBMetaDataProvider
4+
import com.metaobjects.field.EnumField
45
import com.metaobjects.field.ObjectField
56
import com.metaobjects.generator.GeneratorIOWriter
67
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
@@ -81,7 +82,17 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
8182
if (field is ObjectField) continue
8283
val isPk = field.name == primaryFieldName
8384
val nullable = !isPk && !KotlinGenUtil.isRequiredField(field)
84-
val baseSpec = KotlinTypeMapper.exposedColumnSpec(field)
85+
val baseSpec = if (field is EnumField) {
86+
// field.enum → typed Exposed enumerationByName column referencing the
87+
// generated enum class. Length matches the historical VARCHAR fallback
88+
// (KotlinTypeMapper.ENUM_VARCHAR_LEN). Same-package class reference, so
89+
// no import is required.
90+
val enumName = KotlinTypeMapper.enumTypeName(field, entity)?.simpleName
91+
?: error("enumTypeName returned null for EnumField '${field.name}' on ${entity.name}")
92+
"enumerationByName(\"${field.name}\", ${KotlinTypeMapper.ENUM_VARCHAR_LEN}, $enumName::class)"
93+
} else {
94+
KotlinTypeMapper.exposedColumnSpec(field)
95+
}
8596
val withAuto = if (isPk && incrementPk) "$baseSpec.autoIncrement()" else baseSpec
8697
val full = if (nullable) "$withAuto.nullable()" else withAuto
8798
append(" val ${field.name} = $full\n")

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.metaobjects.field.LongField
1010
import com.metaobjects.field.MetaField
1111
import com.metaobjects.field.StringField
1212
import com.metaobjects.field.TimestampField
13+
import com.metaobjects.`object`.MetaObject
1314
import com.squareup.kotlinpoet.BOOLEAN
1415
import com.squareup.kotlinpoet.ClassName
1516
import com.squareup.kotlinpoet.DOUBLE
@@ -42,7 +43,33 @@ object KotlinTypeMapper {
4243
private const val UUID_SUBTYPE = "uuid"
4344

4445
/** Default VARCHAR width for string-backed `field.enum` storage (v1). */
45-
private const val ENUM_VARCHAR_LEN = 64
46+
const val ENUM_VARCHAR_LEN = 64
47+
48+
/**
49+
* Compute the generated Kotlin enum-class name for an [EnumField] hung off [entity].
50+
*
51+
* Returns {@code null} when [field] is not an {@link EnumField} (the caller should
52+
* fall through to the generic [kotlinTypeName] mapping). Naming rule:
53+
* {@code <EntityShortName><FieldNamePascalCase>}, in the same Kotlin package as the
54+
* entity (derived from the entity's metadata FQN via [PackageMapping.splitFqn]).
55+
*
56+
* Entity-prefixing prevents collisions across entities (e.g., {@code Player.status} →
57+
* {@code PlayerStatus}; {@code Game.status} → {@code GameStatus}). When [entity] is
58+
* null (e.g., bare-mapper unit tests) the enum class is rendered with no package
59+
* prefix and no entity-name prefix — only the field name pascalised — so the helper
60+
* still produces a useful ClassName for documentation / debug output. Generators that
61+
* actually emit code always pass the owning entity.
62+
*/
63+
fun enumTypeName(field: MetaField<*>, entity: MetaObject?): ClassName? {
64+
if (field !is EnumField) return null
65+
val fieldPascal = field.name.replaceFirstChar { it.uppercase() }
66+
return if (entity == null) {
67+
ClassName("", fieldPascal)
68+
} else {
69+
val (pkg, entityShort) = PackageMapping.splitFqn(entity.name)
70+
ClassName(pkg, entityShort + fieldPascal)
71+
}
72+
}
4673

4774
/** Map a MetaField to its KotlinPoet data-class property TypeName. */
4875
fun kotlinTypeName(field: MetaField<*>): TypeName = when (field) {

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,61 @@ class KotlinEntityGeneratorTest {
8585
outDir.toFile().deleteRecursively()
8686
}
8787
}
88+
89+
// === field.enum coverage ===============================================
90+
91+
private val enumFixture = """{
92+
"metadata.root": { "package": "acme::demo", "children": [
93+
{ "object.entity": { "name": "Player", "children": [
94+
{ "field.long": { "name": "id" } },
95+
{ "field.enum": { "name": "status", "@required": true,
96+
"@values": ["ACTIVE", "INACTIVE", "BANNED"] } }
97+
] } }
98+
] }
99+
}""".trimIndent()
100+
101+
@Test fun enumFieldEmitsTypedEnumClass() {
102+
val outDir = Files.createTempDirectory("kgen-enum-")
103+
try {
104+
val gen = KotlinEntityGenerator()
105+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
106+
gen.execute(loadString("enum-class", enumFixture))
107+
108+
// Separate file for the enum class, in the same package as the entity.
109+
val enumKt = outDir.resolve("acme/demo/PlayerStatus.kt")
110+
assertTrue(Files.exists(enumKt),
111+
"expected $enumKt; files=${Files.walk(outDir).toList()}")
112+
val enumSrc = Files.readString(enumKt)
113+
assertTrue("@Serializable" in enumSrc, "expected @Serializable on enum:\n$enumSrc")
114+
assertTrue("enum class PlayerStatus" in enumSrc, "expected enum class declaration:\n$enumSrc")
115+
// Members emitted verbatim, preserving case.
116+
assertTrue("ACTIVE" in enumSrc, "expected ACTIVE member:\n$enumSrc")
117+
assertTrue("INACTIVE" in enumSrc, "expected INACTIVE member:\n$enumSrc")
118+
assertTrue("BANNED" in enumSrc, "expected BANNED member:\n$enumSrc")
119+
assertTrue("kotlinx.serialization.Serializable" in enumSrc,
120+
"expected kotlinx.serialization import:\n$enumSrc")
121+
} finally {
122+
outDir.toFile().deleteRecursively()
123+
}
124+
}
125+
126+
@Test fun entityPropertyUsesEnumType() {
127+
val outDir = Files.createTempDirectory("kgen-enum-prop-")
128+
try {
129+
val gen = KotlinEntityGenerator()
130+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
131+
gen.execute(loadString("enum-prop", enumFixture))
132+
133+
val playerKt = outDir.resolve("acme/demo/Player.kt")
134+
assertTrue(Files.exists(playerKt))
135+
val src = Files.readString(playerKt)
136+
// status is @required → non-nullable typed enum, NOT String.
137+
assertTrue("val status: PlayerStatus" in src,
138+
"expected typed enum property `val status: PlayerStatus`; saw:\n$src")
139+
assertTrue("val status: String" !in src,
140+
"expected the property to NOT be String anymore; saw:\n$src")
141+
} finally {
142+
outDir.toFile().deleteRecursively()
143+
}
144+
}
88145
}

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGeneratorTest.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,42 @@ class KotlinExposedTableGeneratorTest {
204204
}
205205
}
206206

207+
// === field.enum coverage ================================================
208+
209+
@Test fun enumFieldEmitsEnumerationByName() {
210+
val enumFixture = """{
211+
"metadata.root": { "package": "acme::demo", "children": [
212+
{ "object.entity": { "name": "Player", "children": [
213+
{ "field.long": { "name": "id" } },
214+
{ "field.enum": { "name": "status", "@required": true,
215+
"@values": ["ACTIVE", "INACTIVE", "BANNED"] } },
216+
{ "source.rdb": { "@table": "players" } },
217+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
218+
] } }
219+
] }
220+
}""".trimIndent()
221+
val outDir = Files.createTempDirectory("ktbl-enum-")
222+
try {
223+
val gen = KotlinExposedTableGenerator()
224+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
225+
gen.execute(loadString("enum-table", enumFixture))
226+
227+
val playerTable = outDir.resolve("acme/demo/PlayerTable.kt")
228+
assertTrue(Files.exists(playerTable),
229+
"expected $playerTable; files=${Files.walk(outDir).toList()}")
230+
val src = Files.readString(playerTable)
231+
// Typed enum column, not a varchar.
232+
assertTrue(
233+
"val status = enumerationByName(\"status\", 64, PlayerStatus::class)" in src,
234+
"expected enumerationByName column; saw:\n$src",
235+
)
236+
assertTrue("varchar(\"status\"" !in src,
237+
"expected NO varchar fallback for the enum column; saw:\n$src")
238+
} finally {
239+
outDir.toFile().deleteRecursively()
240+
}
241+
}
242+
207243
@Test fun `onDeleteCascadeAppendedToReferences`() {
208244
val withCascade = """{
209245
"metadata.root": { "package": "acme::demo", "children": [

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.metaobjects.field.LongField
1111
import com.metaobjects.field.PrimitiveField
1212
import com.metaobjects.field.StringField
1313
import com.metaobjects.field.TimestampField
14+
import com.metaobjects.metadata.ktx.loadString
1415
import com.squareup.kotlinpoet.BOOLEAN
1516
import com.squareup.kotlinpoet.ClassName
1617
import com.squareup.kotlinpoet.DOUBLE
@@ -19,6 +20,7 @@ import com.squareup.kotlinpoet.LONG
1920
import com.squareup.kotlinpoet.STRING
2021
import kotlin.test.Test
2122
import kotlin.test.assertEquals
23+
import kotlin.test.assertNull
2224
import kotlin.test.assertTrue
2325

2426
class KotlinTypeMapperTest {
@@ -124,6 +126,31 @@ class KotlinTypeMapperTest {
124126
assertEquals("varchar(\"status\", 64)", KotlinTypeMapper.exposedColumnSpec(f))
125127
}
126128

129+
@Test fun enumTypeNameComputesClassName() {
130+
// Load a small fixture so we have a real MetaObject Player with field.enum status.
131+
// The naming rule under test is <EntityShortName><FieldPascalCase> in the entity's package.
132+
val fx = """{
133+
"metadata.root": { "package": "acme::demo", "children": [
134+
{ "object.entity": { "name": "Player", "children": [
135+
{ "field.long": { "name": "id" } },
136+
{ "field.enum": { "name": "status",
137+
"@values": ["ACTIVE", "INACTIVE", "BANNED"] } }
138+
] } }
139+
] }
140+
}""".trimIndent()
141+
val loader = loadString("enum-name", fx)
142+
val entity = loader.metaObjects.first { it.name == "acme::demo::Player" }
143+
val field = entity.metaFields.first { it.name == "status" } as EnumField
144+
145+
val name = KotlinTypeMapper.enumTypeName(field, entity)
146+
assertEquals("acme.demo", name?.packageName)
147+
assertEquals("PlayerStatus", name?.simpleName)
148+
149+
// Non-enum fields → null (caller falls through to kotlinTypeName).
150+
val idField = entity.metaFields.first { it.name == "id" }
151+
assertNull(KotlinTypeMapper.enumTypeName(idField, entity))
152+
}
153+
127154
@Test fun `uuid field (matched by subtype) maps to java util UUID and uuid exposed column`() {
128155
// `field.uuid` has no dedicated Java class today — mapper matches by subtype name.
129156
// Use a minimal anonymous PrimitiveField with subType="uuid" to drive the path.

0 commit comments

Comments
 (0)