Skip to content

Commit 22f91ab

Browse files
committed
Merge codegen-kotlin Phase D — field.object @storage handling
KotlinEntityGenerator now walks BOTH object.entity AND object.value (needed so field.object references resolve to a generated data class). A field.object @objectref="X" emits a typed reference to the X data class. KotlinExposedTableGenerator handles @storage on field.object: - @storage="flattened" → one column per sub-field, prefixed: addressStreet/addressCity/addressZip on the parent table. Kotlin prop = camelCase concat; SQL column = snake_case. Nullable iff parent field nullable OR sub-field optional. - @storage="jsonb" (and the default when absent) → single jsonb column: metadata = jsonb("metadata", { Json.encodeToString(it) }, { Json.decodeFromString(it) }) Consumers add exposed-json + kotlinx-serialization-json deps. KotlinTypeMapper.exposedColumnSpec(field, colName) overload added to let flattened sub-columns override physical column name without mutating fields. 45 module tests green (41 baseline + 4 new storage tests).
2 parents 01afd95 + 7bc1251 commit 22f91ab

5 files changed

Lines changed: 315 additions & 13 deletions

File tree

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

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

33
import com.metaobjects.field.MetaField
4+
import com.metaobjects.field.ObjectField
45
import com.metaobjects.generator.GeneratorIOWriter
56
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
67
import com.metaobjects.loader.MetaDataLoader
@@ -11,18 +12,24 @@ import com.squareup.kotlinpoet.FunSpec
1112
import com.squareup.kotlinpoet.KModifier
1213
import com.squareup.kotlinpoet.ParameterSpec
1314
import com.squareup.kotlinpoet.PropertySpec
15+
import com.squareup.kotlinpoet.TypeName
1416
import com.squareup.kotlinpoet.TypeSpec
1517
import java.io.OutputStream
1618
import java.io.PrintWriter
1719
import java.nio.file.Path
1820
import java.nio.file.Paths
1921

2022
/**
21-
* Generator: one @Serializable Kotlin data class per `object.entity`.
23+
* Generator: one @Serializable Kotlin data class per `object.entity` and `object.value`.
2224
*
23-
* <p>Skips abstract objects, payload-only `object.value` instances, and any subType other
24-
* than `entity`. KotlinPoet emits whole files; the parent class's print-style writer
25-
* machinery is bypassed in favour of a direct override of [execute].
25+
* <p>Skips abstract objects and any subType other than `entity` or `value`. KotlinPoet emits
26+
* whole files; the parent class's print-style writer machinery is bypassed in favour of a
27+
* direct override of [execute].
28+
*
29+
* <p>Nested objects: when a parent field is a `field.object` with `@objectRef`, the parent
30+
* data class gets a typed property referencing the generated `object.value` data class
31+
* (e.g., `val address: Address`). The `@storage` attr (`flattened` / `jsonb`) does not
32+
* affect Kotlin type emission — it only affects the Exposed table's column shape.
2633
*
2734
* <p>Args:
2835
* <ul>
@@ -38,14 +45,17 @@ class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
3845
override fun execute(loader: MetaDataLoader) {
3946
parseArgs()
4047
val outRoot = Paths.get(outDir.absolutePath)
41-
for (entity in loader.metaObjects) {
42-
if (entity.subType != "entity") continue // ignore object.value; payload generator handles those
43-
emit(entity, outRoot)
48+
for (obj in loader.metaObjects) {
49+
// Emit a data class for both entities AND value objects. Value objects (object.value)
50+
// are referenced by field.object on entities; the value class must exist for the
51+
// entity's typed property to resolve.
52+
if (obj.subType != MetaObject.SUBTYPE_ENTITY && obj.subType != MetaObject.SUBTYPE_VALUE) continue
53+
emit(obj, outRoot, loader)
4454
}
4555
}
4656

47-
private fun emit(entity: MetaObject, outRoot: Path) {
48-
val (pkg, shortName) = PackageMapping.splitFqn(entity.name)
57+
private fun emit(obj: MetaObject, outRoot: Path, loader: MetaDataLoader) {
58+
val (pkg, shortName) = PackageMapping.splitFqn(obj.name)
4959
val serializable = ClassName("kotlinx.serialization", "Serializable")
5060

5161
val typeBuilder = TypeSpec.classBuilder(shortName)
@@ -54,8 +64,8 @@ class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
5464
.addKdoc("GENERATED — do not hand-edit. Regenerated from metadata.\n")
5565

5666
val ctorBuilder = FunSpec.constructorBuilder()
57-
for (field in entity.metaFields) {
58-
val baseType = KotlinTypeMapper.kotlinTypeName(field)
67+
for (field in obj.metaFields) {
68+
val baseType = resolvePropertyType(field, loader)
5969
val nullable = !isRequired(field)
6070
val propType = if (nullable) baseType.copy(nullable = true) else baseType
6171
val propName = field.name
@@ -75,6 +85,43 @@ class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
7585
fileSpec.writeTo(outRoot)
7686
}
7787

88+
/**
89+
* Resolve the Kotlin TypeName for a single property. For `field.object` fields,
90+
* the type is a reference to the generated data class of the field's `@objectRef`
91+
* (e.g., `Address` for `field.object @objectRef="Address"`). The `@storage` attr
92+
* is intentionally NOT consulted here — flattened vs jsonb only affects the
93+
* persistence column shape, not the in-memory shape.
94+
*/
95+
private fun resolvePropertyType(field: MetaField<*>, loader: MetaDataLoader): TypeName {
96+
if (field is ObjectField) {
97+
val ref = readObjectRef(field)
98+
if (ref != null) {
99+
val target = resolveObjectByShortOrFqn(loader, ref)
100+
if (target != null) {
101+
val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name)
102+
return ClassName(targetPkg, targetShort)
103+
}
104+
}
105+
}
106+
return KotlinTypeMapper.kotlinTypeName(field)
107+
}
108+
109+
/** Read the `@objectRef` attr off a field (own-only); null if absent. */
110+
private fun readObjectRef(field: MetaField<*>): String? {
111+
if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, false)) return null
112+
return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, false).valueAsString }
113+
.getOrNull()
114+
}
115+
116+
/** Resolve a MetaObject (entity OR value) by FQN match or short-name match. */
117+
private fun resolveObjectByShortOrFqn(loader: MetaDataLoader, ref: String): MetaObject? {
118+
for (child in loader.metaObjects) {
119+
val short = child.name.substringAfterLast("::")
120+
if (child.name == ref || short == ref) return child
121+
}
122+
return null
123+
}
124+
78125
/**
79126
* Required iff explicit `@required: true` attribute is set on the field; otherwise nullable.
80127
* MVP heuristic — refined when richer required-detection lands (see fr-003 spec).

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

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

33
import com.metaobjects.field.MetaField
4+
import com.metaobjects.field.ObjectField
45
import com.metaobjects.generator.GeneratorIOWriter
56
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
67
import com.metaobjects.identity.MetaIdentity
@@ -54,6 +55,8 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
5455
val incrementPk = primary?.generation == MetaIdentity.GENERATION_INCREMENT
5556

5657
val fkColumns = buildFkColumns(entity, loader)
58+
val objectColumns = buildObjectColumns(entity, primaryFieldName, loader)
59+
val needsJsonbImport = objectColumns.any { it.kind == ObjectColumnKind.JSONB }
5760

5861
val source = buildString {
5962
if (pkg.isNotEmpty()) {
@@ -63,17 +66,29 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
6366
if (fkColumns.any { it.hasReferenceOption }) {
6467
append("import org.jetbrains.exposed.sql.ReferenceOption\n")
6568
}
69+
if (needsJsonbImport) {
70+
append("import org.jetbrains.exposed.sql.json.jsonb\n")
71+
append("import kotlinx.serialization.json.Json\n")
72+
}
6673
append("\n")
6774
append("/** GENERATED — do not hand-edit. Regenerated from metadata. */\n")
6875
append("object $tableObjectName : Table(\"$tableName\") {\n")
6976
for (field in entity.metaFields) {
77+
if (field is ObjectField) {
78+
// ObjectField columns are produced by buildObjectColumns() so we can
79+
// emit @storage flattened (N columns) or jsonb (1 column) uniformly.
80+
continue
81+
}
7082
val isPk = field.name == primaryFieldName
7183
val nullable = !isPk && !isRequired(field)
7284
val baseSpec = KotlinTypeMapper.exposedColumnSpec(field)
7385
val withAuto = if (isPk && incrementPk) "$baseSpec.autoIncrement()" else baseSpec
7486
val full = if (nullable) "$withAuto.nullable()" else withAuto
7587
append(" val ${field.name} = $full\n")
7688
}
89+
for (oc in objectColumns) {
90+
append(" val ${oc.propertyName} = ${oc.columnExpr}\n")
91+
}
7792
for (fk in fkColumns) {
7893
append(" val ${fk.propertyName} = ${fk.columnExpr}\n")
7994
}
@@ -88,6 +103,95 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
88103
Files.writeString(outFile, source)
89104
}
90105

