Skip to content

Commit 81c8adf

Browse files
dmealingclaude
andcommitted
fix(java): canonical wire form for a reflectively-serialized Date (#275 carry-forward)
Closes the last carry-forward item from the #275 batch. The OMDB jsonb temporal path was the motivating blast radius for that entire fix and had no test at any level; writing one surfaced a live defect on the adjacent branch. MetaObjectGsonInitializer registers the metadata-driven serializers against each MetaObject's declared @object class, so a value object bound to a POJO via ObjectClassRegistry is serialized by Gson's default reflection and its Date properties never reached TemporalWireFormat. A @storage:jsonb column holding one stored "Jun 3, 2026, 10:30:00 AM" — the JVM's local zone rather than UTC, varying with default locale, dropping milliseconds so even a Java-only round-trip lost the instant, and unreadable by the other four ports. TemporalGsonAdapter now handles java.util.Date on that builder: writes the canonical instant, reads TemporalWireFormat.parse first and falls back to the former localized default so existing rows still load. The metadata-driven path is untouched — MetaObjectSerializer's DATE branch formats and addProperty's directly instead of delegating to context.serialize. Without an owning MetaField the field.date / @localtime shapes are unknowable, so a POJO-bound temporal writes as a full instant: a deliberate narrowing that is lossless and portable where the previous behavior was neither. Tests assert the stored COLUMN TEXT, not just instant equality — Gson's default format round-trips inside Java while being unportable, so an equality-only assertion would have passed against the bug. The jsonbtest fixture gained a Moment value object covering all three temporal shapes; it previously held only string and int, which is why no temporal value had ever crossed this codec. Mutation-verified: dropping the Z, and dropping the millisecond fraction, each fail the new tests. metadata (1347) and omdb (51) suites green, no regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
1 parent 7a28b81 commit 81c8adf

6 files changed

Lines changed: 384 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,43 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — a POJO-bound temporal value in a jsonb column was written locale- and timezone-dependently (Java/Maven only)
11+
12+
Closes the last carry-forward item from the #275 batch: the OMDB jsonb temporal path — the
13+
motivating blast radius for that whole fix — had **no test at any level**, and adding one surfaced
14+
a live defect on the adjacent branch.
15+
16+
`MetaObjectGsonInitializer` registers the metadata-driven serializers against each `MetaObject`'s
17+
declared `@object` class. A value object bound to a hand-written POJO through `ObjectClassRegistry`
18+
is therefore serialized by Gson's **default reflection**, so its `java.util.Date` properties never
19+
reached `TemporalWireFormat` and took Gson's built-in adapter instead. A `@storage: jsonb` column
20+
holding such an object stored e.g. `"Jun 3, 2026, 10:30:00 AM"` — rendered in the JVM's **local
21+
zone rather than UTC**, varying with the default **locale**, silently dropping **milliseconds** (so
22+
even a Java-only round-trip did not return the original instant), and **unreadable by the other
23+
four ports**, which expect the ISO form in `fixtures/persistence-conformance/normalization.md`.
24+
Same defect class as #275, on the one path #275 did not reach.
25+
26+
A new `TemporalGsonAdapter` is registered for `java.util.Date` on that builder: it writes
27+
`TemporalWireFormat.formatInstant` (the canonical `…Z` instant) and reads tolerantly —
28+
`TemporalWireFormat.parse` first, falling back to Gson's former localized default so rows already
29+
written in the legacy format still load. The metadata-driven path is provably unaffected:
30+
`MetaObjectSerializer`'s `DATE` branch formats and calls `addProperty` itself rather than
31+
delegating to `context.serialize`, so that output is byte-identical.
32+
33+
**Bounded narrowing, deliberate:** without an owning `MetaField` there is no way to know whether a
34+
POJO property is a `field.date` (date-only) or a `@localTime` timestamp (no `Z`), so a POJO-bound
35+
temporal is written as a full instant. That is lossless and portable where the previous behavior
36+
was neither; a value object needing the exact per-field shape should stay on the metadata-driven
37+
path, which consults its `MetaField`.
38+
39+
Gated by three new end-to-end tests in `JsonbFieldDBTest` (metadata-driven single, array, and
40+
POJO-bound), each asserting the **stored column text**, not just instant equality — Gson's default
41+
format round-trips within Java while being unportable, so an equality-only assertion would have
42+
passed against the bug. The `jsonbtest` fixture gained a `Moment` value object carrying all three
43+
temporal shapes; before this it held only `string` and `int`, which is why no temporal value had
44+
ever crossed this codec. Verified non-vacuous by mutation (dropping the `Z`, and dropping the
45+
millisecond fraction, each fail the new tests).
46+
1047
## [0.21.2] — npm `0.21.2` · PyPI `0.21.2` · NuGet `0.21.2` · Maven `7.21.2`
1148

1249
Coordinated PATCH. **Changed product code: npm only** (`codegen-ts`, plus a comment-only note in

‎server/java/metadata/src/main/java/com/metaobjects/io/json/TemporalWireFormat.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,28 @@ public static String format(MetaField mf, Date d) {
7676
return localTime ? base : base + "Z";
7777
}
7878

79+
/**
80+
* Write-side without field context: the tz-aware instant form
81+
* ({@code "YYYY-MM-DDTHH:MM:SS[.fff]Z"}), identical to what {@link #format} produces for a
82+
* default {@code field.timestamp}.
83+
*
84+
* <p>Used where a {@link Date} is serialized with no owning {@link MetaField} to consult —
85+
* notably a {@code java.util.Date} property on a registry-bound POJO, which Gson serializes
86+
* by plain reflection and so never reaches {@link #format}. Absent field context the
87+
* {@code field.date} (date-only) and {@code @localTime} (no {@code Z}) shapes are
88+
* unknowable, so this always emits the fully-qualified instant — the one form that is
89+
* lossless, locale- and timezone-independent, and parseable by every port (and by
90+
* {@link #parse}).
91+
*
92+
* @param d the value to format, or {@code null}
93+
* @return the wire string, or {@code null} if {@code d} is {@code null}
94+
*/
95+
public static String formatInstant(Date d) {
96+
if (d == null) return null;
97+
LocalDateTime wallClock = Instant.ofEpochMilli(d.getTime()).atZone(ZoneOffset.UTC).toLocalDateTime();
98+
return wallClock.format(TIMESTAMP_FMT) + fractionalSuffix(wallClock.getNano()) + "Z";
99+
}
100+
79101
/**
80102
* Read-side: tolerant parse, tried in order: {@link Instant#parse} (the {@code Z} form),
81103
* {@link LocalDateTime#parse} anchored at UTC (the no-{@code Z} form), then

‎server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectGsonInitializer.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ public final static GsonBuilder addAdaptersToBuilder(MetaDataLoader loader, Gson
3131
private final static GsonBuilder addAdaptersToBuilder(MetaDataLoader loader, GsonBuilder builder,
3232
boolean addSerializer, boolean addDeserializer) {
3333

34+
// Canonical java.util.Date wire form for every REFLECTIVELY-serialized Date — chiefly a
35+
// temporal property on a registry-bound POJO, which never reaches MetaObjectSerializer
36+
// and so used to take Gson's locale-, timezone-dependent and millisecond-lossy default.
37+
// The metadata-driven path is unaffected (MetaObjectSerializer's DATE branch formats and
38+
// addProperty's directly rather than delegating to context.serialize).
39+
TemporalGsonAdapter temporal = new TemporalGsonAdapter();
40+
if (addSerializer) builder.registerTypeAdapter(java.util.Date.class, (com.google.gson.JsonSerializer<java.util.Date>) temporal);
41+
if (addDeserializer) builder.registerTypeAdapter(java.util.Date.class, (com.google.gson.JsonDeserializer<java.util.Date>) temporal);
42+
3443
List<Class> classList = new ArrayList<>();
3544

3645
Map<MetaObject,Class> nameClassMap = getMetaObjectToClassMap(loader);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.metaobjects.io.object.gson;
2+
3+
import com.metaobjects.io.json.TemporalWireFormat;
4+
import com.google.gson.*;
5+
6+
import java.lang.reflect.Type;
7+
import java.text.DateFormat;
8+
import java.text.ParseException;
9+
import java.util.Date;
10+
11+
/**
12+
* Canonical {@code java.util.Date} wire form for every Gson path that does NOT go through
13+
* {@link MetaObjectSerializer} — i.e. any {@code Date} Gson reaches by plain reflection.
14+
*
15+
* <p><b>The gap this closes.</b> {@link MetaObjectGsonInitializer} registers
16+
* {@link MetaObjectSerializer}/{@link MetaObjectDeserializer} against each {@link
17+
* com.metaobjects.object.MetaObject}'s declared {@code @object} class. A value object bound to a
18+
* hand-written POJO through {@link com.metaobjects.registry.ObjectClassRegistry} is therefore
19+
* serialized by Gson's DEFAULT reflection instead, and its {@code Date} properties took Gson's
20+
* built-in adapter. That emitted a localized {@code DateFormat.DEFAULT} string — e.g.
21+
* {@code "Jun 3, 2026, 10:30:00 AM"} — which is:
22+
* <ul>
23+
* <li><b>locale-dependent</b> — the same instant serializes differently per JVM default locale;</li>
24+
* <li><b>timezone-dependent</b> — rendered in the JVM's local zone, not UTC, so the stored text
25+
* depends on where the process runs;</li>
26+
* <li><b>millisecond-lossy</b> — {@code .123} is discarded, so even a Java-only round-trip
27+
* does not return the original instant;</li>
28+
* <li><b>unreadable by the other ports</b> — TS/Python/C#/Kotlin all expect the ISO form
29+
* defined in {@code fixtures/persistence-conformance/normalization.md}.</li>
30+
* </ul>
31+
* The visible blast radius is an OMDB {@code @storage:jsonb} column holding a POJO-bound value
32+
* object with a temporal field (see {@code GenericSQLDriver#serializeJsonb}). This is the same
33+
* defect class as #275, on the one path #275 did not reach.
34+
*
35+
* <p><b>Write:</b> always {@link TemporalWireFormat#formatInstant} (the {@code ...Z} instant).
36+
* Field context is unavailable here, so the {@code field.date} and {@code @localTime} shapes
37+
* cannot be reproduced — a POJO-bound temporal property is written as a full instant. That is a
38+
* deliberate, documented narrowing: it is lossless and portable, where the previous behavior was
39+
* neither. A value object that needs the exact per-field shape should stay on the
40+
* metadata-driven path (no POJO binding), which consults its {@link
41+
* com.metaobjects.field.MetaField} and calls {@link TemporalWireFormat#format}.
42+
*
43+
* <p><b>Read:</b> {@link TemporalWireFormat#parse} first (all three canonical shapes), then a
44+
* fallback through Gson's own former default ({@link DateFormat#getDateTimeInstance()}) so rows
45+
* already written in the legacy localized format still load. Write canonical, read tolerant.
46+
*
47+
* <p>This adapter never sees a {@code Date} on the metadata-driven path: {@link
48+
* MetaObjectSerializer}'s {@code DATE} branch formats and calls {@code addProperty} itself rather
49+
* than delegating to {@code context.serialize}, so that output is unaffected.
50+
*/
51+
public final class TemporalGsonAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
52+
53+
@Override
54+
public JsonElement serialize(Date src, Type type, JsonSerializationContext context) {
55+
if (src == null) return JsonNull.INSTANCE;
56+
return new JsonPrimitive(TemporalWireFormat.formatInstant(src));
57+
}
58+
59+
@Override
60+
public Date deserialize(JsonElement json, Type type, JsonDeserializationContext context) {
61+
if (json == null || json.isJsonNull()) return null;
62+
String s = json.getAsString();
63+
try {
64+
return TemporalWireFormat.parse(s);
65+
} catch (IllegalArgumentException canonicalMiss) {
66+
// Legacy: a value written before this adapter existed, in Gson's default
67+
// localized form. Best-effort so old rows still read; millisecond precision
68+
// was already lost when it was written and cannot be recovered here.
69+
try {
70+
return DateFormat.getDateTimeInstance().parse(s);
71+
} catch (ParseException legacyMiss) {
72+
throw new JsonParseException(
73+
"Cannot parse temporal value [" + s + "] as either the canonical wire form "
74+
+ "or the legacy localized form", canonicalMiss);
75+
}
76+
}
77+
}
78+
}

0 commit comments

Comments
 (0)