Skip to content

Commit d35ad79

Browse files
dmealingclaude
andcommitted
feat(conformance): Phase B Unit 3 — Java read-path (temporal + projection)
Make Java's persistence-conformance pass the Phase B temporal + projection scenarios against Testcontainers Postgres, matching the cross-port canonical wire contract (fixtures/persistence-conformance/normalization.md). All work is in the integration-tests runner — OMDB's generic metadata-driven read path is unchanged. Normalization.java: - TIME → "HH:MM:SS" (LocalTime/Time.toString() elided whole-second :00). - DATE → "YYYY-MM-DD" via an explicit formatter; added a java.util.Date branch (OMDB's DateField codec stores a DATE column as a midnight-anchored java.util.Date, which previously fell through to Date.toString()). - TIMESTAMPTZ → "...Z": added an OffsetDateTime branch that renders the UTC instant with a Z suffix; plain TIMESTAMP (Timestamp/LocalDateTime) renders with no Z. java.sql.Timestamp is matched before the generic java.util.Date branch (both extend java.util.Date). ObjectManagerDbAdapter.java — coerceWireType reconciles OMDB's MetaField-typed value with the ACTUAL SQL column type (the wire shape is column-driven, not field.*-driven): - tz-flagged field.timestamp (@dbColumnType: timestamp_with_tz) → surface as an OffsetDateTime in UTC so Normalization appends Z. OMDB reads both plain and tz timestamps as the same java.sql.Timestamp and cannot tell them apart. - aggregate MIN/MAX(int) are declared field.long but are INTEGER view columns: INTEGER → JSON number, BIGINT → JSON string. OMDB reads both as Long; narrow to Integer when the catalog reports INTEGER/SMALLINT so the number form wins. QueryScenarioRunner.java — probe each entity's primary relation (table or view) column SQL types once via ResultSetMetaData (SELECT * ... WHERE 1=0), cached per MetaObject, and thread the map into the adapter. 3 new query scenarios (normalization-wire-types, projection-passthrough, projection-aggregates) + the Unit-1-updated existing ones now pass. Full Java integration suite green: NormalizationFloatTest 4, QueryScenarioTests 14, ApiContractConformanceTest 20 = 38, BUILD SUCCESS. No shared fixtures edited. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 80f4423 commit d35ad79

3 files changed

Lines changed: 123 additions & 11 deletions

File tree

server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import java.time.LocalDate;
1111
import java.time.LocalDateTime;
1212
import java.time.LocalTime;
13+
import java.time.OffsetDateTime;
14+
import java.time.ZoneOffset;
1315
import java.time.format.DateTimeFormatter;
1416
import java.util.ArrayList;
1517
import java.util.Base64;
@@ -33,6 +35,10 @@ private Normalization() {}
3335

3436
private static final DateTimeFormatter TIMESTAMP_FMT =
3537
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
38+
private static final DateTimeFormatter DATE_FMT =
39+
DateTimeFormatter.ofPattern("yyyy-MM-dd");
40+
private static final DateTimeFormatter TIME_FMT =
41+
DateTimeFormatter.ofPattern("HH:mm:ss");
3642

3743
public static Map<String, Object> normalizeRow(Map<String, Object> row) {
3844
TreeMap<String, Object> out = new TreeMap<>();
@@ -53,12 +59,29 @@ public static Object normalizeValue(Object v) {
5359
if (v instanceof BigDecimal bd) return canonicalDecimal(bd);
5460
if (v instanceof UUID u) return u.toString().toLowerCase(java.util.Locale.ROOT);
5561
if (v instanceof byte[] bytes) return Base64.getEncoder().encodeToString(bytes);
56-
if (v instanceof LocalDate d) return d.toString();
57-
if (v instanceof LocalTime t) return t.toString();
58-
if (v instanceof Time t) return t.toLocalTime().toString();
59-
if (v instanceof Timestamp ts) return ts.toLocalDateTime().format(TIMESTAMP_FMT);
62+
if (v instanceof LocalDate d) return d.format(DATE_FMT);
63+
if (v instanceof LocalTime t) return t.format(TIME_FMT);
64+
if (v instanceof Time t) return t.toLocalTime().format(TIME_FMT);
65+
// TIMESTAMPTZ → UTC instant + "Z" suffix. The runner surfaces tz-aware
66+
// timestamp columns as OffsetDateTime (normalized to UTC in
67+
// ObjectManagerDbAdapter) so the zone discriminator survives — a plain
68+
// TIMESTAMP arrives as Timestamp/LocalDateTime and renders with no Z.
69+
if (v instanceof OffsetDateTime odt)
70+
return odt.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime().format(TIMESTAMP_FMT) + "Z";
6071
if (v instanceof LocalDateTime ts) return ts.format(TIMESTAMP_FMT);
61-
if (v instanceof java.sql.Date sd) return sd.toLocalDate().toString();
72+
// java.sql.Date / java.sql.Timestamp BOTH extend java.util.Date, so order
73+
// matters: the sql.Date (DATE column) → "YYYY-MM-DD"; sql.Timestamp and a
74+
// plain util.Date carry a wall clock → "YYYY-MM-DDTHH:MM:SS" (no Z; a tz
75+
// column is handled by the OffsetDateTime branch above). The JVM default
76+
// zone is pinned to UTC by QueryScenarioTests so the wall clock is the UTC
77+
// wall clock the cross-port fixtures expect.
78+
if (v instanceof java.sql.Date sd) return sd.toLocalDate().format(DATE_FMT);
79+
if (v instanceof Timestamp ts) return ts.toLocalDateTime().format(TIMESTAMP_FMT);
80+
if (v instanceof java.util.Date d) {
81+
// OMDB's DateField codec stores a DATE column as a midnight-anchored
82+
// java.util.Date; render the calendar date (UTC) per the DATE wire rule.
83+
return DATE_FMT.format(d.toInstant().atZone(ZoneOffset.UTC));
84+
}
6285
if (v instanceof CharSequence) return v.toString();
6386
if (v instanceof Map<?, ?> m) {
6487
TreeMap<String, Object> sorted = new TreeMap<>();

server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import com.metaobjects.object.MetaObject;
1313
import com.metaobjects.object.value.ValueObject;
1414

15+
import java.sql.Types;
16+
import java.time.ZoneOffset;
1517
import java.util.ArrayList;
1618
import java.util.Collection;
1719
import java.util.LinkedHashMap;
@@ -39,7 +41,8 @@ private ObjectManagerDbAdapter() {}
3941
* <li>{@code op:count} → {@code Long}</li>
4042
* </ul>
4143
*/
42-
static Object execute(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc, QuerySpec spec) throws Exception {
44+
static Object execute(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc, QuerySpec spec,
45+
Map<String, Integer> columnSqlTypes) throws Exception {
4346
Expression filter = buildFilter(spec.by(), spec.filter());
4447

4548
if ("count".equals(spec.op())) {
@@ -53,7 +56,7 @@ static Object execute(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc
5356

5457
Collection<?> raw = omdb.getObjects(conn, mc, opts);
5558
List<Map<String, Object>> rows = new ArrayList<>(raw.size());
56-
for (Object o : raw) rows.add(toRowMap(mc, o));
59+
for (Object o : raw) rows.add(toRowMap(mc, o, columnSqlTypes));
5760

5861
if ("get".equals(spec.op())) return rows.isEmpty() ? null : rows.get(0);
5962
return rows; // op:list
@@ -221,20 +224,68 @@ private static Range buildRange(Integer offset, int limit) {
221224
* For non-ValueObject types the row is keyed by the field's metadata
222225
* name and the value is what {@link ObjectManagerDB} loaded.</p>
223226
*/
224-
private static Map<String, Object> toRowMap(MetaObject mc, Object instance) {
227+
private static Map<String, Object> toRowMap(MetaObject mc, Object instance,
228+
Map<String, Integer> columnSqlTypes) {
225229
Map<String, Object> row = new LinkedHashMap<>();
226230
if (instance instanceof ValueObject vo) {
227-
for (MetaField<?> mf : mc.getMetaFields()) row.put(mf.getName(), maybeParseJson(mf, vo.get(mf.getName())));
231+
for (MetaField<?> mf : mc.getMetaFields())
232+
row.put(mf.getName(), coerceWireType(mf, maybeParseJson(mf, vo.get(mf.getName())), columnSqlTypes));
228233
return row;
229234
}
230235
// Fallback: ask each MetaField for its value off the object.
231236
for (MetaField<?> mf : mc.getMetaFields()) {
232-
try { row.put(mf.getName(), maybeParseJson(mf, mf.getObject(instance))); }
237+
try { row.put(mf.getName(), coerceWireType(mf, maybeParseJson(mf, mf.getObject(instance)), columnSqlTypes)); }
233238
catch (Exception ignored) { row.put(mf.getName(), null); }
234239
}
235240
return row;
236241
}
237242

243+
/**
244+
* Reconcile OMDB's MetaField-typed value with the ACTUAL SQL column type so the
245+
* canonical wire shape ({@code fixtures/persistence-conformance/normalization.md})
246+
* is driven by the column, not the {@code field.*} declaration. OMDB collapses
247+
* every numeric column to its field subtype's Java type and reads both plain and
248+
* tz-aware timestamps as the same {@link java.sql.Timestamp}; two corpus cases
249+
* need the lost distinction restored:
250+
*
251+
* <ul>
252+
* <li><b>Aggregate INTEGER projections.</b> {@code MIN(int)}/{@code MAX(int)}
253+
* are declared {@code field.long} on the view but are INTEGER columns —
254+
* INTEGER → JSON <em>number</em>, BIGINT → JSON <em>string</em>. OMDB reads
255+
* both as {@link Long}; narrow to {@link Integer} when the catalog reports
256+
* an INTEGER/SMALLINT column so {@link Normalization} emits a number.</li>
257+
* <li><b>TIMESTAMPTZ.</b> A {@code field.timestamp} flagged
258+
* {@code @dbColumnType: timestamp_with_tz} is an absolute instant whose wire
259+
* form carries a {@code Z}; surface it as an {@link java.time.OffsetDateTime}
260+
* in UTC so {@link Normalization} appends the suffix (a plain TIMESTAMP stays
261+
* a {@link java.sql.Timestamp} and renders with no {@code Z}).</li>
262+
* </ul>
263+
*/
264+
private static Object coerceWireType(MetaField<?> mf, Object value, Map<String, Integer> columnSqlTypes) {
265+
if (value == null) return null;
266+
if (isTimestampTzField(mf) && value instanceof java.util.Date d) {
267+
return d.toInstant().atOffset(ZoneOffset.UTC);
268+
}
269+
if (value instanceof Long l) {
270+
Integer sqlType = columnSqlTypes.get(mf.getName());
271+
if (sqlType != null && (sqlType == Types.INTEGER || sqlType == Types.SMALLINT || sqlType == Types.TINYINT)) {
272+
return l.intValue();
273+
}
274+
}
275+
return value;
276+
}
277+
278+
/** True for a {@code field.timestamp} carrying {@code @dbColumnType: timestamp_with_tz}. */
279+
private static boolean isTimestampTzField(MetaField<?> mf) {
280+
try {
281+
return mf.hasMetaAttr(com.metaobjects.database.CoreDBMetaDataProvider.DB_COLUMN_TYPE)
282+
&& com.metaobjects.database.CoreDBMetaDataProvider.DB_COLUMN_TYPE_TIMESTAMP_TZ.equals(
283+
mf.getMetaAttr(com.metaobjects.database.CoreDBMetaDataProvider.DB_COLUMN_TYPE).getValueAsString());
284+
} catch (Exception e) {
285+
return false;
286+
}
287+
}
288+
238289
private static final com.fasterxml.jackson.databind.ObjectMapper JSON =
239290
new com.fasterxml.jackson.databind.ObjectMapper();
240291

server/java/integration-tests/src/test/java/com/metaobjects/integration/QueryScenarioRunner.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@
1313
import java.nio.file.Path;
1414
import java.sql.Connection;
1515
import java.sql.DriverManager;
16+
import java.sql.ResultSet;
17+
import java.sql.ResultSetMetaData;
1618
import java.sql.SQLException;
1719
import java.sql.Statement;
20+
import java.util.HashMap;
21+
import java.util.LinkedHashMap;
1822
import java.util.List;
1923
import java.util.Map;
2024
import java.util.UUID;
@@ -60,20 +64,54 @@ public static void run(QueryScenario scenario, PostgresContainer pg, Path canoni
6064

6165
// 3. Run queries.
6266
ObjectConnection oc = omdb.getConnection();
67+
// Per-entity actual SQL column types, probed once from the catalog. OMDB
68+
// collapses a column to its field-subtype Java type (losing INTEGER vs
69+
// BIGINT), but the canonical wire shape is driven by the real column type
70+
// — see ObjectManagerDbAdapter.coerceWireType.
71+
Map<MetaObject, Map<String, Integer>> columnTypeCache = new HashMap<>();
6372
try {
6473
for (QuerySpec spec : scenario.queries()) {
6574
MetaObject mc = findEntityByShortName(loader, spec.entity());
6675
if (mc == null) throw new AssertionError(
6776
scenario.sourcePath() + " / " + spec.name() +
6877
": no MetaObject named '" + spec.entity() + "' in canonical loader");
69-
Object actual = ObjectManagerDbAdapter.execute(omdb, oc, mc, spec);
78+
Map<String, Integer> columnSqlTypes = columnTypeCache.computeIfAbsent(
79+
mc, m -> probeColumnSqlTypes(pg, m));
80+
Object actual = ObjectManagerDbAdapter.execute(omdb, oc, mc, spec, columnSqlTypes);
7081
assertResult(scenario.sourcePath(), spec, actual);
7182
}
7283
} finally {
7384
omdb.releaseConnection(oc);
7485
}
7586
}
7687

88+
/**
89+
* Probe the actual JDBC column type ({@link java.sql.Types}) of each column on
90+
* the entity's primary physical relation (table or view), keyed by column name.
91+
* The corpus declares no {@code @column} renames, so a column name equals its
92+
* {@code field.*} name; {@link ObjectManagerDbAdapter} keys the result by field
93+
* name. A {@code SELECT * ... WHERE 1=0} returns no rows but a fully-typed
94+
* {@link ResultSetMetaData}. Returns an empty map for a non-persistent object.
95+
*/
96+
private static Map<String, Integer> probeColumnSqlTypes(PostgresContainer pg, MetaObject mc) {
97+
String relation = mc.getPrimaryRdbViewName();
98+
if (relation == null) relation = mc.getPrimaryRdbTableName();
99+
if (relation == null) return Map.of();
100+
Map<String, Integer> types = new LinkedHashMap<>();
101+
String sql = "SELECT * FROM \"" + relation + "\" WHERE 1=0";
102+
try (Connection c = openConnection(pg);
103+
Statement s = c.createStatement();
104+
ResultSet rs = s.executeQuery(sql)) {
105+
ResultSetMetaData md = rs.getMetaData();
106+
for (int i = 1; i <= md.getColumnCount(); i++) {
107+
types.put(md.getColumnName(i), md.getColumnType(i));
108+
}
109+
} catch (SQLException e) {
110+
throw new RuntimeException("Failed to probe column types for relation '" + relation + "'", e);
111+
}
112+
return types;
113+
}
114+
77115
/**
78116
* Walk the loader's MetaObjects looking for a match by bare name. The
79117
* cross-port corpus references entities by short name ("Program",

0 commit comments

Comments
 (0)