Skip to content

Commit a688c97

Browse files
dmealingclaude
andcommitted
feat(program-d): Kotlin codegen reads/creates/PATCHes value-object jsonb columns
The generated controller excluded ObjectField from read (rowToEntity), create, and PATCH — VO jsonb columns were invisible. Now included everywhere: - patchSettableFields drops the ObjectField exclusion (MapField + jsonb-open-bag stay excluded — staged out); rowToEntity + the create insert loop include VO columns (the Exposed Column<VO> codec / shared Jackson metaJsonbMapper handles the jsonb text <-> VO record / List<VO>). - PATCH binds a present VO via objectMapper.treeToValue (Jackson, NOT the decorative kotlinx serializer) into the VO record / List<VO>, resolved by a new voElementTypeFqn helper, then validates: keeps validateValue for own constraints and ADDS explicit validator.validate(voElement) per present element (spec section 0 — validateValue does not cascade @Valid). FR-035 tristate preserved. - KotlinEntityGenerator stamps @field:jakarta.validation.Valid on nested VO members so validate(dto) cascades on POST (golden snapshot User.kt reconciled). Both lanes green (reference Exposed + generated MockMvc, over Testcontainers PG); codegen-kotlin 280/0; typed-jsonb roundtrip 1/1 — no regression. field.map and the Kotlin open-bag PATCH remain staged-out follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 4d296d1 commit a688c97

6 files changed

Lines changed: 230 additions & 23 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
154154
for (annotation in validationAnnotations(field)) {
155155
propBuilder.addAnnotation(annotation)
156156
}
157+
// Program D (spec §0): a field.object (value-object) member carries @field:Valid so a
158+
// parent validator.validate(bean) / @Valid cascades into the nested VO's own
159+
// constraints (the create-body POST path). jakarta validateValue does NOT cascade, so
160+
// the PATCH path validates present VO values explicitly in the controller. MapField is
161+
// NOT an ObjectField (dict-of-VO is staged out), so it never gets @Valid here.
162+
if (field is ObjectField) {
163+
propBuilder.addAnnotation(constraint(VALID))
164+
}
157165
}
158166
typeBuilder.addProperty(propBuilder.build())
159167
}
@@ -506,6 +514,11 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
506514
val MAX = ClassName(JAKARTA_CONSTRAINTS, "Max")
507515
val EMAIL = ClassName(JAKARTA_CONSTRAINTS, "Email")
508516

