Skip to content

fix(codegen-ts): emit z.unknown() (not z.string()) for field.string @dbColumnType:jsonb#97

Merged
dmealing merged 1 commit into
mainfrom
fix/jsonb-string-zod-unknown
Jun 29, 2026
Merged

fix(codegen-ts): emit z.unknown() (not z.string()) for field.string @dbColumnType:jsonb#97
dmealing merged 1 commit into
mainfrom
fix/jsonb-string-zod-unknown

Conversation

@dmealing

Copy link
Copy Markdown
Member

Problem

@dbColumnType: jsonb (legal only on field.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 bare field.object without @objectRef and the error text directs authors here; the embedded ai library uses it). Its Drizzle column is a bare jsonb(), which node-postgres returns as a parsed JS value (object/array/scalar) — not a string.

But the generated artifacts disagreed with the column:

  • Zod (zod-validators.ts): emitted z.string() (the string branch ignored @dbColumnType).
  • TS type (inferred-types.ts): emitted string.

So a POST of the real JSON object was 400-rejected by the generated InsertSchema, 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-write z.record(z.unknown()) validators to compensate.

Evidence (generated output, before)

payload: jsonb("payload"),                                  // Drizzle: object-returning
export const DocInsertSchema = z.object({ payload: z.string().optional() });  // Zod: string  ← disagree

Fix

  • zodFieldExpr emits z.unknown() when @dbColumnType === jsonb (mirrors the existing objectRef-less field.object → z.unknown() precedent).
  • 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).
  • New 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.

Tests

  • codegen-ts suite: 867 pass / 0 fail; typecheck clean.
  • Full server/typescript suite: 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 (@dbColumnType is a physical override; the logical subtype stays string). Tracked separately for per-port verification — not fixed blindly here.

Found via the metaobjects-audit skill against a real adopter.

🤖 Generated with Claude Code

…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
@dmealing
dmealing merged commit b437fe8 into main Jun 29, 2026
27 of 29 checks passed
@dmealing
dmealing deleted the fix/jsonb-string-zod-unknown branch June 29, 2026 15:47
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant