Skip to content

Commit 288e959

Browse files
dmealingclaude
andcommitted
test(java): jsonb open-bag runtime-write gate + api-contract harness (#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
1 parent ce9f0e4 commit 288e959

8 files changed

Lines changed: 998 additions & 8 deletions

File tree

fixtures/api-contract-conformance/jsonb/README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,17 @@ booted over HTTP) are wired and green for:
6868
emitted `Document.routes.ts` over Postgres testcontainer).
6969
- **Python** — reference (FastAPI + pg8000 over Postgres testcontainer) +
7070
generated (`render_router` emitted router + in-memory seam).
71-
72-
Deferred (corpus is ready; follow-up): **Java**, **Kotlin**, **C#**. Their
73-
api-contract harnesses bind the per-entity DTO shape statically (the JVM
74-
generated harness reflects a fixed `Author` constructor arity + ships a
75-
hand-written in-memory repo source; the C# generated lane is full-stack EF +
76-
Kestrel), so adding the `Document` entity means a new reference server +
77-
generated harness per port (mirroring their `m2m/` and `tph/` sub-corpus
78-
harnesses) rather than a focused fixture edit. Tracked in #98.
71+
- **Java** — reference (`JsonbReferenceServer`: JDK `HttpServer` + Postgres
72+
testcontainer) + generated (`GeneratedJsonbControllerHarness`: codegen-spring
73+
`DocumentController`/`DocumentDto` compiled and booted over MockMvc behind the
74+
in-memory repo seam). The generated DTO types the bag as `Object` (#103). The
75+
runtime/OMDB write half is gated separately by
76+
`integration-tests/.../OpenJsonbWriteRoundtripTest` (writes the open bag
77+
through `ObjectManagerDB.createObject` and reads it back parsed).
78+
79+
Deferred (corpus is ready; follow-up): **Kotlin**, **C#**. Their
80+
api-contract harnesses bind the per-entity DTO shape statically (the C#
81+
generated lane is full-stack EF + Kestrel), so adding the `Document` entity
82+
means a new reference server + generated harness per port (mirroring their
83+
`m2m/` and `tph/` sub-corpus harnesses) rather than a focused fixture edit.
84+
Tracked in #98.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package com.metaobjects.integration;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.metaobjects.loader.MetaDataLoader;
5+
import com.metaobjects.manager.ObjectConnection;
6+
import com.metaobjects.manager.QueryOptions;
7+
import com.metaobjects.manager.db.ObjectManagerDB;
8+
import com.metaobjects.manager.db.driver.PostgresDriver;
9+
import com.metaobjects.manager.exp.Expression;
10+
import com.metaobjects.object.MetaObject;
11+
import com.metaobjects.object.value.ValueObject;
12+
import com.metaobjects.registry.ObjectClassRegistry;
13+
import org.junit.jupiter.api.BeforeAll;
14+
import org.junit.jupiter.api.Test;
15+
16+
import javax.sql.DataSource;
17+
import java.io.PrintWriter;
18+
import java.nio.file.Path;
19+
import java.sql.Connection;
20+
import java.sql.DriverManager;
21+
import java.sql.SQLException;
22+
import java.sql.Statement;
23+
import java.time.LocalDate;
24+
import java.time.LocalTime;
25+
import java.time.ZoneOffset;
26+
import java.util.Collection;
27+
import java.util.Map;
28+
import java.util.UUID;
29+
import java.util.logging.Logger;
30+
31+
import static org.junit.jupiter.api.Assertions.assertEquals;
32+
import static org.junit.jupiter.api.Assertions.assertNotNull;
33+
34+
/**
35+
* Mandate 1 (#98) — the OMDB runtime WRITE gate for an <em>open-JSON-bag</em>
36+
* column ({@code field.string @dbColumnType:jsonb}).
37+
*
38+
* <p><b>Why this test exists.</b> The cross-port jsonb-open-bag contract (#98)
39+
* is that the field exposes a <em>parsed</em> JSON value. The persistence corpus
40+
* proved the READ side ({@code asset-uuid-roundtrip.yaml} seeds {@code payload}
41+
* via raw SQL and reads it back parsed) and the typed {@code @storage:jsonb}
42+
* WRITE side ({@code roundtrip-all-types.yaml} writes a {@code Settings}
43+
* value-object), but no scenario exercised the <em>open</em>
44+
* {@code @dbColumnType:jsonb} bag through OMDB's runtime write codec. This test
45+
* fills that gap: it INSERTs an {@code Asset} via
46+
* {@link ObjectManagerDB#createObject} (NOT raw SQL) and reads it back.</p>
47+
*
48+
* <p><b>OMDB's open-bag contract is JSON text.</b> OMDB's {@code ValueObject}
49+
* model is statically typed by field subtype (ADR-0017): a {@code field.string}
50+
* holds a {@code String}. The open bag is therefore authored as JSON
51+
* <em>text</em> — exactly how the asset-uuid seed and the LLM-trace recorder
52+
* store it — and OMDB's write path serializes/binds it faithfully through
53+
* {@code isOpenJsonbField} → {@code serializeOpenJsonb} → {@code bindJsonbParameter}
54+
* (Postgres {@code setObject(.., Types.OTHER)} — a bare {@code setString} is
55+
* rejected by jsonb's strict input typing). The "post an OBJECT" half of the
56+
* cross-port contract lives at the DTO/serialization boundary (the generated DTO
57+
* is {@code Object}, #103): a consumer serializes the posted object to JSON text
58+
* before it reaches OMDB's {@code field.string}, and the read side re-parses it.
59+
* (Assigning a raw {@code Map} to a {@code field.string} is coerced to its java
60+
* {@code toString()} at set-time by the generic field-typing in
61+
* {@code DataObjectBase}, identical for every string field — not a jsonb
62+
* write-path defect, and out of scope to change here.)</p>
63+
*
64+
* <p>Postgres-only (Testcontainers). Not part of the default Maven reactor —
65+
* run via {@code mvn -f server/java/integration-tests/pom.xml test}.</p>
66+
*/
67+
final class OpenJsonbWriteRoundtripTest {
68+
69+
private static final Path CORPUS = ScenarioLoader.findCorpusRoot();
70+
private static final Path CANONICAL = CORPUS.resolve("canonical");
71+
private static final ObjectMapper JSON = new ObjectMapper();
72+
73+
@BeforeAll
74+
static void bindEntities() {
75+
// Bind Asset to ValueObject so OMDB can instantiate rows without a
76+
// project POJO (mirrors QueryScenarioTests.beforeAll). Skip if a prior
77+
// test class in the same JVM already published a registry with Asset.
78+
if (ObjectClassRegistry.global().resolve("fitness::Asset") != null) return;
79+
ObjectClassRegistry reg = new ObjectClassRegistry();
80+
reg.register(() -> Map.of("fitness::Asset", ValueObject.class));
81+
ObjectClassRegistry.setGlobal(reg);
82+
}
83+
84+
/**
85+
* A JSON value written into the open bag through the OMDB runtime write
86+
* codec ({@code createObject}, NOT raw SQL) reads back as the same parsed
87+
* structure — including a nested object and an array — never a malformed or
88+
* double-encoded blob. This is the WRITE complement to the read-only
89+
* {@code asset-uuid-roundtrip} scenario.
90+
*/
91+
@Test
92+
void openBagJsonValueRoundTripsThroughOmdbWrite() throws Exception {
93+
String payloadJson = "{\"k\":\"v\",\"n\":7,\"nested\":{\"deep\":true},\"list\":[1,2,3]}";
94+
95+
try (PostgresContainer pg = new PostgresContainer()) {
96+
MetaDataLoader loader = MetaDataLoader.fromDirectory(
97+
"open-jsonb-" + UUID.randomUUID().toString().substring(0, 8), CANONICAL);
98+
ObjectManagerDB omdb = newOmdb(pg);
99+
provisionSchema(pg);
100+
101+
MetaObject asset = findByShortName(loader, "Asset");
102+
assertNotNull(asset, "canonical loader must contain Asset");
103+
104+
ValueObject vo = (ValueObject) asset.newInstance();
105+
vo.setString("ownerId", "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA");
106+
vo.setString("payload", payloadJson); // open bag = JSON text
107+
vo.setDate("recordedAt", instant("2026-05-30T14:30:00Z"));
108+
vo.setDate("observedAt", instant("2026-05-30T14:30:00Z"));
109+
vo.setDate("asOfDate", java.util.Date.from(
110+
LocalDate.parse("2026-05-30").atStartOfDay(ZoneOffset.UTC).toInstant()));
111+
vo.setObject("atTime", LocalTime.parse("14:30:00"));
112+
113+
ObjectConnection oc = omdb.getConnection();
114+
try {
115+
omdb.createObject(oc, vo); // INSERT via the OMDB runtime write path
116+
Object id = vo.get("id"); // app-side minted uuid PK
117+
assertNotNull(id, "OMDB should mint the uuid PK on insert");
118+
119+
Collection<?> rows = omdb.getObjects(oc, asset,
120+
new QueryOptions(new Expression("id", id, Expression.EQUAL)));
121+
assertEquals(1, rows.size(), "inserted row must read back");
122+
ValueObject readBack = (ValueObject) rows.iterator().next();
123+
124+
// OMDB returns the jsonb column as raw JSON text. Parse + compare
125+
// structurally — a malformed or double-encoded value fails to
126+
// parse back to the original tree.
127+
Object raw = readBack.get("payload");
128+
assertNotNull(raw, "payload must read back");
129+
assertEquals(JSON.readTree(payloadJson),
130+
JSON.readTree(String.valueOf(raw)),
131+
"open-bag jsonb value must round-trip through OMDB write+read parsed");
132+
} finally {
133+
omdb.releaseConnection(oc);
134+
}
135+
}
136+
}
137+
138+
// -----------------------------------------------------------------------
139+
// OMDB + Postgres plumbing (mirrors QueryScenarioRunner)
140+
// -----------------------------------------------------------------------
141+
142+
private static ObjectManagerDB newOmdb(PostgresContainer pg) throws Exception {
143+
ObjectManagerDB omdb = new ObjectManagerDB();
144+
omdb.setDatabaseDriver(new PostgresDriver());
145+
omdb.setDataSource(dataSource(pg));
146+
omdb.init();
147+
return omdb;
148+
}
149+
150+
private static void provisionSchema(PostgresContainer pg) throws Exception {
151+
String ddl = ScenarioLoader.readCanonicalSchema(CORPUS);
152+
try (Connection c = openConnection(pg); Statement s = c.createStatement()) {
153+
s.execute(ddl);
154+
}
155+
}
156+
157+
private static MetaObject findByShortName(MetaDataLoader loader, String shortName) {
158+
for (MetaObject mc : loader.getMetaObjects()) {
159+
if (mc.getShortName().equals(shortName)) return mc;
160+
}
161+
return null;
162+
}
163+
164+
private static java.util.Date instant(String iso) {
165+
return java.util.Date.from(java.time.Instant.parse(iso));
166+
}
167+
168+
private static Connection openConnection(PostgresContainer pg) throws SQLException {
169+
return DriverManager.getConnection(pg.jdbcUrl(), pg.username(), pg.password());
170+
}
171+
172+
private static DataSource dataSource(PostgresContainer pg) {
173+
return new DataSource() {
174+
@Override public Connection getConnection() throws SQLException { return openConnection(pg); }
175+
@Override public Connection getConnection(String u, String p) throws SQLException { return openConnection(pg); }
176+
@Override public PrintWriter getLogWriter() { return null; }
177+
@Override public void setLogWriter(PrintWriter out) {}
178+
@Override public void setLoginTimeout(int seconds) {}
179+
@Override public int getLoginTimeout() { return 0; }
180+
@Override public Logger getParentLogger() { return Logger.getLogger("open-jsonb-test"); }
181+
@Override public <T> T unwrap(Class<T> iface) { return null; }
182+
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
183+
};
184+
}
185+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.metaobjects.integration.api;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.metaobjects.integration.PostgresContainer;
5+
import com.metaobjects.integration.api.ApiContractScenarios.ApiRequest;
6+
import com.metaobjects.integration.api.ApiContractScenarios.ApiScenario;
7+
import org.junit.jupiter.params.ParameterizedTest;
8+
import org.junit.jupiter.params.provider.Arguments;
9+
import org.junit.jupiter.params.provider.MethodSource;
10+
11+
import java.net.URI;
12+
import java.net.http.HttpClient;
13+
import java.net.http.HttpRequest;
14+
import java.net.http.HttpResponse;
15+
import java.util.List;
16+
import java.util.stream.Stream;
17+
18+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
19+
20+
/**
21+
* Cross-port API contract conformance for the jsonb open-bag corpus
22+
* ({@code fixtures/api-contract-conformance/jsonb/}) — Java reference lane.
23+
*
24+
* <p>One JUnit invocation per scenario: a fresh Postgres testcontainer + the
25+
* hand-rolled {@link JsonbReferenceServer}, walking the scenario's requests and
26+
* asserting each response against the cross-port {@code expect.body.*}
27+
* vocabulary ({@link ApiContractAssertions}). Locks the API-boundary half of the
28+
* open-bag contract (a posted JSON object reads back PARSED) on the reference
29+
* server; {@link JsonbGeneratedApiContractConformanceTest} locks it on the
30+
* GENERATED Spring controller.</p>
31+
*
32+
* <p>Run on-demand:
33+
* {@code mvn -f server/java/integration-tests/pom.xml test -Dtest=JsonbApiContractConformanceTest}</p>
34+
*/
35+
final class JsonbApiContractConformanceTest {
36+
37+
private static final ObjectMapper MAPPER = new ObjectMapper();
38+
private static final List<ApiScenario> SCENARIOS =
39+
ApiContractScenarioLoader.loadScenarios(JsonbCorpus.scenariosDir());
40+
private static final List<java.util.Map<String, Object>> SEED_ROWS = JsonbCorpus.seedRows();
41+
42+
static Stream<Arguments> scenarios() {
43+
return SCENARIOS.stream().map(s -> Arguments.of(s.name(), s));
44+
}
45+
46+
@ParameterizedTest(name = "{0}")
47+
@MethodSource("scenarios")
48+
void scenario(String name, ApiScenario scenario) {
49+
assertDoesNotThrow(() -> {
50+
try (PostgresContainer pg = new PostgresContainer();
51+
JsonbReferenceServer server = new JsonbReferenceServer(pg, SEED_ROWS)) {
52+
HttpClient client = HttpClient.newHttpClient();
53+
for (ApiRequest req : scenario.requests()) {
54+
HttpRequest.Builder builder =
55+
HttpRequest.newBuilder(URI.create(server.baseUrl() + req.path()));
56+
HttpRequest.BodyPublisher publisher;
57+
if (req.body() != null) {
58+
builder.header("Content-Type", "application/json");
59+
publisher = HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(req.body()));
60+
} else {
61+
publisher = HttpRequest.BodyPublishers.noBody();
62+
}
63+
switch (req.method()) {
64+
case "GET" -> builder.GET();
65+
case "DELETE" -> builder.DELETE();
66+
default -> builder.method(req.method(), publisher);
67+
}
68+
HttpResponse<String> res = client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
69+
Object parsed = (res.body() == null || res.body().isEmpty())
70+
? null : MAPPER.readValue(res.body(), Object.class);
71+
ApiContractAssertions.assertResponse(scenario.name(), req, res.statusCode(), parsed);
72+
}
73+
}
74+
});
75+
}
76+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.metaobjects.integration.api;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
5+
import java.io.IOException;
6+
import java.io.UncheckedIOException;
7+
import java.nio.charset.StandardCharsets;
8+
import java.nio.file.Files;
9+
import java.nio.file.Path;
10+
import java.util.List;
11+
import java.util.Map;
12+
13+
/**
14+
* Locates the jsonb open-bag sub-corpus
15+
* ({@code fixtures/api-contract-conformance/jsonb/}) and loads its seed rows.
16+
* Shared by the reference ({@link JsonbApiContractConformanceTest}) and generated
17+
* ({@link JsonbGeneratedApiContractConformanceTest}) lanes.
18+
*/
19+
final class JsonbCorpus {
20+
private JsonbCorpus() {}
21+
22+
private static final ObjectMapper MAPPER = new ObjectMapper();
23+
24+
/** The jsonb sub-corpus root ({@code <api-contract-conformance>/jsonb}). */
25+
static Path root() {
26+
return ApiContractScenarioLoader.findCorpusRoot().resolve("jsonb");
27+
}
28+
29+
static Path scenariosDir() {
30+
return root().resolve("scenarios");
31+
}
32+
33+
static Path metaJson() {
34+
return root().resolve("meta.json");
35+
}
36+
37+
/** The {@code rows} array from {@code jsonb/seed.json} (payload values are JSON objects). */
38+
@SuppressWarnings("unchecked")
39+
static List<Map<String, Object>> seedRows() {
40+
try {
41+
String text = Files.readString(root().resolve("seed.json"), StandardCharsets.UTF_8);
42+
Map<String, Object> parsed = MAPPER.readValue(text, Map.class);
43+
Object rows = parsed.get("rows");
44+
if (!(rows instanceof List<?>))
45+
throw new IllegalStateException("jsonb/seed.json: missing 'rows' array");
46+
return (List<Map<String, Object>>) rows;
47+
} catch (IOException e) {
48+
throw new UncheckedIOException(e);
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)