517+
/** Program D: `@field:Valid` on a value-object member so a parent `@Valid` / whole-bean
518+
* validate cascades into the nested VO's own constraints (lives in `jakarta.validation`,
519+
* NOT `jakarta.validation.constraints`). */
520+
val VALID = ClassName("jakarta.validation", "Valid")
521+
509522
/**
510523
* Canonical DNS-hostname matcher for `@stringFormat: hostname` (ADR-0036/0037 Wave 3).
511524
* Codegen-owned (NOT author `validator.regex`) — one or more dot-separated labels, each

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

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,16 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
151151
val pkFieldName = primary?.fields?.firstOrNull() ?: DEFAULT_PK_FIELD
152152
val pkParamType = primaryKeyParamType(entity, pkFieldName)
153153

154-
// FR-035: the PATCH-settable columns = scalar fields minus the PK. ObjectField / MapField
155-
// (jsonb value-objects) and the `field.string @dbColumnType=jsonb` open bag are excluded:
156-
// their in-process type is a kotlinx JsonElement / VO with no clean Jackson treeToValue
157-
// bind on the raw-JsonNode patch path. (create DOES write the open bag from the DTO;
158-
// an open bag is thus a create-only CRUD column — see KNOWN_GAPS.)
159-
// When this is EMPTY (a PK + only object/map/jsonb columns) the controller emits no
160-
// ObjectMapper ctor param / TypeReference import (they'd be unused → allWarningsAsErrors).
154+
// FR-035: the PATCH-settable columns = scalar + value-object fields minus the PK. Program D:
155+
// a field.object value-object jsonb column IS settable — bound via Jackson treeToValue into
156+
// the generated VO record / List<VO> and validated (spec §0). Still EXCLUDED: MapField
157+
// (dict-of-VO, staged out) and the `field.string @dbColumnType=jsonb` open bag (its
158+
// in-process type is a kotlinx JsonElement the raw-JsonNode patch path can't bind — a
159+
// create-only column, see KNOWN_GAPS).
160+
// When this is EMPTY (a PK + only map/open-bag columns) the controller emits no ObjectMapper
161+
// ctor param / TypeReference import (they'd be unused → allWarningsAsErrors).
161162
val patchSettableFields = entity.metaFields.filter {
162-
it !is ObjectField && it !is MapField && it.name != pkFieldName &&
163+
it !is MapField && it.name != pkFieldName &&
163164
!KotlinTypeMapper.isJsonbOpenBag(it)
164165
}
165166
val hasPatchFields = patchSettableFields.isNotEmpty()
@@ -293,14 +294,16 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
293294
// type at compile time (no reflection / runtime cast surprises).
294295
emitFilterPipeline(this, shortName, tableObjectName, allowlistName, scalarFields)
295296

296-
// rowTo<Entity>: ResultRow → data class. Scalar fields only; ObjectField is
297-
// skipped here for the same reason as the sort allowlist (the Table object's
298-
// jsonb/flattened column shape would require type-specific deserialization
299-
// which is generator-future work).
297+
// rowTo<Entity>: ResultRow → data class. Program D: a field.object value-object jsonb
298+
// column IS read here — the Exposed Column<VO> codec (the shared Jackson metaJsonbMapper)
299+
// already decodes the jsonb text to the VO record / List<VO>, so `row[Table.col]` yields
300+
// the typed value the data-class property expects. MapField (dict-of-VO) stays skipped
301+
// (staged out). The `field.string @dbColumnType=jsonb` open bag is a StringField, so it is
302+
// read here too (its column decodes to a kotlinx JsonElement).
300303
append("/** GENERATED — map an Exposed ResultRow to the ${shortName} data class. */\n")
301304
append("private fun rowTo${shortName}(row: ResultRow): ${shortName} = ${shortName}(\n")
302305
for (field in entity.metaFields) {
303-
if (field is ObjectField || field is MapField) continue
306+
if (field is MapField) continue
304307
append(" ${field.name} = row[${tableObjectName}.${field.name}],\n")
305308
}
306309
append(")\n\n")
@@ -373,7 +376,10 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
373376
append(" if (validator.validate(dto).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
374377
append(" val newId = ${tableObjectName}.insert {\n")
375378
for (field in entity.metaFields) {
376-
if (field is ObjectField || field is MapField) continue
379+
// Program D: a field.object value-object jsonb column IS written on create — the
380+
// DTO property is the typed VO (record / List<VO>) and the Exposed Column<VO> codec
381+
// encodes it to jsonb. MapField (dict-of-VO) stays skipped (staged out).
382+
if (field is MapField) continue
377383
// Skip the PK column on insert — the table's @generation=increment owns it.
378384
// If the entity has no auto-incrementing PK the consumer can override the
379385
// generated handler; this is the 95% case.
@@ -400,10 +406,11 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
400406
// null on a nullable field CLEARS it; an omitted field is untouched; a present value
401407
// binds through the configured ObjectMapper (same codecs as create). An empty
402408
// effective patch is a no-op read-back (avoids Exposed's empty-SET error).
403-
// Settable columns are `patchSettableFields` (computed up top): scalar fields minus
404-
// the PK, EXCLUDING object/map/jsonb-open-bag (an open bag is a create-only CRUD
405-
// column — PATCH can't bind a kotlinx JsonElement; see KNOWN_GAPS). When empty, no
406-
// update{} block is emitted (an empty Exposed SET throws) — a no-op read-back.
409+
// Settable columns are `patchSettableFields` (computed up top): scalar + value-object
410+
// fields minus the PK, EXCLUDING map/jsonb-open-bag (an open bag is a create-only CRUD
411+
// column — PATCH can't bind a kotlinx JsonElement; see KNOWN_GAPS). Program D: a present
412+
// field.object VO binds via Jackson treeToValue and is validated in FULL (spec §0).
413+
// When empty, no update{} block is emitted (an empty Exposed SET throws) — a no-op read-back.
407414
append(" @RequestMapping(value = [\"/{id}\"], method = [RequestMethod.PATCH, RequestMethod.PUT])\n")
408415
append(" fun update(@PathVariable id: $pkParamType, @RequestBody body: JsonNode): ResponseEntity<Any> = transaction {\n")
409416
append(" if (!body.isObject) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
@@ -427,10 +434,17 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
427434
// @required field was already 400'd above; present-null on a nullable field clears it
428435
// (no bind, no validation).
429436
for (field in patchSettableFields) {
430-
// The DATA-CLASS element type (a field.enum's materialized enum class, not the
431-
// wire String) so the bound value matches the Exposed Column<E>.
432-
val elem = KotlinTypeMapper.enumTypeName(field, entity) ?: KotlinTypeMapper.kotlinTypeName(field)
433-
val ktType = if (field.isArrayType) "kotlin.collections.List<$elem>" else "$elem"
437+
// The DATA-CLASS element type: a field.enum's materialized enum class; a
438+
// field.object's referenced value-object record (Program D — bound via Jackson
439+
// treeToValue into the VO / List<VO>); else the scalar Kotlin type — so the bound
440+
// value matches the Exposed Column<E>. A VO type is emitted fully-qualified so the
441+
// controller needs no extra import (mirrors the Exposed column codec).
442+
val elem: String = if (field is ObjectField) {
443+
voElementTypeFqn(field, loader)
444+
} else {
445+
(KotlinTypeMapper.enumTypeName(field, entity) ?: KotlinTypeMapper.kotlinTypeName(field)).toString()
446+
}
447+
val ktType = if (field.isArrayType) "kotlin.collections.List<$elem>" else elem
434448
val fn = field.name
435449
val cap = capitalizeFirst(fn)
436450
append(" val has$cap = body.has(\"$fn\")\n")
@@ -443,6 +457,17 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
443457
append(" val v$cap: $ktType? = if (has$cap && !null$cap) objectMapper.treeToValue(body.get(\"$fn\"), object : TypeReference<$ktType>() {}) else null\n")
444458
append(" if (has$cap && !null$cap && validator.validateValue(${shortName}::class.java, \"$fn\", v$cap).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
445459
}
460+
// Program D (spec §0): jakarta validateValue does NOT cascade @Valid into a nested
461+
// value-object's constraints, so a present VO is validated EXPLICITLY —
462+
// validator.validate(voElement) per element (single VO whole; array VO per element),
463+
// 400 on any violation. (A present-null / cleared value is already handled above.)
464+
if (field is ObjectField) {
465+
if (field.isArrayType) {
466+
append(" if (v$cap != null && v$cap.any { validator.validate(it).isNotEmpty() }) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
467+
} else {
468+
append(" if (v$cap != null && validator.validate(v$cap).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
469+
}
470+
}
446471
}
447472
// Apply the already-bound + validated values. Columns are qualified (`Table.col`)
448473
// so a field named like the `body`/`id` params can't shadow them.
@@ -1279,6 +1304,25 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
12791304
}
12801305
}
12811306

1307+
/** Read the `@objectRef` attr off a value-object [field] (resolving); null when absent. */
1308+
private fun readObjectRef(field: ObjectField): String? {
1309+
if (!field.hasMetaAttr(ObjectField.ATTR_OBJECTREF, true)) return null
1310+
return runCatching { field.getMetaAttr(ObjectField.ATTR_OBJECTREF, true).valueAsString }.getOrNull()
1311+
}
1312+
1313+
/**
1314+
* The fully-qualified Kotlin type of the value object a [field] `field.object` references
1315+
* (e.g. `acme.store.Marker`) — the Jackson bind target for the PATCH TypeReference. Emitted
1316+
* fully-qualified so the generated controller needs no extra import (mirrors the Exposed
1317+
* column codec in [KotlinExposedTableGenerator]). Falls back to a Jackson `JsonNode` when the
1318+
* `@objectRef` cannot resolve (defensive — the loader gates the attr's presence).
1319+
*/
1320+
private fun voElementTypeFqn(field: ObjectField, loader: MetaDataLoader): String {
1321+
val target = readObjectRef(field)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) }
1322+
return target?.let { PackageMapping.toKotlin(it.name) }
1323+
?: "com.fasterxml.jackson.databind.JsonNode"
1324+
}
1325+
12821326
private companion object {
12831327
/** Default primary-key field name when the entity declares no identity.primary. */
12841328
const val DEFAULT_PK_FIELD = "id"

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,54 @@ class KotlinEntityGeneratorTest {
166166
}
167167
}
168168

169+
@Test fun valueObjectMembersCarryValidAndNestedConstraints() {
170+
// Program D: a field.object value-object member on an entity carries @field:Valid so a
171+
// parent validator.validate(bean) cascades into the nested VO's own constraints; the VO's
172+
// own members keep their jakarta constraints (Marker.label @required @maxLength 40 →
173+
// @field:NotNull + @field:Size(min = 1, max = 40)). Mirrors the gate fixture.
174+
val fx = """{
175+
"metadata.root": { "package": "acme::store", "children": [
176+
{ "object.value": { "name": "Marker", "children": [
177+
{ "field.string": { "name": "label", "@required": true, "@maxLength": 40 } },
178+
{ "field.int": { "name": "score" } }
179+
] } },
180+
{ "object.entity": { "name": "Document", "children": [
181+
{ "field.long": { "name": "id" } },
182+
{ "field.object": { "name": "primaryMarker", "@objectRef": "Marker",
183+
"@storage": "jsonb", "@required": true } },
184+
{ "field.object": { "name": "markers", "@objectRef": "Marker",
185+
"@storage": "jsonb", "isArray": true } }
186+
] } }
187+
] }
188+
}""".trimIndent()
189+
190+
val outDir = Files.createTempDirectory("kgen-vo-valid-")
191+
try {
192+
val gen = KotlinEntityGenerator()
193+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
194+
gen.execute(loadString("vo-valid", fx))
195+
196+
val docSrc = Files.readString(outDir.resolve("acme/store/Document.kt"))
197+
// The single + array VO members both carry @field:Valid (cascade on POST).
198+
assertTrue("@field:Valid" in docSrc, "expected @field:Valid on VO members in:\n$docSrc")
199+
assertTrue("import jakarta.validation.Valid" in docSrc,
200+
"expected jakarta.validation.Valid import in:\n$docSrc")
201+
assertTrue("val primaryMarker: Marker" in docSrc,
202+
"expected typed VO property in:\n$docSrc")
203+
204+
val markerSrc = Files.readString(outDir.resolve("acme/store/Marker.kt"))
205+
// The VO's own members carry the jakarta constraints the nested validation enforces.
206+
assertTrue("@field:NotNull" in markerSrc, "expected @field:NotNull on Marker.label in:\n$markerSrc")
207+
assertTrue("@field:Size(" in markerSrc && "max = 40" in markerSrc,
208+
"expected @field:Size(...max = 40) on Marker.label in:\n$markerSrc")
209+
// A pure-value VO with no nested VO member carries NO @field:Valid of its own.
210+
assertFalse("@field:Valid" in markerSrc,
211+
"Marker has no nested VO member — expected NO @field:Valid in:\n$markerSrc")
212+
} finally {
213+
outDir.toFile().deleteRecursively()
214+
}
215+
}
216+
169217
// === field.enum coverage ===============================================
170218

171219
private val enumFixture = """{

server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/User.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package acme.commerce
22

3+
import jakarta.validation.Valid
34
import jakarta.validation.constraints.Size
45
import kotlin.Long
56
import kotlin.String
@@ -11,6 +12,8 @@ public data class User(
1112
public val id: Long? = null,
1213
@field:Size(max = 255)
1314
public val email: String? = null,
15+
@field:Valid
1416
public val address: Address? = null,
17+
@field:Valid
1518
public val preferences: UserMetadata? = null,
1619
)

0 commit comments

Comments
 (0)