Skip to content

cross-port: verify+fix field.string @dbColumnType:jsonb → validator/DTO typing in C#/Python/Java/Kotlin (TS fixed in #97) #98

Closed

Description

@dmealing

Follow-up to #97 (the TypeScript fix).

Context

@dbColumnType: jsonb on a field.string is the sanctioned "open JSON bag" pattern. The physical column is jsonb (returns a parsed JSON value), but @dbColumnType is a physical override — the logical subtype stays string. In TS, the Zod + TS-type emitters therefore typed the field as a string while the column returned an object (fixed in #97 → z.unknown() / unknown).

Ask

The same logical miss likely exists in the other ports' generated validation / DTO code, since each emits per-port-idiomatic validators/DTOs off the logical subtype:

  • Python — Pydantic model field type for field.string @dbColumnType:jsonb (should be Any / dict | list | ..., not str).
  • C# — DTO property type + any DataAnnotations (should be a JSON-capable type, not string).
  • Java — DTO/record field type + Jackson handling.
  • Kotlin — data-class property type.

This is not yet confirmed per-port — each port's column-mapper + validator/DTO emitter needs to be read to confirm (a) it honors @dbColumnType: jsonb (emits a jsonb/json physical column) and (b) whether its DTO/validator then mistypes the field as a string. Do not assume; verify against the actual emitter + a generated-output sample (the TS investigation found two sibling "bugs" that turned out to be false positives, so confirm before fixing).

Gate

@dbColumnType: jsonb legal-pairing is already registered (DB_COLUMN_TYPE_LEGAL_SUBTYPES[jsonb] = [field.string]), so no registry-conformance change. Add a per-port codegen/validation test asserting the open-JSON (not string) shape — validation/DTO emission is per-port-idiomatic (not byte-identical render-conformance), so each port gates its own.

Surfaced via the metaobjects-audit skill against a real adopter; TS confirmed + fixed in #97.

Activity

  1. dmealing commented on Jun 29, 2026

    @dmealing
    MemberAuthor

    Per-port verification results (verify-don't-assume)

    The "same logical bug in all ports" assumption was wrong — the behavior is driver- and layer-specific:

    Port Persistence read (driver) REST DTO / API-contract type Verdict
    TS node-postgres → parsed object z.string() → z.unknown() BUG, fixed (#97)
    Python pg8000 → parsed dict Pydantic str (a lie — runtime test asserts dict) BUG, fixing (type_map.py → Any)
    Java OMDB (dynamic, no typed field) SpringTypeMapper → String in the generated <Entity>Dto/payload — a client can't POST a JSON object BUG at the REST-contract layer (SpringTypeMapper is REST-only)
    C# Npgsql → raw JSON text EF entity prop string? — gated by RuntimeReturnTypeTests as the intended contract By design (raw text) — not an accidental bug
    Kotlin Exposed jsonb(col,{it},{it}) + JDBC → raw text → entity String is correct BUT KotlinPayloadGenerator (REST) also types it String Persistence String correct; REST payload is the open question

    The real issue: cross-port API-contract inconsistency

    A jsonb column holds an arbitrary JSON value. TS + Python now expose it at the API boundary as a parsed value (unknown/Any). The JVM + C# ports expose it as a raw JSON string — forcing clients to double-encode ("{\"k\":\"v\"}" instead of {"k":"v"}). C#/Kotlin gate the raw-text behavior with runtime tests, so this is a deliberate contract, not a slip.

    This needs a cross-port contract decision (tracked here): should a jsonb open-bag be a parsed JSON value or raw text at the API/wire boundary, uniformly? Pending maintainer direction before fixing the JVM/C# ports. Python (a clear runtime-vs-annotation contradiction) is being fixed regardless.

  2. dmealing commented on Jun 29, 2026

    @dmealing
    MemberAuthor

    Resolution — uniform parsed JSON value at the API boundary (maintainer decision)

    All five ports now expose a field.string @dbColumnType:jsonb open-bag as a parsed JSON value at the API/wire boundary (wire form unchanged — byte-identical {...}):

    Port PR Type Surface coverage
    TS #97 z.unknown() / unknown full (validation + contract)
    Python #99 Any (pg8000 returns dict) full (Pydantic = API + runtime)
    Java #103 Object full (DTO/payload; OMDB persistence is dynamic)
    C# #105 JsonDocument (Npgsql native) full — persistence + CRUD; 80 Testcontainers tests green
    Kotlin #104 kotlinx JsonElement payload/declared-api only

    Remaining gap — Kotlin entity-CRUD surface

    Kotlin's derived-CRUD controller reuses the Exposed entity data class (bound to the raw-text identity-codec jsonb column) as its request/response DTO, so the derived-CRUD REST contract still exposes the field as String. C# avoided this because Npgsql maps jsonb ↔ JsonDocument natively (one type change covers persistence + API). The equivalent Kotlin move is to decode the Exposed jsonb-bag column to JsonElement (a real codec instead of the {it},{it} identity passthrough), so the entity property — and thus the CRUD contract — becomes parsed too. That changes Kotlin's persistence-layer type (currently String, asserted in KotlinTypeMapperTest) and the persistence-conformance round-trip. Tracked as a follow-up.

    #98 is satisfied for the payload/declared-api surface across all ports + the entity-CRUD surface for TS/Python/Java/C#; the Kotlin entity-CRUD parity is the one open item.

  3. dmealing commented on Jun 29, 2026

    @dmealing
    MemberAuthor

    All five ports now expose a field.string @dbColumnType:jsonb open-bag as a parsed JSON value at the API boundary (wire form byte-identical): TS z.unknown() (#97), Python Any (#99), Java Object (#103), Kotlin JsonElement (#104), C# JsonDocument (#105) — all merged to main. Closing. (Cross-port api-contract conformance gate tracked separately.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions