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