fix(codegen-kotlin): expose field.string @dbColumnType:jsonb as a parsed JSON value in REST payloads (#98)#104
Conversation
…sed JSON value in REST payloads (#98) The `field.string @dbColumnType=jsonb` open JSON bag had its physical column emitted as JSONB but was typed `String` everywhere, forcing REST clients to double-encode (`"{\"k\":\"v\"}"` instead of `{"k":"v"}`). Per the maintainer decision on #98, expose the bag at the serialization/REST payload boundary as a parsed JSON value, uniformly (matching TS `z.unknown()` #97, Python `Any` #99). Two layers, treated differently: - Persistence (unchanged): the Exposed column stays the identity-codec `jsonb(name, { it }, { it })` and the entity data-class property stays `String` — the JDBC driver returns jsonb as raw text. `KotlinTypeMapper.kotlinTypeName` / `exposedColumnSpec` are untouched (existing `KotlinTypeMapperTest` stays green). - REST payload (fixed): add `KotlinTypeMapper.payloadTypeName` — identical to `kotlinTypeName` except the jsonb open bag maps to kotlinx `JsonElement` (the `@Serializable` payload substrate this port already uses). `KotlinPayloadGenerator` now resolves field types through it; the lenient->strict extract mapper bridges `String -> JsonElement` via `Json.parseToJsonElement`; api-docs documents the payload field as the parsed value it actually emits. The type mapper is shared between persistence and the payload surface, so the split is made by a dedicated `payloadTypeName` path keyed on the new `isJsonbOpenBag` predicate (single source of truth for payload + extractor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
…ce+CRUD, not just payload (#98) 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<JsonElement>`). 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
|
Follow-up commit The derived entity-CRUD controller reuses the Exposed entity data class as its DTO, and that property was still
Net: column / entity data class (CRUD DTO) / payload all expose Tests (TDD): |
…ation (#98) (#112) Wire the Kotlin api-contract jsonb sub-corpus (Document entity, field.string @dbColumnType=jsonb open JSON bag) in both lanes — a hand-rolled reference Exposed server and the GENERATED Spring controller hosted over MockMvc against a Postgres testcontainer — and fix a real codegen bug the generated lane surfaced. Runtime-write verification (Mandate 1 — clean): the generated Kotlin validator (KotlinValidatorGenerator) is a structural drift gate only — no per-field string/length/required check is emitted; the entity generator's @Valid annotations derive solely from @required/@maxLength/validator.*, none of which the open bag carries. The Exposed write codec takes a kotlinx JsonElement natively. The persistence-conformance op:roundtrip gate (asset-uuid-roundtrip.yaml) already inserts an object through the Kotlin runtime and reads it back parsed (green since #104). So there is no string-validator gap on the jsonb write path. Codegen fix the generated lane caught: a field.string @dbColumnType=jsonb field produced a <Entity>Controller that referenced the column's kotlinx JsonElement element type in its (dead) FR-009 filter dispatch (`p.value as JsonElement`) without importing it — the controller did not compile. The open bag is neither a scalar filter nor sort target (the FilterAllowlist already omits it — it is not @filterable), so it is now excluded from the controller's filter dispatch + sort allowlist in both the vanilla and TPH emit paths. Locked by a focused unit test; all 256 codegen-kotlin tests stay green (non-jsonb controllers byte-identical). Serialization seam: a JsonElement is a sealed polymorphic type that neither Spring JSON converter handles by default — vanilla Jackson cannot construct it, and the built-in KotlinSerializationJsonHttpMessageConverter refuses polymorphic bodies (HTTP 415). The generated lane wires a small Jackson JsonElement codec module (the consumer's serialization wiring, like the DB bootstrap — not a change to the generated artifact). Tests: JsonbApiContractConformanceTest (reference) + JsonbGeneratedApiContractConformanceTest (generated) green; Author generated lane (20) unaffected; codegen-kotlin 256/256. Part of #98. Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Part of #98 — the Kotlin port.
The two-layer nuance
field.string @dbColumnType=jsonbis the sanctioned "open JSON bag": the physical column is JSONB, but the logical subtype staysstring. Kotlin has both a persistence layer and a REST layer, and they are treated differently:Stringstays (unchanged).KotlinExposedTableGeneratorkeeps emitting the identity-codecjsonb("col", { it }, { it }), and the entity data-class property staysString— the JDBC driver returns jsonb as raw text, soStringis correct.KotlinTypeMapper.kotlinTypeName/exposedColumnSpecare untouched. The entity-CRUD controller reuses the entity data class as its@RequestBody/response DTO androwTo<Entity>maps the String-typed column into it, so it correctly staysStringtoo.JsonElement(the@Serializablesubstrate this port already uses forfield.mapjsonb columns), so a client sends/receives a real JSON object instead of a double-encoded string. Matches TSz.unknown()(fix(codegen-ts): emit z.unknown() (not z.string()) for field.string @dbColumnType:jsonb #97) and PythonAny(fix(codegen-python): emit Any (not str) for field.string @dbColumnType:jsonb (#98) #99).Layering found (is the type mapper shared? how differentiated?)
KotlinTypeMapper.kotlinTypeNameis shared by the persistence path (entity data class, Exposed table, entity-CRUD controller DTO) and the payload path (KotlinPayloadGenerator). To split them without disturbing persistence, I added a dedicatedpayloadTypeNamemethod keyed on a newisJsonbOpenBagpredicate (the single source of truth). It is identical tokotlinTypeNamefor every subtype except the jsonb open bag, which maps toJsonElement.KotlinPayloadGeneratorresolves field types throughpayloadTypeName.KotlinExtractorGenerator) bridgesString → JsonElementviaJson.parseToJsonElementso generated extractors compile.KotlinApiModelBuilder) documents the payload field as the parsed value it actually emits; the entity model field still documentsString.JSON-value type chosen — why
JsonElementKotlinPayloadGeneratoremits@Serializabledata classes (kotlinx.serialization).kotlinx.serialization.json.JsonElementis the idiomatic "any JSON value" type there (natively serializable; the sameJsonalready used by thejsonb(...)column codec forfield.map).Any?/Map<String, Any?>are not directly @serializable, and JacksonJsonNodeis the wrong substrate for this port.Test evidence (TDD, RED→GREEN)
cd server/java && mvn -pl codegen-kotlin -am test→ BUILD SUCCESS, 254 tests, 0 failures. New tests:KotlinTypeMapperTest—payloadTypeName(jsonb)==JsonElementwhilekotlinTypeName(jsonb)==StringandexposedColumnSpec(jsonb)==jsonb("...", { it }, { it })(persistence unchanged); plainfield.stringpayload staysString.KotlinPayloadGeneratorTest— emitted payload data class hasval config: JsonElement(+import kotlinx.serialization.json.JsonElement) for the jsonb field while a sibling plain string staysval label: String.KotlinTypeMapperTestpersistence assertions (incl.kotlinTypeName == String) stay green; api-docs accuracy gate + cross-port conformance green.Deferrals / findings
String: the Spring controller reuses the entity data class as its DTO, and that class is the persistence row-holder bound to the String-typed identity-codec jsonb column. Exposing it as a parsed value there would require either changing the column codec (out of scope — persistence must stay raw text) or introducing a separate REST DTO +rowTo/insert conversion (a new generator surface). This PR fixes the cleanly-separable typed-payload surface; the entity-CRUD double-encoding is the same deliberate raw-text contract C# also has (per the cross-port: verify+fix field.string @dbColumnType:jsonb → validator/DTO typing in C#/Python/Java/Kotlin (TS fixed in #97) #98 maintainer table).integration-tests-kotlin) was not run; unit/codegen tests cover the change.🤖 Generated with Claude Code