|
| 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 | +} |
0 commit comments