From b82ce07a6f8ee949119a261ef0900c93c8fc6718 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Mon, 29 Jun 2026 08:22:00 -0400
Subject: [PATCH 1/2] fix(codegen-kotlin): expose field.string
@dbColumnType:jsonb as a parsed JSON value in REST payloads (#98)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
---
.../kotlin/KotlinExtractorGenerator.kt | 15 +++++++
.../kotlin/KotlinPayloadGenerator.kt | 31 ++++++++-------
.../generator/kotlin/KotlinTypeMapper.kt | 39 +++++++++++++++++++
.../kotlin/apidocs/KotlinApiModelBuilder.kt | 16 ++++++--
.../kotlin/KotlinPayloadGeneratorTest.kt | 38 ++++++++++++++++++
.../generator/kotlin/KotlinTypeMapperTest.kt | 31 +++++++++++++++
6 files changed, 152 insertions(+), 18 deletions(-)
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt
index 2f97dcd2e..3c82ad4ea 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt
@@ -311,6 +311,14 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
else "m.$name!!.filterNotNull().map { $conv }"
}
+ // jsonb open bag (`field.string @dbColumnType=jsonb`): the strict payload types this as a
+ // parsed JSON value (kotlinx `JsonElement`, via KotlinTypeMapper.payloadTypeName — issue #98)
+ // while the lenient mirror leaf stays `String` (the LLM emits text). Bridge String → JsonElement
+ // by parsing. FQN-qualified so the emitted code resolves without an import (as elsewhere here).
+ if (KotlinTypeMapper.isJsonbOpenBag(field)) {
+ return "kotlinx.serialization.json.Json.parseToJsonElement(m.$name!!)"
+ }
+
// Scalar (single): mirror is T?, strict is T — null-assert.
return "m.$name!!"
}
@@ -331,6 +339,13 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
* FAILS LOUD at codegen time ([GeneratorException]) rather than emitting non-compiling code.
*/
private fun scalarArrayElementConversion(field: MetaField<*>): String? {
+ // jsonb open bag element (`field.string @dbColumnType=jsonb` + isArray): the strict payload
+ // element is a parsed JSON value (kotlinx `JsonElement`, via payloadTypeName — issue #98);
+ // the mirror element stays `String`. Parse each element. Checked first because the generic
+ // dispatch below keys on kotlinTypeName, which (correctly, for persistence) reports `String`.
+ if (KotlinTypeMapper.isJsonbOpenBag(field)) {
+ return "kotlinx.serialization.json.Json.parseToJsonElement(it)"
+ }
// Same path the payload generator wraps in List<…> for a scalar-array element.
val elementType = KotlinTypeMapper.kotlinTypeName(field)
return when (elementType) {
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt
index f014bb683..90ef9215e 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt
@@ -43,7 +43,9 @@ import java.nio.file.Paths
* (@agg sum/min/max) — type of the referenced `@of` field.
* {@code origin.collection} (@via "Parent.rel") — {@code List}, and the
* nested payload class is recursively emitted alongside (deduped per execute() run).
- * No origin child — fall back to {@link KotlinTypeMapper#kotlinTypeName(MetaField)}.
+ * No origin child — fall back to {@link KotlinTypeMapper#payloadTypeName(MetaField)}
+ * (parsed JSON value for a `field.string @dbColumnType=jsonb` open bag; otherwise the
+ * same mapping as {@code kotlinTypeName}).
*
*/
open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
@@ -133,8 +135,9 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
/**
* Resolve the Kotlin TypeName of a single payload-VO field, honoring any
- * `origin.*` child. Falls back to [KotlinTypeMapper.kotlinTypeName] when no
- * origin is present.
+ * `origin.*` child. Falls back to [KotlinTypeMapper.payloadTypeName] when no
+ * origin is present (parsed JSON value for a `field.string @dbColumnType=jsonb` open
+ * bag, otherwise identical to `kotlinTypeName`).
*/
protected open fun resolveFieldType(
field: MetaField<*>,
@@ -154,7 +157,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
is CollectionOrigin -> resolveCollectionType(
origin, loader, nestedPkg, outRoot, emittedNestedFqns, emittedEnumFqns, field
)
- else -> KotlinTypeMapper.kotlinTypeName(field)
+ else -> KotlinTypeMapper.payloadTypeName(field)
}
}
@@ -185,7 +188,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
// Scalar array (`isArray: true` on a non-object field): model as List in the
// strict payload (matching the cross-port payload shape). Without this, `kotlinTypeName`
// returns the bare element type and the array semantics are lost.
- val scalarType = KotlinTypeMapper.kotlinTypeName(field)
+ val scalarType = KotlinTypeMapper.payloadTypeName(field)
if (field.isArrayType()) {
return ClassName("kotlin.collections", "List").parameterizedBy(scalarType)
}
@@ -207,7 +210,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
emittedNestedFqns: MutableSet,
emittedEnumFqns: MutableSet,
): TypeName {
- val fallbackType = { KotlinTypeMapper.kotlinTypeName(field) }
+ val fallbackType = { KotlinTypeMapper.payloadTypeName(field) }
val target = try {
field.objectRef
} catch (e: RuntimeException) {
@@ -248,10 +251,10 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
loader: MetaDataLoader,
fallbackField: MetaField<*>,
): TypeName {
- val from = origin.from ?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
+ val from = origin.from ?: return KotlinTypeMapper.payloadTypeName(fallbackField)
val sourceField = resolveDottedFieldRef(loader, from)
- ?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
- return KotlinTypeMapper.kotlinTypeName(sourceField)
+ ?: return KotlinTypeMapper.payloadTypeName(fallbackField)
+ return KotlinTypeMapper.payloadTypeName(sourceField)
}
/**
@@ -269,12 +272,12 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
MetaOrigin.AGG_COUNT -> LONG
MetaOrigin.AGG_AVG -> DOUBLE
MetaOrigin.AGG_SUM, MetaOrigin.AGG_MIN, MetaOrigin.AGG_MAX -> {
- val of = origin.of ?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
+ val of = origin.of ?: return KotlinTypeMapper.payloadTypeName(fallbackField)
val sourceField = resolveDottedFieldRef(loader, of)
- ?: return KotlinTypeMapper.kotlinTypeName(fallbackField)
- KotlinTypeMapper.kotlinTypeName(sourceField)
+ ?: return KotlinTypeMapper.payloadTypeName(fallbackField)
+ KotlinTypeMapper.payloadTypeName(sourceField)
}
- else -> KotlinTypeMapper.kotlinTypeName(fallbackField)
+ else -> KotlinTypeMapper.payloadTypeName(fallbackField)
}
}
@@ -293,7 +296,7 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
emittedEnumFqns: MutableSet,
fallbackField: MetaField<*>,
): TypeName {
- val fallbackType = { KotlinTypeMapper.kotlinTypeName(fallbackField) }
+ val fallbackType = { KotlinTypeMapper.payloadTypeName(fallbackField) }
val via = origin.via ?: return fallbackType()
val (parentName, relName) = KotlinGenUtil.splitDottedRef(via) ?: return fallbackType()
val parent = KotlinGenUtil.resolveObjectByShortOrFqn(loader, parentName) ?: return fallbackType()
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt
index 391531d03..254cd4c1b 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt
@@ -118,6 +118,18 @@ object KotlinTypeMapper {
/** FQN of the Exposed `jsonb` extension function (raw-string open-JSON path). */
private const val EXPOSED_JSONB_IMPORT = "org.jetbrains.exposed.sql.json.jsonb"
+ /**
+ * The kotlinx-serialization JSON-value type a `field.string @dbColumnType=jsonb` open-bag is
+ * exposed as in the REST/serialization PAYLOAD (issue #98). `JsonElement` is the idiomatic
+ * "any JSON value" type for the `@Serializable` payload data classes [KotlinPayloadGenerator]
+ * emits (kotlinx is already this port's payload substrate — the same `Json` used by the
+ * `jsonb(...)` column codec for `field.map`), so a client sends/receives a real JSON object
+ * rather than a double-encoded string. The PERSISTENCE side is deliberately unaffected: the
+ * entity data-class property ([kotlinTypeName]) stays `String` (raw text from the JDBC driver)
+ * and the Exposed column ([exposedColumnSpec]) stays the identity-codec `jsonb(name,{it},{it})`.
+ */
+ private val PAYLOAD_JSON_VALUE_TYPE = ClassName("kotlinx.serialization.json", "JsonElement")
+
/**
* Name of the generated, file-local Exposed extension function emitted for a
* `@dbColumnType=timestamp_with_tz` [TimestampField]. It returns a
@@ -250,6 +262,33 @@ object KotlinTypeMapper {
)
}
+ /**
+ * True iff [field] is the `field.string @dbColumnType=jsonb` open JSON bag — the sanctioned
+ * "arbitrary JSON value" pattern whose physical column is JSONB while its logical subtype stays
+ * `string`. The single source of truth both the payload type ([payloadTypeName]) and the
+ * extract-mapper bridge ([KotlinExtractorGenerator]) dispatch on, so the two sites stay in
+ * lockstep. Resolved THROUGH the `extends` chain (same as [dbColumnType]) so a projection field
+ * that binds a base-entity jsonb column via `extends:` inherits the open-bag treatment.
+ */
+ fun isJsonbOpenBag(field: MetaField<*>): Boolean =
+ field is StringField && dbColumnType(field) == DB_COLUMN_TYPE_JSONB
+
+ /**
+ * Map a MetaField to its KotlinPoet TypeName for a REST/serialization **payload** property
+ * (the `@Serializable` projection data classes emitted by [KotlinPayloadGenerator]).
+ *
+ * Identical to [kotlinTypeName] for every subtype EXCEPT the `field.string @dbColumnType=jsonb`
+ * open bag, which becomes the parsed JSON value [PAYLOAD_JSON_VALUE_TYPE] instead of `String`
+ * (issue #98). This is the deliberate payload/persistence split: the persistence holder
+ * ([kotlinTypeName]) and the Exposed column ([exposedColumnSpec]) keep the raw-text `String`
+ * contract; only the API/wire boundary exposes the bag as a real JSON value (matching the
+ * TS `unknown` / Python `Any` ports). Generators that drive PERSISTENCE (entity data class,
+ * Exposed table, the entity-CRUD controller DTO, which reuses the entity data class) must keep
+ * calling [kotlinTypeName], not this.
+ */
+ fun payloadTypeName(field: MetaField<*>): TypeName =
+ if (isJsonbOpenBag(field)) PAYLOAD_JSON_VALUE_TYPE else kotlinTypeName(field)
+
/**
* The Kotlin value TypeName for a scalar-valued [MapField] (the type named by its
* `@valueType` attr — string/int/long/double/float/decimal/boolean/date/time/
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
index 21eb3ffec..dc8d17aeb 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
@@ -381,18 +381,26 @@ class KotlinApiModelBuilder {
val rows = mutableListOf()
for (f in vo.metaFields) {
if (f is ObjectField) continue
- rows.add(FieldShape(f.name, kotlinTypeLabel(f, vo), optional = !KotlinGenUtil.isRequiredField(f)))
+ rows.add(FieldShape(f.name, kotlinTypeLabel(f, vo, forPayload = true), optional = !KotlinGenUtil.isRequiredField(f)))
}
return rows
}
- /** The simple Kotlin type label for a documented field (enum → generated enum class name). */
- private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject): String {
+ /**
+ * The simple Kotlin type label for a documented field (enum → generated enum class name).
+ *
+ * [forPayload] selects the payload type path ([KotlinTypeMapper.payloadTypeName]) so a
+ * `field.string @dbColumnType=jsonb` open bag is documented as the parsed JSON value
+ * (`JsonElement`) the payload data class actually emits (issue #98); the entity model field
+ * (forPayload = false) stays `String`, matching its data-class property. Keeps the documented
+ * type == the generated type on both surfaces.
+ */
+ private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject, forPayload: Boolean = false): String {
KotlinTypeMapper.enumTypeName(field, owner)?.let { enumType ->
val simple = enumType.simpleName
return if (field.isArrayType) "List<$simple>" else simple
}
- val tn = KotlinTypeMapper.kotlinTypeName(field)
+ val tn = if (forPayload) KotlinTypeMapper.payloadTypeName(field) else KotlinTypeMapper.kotlinTypeName(field)
val simple = (tn as? com.squareup.kotlinpoet.ClassName)?.simpleName ?: tn.toString()
return if (field.isArrayType) "List<$simple>" else simple
}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt
index 288135701..d9bdbb202 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt
@@ -40,6 +40,44 @@ class KotlinPayloadGeneratorTest {
}
}
+ @Test fun `jsonb open-bag field is a parsed JSON value in the payload, plain string stays String`() {
+ // Issue #98: a `field.string @dbColumnType=jsonb` payload property is exposed as a parsed
+ // JSON value (kotlinx `JsonElement`), NOT a (double-encoded) String. A sibling plain
+ // `field.string` on the same VO stays `String`, proving the divergence is scoped to the
+ // jsonb open-bag. The persistence side (Exposed column / entity data class) is unaffected —
+ // KotlinPayloadGenerator emits no table/row code.
+ val fx = """{
+ "metadata.root": { "package": "acme::demo", "children": [
+ { "object.value": { "name": "Settings", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.string": { "name": "config", "@dbColumnType": "jsonb" } },
+ { "field.string": { "name": "label" } }
+ ] } },
+ { "template.prompt": { "name": "SettingsPrompt",
+ "@payloadRef": "Settings", "@textRef": "demo/settings" } }
+ ] }
+ }""".trimIndent()
+
+ val outDir = Files.createTempDirectory("kpay-jsonb-")
+ try {
+ val gen = KotlinPayloadGenerator()
+ gen.setArgs(mapOf("outputDir" to outDir.toString()))
+ gen.execute(loadString("test-jsonb", fx))
+
+ val emitted = outDir.resolve("acme/demo/prompts/SettingsPromptPayload.kt")
+ assertTrue(Files.exists(emitted),
+ "expected $emitted; files=${Files.walk(outDir).toList()}")
+ val src = Files.readString(emitted)
+ // The open-bag field → parsed JSON value.
+ assertTrue("val config: JsonElement" in src, src)
+ assertTrue("import kotlinx.serialization.json.JsonElement" in src, src)
+ // The plain string field → String (no double-encoding).
+ assertTrue("val label: String" in src, src)
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
+
// -----------------------------------------------------------------------
// origin.* coverage — FR-004 payload-VO field-value provenance
// -----------------------------------------------------------------------
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
index b1de208bf..91ef04105 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
@@ -231,6 +231,37 @@ class KotlinTypeMapperTest {
assertEquals("org.jetbrains.exposed.sql.json.jsonb", KotlinTypeMapper.exposedColumnImport(f))
}
+ @Test fun `string field with dbColumnType=jsonb exposes a parsed JSON value in the REST payload`() {
+ // Issue #98 (cross-port jsonb open-bag REST contract). A `field.string @dbColumnType=jsonb`
+ // is the sanctioned "open JSON bag": the physical column is JSONB but the LOGICAL subtype
+ // stays `string`. The two layers are deliberately split:
+ // - PERSISTENCE (kotlinTypeName / exposedColumnSpec): 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, so String is correct here.
+ // - REST PAYLOAD (payloadTypeName): the typed projection exposes the bag as a PARSED JSON
+ // value (kotlinx `JsonElement`) so a client sends/receives a real JSON object, never a
+ // double-encoded string. Matches TS `z.unknown()` (#97) and Python `Any` (#99).
+ val f = StringField("rubricWeights")
+ f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb"))
+
+ // REST payload → parsed JSON value (the fix).
+ assertEquals(
+ ClassName("kotlinx.serialization.json", "JsonElement"),
+ KotlinTypeMapper.payloadTypeName(f),
+ )
+ // Persistence stays String — the entity data-class property is the raw-JSON holder for the
+ // identity-codec jsonb column. (This MUST NOT regress; the column assertion above gates it.)
+ assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f))
+ assertEquals("jsonb(\"rubric_weights\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f))
+ }
+
+ @Test fun `plain string field payload stays String`() {
+ // payloadTypeName only diverges from kotlinTypeName for the jsonb open-bag; a plain
+ // `field.string` payload property is still `String` (no double-encoding concern).
+ val f = StringField("name")
+ assertEquals(STRING, KotlinTypeMapper.payloadTypeName(f))
+ }
+
@Test fun `string field with dbColumnType=jsonb is case-insensitive`() {
// `@dbColumnType` lookup case-folds (see `KotlinTypeMapper.dbColumnType`); both
// `"JSONB"` and `"jsonb"` route to the JSONB branch.
From a0247c4bf599d3a16057da03a22bf7ef84f7c808 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Mon, 29 Jun 2026 09:21:34 -0400
Subject: [PATCH 2/2] fix(codegen-kotlin): parse jsonb open-bag to JsonElement
at persistence+CRUD, not just payload (#98)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #104 exposed a `field.string @dbColumnType=jsonb` open bag as a parsed kotlinx
`JsonElement` only in the REST *payload* surface; the derived entity-CRUD controller
reuses the Exposed entity data class as its DTO, and that property was still `String`
(the Exposed column used an identity codec `jsonb(col, { it }, { it })` keeping raw
JSON text). So the entity-CRUD contract still exposed a jsonb bag as a double-encoded
`String`, unlike TS (`z.unknown()`), Python (`Any`), Java, and C# (`JsonDocument`).
Per the maintainer decision on #98 — uniform parsed value at the API boundary, all
ports — this does the Kotlin analogue of the C# `jsonb <-> JsonDocument` change:
- `KotlinTypeMapper.kotlinTypeName` now maps the jsonb open bag to `JsonElement`
(keyed on the existing `isJsonbOpenBag` predicate from #104), so the entity data
class — and thus the reused entity-CRUD DTO — is a parsed JSON value.
- `KotlinTypeMapper.exposedColumnSpec` changes the jsonb codec from the identity
passthrough to a real `JsonElement` codec: encode `{ it.toString() }` (a
JsonElement's toString is canonical JSON), decode `{ Json.parseToJsonElement(it) }`
(concrete return anchors the column generic to `Column`). It is STILL
a real `jsonb(...)` column — only the codec/type changes, not that it is jsonb.
- `KotlinExposedTableGenerator` emits the `kotlinx.serialization.json.Json` import
for tables carrying a jsonb open bag (direct or flattened sub-field), without
double-emitting the `jsonb` extension import.
Net: column / entity data class (CRUD DTO) / payload all expose `JsonElement` for a
jsonb bag — fully uniform. A plain `field.string` stays `String`; `@dbColumnType:uuid`
stays `String`. The wire/serialized form is byte-identical to the other ports (`{...}`);
only the in-process Kotlin type changes String -> JsonElement.
Tests (TDD, RED->GREEN):
- KotlinTypeMapperTest: the jsonb case now asserts `kotlinTypeName == JsonElement` and
the new column codec, while still asserting the column is a real `jsonb(...)`.
- KotlinEntityGeneratorTest: new test proving the entity data class types the jsonb
field as `JsonElement` (the entity-CRUD path).
- integration-tests-kotlin: the hand-written reference `AssetTable.payload` and the
`QueryScenarioRunner` read/write coercion + `RuntimeReturnTypeTest` updated to the
parsed `JsonElement` read-back (wire form unchanged). Full module green (90/90)
against Testcontainers Postgres.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
---
.../kotlin/KotlinExposedTableGenerator.kt | 21 ++++++-
.../generator/kotlin/KotlinTypeMapper.kt | 52 +++++++++++-------
.../kotlin/apidocs/KotlinApiModelBuilder.kt | 10 ++--
.../kotlin/KotlinEntityGeneratorTest.kt | 36 ++++++++++++
.../generator/kotlin/KotlinTypeMapperTest.kt | 55 +++++++++++--------
.../integration/kotlin/QueryScenarioRunner.kt | 20 ++++---
.../kotlin/RuntimeReturnTypeTest.kt | 21 ++++---
.../integration/kotlin/tables/AssetTable.kt | 14 +++--
8 files changed, 158 insertions(+), 71 deletions(-)
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
index 1497c2f38..5b4f59547 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
@@ -210,7 +210,20 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase
+ f is ObjectField && readStorage(f) == STORAGE_FLATTENED &&
+ (readObjectRef(f)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) }
+ ?.metaFields?.any { KotlinTypeMapper.isJsonbOpenBag(it) } ?: false)
+ }
+ val needsJsonbImport =
+ objectColumns.any { it.kind == ObjectColumnKind.JSONB } || hasStringJsonbOpenBag
val needsRefOptForDecor = refDecorations.values.any { it.hasReferenceOption }
// Does any column on this table use the TZ-aware `@dbColumnType=timestamp_with_tz`
@@ -252,6 +265,10 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase): TypeName = when (field) {
// `@dbColumnType=uuid_array/text_array` makes a field.string a native SQL array,
- // so the property is a List / List. Plain strings stay String.
+ // so the property is a List / List. `@dbColumnType=jsonb` is the open
+ // JSON bag — a parsed JSON value (kotlinx `JsonElement`, issue #98), uniform with the
+ // payload + Exposed column codec so the entity-CRUD DTO is never a double-encoded String.
+ // Plain strings stay String.
is StringField -> when (dbColumnType(field)) {
DB_COLUMN_TYPE_UUID_ARRAY -> LIST.parameterizedBy(ClassName("java.util", "UUID"))
DB_COLUMN_TYPE_TEXT_ARRAY -> LIST.parameterizedBy(STRING)
+ DB_COLUMN_TYPE_JSONB -> JSON_VALUE_TYPE
else -> STRING
}
is IntegerField -> INT
@@ -277,17 +285,16 @@ object KotlinTypeMapper {
* Map a MetaField to its KotlinPoet TypeName for a REST/serialization **payload** property
* (the `@Serializable` projection data classes emitted by [KotlinPayloadGenerator]).
*
- * Identical to [kotlinTypeName] for every subtype EXCEPT the `field.string @dbColumnType=jsonb`
- * open bag, which becomes the parsed JSON value [PAYLOAD_JSON_VALUE_TYPE] instead of `String`
- * (issue #98). This is the deliberate payload/persistence split: the persistence holder
- * ([kotlinTypeName]) and the Exposed column ([exposedColumnSpec]) keep the raw-text `String`
- * contract; only the API/wire boundary exposes the bag as a real JSON value (matching the
- * TS `unknown` / Python `Any` ports). Generators that drive PERSISTENCE (entity data class,
- * Exposed table, the entity-CRUD controller DTO, which reuses the entity data class) must keep
- * calling [kotlinTypeName], not this.
+ * For the `field.string @dbColumnType=jsonb` open bag this is the parsed JSON value
+ * [JSON_VALUE_TYPE] (issue #98). As of the #98 uniform-parsed-value cutover this now AGREES
+ * with [kotlinTypeName] at every subtype — the persistence holder (entity data class reused as
+ * the entity-CRUD DTO) and the Exposed column ([exposedColumnSpec]) ALSO expose the bag as a
+ * `JsonElement`, so there is no longer a payload/persistence split. Retained as a distinct,
+ * intention-revealing entry point for the payload generators (and to keep them robust if the
+ * two surfaces ever diverge again).
*/
fun payloadTypeName(field: MetaField<*>): TypeName =
- if (isJsonbOpenBag(field)) PAYLOAD_JSON_VALUE_TYPE else kotlinTypeName(field)
+ if (isJsonbOpenBag(field)) JSON_VALUE_TYPE else kotlinTypeName(field)
/**
* The Kotlin value TypeName for a scalar-valued [MapField] (the type named by its
@@ -391,7 +398,14 @@ object KotlinTypeMapper {
// would never round-trip to JSONB through the introspection corpus.
when (dbColumnType(field)) {
DB_COLUMN_TYPE_UUID -> "uuid(\"$colName\")"
- DB_COLUMN_TYPE_JSONB -> "jsonb(\"$colName\", { it }, { it })"
+ // `@dbColumnType=jsonb` open bag (#98): decode the JSONB text to a kotlinx
+ // `JsonElement` (so the data-class property + CRUD DTO is a parsed JSON value, not
+ // a double-encoded String) and encode it back via `toString()` (a JsonElement's
+ // toString is canonical JSON). `Json.parseToJsonElement` returns a concrete
+ // `JsonElement`, which ANCHORS the Exposed column's generic to `Column`
+ // (the `{ it }` identity codec would have left it `Column`). The table file
+ // imports `kotlinx.serialization.json.Json` (see KotlinExposedTableGenerator).
+ DB_COLUMN_TYPE_JSONB -> "jsonb(\"$colName\", { it.toString() }, { Json.parseToJsonElement(it) })"
// Native SQL array columns via Exposed's `array("col", columnType)` Table
// member. The element ColumnType is explicit (UUIDColumnType / TextColumnType)
// so emission never depends on Exposed's reified resolveColumnType picking
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
index dc8d17aeb..777f6df31 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
@@ -389,11 +389,11 @@ class KotlinApiModelBuilder {
/**
* The simple Kotlin type label for a documented field (enum → generated enum class name).
*
- * [forPayload] selects the payload type path ([KotlinTypeMapper.payloadTypeName]) so a
- * `field.string @dbColumnType=jsonb` open bag is documented as the parsed JSON value
- * (`JsonElement`) the payload data class actually emits (issue #98); the entity model field
- * (forPayload = false) stays `String`, matching its data-class property. Keeps the documented
- * type == the generated type on both surfaces.
+ * [forPayload] selects the payload type path ([KotlinTypeMapper.payloadTypeName]); the entity
+ * model field path uses [KotlinTypeMapper.kotlinTypeName]. As of the #98 uniform-parsed-value
+ * cutover BOTH surfaces document a `field.string @dbColumnType=jsonb` open bag as the parsed
+ * JSON value (`JsonElement`) — the payload data class and the entity data class (the reused
+ * entity-CRUD DTO) now agree. Keeps the documented type == the generated type on both surfaces.
*/
private fun kotlinTypeLabel(field: MetaField<*>, owner: MetaObject, forPayload: Boolean = false): String {
KotlinTypeMapper.enumTypeName(field, owner)?.let { enumType ->
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt
index d87f0ec1f..40befc08f 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGeneratorTest.kt
@@ -126,6 +126,42 @@ class KotlinEntityGeneratorTest {
}
}
+ @Test fun jsonbOpenBagEntityPropertyIsParsedJsonValue() {
+ // Issue #98: the `field.string @dbColumnType=jsonb` open bag is a PARSED JSON value
+ // (kotlinx `JsonElement`) on the entity data class too — not a (double-encoded) raw-JSON
+ // `String`. The entity data class is REUSED as the derived entity-CRUD request/response DTO,
+ // so this is the entity-CRUD REST contract reaching full parity with the payload surface
+ // (and with TS/Python/Java/C#). A sibling plain `field.string` stays `String`.
+ val fx = """{
+ "metadata.root": { "package": "acme::demo", "children": [
+ { "object.entity": { "name": "Rubric", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.string": { "name": "weights", "@dbColumnType": "jsonb" } },
+ { "field.string": { "name": "label" } }
+ ] } }
+ ] }
+ }""".trimIndent()
+
+ val outDir = Files.createTempDirectory("kgen-jsonb-")
+ try {
+ val gen = KotlinEntityGenerator()
+ gen.setArgs(mapOf("outputDir" to outDir.toString()))
+ gen.execute(loadString("jsonb", fx))
+
+ val src = Files.readString(outDir.resolve("acme/demo/Rubric.kt"))
+ // jsonb open bag → parsed JSON value, imported.
+ assertTrue("val weights: JsonElement" in src, "expected `val weights: JsonElement` in:\n$src")
+ assertTrue("import kotlinx.serialization.json.JsonElement" in src,
+ "expected JsonElement import in:\n$src")
+ // plain string stays String (no double-encoding concern).
+ assertTrue("val label: String" in src, "expected `val label: String` in:\n$src")
+ // must NOT regress to a raw-JSON String holder for the bag.
+ assertFalse("val weights: String" in src, "jsonb open bag must not be String:\n$src")
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
+
// === field.enum coverage ===============================================
private val enumFixture = """{
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
index 91ef04105..2774b182b 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
@@ -219,40 +219,45 @@ class KotlinTypeMapperTest {
@Test fun `string field with dbColumnType=jsonb emits real jsonb column not text`() {
// R6 Plan 2b: `@dbColumnType=jsonb` on a `field.string` selects a native Postgres
- // JSONB column (matching the other 4 ports). The Exposed column is the
- // `jsonb(name, encoder, decoder)` extension with identity String functions (the
- // property stays a raw-JSON `String`), NOT the old `text(...)` — a TEXT column
+ // JSONB column (matching the other 4 ports). NOT the old `text(...)` — a TEXT column
// never round-trips to JSONB in the introspection corpus.
+ //
+ // Issue #98: the column codec now PARSES the JSONB text to a kotlinx `JsonElement`
+ // (decode `Json.parseToJsonElement`, encode `it.toString()`) instead of the old
+ // identity passthrough `{ it }, { it }`, so the entity data-class property is a parsed
+ // JSON value, not a raw-JSON `String`. The decode anchors the Exposed column's generic
+ // to `JsonElement`. It is STILL a real `jsonb(...)` column — only the codec/type changes.
val f = StringField("rubricWeights")
f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb"))
- assertEquals("jsonb(\"rubric_weights\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f))
- assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f))
+ assertEquals(
+ "jsonb(\"rubric_weights\", { it.toString() }, { Json.parseToJsonElement(it) })",
+ KotlinTypeMapper.exposedColumnSpec(f),
+ )
+ assertEquals(ClassName("kotlinx.serialization.json", "JsonElement"), KotlinTypeMapper.kotlinTypeName(f))
// The column needs the exposed-json `jsonb` extension import.
assertEquals("org.jetbrains.exposed.sql.json.jsonb", KotlinTypeMapper.exposedColumnImport(f))
}
- @Test fun `string field with dbColumnType=jsonb exposes a parsed JSON value in the REST payload`() {
+ @Test fun `string field with dbColumnType=jsonb exposes a parsed JSON value at every layer`() {
// Issue #98 (cross-port jsonb open-bag REST contract). A `field.string @dbColumnType=jsonb`
// is the sanctioned "open JSON bag": the physical column is JSONB but the LOGICAL subtype
- // stays `string`. The two layers are deliberately split:
- // - PERSISTENCE (kotlinTypeName / exposedColumnSpec): 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, so String is correct here.
- // - REST PAYLOAD (payloadTypeName): the typed projection exposes the bag as a PARSED JSON
- // value (kotlinx `JsonElement`) so a client sends/receives a real JSON object, never a
- // double-encoded string. Matches TS `z.unknown()` (#97) and Python `Any` (#99).
+ // stays `string`. Per the maintainer decision the bag is a PARSED JSON value (kotlinx
+ // `JsonElement`) UNIFORMLY at every layer — payload, entity data class (the reused CRUD DTO),
+ // and the Exposed column codec — so a client sends/receives a real JSON object, never a
+ // double-encoded string. Matches TS `z.unknown()` (#97), Python `Any` (#99), C# `JsonDocument`.
val f = StringField("rubricWeights")
f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb"))
- // REST payload → parsed JSON value (the fix).
+ val jsonElement = ClassName("kotlinx.serialization.json", "JsonElement")
+ // REST payload → parsed JSON value.
+ assertEquals(jsonElement, KotlinTypeMapper.payloadTypeName(f))
+ // Entity data class / entity-CRUD DTO → parsed JSON value (now uniform with payload).
+ assertEquals(jsonElement, KotlinTypeMapper.kotlinTypeName(f))
+ // The Exposed column stays a real jsonb column but parses to JsonElement.
assertEquals(
- ClassName("kotlinx.serialization.json", "JsonElement"),
- KotlinTypeMapper.payloadTypeName(f),
+ "jsonb(\"rubric_weights\", { it.toString() }, { Json.parseToJsonElement(it) })",
+ KotlinTypeMapper.exposedColumnSpec(f),
)
- // Persistence stays String — the entity data-class property is the raw-JSON holder for the
- // identity-codec jsonb column. (This MUST NOT regress; the column assertion above gates it.)
- assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f))
- assertEquals("jsonb(\"rubric_weights\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f))
}
@Test fun `plain string field payload stays String`() {
@@ -267,7 +272,10 @@ class KotlinTypeMapperTest {
// `"JSONB"` and `"jsonb"` route to the JSONB branch.
val f = StringField("featureFlags")
f.addMetaAttr(StringAttribute.create("dbColumnType", "JSONB"))
- assertEquals("jsonb(\"feature_flags\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f))
+ assertEquals(
+ "jsonb(\"feature_flags\", { it.toString() }, { Json.parseToJsonElement(it) })",
+ KotlinTypeMapper.exposedColumnSpec(f),
+ )
}
@Test fun `string field with dbColumnType=jsonb ignores maxLength`() {
@@ -276,7 +284,10 @@ class KotlinTypeMapperTest {
val f = StringField("blob")
f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb"))
f.addMetaAttr(IntAttribute.create("maxLength", 64))
- assertEquals("jsonb(\"blob\", { it }, { it })", KotlinTypeMapper.exposedColumnSpec(f))
+ assertEquals(
+ "jsonb(\"blob\", { it.toString() }, { Json.parseToJsonElement(it) })",
+ KotlinTypeMapper.exposedColumnSpec(f),
+ )
}
// === Currency / Enum / UUID coverage ===
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt
index 67efb39a9..bcc4c93b4 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt
@@ -438,8 +438,11 @@ object QueryScenarioRunner {
}
type == "date" -> LocalDate.parse(raw.toString())
type.contains("time") -> LocalTime.parse(raw.toString())
- // jsonb raw-String column: serialize the authoring Map to a JSON string so the column
- // writes a real Postgres JSONB value (a bare String bind would be rejected by jsonb).
+ // jsonb column: bind the authoring Map as a JSON string. Both jsonb codec styles in this
+ // module accept it via type erasure — the open-bag `Column` encoder
+ // (`{ it.toString() }`) and the object/map `Column` identity encoder (`{ it }`)
+ // both receive the String and write a real Postgres JSONB value (a bare String bind
+ // would be rejected by jsonb). Read-back (#98) parses it back per-column.
type.contains("jsonb") -> if (raw is String) raw else JSON.writeValueAsString(raw)
else -> raw
}
@@ -641,12 +644,13 @@ object QueryScenarioRunner {
// LocalDateTime so it lands on the no-`Z` branch. Instant (the TZ-aware shape) is left
// as-is for Normalization's Instant branch.
if (v is Timestamp) v = v.toLocalDateTime()
- // `@dbColumnType:jsonb` open-JSON column round-trips as a raw JSON String
- // (identity decode). Parse it to a Map so Normalization sorts the keys and
- // the `expect` block (a YAML object) compares byte-equal. Detected by the
- // column's SQL type (`jsonb`) so it stays generic across jsonb columns.
- if (v is String && col.columnType.sqlType().lowercase().contains("jsonb")) {
- v = JSON.readValue(v, Map::class.java)
+ // `@dbColumnType:jsonb` open-JSON column (#98) reads back as a parsed kotlinx
+ // JsonElement (or, for an object/map jsonb column, a raw JSON String). Convert
+ // whatever it is to a Map (via its JSON text — JsonElement.toString() is canonical
+ // JSON) so Normalization sorts the keys and the `expect` block (a YAML object) compares
+ // byte-equal. Detected by the column's SQL type (`jsonb`) so it stays generic.
+ if (v != null && col.columnType.sqlType().lowercase().contains("jsonb")) {
+ v = JSON.readValue(v.toString(), Map::class.java)
}
out[col.name] = v
}
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt
index e238a4ff2..8b81916d1 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt
@@ -27,9 +27,9 @@ import kotlin.test.assertTrue
* - `Measurement.preciseKg` (NUMERIC) → [BigDecimal] (exact native decimal).
* - `Asset.recordedAt` (TIMESTAMPTZ) → [Instant] (native temporal, NOT a String —
* the metaobjects `instantWithTimeZone` Column path matches the `Instant` data class).
- * - `Asset.payload` (jsonb) → [String] (Exposed surfaces the open-JSON
- * column via identity decode; the parse-to-Map step is a harness concern. We assert what
- * the runtime genuinely returns — raw JSON text, not a pre-canonicalized/key-sorted string).
+ * - `Asset.payload` (jsonb) → [kotlinx.serialization.json.JsonElement] (#98: the
+ * open-JSON column decodes to a parsed JSON value, uniform with the entity data class + REST
+ * payload — NOT a raw-JSON String; the parse-to-Map key-sorting step is a harness concern).
*
* Per-port gate (native types differ per language), not a byte-identical
* cross-port corpus. Catches the Python-outlier class of regression: a runtime
@@ -98,17 +98,16 @@ class RuntimeReturnTypeTest {
assertTrue(recordedAt is Temporal, "Asset.recordedAt must be a java.time temporal")
assertTrue(recordedAt !is String, "Asset.recordedAt must not be a wire-string")
- // jsonb: Exposed surfaces the open-JSON column via identity decode → raw JSON
- // text (String). The parse-to-Map (key-sorting) step is a harness concern
- // (QueryScenarioRunner.rowToMap), NOT baked into the runtime. We assert the
- // runtime's genuine native return and document that canonicalization happens
- // at the boundary.
+ // jsonb (#98): the open-JSON column decodes to a parsed kotlinx `JsonElement`
+ // (NOT a raw-JSON String), uniform with the generated entity data-class property
+ // and the REST payload. The key-sorting parse-to-Map step is a harness concern
+ // (QueryScenarioRunner.rowToMap); here we assert the runtime's genuine native return.
val payload = a[AssetTable.payload]
assertNotNull(payload, "Asset.payload should be present")
assertTrue(
- payload is String,
- "Asset.payload (jsonb) is surfaced via Exposed identity decode as raw JSON " +
- "text; got: ${payload::class}",
+ payload is kotlinx.serialization.json.JsonElement,
+ "Asset.payload (jsonb) is surfaced as a parsed JsonElement (#98); " +
+ "got: ${payload::class}",
)
}
}
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt
index abb563a59..eebac2936 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetTable.kt
@@ -6,6 +6,7 @@ import org.jetbrains.exposed.sql.UUIDColumnType
import org.jetbrains.exposed.sql.javatime.date
import org.jetbrains.exposed.sql.javatime.datetime
import org.jetbrains.exposed.sql.json.jsonb
+import kotlinx.serialization.json.Json
/**
* Hand-written reference Exposed Table mirroring `Asset` from
@@ -16,7 +17,7 @@ import org.jetbrains.exposed.sql.json.jsonb
* - `field.uuid` PK + `@generation:uuid` → `uuid("id")` + gen_random_uuid() DEFAULT
* - `field.uuid` (non-key, @required) → `uuid("ownerId")` (Postgres native uuid)
* - `field.string` + `@dbColumnType:uuid` → `uuid("externalId")` (native uuid column; generated DATA-CLASS property stays String)
- * - `field.string` + `@dbColumnType:jsonb` → `jsonb("payload", …)` (real Postgres JSONB)
+ * - `field.string` + `@dbColumnType:jsonb` → `jsonb("payload", …)` (real Postgres JSONB; parsed to kotlinx JsonElement, issue #98)
* - `field.timestamp` + `@dbColumnType:timestamp_with_tz` → `instantWithTimeZone("recordedAt")`
* (a `Column` whose DDL is `TIMESTAMP WITH TIME ZONE` — matches the
* `Instant` data-class property with zero coercion; see [instantWithTimeZone])
@@ -33,10 +34,13 @@ object AssetTable : Table("assets") {
// `@dbColumnType:uuid` on a field.string → native uuid column. Exposed surfaces this as
// a java.util.UUID at the SQL boundary; the normalizer lowercases it canonically.
val externalId = uuid("externalId")
- // `@dbColumnType:jsonb` open-JSON column. Identity encode/decode keeps the raw JSON text
- // (the property is String); the runner parses it to a Map before normalization so the
- // jsonb re-serializes with sorted keys per the normalization contract.
- val payload = jsonb("payload", { it }, { it })
+ // `@dbColumnType:jsonb` open-JSON column (#98). The codec PARSES the JSONB text to a kotlinx
+ // `JsonElement` (decode `Json.parseToJsonElement`, encode `it.toString()`), so the column is
+ // `Column` and a read returns a parsed JSON value (uniform with the generated
+ // entity data-class property + REST payload). The runner converts the JsonElement to a Map
+ // before normalization so the jsonb re-serializes with sorted keys per the normalization
+ // contract. Byte-for-byte the codec KotlinExposedTableGenerator now emits for this column.
+ val payload = jsonb("payload", { it.toString() }, { Json.parseToJsonElement(it) })
// `recordedAt` is a TIMESTAMPTZ column surfaced as java.time.Instant (the metaobjects
// `instantWithTimeZone` Column path — matches the `Instant` data class with no
// OffsetDateTime coercion). The instant is already UTC, so Normalization renders it at