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()
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..d0d2c4af0 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,22 @@ 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 (issue #98). `JsonElement` is the idiomatic "any JSON value" type for the
+ * `@Serializable` data classes this port 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.
+ *
+ * Used UNIFORMLY at every layer — the persistence holder ([kotlinTypeName], i.e. the entity
+ * data class reused as the entity-CRUD DTO), the REST payload ([payloadTypeName]), and the
+ * Exposed column codec ([exposedColumnSpec], which decodes the JSONB text to a `JsonElement`).
+ * Matching the maintainer decision on #98: uniform parsed value at the API boundary across all
+ * ports (TS `z.unknown()`, Python `Any`, C# `JsonDocument`). The wire/serialized form stays
+ * byte-identical to those ports (`{...}`); only the in-process Kotlin type is `JsonElement`.
+ */
+ private val 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
@@ -198,10 +214,14 @@ object KotlinTypeMapper {
/** Map a MetaField to its KotlinPoet data-class property TypeName. */
fun kotlinTypeName(field: MetaField<*>): 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
@@ -250,6 +270,32 @@ 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]).
+ *
+ * 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)) 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/
@@ -352,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 21eb3ffec..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
@@ -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]); 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 ->
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/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/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..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,24 +219,63 @@ 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 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`. 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"))
+
+ 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(
+ "jsonb(\"rubric_weights\", { it.toString() }, { Json.parseToJsonElement(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.
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`() {
@@ -245,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