Skip to content

fix(codegen-spring): expose field.string @dbColumnType:jsonb as a parsed JSON value in DTOs/payloads (#98)#103

Merged
dmealing merged 1 commit into
mainfrom
fix/java-jsonb-parsed-value-98
Jun 29, 2026
Merged

fix(codegen-spring): expose field.string @dbColumnType:jsonb as a parsed JSON value in DTOs/payloads (#98)#103
dmealing merged 1 commit into
mainfrom
fix/java-jsonb-parsed-value-98

Conversation

@dmealing

Copy link
Copy Markdown
Member

The bug

SpringTypeMapper.javaTypeName typed the StringField arm unconditionally as String. A field.string @dbColumnType:jsonb open JSON bag therefore landed as String in the generated <Entity>Dto record + payload — the REST/API contract. Consequences at the boundary:

  • Jackson cannot bind a posted JSON object/array to a String component.
  • A stored JSON bag is serialised back as a double-encoded (escaped) string instead of real JSON.

The decision: parsed JSON value at the boundary

Per the maintainer decision on #98, a jsonb open-bag must be exposed at the REST/API boundary as a parsed JSON value, uniformly across ports — matching the TS fix z.unknown() (#97) and the Python fix Any (#99). For Java the field's DTO/payload type is now Object:

  • Dependency-free — no Jackson import forced into generated contracts; Jackson already maps arbitrary JSON to Map/List/scalar against an Object target.
  • The legal pairing is jsonb → StringField only (validated in the loader ValidationPhase), so the guard is safe.

Before / after

SpringTypeMapper.javaTypeName(field) for a field.string:

field before after
plain field.string String String
field.string @dbColumnType:jsonb String Object
field.string @dbColumnType:uuid String String

The change is a new jsonbOptIn(field) guard (mirroring the existing timestampWithTzOptIn() pattern, keyed off CoreDBMetaDataProvider.DB_COLUMN_TYPE_JSONB — no inline "jsonb" literal). It flows uniformly to every REST/contract consumer of the mapper (SpringDtoGenerator, SpringPayloadGenerator, SpringOutputParserGenerator, api-docs JavaFieldShapes). OMDB persistence is dynamic and does not use the mapper, so it is unaffected — the right, contained layer.

Test evidence (TDD)

Added three arms to SpringTypeMapperTest: jsonb → Object, jsonb case-insensitive → Object, and @dbColumnType:uuid stays String (plain-string-stays-String already covered).

  • RED: the two jsonb arms failed expected:<[Object]> but was:<[String]>; the uuid-stays-String arm passed.
  • GREEN: mvn -pl codegen-spring -am test146/146 pass, including SpringTypeMapperTest (17) and GeneratedTraceHelperCompileRunTest (which declares field.string @dbColumnType:jsonb llmRequest/llmResponse and compile-runs the emitted helper — green unchanged, since the trace helper routes those columns through the OMDB row builder, not the DTO mapper).

JVM Testcontainers/integration corpora were not run in this environment (Docker not exercised); the unit + codegen + compile-run gates above all pass.

Part of #98 (C#/Kotlin still pending — issue intentionally left open).

🤖 Generated with Claude Code

…sed JSON value in DTOs/payloads (#98)

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
@dmealing
dmealing merged commit 07cf004 into main Jun 29, 2026
27 of 29 checks passed
@dmealing
dmealing deleted the fix/java-jsonb-parsed-value-98 branch June 29, 2026 15:47
dmealing added a commit that referenced this pull request Jun 29, 2026
…98)

Complete the Java side of the jsonb-open-bag cross-port consistency work.

Mandate 1 — OMDB runtime write path audit: OMDB has NO write-path gap for
the open bag (`field.string @dbColumnType:jsonb`). Its write path correctly
serializes + binds a JSON-text value via isOpenJsonbField -> serializeOpenJsonb
-> bindJsonbParameter (Postgres setObject(.., Types.OTHER)). The persistence
corpus never exercised this through the runtime write codec (asset-uuid seeds
payload via raw SQL and only READS; roundtrip-all-types writes the *typed*
@storage:jsonb path), so OpenJsonbWriteRoundtripTest fills the gap: it INSERTs
an Asset whose payload is JSON text through ObjectManagerDB.createObject (NOT
raw SQL) and reads it back parsed.

The apparent "object is rejected" is NOT an OMDB write-path defect: OMDB's
ValueObject model is statically typed by field subtype (ADR-0017), so a
structured value assigned to a field.string is coerced to its java toString()
at SET time (DataObjectBase._setObjectAttribute -> DataConverter.toType(STRING,
..)) — generic field-typing, identical for every string field, by design.
(Verified empirically: a Map bound to the bag binds the malformed text
"{a=1, b=2}" and Postgres rejects it with "invalid input syntax for type
json".) The cross-port "write an OBJECT" half lives at the DTO/serialization
boundary (the generated DTO is Object, #103): a consumer serializes the posted
object to JSON text before it reaches OMDB's field.string, and the read side
re-parses it. No OMDB production change was needed — hence the test() prefix.

Mandate 2 — api-contract Java harness for the jsonb sub-corpus, mirroring the
single-entity Author and m2m/tph harnesses, both lanes:
  - JsonbReferenceServer (hand-rolled JDK HttpServer + Postgres testcontainer).
  - GeneratedJsonbControllerHarness (codegen-spring DocumentController/DocumentDto
    compiled + booted over MockMvc behind the in-memory DocumentRepository seam).
The generated DocumentDto types the bag as Object (#103), so a posted JSON
object binds and reads back PARSED, never a JSON-encoded string. This LOCKS the
API-boundary contract.

Tests (Testcontainers Postgres, mvn -f integration-tests/pom.xml):
  - OpenJsonbWriteRoundtripTest                  1/1 green
  - JsonbApiContractConformanceTest (reference)  1/1 green
  - JsonbGeneratedApiContractConformanceTest     1/1 green
  - existing ApiContract*/M2m*/Tph*              48/48 green (no regression)

Deferred: Kotlin + C# api-contract jsonb harnesses (sibling worktrees); a
cross-port persistence-conformance open-bag roundtrip scenario (would require
all five ports' roundtrip writers, out of this Java-scoped change).

Part of #98

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
dmealing added a commit that referenced this pull request Jun 29, 2026
…98) (#113)

Complete the Java side of the jsonb-open-bag cross-port consistency work.

Mandate 1 — OMDB runtime write path audit: OMDB has NO write-path gap for
the open bag (`field.string @dbColumnType:jsonb`). Its write path correctly
serializes + binds a JSON-text value via isOpenJsonbField -> serializeOpenJsonb
-> bindJsonbParameter (Postgres setObject(.., Types.OTHER)). The persistence
corpus never exercised this through the runtime write codec (asset-uuid seeds
payload via raw SQL and only READS; roundtrip-all-types writes the *typed*
@storage:jsonb path), so OpenJsonbWriteRoundtripTest fills the gap: it INSERTs
an Asset whose payload is JSON text through ObjectManagerDB.createObject (NOT
raw SQL) and reads it back parsed.

The apparent "object is rejected" is NOT an OMDB write-path defect: OMDB's
ValueObject model is statically typed by field subtype (ADR-0017), so a
structured value assigned to a field.string is coerced to its java toString()
at SET time (DataObjectBase._setObjectAttribute -> DataConverter.toType(STRING,
..)) — generic field-typing, identical for every string field, by design.
(Verified empirically: a Map bound to the bag binds the malformed text
"{a=1, b=2}" and Postgres rejects it with "invalid input syntax for type
json".) The cross-port "write an OBJECT" half lives at the DTO/serialization
boundary (the generated DTO is Object, #103): a consumer serializes the posted
object to JSON text before it reaches OMDB's field.string, and the read side
re-parses it. No OMDB production change was needed — hence the test() prefix.

Mandate 2 — api-contract Java harness for the jsonb sub-corpus, mirroring the
single-entity Author and m2m/tph harnesses, both lanes:
  - JsonbReferenceServer (hand-rolled JDK HttpServer + Postgres testcontainer).
  - GeneratedJsonbControllerHarness (codegen-spring DocumentController/DocumentDto
    compiled + booted over MockMvc behind the in-memory DocumentRepository seam).
The generated DocumentDto types the bag as Object (#103), so a posted JSON
object binds and reads back PARSED, never a JSON-encoded string. This LOCKS the
API-boundary contract.

Tests (Testcontainers Postgres, mvn -f integration-tests/pom.xml):
  - OpenJsonbWriteRoundtripTest                  1/1 green
  - JsonbApiContractConformanceTest (reference)  1/1 green
  - JsonbGeneratedApiContractConformanceTest     1/1 green
  - existing ApiContract*/M2m*/Tph*              48/48 green (no regression)

Deferred: Kotlin + C# api-contract jsonb harnesses (sibling worktrees); a
cross-port persistence-conformance open-bag roundtrip scenario (would require
all five ports' roundtrip writers, out of this Java-scoped change).

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