Skip to content

Commit b82ce07

Browse files
dmealingclaude
andcommitted
fix(codegen-kotlin): expose field.string @dbColumnType:jsonb as a parsed JSON value in REST payloads (#98)
The `field.string @dbColumnType=jsonb` open JSON bag had its physical column emitted as JSONB but was typed `String` everywhere, forcing REST clients to double-encode (`"{\"k\":\"v\"}"` instead of `{"k":"v"}`). Per the maintainer decision on #98, expose the bag at the serialization/REST payload boundary as a parsed JSON value, uniformly (matching TS `z.unknown()` #97, Python `Any` #99). Two layers, treated differently: - Persistence (unchanged): the Exposed column stays the identity-codec `jsonb(name, { it }, { it })` and the entity data-class property stays `String` — the JDBC driver returns jsonb as raw text. `KotlinTypeMapper.kotlinTypeName` / `exposedColumnSpec` are untouched (existing `KotlinTypeMapperTest` stays green). - REST payload (fixed): add `KotlinTypeMapper.payloadTypeName` — identical to `kotlinTypeName` except the jsonb open bag maps to kotlinx `JsonElement` (the `@Serializable` payload substrate this port already uses). `KotlinPayloadGenerator` now resolves field types through it; the lenient->strict extract mapper bridges `String -> JsonElement` via `Json.parseToJsonElement`; api-docs documents the payload field as the parsed value it actually emits. The type mapper is shared between persistence and the payload surface, so the split is made by a dedicated `payloadTypeName` path keyed on the new `isJsonbOpenBag` predicate (single source of truth for payload + extractor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
1 parent 4e262b3 commit b82ce07

6 files changed

Lines changed: 152 additions & 18 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase<MetaObject>()
311311
else "m.$name!!.filterNotNull().map { $conv }"
312312
}
313313

314+
// jsonb open bag (`field.string @dbColumnType=jsonb`): the strict payload types this as a
315+
// parsed JSON value (kotlinx `JsonElement`, via KotlinTypeMapper.payloadTypeName — issue #98)
316+
// while the lenient mirror leaf stays `String` (the LLM emits text). Bridge String → JsonElement
317+
// by parsing. FQN-qualified so the emitted code resolves without an import (as elsewhere here).
318+
if (KotlinTypeMapper.isJsonbOpenBag(field)) {
319+
return "kotlinx.serialization.json.Json.parseToJsonElement(m.$name!!)"
320+
}
321+
314322
// Scalar (single): mirror is T?, strict is T — null-assert.
315323
return "m.$name!!"
316324
}
@@ -331,6 +339,13 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase<MetaObject>()
331339
* FAILS LOUD at codegen time ([GeneratorException]) rather than emitting non-compiling code.</p>
332340
*/
333341
private fun scalarArrayElementConversion(field: MetaField<*>): String? {
342+
// jsonb open bag element (`field.string @dbColumnType=jsonb` + isArray): the strict payload
343+
// element is a parsed JSON value (kotlinx `JsonElement`, via payloadTypeName — issue #98);
344+
// the mirror element stays `String`. Parse each element. Checked first because the generic
345+
// dispatch below keys on kotlinTypeName, which (correctly, for persistence) reports `String`.
346+
if (KotlinTypeMapper.isJsonbOpenBag(field)) {
347+
return "kotlinx.serialization.json.Json.parseToJsonElement(it)"
348+
}
334349
// Same path the payload generator wraps in List<…> for a scalar-array element.
335350
val elementType = KotlinTypeMapper.kotlinTypeName(field)
336351
return when (elementType) {

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ import java.nio.file.Paths
4343
* (@agg sum/min/max) — type of the referenced `@of` field.</li>
4444
* <li>{@code origin.collection} (@via "Parent.rel") — {@code List<TargetPayload>}, and the
4545
* nested payload class is recursively emitted alongside (deduped per execute() run).</li>
46-
* <li>No origin child — fall back to {@link KotlinTypeMapper#kotlinTypeName(MetaField)}.</li>
46+
* <li>No origin child — fall back to {@link KotlinTypeMapper#payloadTypeName(MetaField)}
47+
* (parsed JSON value for a `field.string @dbColumnType=jsonb` open bag; otherwise the
48+
* same mapping as {@code kotlinTypeName}).</li>
4749
* </ul>
4850
*/
4951
open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
@@ -133,8 +135,9 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
133135

134136
/**
135137
* Resolve the Kotlin TypeName of a single payload-VO field, honoring any
136-
* `origin.*` child. Falls back to [KotlinTypeMapper.kotlinTypeName] when no
137-
* origin is present.
138+
* `origin.*` child. Falls back to [KotlinTypeMapper.payloadTypeName] when no
139+
* origin is present (parsed JSON value for a `field.string @dbColumnType=jsonb` open
140+
* bag, otherwise identical to `kotlinTypeName`).
138141
*/
139142
protected open fun resolveFieldType(
140143
field: MetaField<*>,
@@ -154,7 +157,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
154157
is CollectionOrigin -> resolveCollectionType(
155158
origin, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, field
156159
)
157-
else -> KotlinTypeMapper.kotlinTypeName(field)
160+
else -> KotlinTypeMapper.payloadTypeName(field)
158161
}
159162
}
160163

@@ -185,7 +188,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
185188
// Scalar array (`isArray: true` on a non-object field): model as List<ElementType> in the
186189
// strict payload (matching the cross-port payload shape). Without this, `kotlinTypeName`
187190
// returns the bare element type and the array semantics are lost.
188-
val scalarType = KotlinTypeMapper.kotlinTypeName(field)
191+
val scalarType = KotlinTypeMapper.payloadTypeName(field)
189192
if (field.isArrayType()) {
190193
return ClassName("kotlin.collections", "List").parameterizedBy(scalarType)
191194
}
@@ -207,7 +210,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
207210
emittedNestedFqns: MutableSet<String>,
208211
emittedEnumFqns: MutableSet<String>,
209212
): TypeName {
210-
val fallbackType = { KotlinTypeMapper.kotlinTypeName(field) }
213+
val fallbackType = { KotlinTypeMapper.payloadTypeName(field) }
211214
val target = try {
212215
field.objectRef
213216
} catch (e: RuntimeException) {
@@ -248,10 +251,10 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
248251
loader: MetaDataLoader,
249252
fallbackField: MetaField<*>,
250253
): TypeName {
251-
val from = origin.from ?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
254+
val from = origin.from ?: return KotlinTypeMapper.payloadTypeName(fallbackField)
252255
val sourceField = resolveDottedFieldRef(loader, from)
253-
?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
254-
return KotlinTypeMapper.kotlinTypeName(sourceField)
256+
?: return KotlinTypeMapper.payloadTypeName(fallbackField)
257+
return KotlinTypeMapper.payloadTypeName(sourceField)
255258
}
256259

257260
/**
@@ -269,12 +272,12 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
269272
MetaOrigin.AGG_COUNT -> LONG
270273
MetaOrigin.AGG_AVG -> DOUBLE
271274
MetaOrigin.AGG_SUM, MetaOrigin.AGG_MIN, MetaOrigin.AGG_MAX -> {
272-
val of = origin.of ?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
275+
val of = origin.of ?: return KotlinTypeMapper.payloadTypeName(fallbackField)
273276
val sourceField = resolveDottedFieldRef(loader, of)
274-
?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
275-
KotlinTypeMapper.kotlinTypeName(sourceField)
277+
?: return KotlinTypeMapper.payloadTypeName(fallbackField)
278+
KotlinTypeMapper.payloadTypeName(sourceField)
276279
}
277-
else -> KotlinTypeMapper.kotlinTypeName(fallbackField)
280+
else -> KotlinTypeMapper.payloadTypeName(fallbackField)
278281
}
279282
}
280283

@@ -293,7 +296,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
293296
emittedEnumFqns: MutableSet<String>,
294297
fallbackField: MetaField<*>,
295298
): TypeName {
296-
val fallbackType = { KotlinTypeMapper.kotlinTypeName(fallbackField) }
299+
val fallbackType = { KotlinTypeMapper.payloadTypeName(fallbackField) }
297300
val via = origin.via ?: return fallbackType()
298301
val (parentName, relName) = KotlinGenUtil.splitDottedRef(via) ?: return fallbackType()
299302
val parent = KotlinGenUtil.resolveObjectByShortOrFqn(loader, parentName) ?: return fallbackType()

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ object KotlinTypeMapper {
118118
/** FQN of the Exposed `jsonb` extension function (raw-string open-JSON path). */
119119
private const val EXPOSED_JSONB_IMPORT = "org.jetbrains.exposed.sql.json.jsonb"
120120

121+
/**
122+
* The kotlinx-serialization JSON-value type a `field.string @dbColumnType=jsonb` open-bag is
123+
* exposed as in the REST/serialization PAYLOAD (issue #98). `JsonElement` is the idiomatic
124+
* "any JSON value" type for the `@Serializable` payload data classes [KotlinPayloadGenerator]
125+
* emits (kotlinx is already this port's payload substrate — the same `Json` used by the
126+
* `jsonb(...)` column codec for `field.map`), so a client sends/receives a real JSON object
127+
* rather than a double-encoded string. The PERSISTENCE side is deliberately unaffected: the
128+
* entity data-class property ([kotlinTypeName]) stays `String` (raw text from the JDBC driver)
129+
* and the Exposed column ([exposedColumnSpec]) stays the identity-codec `jsonb(name,{it},{it})`.
130+
*/
131+
private val PAYLOAD_JSON_VALUE_TYPE = ClassName("kotlinx.serialization.json", "JsonElement")
132+
121133
/**
122134
* Name of the generated, file-local Exposed extension function emitted for a
123135
* `@dbColumnType=timestamp_with_tz` [TimestampField]. It returns a
@@ -250,6 +262,33 @@ object KotlinTypeMapper {
250262
)
251263
}
252264

265+
/**
266+
* True iff [field] is the `field.string @dbColumnType=jsonb` open JSON bag — the sanctioned
267+
* "arbitrary JSON value" pattern whose physical column is JSONB while its logical subtype stays
268+
* `string`. The single source of truth both the payload type ([payloadTypeName]) and the
269+
* extract-mapper bridge ([KotlinExtractorGenerator]) dispatch on, so the two sites stay in
270+
* lockstep. Resolved THROUGH the `extends` chain (same as [dbColumnType]) so a projection field
271+
* that binds a base-entity jsonb column via `extends:` inherits the open-bag treatment.
272+
*/
273+
fun isJsonbOpenBag(field: MetaField<*>): Boolean =
274+
field is StringField && dbColumnType(field) == DB_COLUMN_TYPE_JSONB
275+
276+
/**
277+
* Map a MetaField to its KotlinPoet TypeName for a REST/serialization **payload** property
278+
* (the `@Serializable` projection data classes emitted by [KotlinPayloadGenerator]).
279+
*
280+
* Identical to [kotlinTypeName] for every subtype EXCEPT the `field.string @dbColumnType=jsonb`
281+
* open bag, which becomes the parsed JSON value [PAYLOAD_JSON_VALUE_TYPE] instead of `String`
282+
* (issue #98). This is the deliberate payload/persistence split: the persistence holder
283+
* ([kotlinTypeName]) and the Exposed column ([exposedColumnSpec]) keep the raw-text `String`
284+
* contract; only the API/wire boundary exposes the bag as a real JSON value (matching the
285+
* TS `unknown` / Python `Any` ports). Generators that drive PERSISTENCE (entity data class,
286+
* Exposed table, the entity-CRUD controller DTO, which reuses the entity data class) must keep
287+
* calling [kotlinTypeName], not this.
288+
*/
289+
fun payloadTypeName(field: MetaField<*>): TypeName =
290+
if (isJsonbOpenBag(field)) PAYLOAD_JSON_VALUE_TYPE else kotlinTypeName(field)
291+
253292
/**
254293
* The Kotlin value TypeName for a scalar-valued [MapField] (the type named by its
255294
* `@valueType` attr — string/int/long/double/float/decimal/boolean/date/time/

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -381,18 +381,26 @@ class KotlinApiModelBuilder {
381381
val rows = mutableListOf<FieldShape>()
382382
for (f in vo.metaFields) {
383383
if (f is ObjectField) continue
384-
rows.add(FieldShape(f.name, kotlinTypeLabel(f, vo), optional = !KotlinGenUtil.isRequiredField(f)))
384+
rows.add(FieldShape(f.name, kotlinTypeLabel(f, vo, forPayload = true), optional = !KotlinGenUtil.isRequiredField(f)))
385385
}
386386
return rows
387387
}
388388

389-
/** The simple Kotlin type label for a documented field (enum → generated enum class name). */
390-
private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject): String {
389+
/**
390+
* The simple Kotlin type label for a documented field (enum → generated enum class name).
391+
*
392+
* [forPayload] selects the payload type path ([KotlinTypeMapper.payloadTypeName]) so a
393+
* `field.string @dbColumnType=jsonb` open bag is documented as the parsed JSON value
394+
* (`JsonElement`) the payload data class actually emits (issue #98); the entity model field
395+
* (forPayload = false) stays `String`, matching its data-class property. Keeps the documented
396+
* type == the generated type on both surfaces.
397+
*/
398+
private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject, forPayload: Boolean = false): String {
391399
KotlinTypeMapper.enumTypeName(field, owner)?.let { enumType ->
392400
val simple = enumType.simpleName
393401
return if (field.isArrayType) "List<$simple>" else simple
394402
}
395-
val tn = KotlinTypeMapper.kotlinTypeName(field)
403+
val tn = if (forPayload) KotlinTypeMapper.payloadTypeName(field) else KotlinTypeMapper.kotlinTypeName(field)
396404
val simple = (tn as? com.squareup.kotlinpoet.ClassName)?.simpleName ?: tn.toString()
397405
return if (field.isArrayType) "List<$simple>" else simple
398406
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,44 @@ class KotlinPayloadGeneratorTest {
4040
}
4141
}
4242

43+
@Test fun `jsonb open-bag field is a parsed JSON value in the payload, plain string stays String`() {
44+
// Issue #98: a `field.string @dbColumnType=jsonb` payload property is exposed as a parsed
45+
// JSON value (kotlinx `JsonElement`), NOT a (double-encoded) String. A sibling plain
46+
// `field.string` on the same VO stays `String`, proving the divergence is scoped to the
47+
// jsonb open-bag. The persistence side (Exposed column / entity data class) is unaffected —
48+
// KotlinPayloadGenerator emits no table/row code.
49+
val fx = """{
50+
"metadata.root": { "package": "acme::demo", "children": [
51+
{ "object.value": { "name": "Settings", "children": [
52+
{ "field.long": { "name": "id" } },
53+
{ "field.string": { "name": "config", "@dbColumnType": "jsonb" } },
54+
{ "field.string": { "name": "label" } }
55+
] } },
56+
{ "template.prompt": { "name": "SettingsPrompt",
57+
"@payloadRef": "Settings", "@textRef": "demo/settings" } }
58+
] }
59+
}""".trimIndent()
60+
61+
val outDir = Files.createTempDirectory("kpay-jsonb-")
62+
try {
63+
val gen = KotlinPayloadGenerator()
64+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
65+
gen.execute(loadString("test-jsonb", fx))
66+
67+
val emitted = outDir.resolve("acme/demo/prompts/SettingsPromptPayload.kt")
68+
assertTrue(Files.exists(emitted),
69+
"expected $emitted; files=${Files.walk(outDir).toList()}")
70+
val src = Files.readString(emitted)
71+
// The open-bag field → parsed JSON value.
72+
assertTrue("val config: JsonElement" in src, src)
73+
assertTrue("import kotlinx.serialization.json.JsonElement" in src, src)
74+
// The plain string field → String (no double-encoding).
75+
assertTrue("val label: String" in src, src)
76+
} finally {
77+
outDir.toFile().deleteRecursively()
78+
}
79+
}
80+
4381
// -----------------------------------------------------------------------
4482
// origin.* coverage — FR-004 payload-VO field-value provenance
4583
// -----------------------------------------------------------------------

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,37 @@ class KotlinTypeMapperTest {
231231
assertEquals("org.jetbrains.exposed.sql.json.jsonb", KotlinTypeMapper.exposedColumnImport(f))
232232
}
233233

234+
@Test fun `string field with dbColumnType=jsonb exposes a parsed JSON value in the REST payload`() {
235+
// Issue #98 (cross-port jsonb open-bag REST contract). A `field.string @dbColumnType=jsonb`
236+
// is the sanctioned "open JSON bag": the physical column is JSONB but the LOGICAL subtype
237+
// stays `string`. The two layers are deliberately split:
238+
// - PERSISTENCE (kotlinTypeName / exposedColumnSpec): the Exposed column stays the
239+
// identity-codec `jsonb(name, { it }, { it })` and the entity data-class property stays
240+
// `String` — the JDBC driver returns jsonb as raw text, so String is correct here.
241+
// - REST PAYLOAD (payloadTypeName): the typed projection exposes the bag as a PARSED JSON
242+
// value (kotlinx `JsonElement`) so a client sends/receives a real JSON object, never a
243+
// double-encoded string. Matches TS `z.unknown()` (#97) and Python `Any` (#99).
244+
val f = StringField("rubricWeights")
245+
f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb"))
246+
247+
// REST payload → parsed JSON value (the fix).
248+
assertEquals(
249+
ClassName("kotlinx.serialization.json", "JsonElement"),
250+
KotlinTypeMapper.payloadTypeName(f),
251+
)
252+
// Persistence stays String — the entity data-class property is the raw-JSON holder for the
253+
// identity-codec jsonb column. (This MUST NOT regress; the column assertion above gates it.)
254+
assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f))
255+
assertEquals("jsonb(\"rubric_weights\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f))
256+
}
257+
258+
@Test fun `plain string field payload stays String`() {
259+
// payloadTypeName only diverges from kotlinTypeName for the jsonb open-bag; a plain
260+
// `field.string` payload property is still `String` (no double-encoding concern).
261+
val f = StringField("name")
262+
assertEquals(STRING, KotlinTypeMapper.payloadTypeName(f))
263+
}
264+
234265
@Test fun `string field with dbColumnType=jsonb is case-insensitive`() {
235266
// `@dbColumnType` lookup case-folds (see `KotlinTypeMapper.dbColumnType`); both
236267
// `"JSONB"` and `"jsonb"` route to the JSONB branch.

0 commit comments

Comments
 (0)