106+
// === field.object + @storage column emission ============================
107+
108+
private enum class ObjectColumnKind { FLATTENED, JSONB }
109+
110+
/** A single Exposed column derived from a `field.object` (one per flattened sub-field, or one total for jsonb). */
111+
private data class ObjectColumnSpec(
112+
val propertyName: String,
113+
val columnExpr: String,
114+
val kind: ObjectColumnKind,
115+
)
116+
117+
/**
118+
* Build the Exposed columns contributed by each `field.object` on [entity].
119+
*
120+
* <ul>
121+
* <li>{@code @storage="flattened"}: one column per field of the referenced
122+
* `object.value`, with property name {@code <parentField><SubFieldCap>} and
123+
* physical column {@code <parentField>_<subField>} (snake-joined). Nullable
124+
* sub-fields → nullable columns.</li>
125+
* <li>{@code @storage="jsonb"} or absent (default-to-jsonb per CLAUDE.md):
126+
* one {@code jsonb(name, encoder, decoder)} column using kotlinx.serialization Json.</li>
127+
* </ul>
128+
*
129+
* Skips field.object children whose `@objectRef` cannot be resolved (defensive — the loader's
130+
* validation phase already gates the attr being present).
131+
*/
132+
private fun buildObjectColumns(
133+
entity: MetaObject,
134+
primaryFieldName: String?,
135+
loader: MetaDataLoader,
136+
): List<ObjectColumnSpec> {
137+
val result = mutableListOf<ObjectColumnSpec>()
138+
for (field in entity.metaFields) {
139+
if (field !is ObjectField) continue
140+
val parentName = field.name
141+
val storage = readStorage(field) // null → default to jsonb
142+
if (storage == STORAGE_FLATTENED) {
143+
val ref = readObjectRef(field) ?: continue
144+
val target = resolveObjectByShortOrFqn(loader, ref) ?: continue
145+
val parentNullable = parentName != primaryFieldName && !isRequired(field)
146+
for (subField in target.metaFields) {
147+
val propertyName = parentName + subField.name.replaceFirstChar { it.uppercase() }
148+
val colName = parentName + "_" + subField.name
149+
val baseSpec = KotlinTypeMapper.exposedColumnSpec(subField, colName)
150+
// Sub-column is nullable iff the parent is nullable OR the sub-field itself is.
151+
val nullable = parentNullable || !isRequired(subField)
152+
val full = if (nullable) "$baseSpec.nullable()" else baseSpec
153+
result.add(ObjectColumnSpec(propertyName, full, ObjectColumnKind.FLATTENED))
154+
}
155+
} else {
156+
// jsonb (explicit) OR absent (default per CLAUDE.md back-compat rule).
157+
val colName = parentName
158+
val expr = "jsonb(\"$colName\", { Json.encodeToString(it) }, { Json.decodeFromString(it) })"
159+
val parentNullable = parentName != primaryFieldName && !isRequired(field)
160+
val full = if (parentNullable) "$expr.nullable()" else expr
161+
result.add(ObjectColumnSpec(parentName, full, ObjectColumnKind.JSONB))
162+
}
163+
}
164+
return result
165+
}
166+
167+
/** Read the `@storage` attr (own-only); null when absent. */
168+
private fun readStorage(field: ObjectField): String? {
169+
if (!field.hasMetaAttr(ATTR_STORAGE, false)) return null
170+
return runCatching { field.getMetaAttr(ATTR_STORAGE, false).valueAsString }.getOrNull()
171+
}
172+
173+
/** Read the `@objectRef` attr (own-only); null when absent. */
174+
private fun readObjectRef(field: ObjectField): String? {
175+
if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, false)) return null
176+
return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, false).valueAsString }
177+
.getOrNull()
178+
}
179+
180+
/** Resolve a MetaObject (entity OR value) by FQN match or short-name match. */
181+
private fun resolveObjectByShortOrFqn(loader: MetaDataLoader, ref: String): MetaObject? {
182+
for (child in loader.metaObjects) {
183+
val short = child.name.substringAfterLast("::")
184+
if (child.name == ref || short == ref) return child
185+
}
186+
return null
187+
}
188+
189+
private companion object {
190+
/** Cross-language @storage attr on field.object — values: flattened | jsonb (default). */
191+
const val ATTR_STORAGE = "storage"
192+
const val STORAGE_FLATTENED = "flattened"
193+
}
194+
91195
// === FK column emission from relationship.composition ====================
92196

