You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0fb7774
Browse filesBrowse the repository at this point in the historyBrowse files
feat(fr-035): present-key PATCH tristate — Kotlin port (completes all 5 ports)
Green the cross-port `update-explicit-null-clears` gate on Kotlin (both lanes),
completing FR-035 Part B across TS / Python / C# / Java / Kotlin. The generated
controller bound `@Valid @RequestBody dto: <Entity>` (a non-null-ctor data class),
so a partial PATCH omitting a @required field 400'd at Jackson binding and an
explicit null was a silent no-op. Rebind the update handler off the typed data
class onto the raw JSON tree — minimal inline, NOT the repo-delegation refactor:
- codegen-kotlin: the non-TPH `<Entity>Controller` constructor-injects an
`ObjectMapper` (only when it has settable scalar fields — a PK+object/jsonb-only
entity keeps a no-arg controller, so no unused-param `-Werror` break) and the
update handler binds `@RequestBody body: JsonNode`. Per body key: a non-object
body → 400; an explicit null on a @required field → 400; a present value binds
through the configured `ObjectMapper` (`treeToValue` with a `TypeReference` so
boxing / generics / inline enums survive) and per-field-dispatches into the
inline Exposed `update {}` (required → typed local; nullable → `if (n.isNull)
it[Table.col]=null else …`); an absent key is untouched; an effectively-empty
patch is a no-op read-back. Columns are qualified `it[Table.col]` (a field named
like the `body` param would otherwise shadow the column). A value whose JSON
shape can't bind → 400 (JsonMappingException), matching create, not a 500.
- reference server + KNOWN_GAPS + `PatchTristateEdgeCasesTest` (no-settable-field
controller emits no ObjectMapper/TypeReference; `PATCH {"name":{…}}` → 400).
Known gaps (KNOWN_GAPS.md, cross-port PATCH-4 follow-ups): (1) present-VALUE
constraint validation (@Size/@Pattern/@notblank) is not run on PATCH values (only
null-on-@required → 400) — matches C#/Java; TS/Python enforce it. (2) a
`field.string @dbColumnType:jsonb` open bag is a create-only column (kotlinx
JsonElement can't bind on the raw-JsonNode PATCH path without tripping the #179
open-bag guard) — PATCH leaves it untouched.
Gate green both Kotlin lanes (generated 23/0; reference 23/0 — the reference lane
occasionally flakes on PG-testcontainer readiness under load, a known
environmental issue, not a code failure); codegen-kotlin 48 reports / 0 failures;
jsonb 1/0 both, m2m 3/0 both, tph generated 5/0, enum/uuid run-tests green.
Generated Kotlin compiles under allWarningsAsErrors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
Copy file name to clipboardExpand all lines: server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
+82-22Lines changed: 82 additions & 22 deletions
Original file line number
Diff line number
Diff line change
@@ -151,6 +151,19 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
151
151
val pkFieldName = primary?.fields?.firstOrNull() ?:DEFAULT_PK_FIELD
152
152
val pkParamType = primaryKeyParamType(entity, pkFieldName)
153
153
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
append(" if (dto.${field.name} != null) it[${field.name}] = dto.${field.name}\n")
404
+
append(" if (body.has(\"${field.name}\") && body.get(\"${field.name}\").isNull) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
373
405
}
374
406
}
375
-
append(" }\n")
376
-
append(" if (updated == 0) ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n")
377
-
append(" else {\n")
378
-
append(" val row = ${tableObjectName}.selectAll().where { ${tableObjectName}.${pkFieldName} eq id }.single()\n")
379
-
append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n")
380
-
append(" }\n")
407
+
if (hasPatchFields) {
408
+
val settableNamesList = patchSettableFields.joinToString(", ") { "\"${it.name}\"" }
409
+
append(" if (listOf($settableNamesList).any { body.has(it) }) {\n")
410
+
// A present value that cannot bind to its column's Kotlin type (e.g. a JSON object
411
+
// for a String column) throws from treeToValue INSIDE the statement build — map
412
+
// that to 400 (the create @Valid path 400s the same value), not a 500. Catch the
413
+
// specific Jackson JsonMappingException so no unrelated exception is swallowed.
414
+
append(" try {\n")
415
+
append("${tableObjectName}.update({ ${tableObjectName}.${pkFieldName} eq id }) {\n")
416
+
for (field in patchSettableFields) {
417
+
// The DATA-CLASS element type (a field.enum's materialized enum class, not the
418
+
// wire String) so the bound value matches the Exposed Column<E>. Columns are
419
+
// qualified (`Table.col`) so a field named like the `body` param can't shadow them.
420
+
val elem =KotlinTypeMapper.enumTypeName(field, entity) ?:KotlinTypeMapper.kotlinTypeName(field)
421
+
val ktType =if (field.isArrayType) "kotlin.collections.List<$elem>"else"$elem"
422
+
val col ="$tableObjectName.${field.name}"
423
+
// A typed local coerces the ObjectMapper's platform return type to the exact
424
+
// column type (matching the data-class property the old handler assigned) so
append(" if (body.has(\"${field.name}\")) { val v: $ktType = objectMapper.treeToValue(body.get(\"${field.name}\"), object : TypeReference<$ktType>() {}); it[$col] = v }\n")
428
+
} else {
429
+
append(" if (body.has(\"${field.name}\")) { val n = body.get(\"${field.name}\"); if (n.isNull) it[$col] = null else { val v: $ktType = objectMapper.treeToValue(n, object : TypeReference<$ktType>() {}); it[$col] = v } }\n")
Copy file name to clipboardExpand all lines: server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-controller/acme/blog/AuthorController.kt
if (!body.isObject) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") asAny)
288
+
if (body.has("name") && body.get("name").isNull) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") asAny)
289
+
if (listOf("name").any { body.has(it) }) {
290
+
try {
291
+
AuthorTable.update({ AuthorTable.id eq id }) {
292
+
if (body.has("name")) { val v: kotlin.String= objectMapper.treeToValue(body.get("name"), object:TypeReference<kotlin.String>() {}); it[AuthorTable.name] = v }
if (!body.isObject) return@transaction ResponseEntity.badRequest().body(mapOf("error" to "validation") asAny)
288
+
if (listOf("label").any { body.has(it) }) {
289
+
try {
290
+
AuthLineTable.update({ AuthLineTable.id eq id }) {
291
+
if (body.has("label")) { val n = body.get("label"); if (n.isNull) it[AuthLineTable.label] =nullelse { val v: kotlin.String= objectMapper.treeToValue(n, object:TypeReference<kotlin.String>() {}); it[AuthLineTable.label] = v } }
Copy file name to clipboardExpand all lines: server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/AuthorApiServer.kt
+6Lines changed: 6 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -197,6 +197,12 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
197
197
@Suppress("UNCHECKED_CAST")
198
198
val body = readJsonBody(exchange) as?Map<String, Any?>
199
199
?:return sendJson(exchange, 400, mapOf("error" to "validation"))
200
+
// FR-035 PATCH-2: an explicit null on a @required field (name / createdAt, per
201
+
// meta.json) is a 400 — a present null on the nullable bio clears it, and an
202
+
// omitted required field is untouched (never a 400).
203
+
for (f inlistOf("name", "createdAt")) {
204
+
if (body.containsKey(f) && body[f] ==null) return sendJson(exchange, 400, mapOf("error" to "validation"))
205
+
}
200
206
val updated = transaction(db) {
201
207
AuthorTable.update({ AuthorTable.id eq id }) {
202
208
(body["name"] as?String)?.let { v -> it[name] = v }
Copy file name to clipboardExpand all lines: server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/EnumFilterControllerRunTest.kt
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -89,7 +89,7 @@ class EnumFilterControllerRunTest {
0 commit comments