fix(codegen-ts): emit z.unknown() (not z.string()) for field.string @dbColumnType:jsonb#97
Merged
Merged
Conversation
…dbColumnType:jsonb `@dbColumnType: jsonb` (legal only on field.string) is the sanctioned "open JSON bag" escape hatch — a genuinely untyped JSON column with no value-object to reference (the loader rejects a bare field.object without @objectref and directs authors here). Its Drizzle column is a bare `jsonb()`, which node-postgres returns as a PARSED JS value (object/array/scalar), not a string. But the Zod emitter typed it `z.string()` and the TS-type emitter typed it `string`, so the two generated artifacts disagreed with the column: a POST of the real JSON object was 400-rejected by the InsertSchema, and a read returned a value that failed the same schema — also contradicting the native-object runtime-return contract (ADR-0019). - zod-validators.ts: zodFieldExpr emits z.unknown() when @dbColumnType === jsonb (mirrors the existing objectRef-less field.object → z.unknown() precedent). - inferred-types.ts: fieldTsTypeString + valueObjectFieldType emit `unknown` for the same case, preserving the documented Zod/TS-type lock-step invariant (so a contract-only runtime:false target's TS type matches its Zod). - Adds jsonb-open-bag-zod.test.ts (TDD red→green) + a direct fieldTsTypeString unit assertion. field.object @storage:jsonb (the structured-VO path) was already correct (jsonb().$type<VO>() + <VO>InsertSchema) and is unaffected. The same logical miss likely exists in the other ports' validator/DTO emitters (@dbColumnType is a physical override; the logical subtype stays string) — tracked separately for per-port verification. Found via the metaobjects-audit skill against a real adopter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
This was referenced Jun 29, 2026
dmealing
added a commit
that referenced
this pull request
Jun 29, 2026
…e:jsonb (#98) (#99) pg8000 auto-decodes a jsonb column to a native Python object (dict/list/ scalar) at read time, so typing a field.string @dbColumnType:jsonb field as `str` in the generated Pydantic model is a type lie (the runtime return-type integration test already asserts the value is a dict). Emit `Any` instead — the Python analogue of the TS z.unknown() fix in #97 — threading `from typing import Any` into the module imports via the existing PyType import mechanism. field.object @storage:jsonb (structured VO) and field.string @dbColumnType:uuid are unaffected. Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
dmealing
added a commit
that referenced
this pull request
Jun 29, 2026
…sed JSON value in DTOs/payloads (#98) (#103) A `field.string @dbColumnType:jsonb` open JSON bag was typed `String` in the generated `<Entity>Dto`/payload records (the REST contract), so Jackson could not bind a posted JSON object and serialised a stored bag as a double-encoded string. SpringTypeMapper.javaTypeName now returns `Object` (dependency-free; Jackson maps arbitrary JSON to Map/List/scalar) when a StringField carries @dbColumnType:jsonb, via a new jsonbOptIn() guard mirroring the existing timestampWithTzOptIn() pattern. This flows uniformly to every REST/contract consumer of the mapper (DTO, payload, output-parser, api-docs); OMDB persistence is dynamic and unaffected. Matches the TS `z.unknown()` (#97) and Python `Any` (#99) cross-port fixes. Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
dmealing
added a commit
that referenced
this pull request
Jun 29, 2026
…sed JSON value in REST payloads (#98) (#104) * fix(codegen-kotlin): expose field.string @dbColumnType:jsonb as a parsed JSON value in REST payloads (#98) The `field.string @dbColumnType=jsonb` open JSON bag had its physical column emitted as JSONB but was typed `String` everywhere, forcing REST clients to double-encode (`"{\"k\":\"v\"}"` instead of `{"k":"v"}`). Per the maintainer decision on #98, expose the bag at the serialization/REST payload boundary as a parsed JSON value, uniformly (matching TS `z.unknown()` #97, Python `Any` #99). Two layers, treated differently: - Persistence (unchanged): the Exposed column stays the identity-codec `jsonb(name, { it }, { it })` and the entity data-class property stays `String` — the JDBC driver returns jsonb as raw text. `KotlinTypeMapper.kotlinTypeName` / `exposedColumnSpec` are untouched (existing `KotlinTypeMapperTest` stays green). - REST payload (fixed): add `KotlinTypeMapper.payloadTypeName` — identical to `kotlinTypeName` except the jsonb open bag maps to kotlinx `JsonElement` (the `@Serializable` payload substrate this port already uses). `KotlinPayloadGenerator` now resolves field types through it; the lenient->strict extract mapper bridges `String -> JsonElement` via `Json.parseToJsonElement`; api-docs documents the payload field as the parsed value it actually emits. The type mapper is shared between persistence and the payload surface, so the split is made by a dedicated `payloadTypeName` path keyed on the new `isJsonbOpenBag` predicate (single source of truth for payload + extractor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky * fix(codegen-kotlin): parse jsonb open-bag to JsonElement at persistence+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 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
dmealing
added a commit
that referenced
this pull request
Jun 29, 2026
…sed JSON value (#98) (#105) A `field.string` carrying `@dbColumnType: jsonb` is the sanctioned "open JSON bag": the physical column is jsonb (returns a parsed JSON value), so the generated EF Core entity property must surface a JSON-value CLR type, not a double-encoded string. This changes the C# contract (previously raw JSON text in a `string` property) to the uniform parsed-value contract — matching TS `z.unknown()` (#97) and Python `Any` (#99). CLR type: `System.Text.Json.JsonDocument`. Npgsql maps jsonb <-> JsonDocument natively (no `EnableDynamicJson` required — the project relies on the built-in weakly-typed json mappings), it round-trips an arbitrary JSON object, and System.Text.Json (de)serializes it as a real JSON value at the minimal-API boundary (POST -> read returns the object, not an escaped string). - CSharpNaming: `DbColumnTypeClrOverride` resolves jsonb -> JsonDocument at the type-mapping seam (uuid/timestamp_with_tz keep their logical CLR type and convert at the DB seam, ADR-0013); `RequiresSystemTextJson` drives a conditional `using System.Text.Json;`. Named constants only (no inline "jsonb"). - EntityGenerator: ScalarProperty applies the override; the three emit paths (entity / abstract shape / value-object POCO) add the using when needed. - DbContext config unchanged (`.HasColumnType("jsonb")` still emitted). - Regenerated the committed integration fixture (Asset.g.cs) via the drift harness. - Updated the runtime gate (RuntimeReturnTypeTests: Asset.Payload is now a parsed JsonDocument, not string) and the persistence runner seam (EntityRow passes the JsonDocument through; Normalization routes its raw JSON through the existing sorted-key jsonb path so the cross-port wire form stays byte-identical). TDD: new JsonbColumnCodegenTests (jsonb -> JsonDocument?, uuid/plain string stay string?, jsonb still maps to a jsonb column, generated entity compiles). Tests: full C# suite green (Codegen 247, Conformance 666, Render 290, Cli 38) + all 80 Docker/Testcontainers integration tests (RuntimeReturnType + persistence query asset-jsonb round-trip) green. Part of #98 Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
@dbColumnType: jsonb(legal only onfield.string) is the framework's sanctioned open JSON bag escape hatch — a genuinely untyped JSON column with no value-object to reference (the loader rejects a barefield.objectwithout@objectRefand the error text directs authors here; the embeddedailibrary uses it). Its Drizzle column is a barejsonb(), which node-postgres returns as a parsed JS value (object/array/scalar) — not a string.But the generated artifacts disagreed with the column:
zod-validators.ts): emittedz.string()(the string branch ignored@dbColumnType).inferred-types.ts): emittedstring.So a
POSTof the real JSON object was 400-rejected by the generatedInsertSchema, and a read returned a value that failed the same schema — also contradicting the native-object runtime-return contract (ADR-0019). An adopter had to hand-writez.record(z.unknown())validators to compensate.Evidence (generated output, before)
Fix
zodFieldExpremitsz.unknown()when@dbColumnType === jsonb(mirrors the existing objectRef-lessfield.object → z.unknown()precedent).fieldTsTypeString+valueObjectFieldTypeemitunknownfor the same case, preserving the documented Zod/TS-type lock-step invariant (so a contract-onlyruntime:falsetarget's TS type matches its Zod).jsonb-open-bag-zod.test.ts(TDD red→green) + a directfieldTsTypeStringunit assertion.field.object @storage:jsonb(the structured-VO path) was already correct (jsonb().$type<VO>()+<VO>InsertSchema) and is unaffected.Tests
codegen-tssuite: 867 pass / 0 fail; typecheck clean.server/typescriptsuite: no new failures (2 pre-existing/environmental fails confirmed present with this change stashed).Cross-port
The same logical miss likely exists in the other ports' validator/DTO emitters (
@dbColumnTypeis a physical override; the logical subtype stays string). Tracked separately for per-port verification — not fixed blindly here.Found via the
metaobjects-auditskill against a real adopter.🤖 Generated with Claude Code