93197
/** A foreign-key column derived from a `relationship.composition` child. */

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ object KotlinTypeMapper {
6666
}
6767

6868
/** Map a MetaField to the Exposed `Table` column statement (e.g., `varchar("name", 100)`). */
69-
fun exposedColumnSpec(field: MetaField<*>): String {
70-
val colName = field.name
69+
fun exposedColumnSpec(field: MetaField<*>): String = exposedColumnSpec(field, field.name)
70+
71+
/**
72+
* Same as [exposedColumnSpec], but with an explicit physical column name. Used by the
73+
* `@storage: "flattened"` codepath to emit prefixed columns (e.g., `address_street`)
74+
* for nested object.value fields without mutating the underlying MetaField.
75+
*/
76+
fun exposedColumnSpec(field: MetaField<*>, colName: String): String {
7177
return when {
7278
field is StringField -> "varchar(\"$colName\", ${stringMaxLength(field)})"
7379
field is IntegerField -> "integer(\"$colName\")"

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,50 @@ class KotlinEntityGeneratorTest {
3939
outDir.toFile().deleteRecursively()
4040
}
4141
}
42+
43+
@Test fun fieldObjectEmitsTypedNestedReference() {
44+
// User has a field.object → Address; Address is an object.value with three fields.
45+
// Expected: User.kt has `val address: Address?` AND Address.kt is also emitted
46+
// as a @Serializable data class.
47+
val fx = """{
48+
"metadata.root": { "package": "acme::demo", "children": [
49+
{ "object.value": { "name": "Address", "children": [
50+
{ "field.string": { "name": "street", "@required": true } },
51+
{ "field.string": { "name": "city", "@required": true } },
52+
{ "field.string": { "name": "zip", "@required": true } }
53+
] } },
54+
{ "object.entity": { "name": "User", "children": [
55+
{ "field.long": { "name": "id" } },
56+
{ "field.object": { "name": "address",
57+
"@objectRef": "Address", "@storage": "flattened" } }
58+
] } }
59+
] }
60+
}""".trimIndent()
61+
62+
val outDir = Files.createTempDirectory("kgen-fo-")
63+
try {
64+
val gen = KotlinEntityGenerator()
65+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
66+
gen.execute(loadString("fo", fx))
67+
68+
val userKt = outDir.resolve("acme/demo/User.kt")
69+
val addrKt = outDir.resolve("acme/demo/Address.kt")
70+
assertTrue(Files.exists(userKt),
71+
"expected $userKt; files=${Files.walk(outDir).toList()}")
72+
assertTrue(Files.exists(addrKt),
73+
"expected $addrKt (object.value should be emitted); files=${Files.walk(outDir).toList()}")
74+
75+
val userSrc = Files.readString(userKt)
76+
assertTrue("val address: Address?" in userSrc,
77+
"expected typed nested ref `val address: Address?` in:\n$userSrc")
78+
79+
val addrSrc = Files.readString(addrKt)
80+
assertTrue("data class Address" in addrSrc, addrSrc)
81+
assertTrue("val street: String" in addrSrc, addrSrc)
82+
assertTrue("val city: String" in addrSrc, addrSrc)
83+
assertTrue("val zip: String" in addrSrc, addrSrc)
84+
} finally {
85+
outDir.toFile().deleteRecursively()
86+
}
87+
}
4288
}

0 commit comments

Comments
 (0)