Skip to content

Commit 7519bf4

Browse files
committed
fix(jvm): ADR-0036 Wave 2 — api-contract harness + Kotlin controller emit Instant for createdAt
`Author.createdAt` is a bare `field.timestamp`, which under ADR-0036 Wave 2 generates as `java.time.Instant` (instant/tz-aware default). Align the JVM api-contract test harnesses (Java + Kotlin) — and close one genuine Wave-2 generator gap — so the generated-controller lane compiles and round-trips. createdAt wire form is now an absolute UTC instant (`yyyy-MM-ddTHH:mm:ssZ`). Offset-less corpus seed/scenario strings are interpreted as UTC (append `Z`), mirroring the C# lane. Java: - generated/GeneratedAuthorControllerHarness: DTO ctor reflection signature `LocalDateTime.class` → `Instant.class`; build createdAt via `Instant.parse` (UTC-interpreting); register a UTC Instant Jackson deserializer so offset-less request bodies deserialize into the generated DTO's `Instant` field. - generated/InMemoryAuthorRepositorySource: `AuthorDto.createdAt` is `Instant`; filter comparison parses operands as UTC instants; refreshed stale comment. - AuthorApiServer (reference lane): `createdAt` column → `timestamp with time zone`; read/write/filter via UTC `OffsetDateTime`/`Instant`; emit `...Z` wire. Kotlin: - generated/GeneratedAuthorControllerHarness: register a UTC Instant Jackson deserializer for the generated `Author.createdAt: Instant`. - AuthorApiServer (reference lane): `AuthorTable.createdAt` → Exposed `timestamp()` (Instant); parse/format/filter via UTC instants; emit `...Z`. - KotlinSpringControllerGenerator (generator gap): a default `field.timestamp` column is `Column<Instant>`, but the controller imported only `LocalDateTime` and the timestamp filter-coercer produced a `LocalDateTime` — a missing import + a ClassCast at the `p.value as Instant` dispatch cast. Now import `java.time.Instant` when an Instant-typed timestamp column is in the filter surface, and emit an Instant-producing (UTC-interpreting) coercer for it. Instant-free entities stay byte-identical (snapshot golden unchanged). Verified against Testcontainers Postgres (Docker available): all Java + Kotlin api-contract integration tests green (53/53 each, incl. generated/TPH/jsonb/m2m lanes); codegen-kotlin 255/255 (snapshot golden byte-identical). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n
1 parent c65bf22 commit 7519bf4

6 files changed

Lines changed: 164 additions & 53 deletions

File tree

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
197197
append("import java.net.URLDecoder\n")
198198
append("import java.nio.charset.StandardCharsets\n")
199199
append("import java.sql.Timestamp\n")
200+
// ADR-0036 Wave 2: a default field.timestamp column maps to java.time.Instant
201+
// (the WHERE-arm casts `p.value as Instant`); import it only when such a column
202+
// is present so entities without one stay byte-identical.
203+
if (scalarFields.any { it.elementType == "Instant" }) {
204+
append("import java.time.Instant\n")
205+
}
200206
append("import java.time.LocalDate\n")
201207
append("import java.time.LocalDateTime\n")
202208
append("import java.time.LocalTime\n")
@@ -459,6 +465,12 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
459465
append("import java.net.URLDecoder\n")
460466
append("import java.nio.charset.StandardCharsets\n")
461467
append("import java.sql.Timestamp\n")
468+
// ADR-0036 Wave 2: import java.time.Instant only when a default field.timestamp
469+
// column (→ Instant) is in the filter surface — keeps Instant-free entities
470+
// byte-identical.
471+
if (filterSpecs.any { it.elementType == "Instant" }) {
472+
append("import java.time.Instant\n")
473+
}
462474
append("import java.time.LocalDate\n")
463475
append("import java.time.LocalDateTime\n")
464476
append("import java.time.LocalTime\n")
@@ -743,12 +755,30 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
743755
emitTypedCoercer(out, shortName, "Double", "java.lang.Double.parseDouble")
744756
emitTypedCoercer(out, shortName, "Date", "LocalDate.parse")
745757
emitTypedCoercer(out, shortName, "Time", "LocalTime.parse")
746-
// Timestamp needs a per-entity formatter (the cross-port wire form is
747-
// 'yyyy-MM-dd\'T\'HH:mm:ss' without a zone), so it can't share the simple
748-
// single-arg parse-fn path of emitTypedCoercer.
758+
// Timestamp coercer. ADR-0036 Wave 2: a default `field.timestamp` column is an
759+
// absolute `java.time.Instant` (its WHERE-arm casts `p.value as Instant`), so the
760+
// coerced value MUST be an Instant — coercing into LocalDateTime would ClassCastException
761+
// at the dispatch cast. The cross-port wire form is offset-less wall-clock
762+
// ('yyyy-MM-dd'T'HH:mm:ss', no zone); an offset-less value is interpreted as UTC
763+
// (append `Z`) before Instant.parse. The `@localTime:true` opt-out column is a naive
764+
// `LocalDateTime` and uses the zone-less formatter directly.
765+
// No timestamp field present → the coercer is dead scaffolding; keep its historical
766+
// LocalDateTime shape so Instant-free entities stay byte-identical (and don't
767+
// reference the un-imported Instant).
768+
val timestampElementType = scalarFields
769+
.firstOrNull { it.subType == TimestampField.SUBTYPE_TIMESTAMP }
770+
?.elementType
771+
?: "LocalDateTime"
749772
out.append("private val ${shortName}TimestampFmt: DateTimeFormatter = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n\n")
750773
out.append("private fun coerce${shortName}Timestamp(op: String, raw: String): ${shortName}CoercedValue? {\n")
751-
out.append(" val parse: (String) -> LocalDateTime? = { s -> runCatching { LocalDateTime.parse(s, ${shortName}TimestampFmt) }.getOrNull() }\n")
774+
if (timestampElementType == "Instant") {
775+
out.append(" val parse: (String) -> Instant? = { s ->\n")
776+
out.append(" val withZone = if (s.endsWith(\"Z\") || s.contains(\"+\")) s else s + \"Z\"\n")
777+
out.append(" runCatching { Instant.parse(withZone) }.getOrNull()\n")
778+
out.append(" }\n")
779+
} else {
780+
out.append(" val parse: (String) -> LocalDateTime? = { s -> runCatching { LocalDateTime.parse(s, ${shortName}TimestampFmt) }.getOrNull() }\n")
781+
}
752782
out.append(" if (op == \"in\") {\n")
753783
out.append(" val parts = raw.split(\",\").map { it.trim() }\n")
754784
out.append(" val list = parts.map { parse(it) ?: return null }\n")

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/AuthorApiServer.kt

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ import org.jetbrains.exposed.sql.Table
1515
import org.jetbrains.exposed.sql.and
1616
import org.jetbrains.exposed.sql.deleteWhere
1717
import org.jetbrains.exposed.sql.insert
18-
import org.jetbrains.exposed.sql.javatime.datetime
18+
import org.jetbrains.exposed.sql.javatime.timestamp
1919
import org.jetbrains.exposed.sql.selectAll
2020
import org.jetbrains.exposed.sql.transactions.transaction
2121
import org.jetbrains.exposed.sql.update
2222
import java.net.InetSocketAddress
2323
import java.sql.DriverManager
24-
import java.time.LocalDateTime
24+
import java.time.Instant
25+
import java.time.ZoneOffset
2526
import java.time.format.DateTimeFormatter
2627

2728
/**
@@ -235,8 +236,9 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
235236
"id" to row[AuthorTable.id],
236237
"name" to row[AuthorTable.name],
237238
"bio" to row[AuthorTable.bio],
238-
// Normalize to ISO-8601 without zone (matches the seed/wire format).
239-
"createdAt" to ts.format(TIMESTAMP_FMT),
239+
// ADR-0036 Wave 2: createdAt is an absolute instant — normalize to the UTC wire
240+
// form yyyy-MM-ddTHH:mm:ssZ.
241+
"createdAt" to formatInstant(ts),
240242
)
241243
}
242244

@@ -286,11 +288,16 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
286288
return ValidSort(field, dir)
287289
}
288290

289-
private fun parseTimestamp(s: String): LocalDateTime {
290-
// The corpus uses `yyyy-MM-ddTHH:mm:ss` (no zone) for wall-clock times.
291-
return LocalDateTime.parse(s, TIMESTAMP_FMT)
291+
private fun parseTimestamp(s: String): Instant {
292+
// Corpus values are offset-less wall-clock (yyyy-MM-ddTHH:mm:ss); per the instant
293+
// wire contract an offset-less value is interpreted as UTC (append Z).
294+
val withZone = if (s.endsWith("Z") || s.contains("+")) s else s + "Z"
295+
return Instant.parse(withZone)
292296
}
293297

298+
private fun formatInstant(instant: Instant): String =
299+
TIMESTAMP_FMT.format(instant.atOffset(ZoneOffset.UTC)) + "Z"
300+
294301
private object InvalidSort
295302
private data class ValidSort(val field: String, val dir: SortOrder)
296303

@@ -369,7 +376,7 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
369376
/** Coerce a single non-list, non-isNull value into the per-subtype Kotlin type. */
370377
private fun coerceScalar(raw: String, subType: String): Any? = when (subType) {
371378
"string" -> raw
372-
"datetime" -> runCatching { LocalDateTime.parse(raw, TIMESTAMP_FMT) }.getOrNull()
379+
"datetime" -> runCatching { parseTimestamp(raw) }.getOrNull()
373380
"number" -> raw.toLongOrNull()
374381
"boolean" -> when (raw) { "true" -> true; "false" -> false; else -> null }
375382
else -> null
@@ -436,15 +443,15 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
436443
}
437444

438445
@Suppress("UNCHECKED_CAST")
439-
private fun SqlExpressionBuilder.datetimeColOp(col: Column<LocalDateTime>, p: FilterPredicate): Op<Boolean> =
446+
private fun SqlExpressionBuilder.datetimeColOp(col: Column<Instant>, p: FilterPredicate): Op<Boolean> =
440447
when (p.op) {
441-
"eq" -> col eq (p.value as LocalDateTime)
442-
"ne" -> col neq (p.value as LocalDateTime)
443-
"gt" -> col greater (p.value as LocalDateTime)
444-
"gte" -> col greaterEq (p.value as LocalDateTime)
445-
"lt" -> col less (p.value as LocalDateTime)
446-
"lte" -> col lessEq (p.value as LocalDateTime)
447-
"in" -> col inList (p.value as List<LocalDateTime>)
448+
"eq" -> col eq (p.value as Instant)
449+
"ne" -> col neq (p.value as Instant)
450+
"gt" -> col greater (p.value as Instant)
451+
"gte" -> col greaterEq (p.value as Instant)
452+
"lt" -> col less (p.value as Instant)
453+
"lte" -> col lessEq (p.value as Instant)
454+
"in" -> col inList (p.value as List<Instant>)
448455
"isNull" -> if (p.value as Boolean) col.isNull() else col.isNotNull()
449456
else -> throw IllegalStateException("unsupported op for datetime col: ${p.op}")
450457
}
@@ -479,7 +486,7 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
479486
val id = long("id").autoIncrement()
480487
val name = varchar("name", 100)
481488
val bio = varchar("bio", 1000).nullable()
482-
val createdAt = datetime("createdAt")
489+
val createdAt = timestamp("createdAt")
483490

484491
override val primaryKey = PrimaryKey(id)
485492
}

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package com.metaobjects.integration.kotlin.api.generated
22

3+
import com.fasterxml.jackson.core.JsonParser
4+
import com.fasterxml.jackson.databind.DeserializationContext
5+
import com.fasterxml.jackson.databind.JsonDeserializer
36
import com.fasterxml.jackson.databind.ObjectMapper
47
import com.fasterxml.jackson.databind.SerializationFeature
8+
import com.fasterxml.jackson.databind.module.SimpleModule
59
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
610
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
711
import com.metaobjects.generator.kotlin.KotlinEntityGenerator
@@ -26,6 +30,7 @@ import java.net.URI
2630
import java.nio.charset.StandardCharsets
2731
import java.nio.file.Files
2832
import java.nio.file.Path
33+
import java.time.Instant
2934
import java.util.concurrent.atomic.AtomicInteger
3035
import kotlin.io.path.isRegularFile
3136
import kotlin.io.path.readText
@@ -73,10 +78,27 @@ class GeneratedAuthorControllerHarness(
7378
private val seedRows: List<Map<String, Any?>>,
7479
) : AutoCloseable {
7580

76-
/** Jackson mapper used by MockMvc's converter and to (de)serialize bodies. */
81+
/**
82+
* Jackson mapper used by MockMvc's converter and to (de)serialize bodies.
83+
*
84+
* ADR-0036 Wave 2: the generated `Author.createdAt` is a `java.time.Instant`. Corpus
85+
* scenario/seed bodies are offset-less wall-clock (yyyy-MM-ddTHH:mm:ss), which Jackson's
86+
* default Instant deserializer rejects — so register an Instant deserializer that
87+
* interprets an offset-less value as UTC (the instant wire contract; mirrors the C# lane).
88+
*/
7789
private val mapper: ObjectMapper = ObjectMapper()
7890
.registerKotlinModule()
7991
.registerModule(JavaTimeModule())
92+
.registerModule(
93+
SimpleModule().addDeserializer(Instant::class.java, object : JsonDeserializer<Instant?>() {
94+
override fun deserialize(p: JsonParser, ctx: DeserializationContext): Instant? {
95+
val raw = p.valueAsString
96+
if (raw.isNullOrEmpty()) return null
97+
val s = if (raw.endsWith("Z") || raw.contains("+")) raw else raw + "Z"
98+
return Instant.parse(s)
99+
}
100+
})
101+
)
80102
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
81103

82104
private val classLoader: ClassLoader

server/java/integration-tests/src/test/java/com/metaobjects/integration/api/AuthorApiServer.java

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
import java.sql.ResultSet;
1818
import java.sql.SQLException;
1919
import java.sql.Statement;
20-
import java.sql.Timestamp;
21-
import java.time.LocalDateTime;
20+
import java.time.Instant;
21+
import java.time.OffsetDateTime;
22+
import java.time.ZoneOffset;
2223
import java.time.format.DateTimeFormatter;
2324
import java.util.ArrayList;
2425
import java.util.LinkedHashMap;
@@ -43,7 +44,10 @@
4344
* JDBC because they include {@code RESTART IDENTITY}.</p>
4445
*/
4546
final class AuthorApiServer implements AutoCloseable {
46-
private static final DateTimeFormatter TIMESTAMP_FMT =
47+
// ADR-0036 Wave 2: createdAt is a bare field.timestamp → instant/tz-aware. The wire
48+
// form is an absolute instant (yyyy-MM-ddTHH:mm:ss[.fff]Z). Corpus seed/body strings
49+
// are offset-less wall-clock; an offset-less value is interpreted as UTC.
50+
private static final DateTimeFormatter INSTANT_FMT =
4751
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
4852
// Mirrors the TS server's SORT_ALLOWLIST; cross-port contract.
4953
private static final Set<String> SORT_ALLOWLIST = Set.of("id", "name", "createdAt");
@@ -84,7 +88,7 @@ private record FilterResult(List<FilterPredicate> predicates, String error) {
8488
id BIGSERIAL PRIMARY KEY,
8589
name VARCHAR(100) NOT NULL,
8690
bio VARCHAR(1000),
87-
"createdAt" TIMESTAMP NOT NULL
91+
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL
8892
)""");
8993
}
9094
this.httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
@@ -123,7 +127,7 @@ void applySeed(List<Map<String, Object>> rows) {
123127
ps.setLong(1, ((Number) r.get("id")).longValue());
124128
ps.setString(2, (String) r.get("name"));
125129
ps.setString(3, (String) r.get("bio"));
126-
ps.setTimestamp(4, Timestamp.valueOf(parseTimestamp((String) r.get("createdAt"))));
130+
ps.setObject(4, parseInstant((String) r.get("createdAt")));
127131
ps.executeUpdate();
128132
}
129133
}
@@ -324,7 +328,7 @@ private static Object coerceScalar(String raw, String subType) {
324328
switch (subType) {
325329
case "string": return raw;
326330
case "datetime":
327-
try { return Timestamp.valueOf(parseTimestamp(raw)); }
331+
try { return parseInstant(raw); }
328332
catch (Exception e) { return INVALID_VALUE; }
329333
case "boolean":
330334
if (raw.equals("true")) return Boolean.TRUE;
@@ -412,7 +416,7 @@ private void createAuthor(HttpExchange exchange) throws IOException, SQLExceptio
412416
"INSERT INTO \"authors\" (name, bio, \"createdAt\") VALUES (?, ?, ?) RETURNING id")) {
413417
ps.setString(1, (String) body.get("name"));
414418
ps.setString(2, (String) body.get("bio"));
415-
ps.setTimestamp(3, Timestamp.valueOf(parseTimestamp((String) body.get("createdAt"))));
419+
ps.setObject(3, parseInstant((String) body.get("createdAt")));
416420
try (ResultSet rs = ps.executeQuery()) {
417421
rs.next();
418422
newId = rs.getLong(1);
@@ -443,7 +447,7 @@ private void updateAuthor(HttpExchange exchange, String idStr) throws IOExceptio
443447
if (body.containsKey("bio")) { setClauses.add("bio = ?"); values.add(body.get("bio")); }
444448
if (body.get("createdAt") instanceof String cAt) {
445449
setClauses.add("\"createdAt\" = ?");
446-
values.add(Timestamp.valueOf(parseTimestamp(cAt)));
450+
values.add(parseInstant(cAt));
447451
}
448452
if (setClauses.isEmpty()) { sendJson(exchange, 400, Map.of("error", "validation")); return; }
449453

@@ -497,9 +501,9 @@ private Map<String, Object> rowToMap(ResultSet rs) throws SQLException {
497501
row.put("id", rs.getLong("id"));
498502
row.put("name", rs.getString("name"));
499503
row.put("bio", rs.getString("bio"));
500-
Timestamp ts = rs.getTimestamp("createdAt");
501-
// Normalize to ISO-8601 without zone (matches the seed/wire format).
502-
row.put("createdAt", ts == null ? null : ts.toLocalDateTime().format(TIMESTAMP_FMT));
504+
OffsetDateTime ts = rs.getObject("createdAt", OffsetDateTime.class);
505+
// Normalize to a UTC instant on the wire: yyyy-MM-ddTHH:mm:ssZ (instant contract).
506+
row.put("createdAt", ts == null ? null : formatInstant(ts.toInstant()));
503507
return row;
504508
}
505509

@@ -536,9 +540,22 @@ private static Map<String, String> parseQs(String raw) {
536540
return out;
537541
}
538542

539-
private static LocalDateTime parseTimestamp(String s) {
540-
// The corpus uses `yyyy-MM-ddTHH:mm:ss` (no zone) for wall-clock times.
541-
return LocalDateTime.parse(s, TIMESTAMP_FMT);
543+
/**
544+
* Parse a corpus createdAt string into a UTC {@link OffsetDateTime} (JDBC-bindable
545+
* to {@code timestamp with time zone}). Corpus values are offset-less wall-clock
546+
* (yyyy-MM-ddTHH:mm:ss); per the instant wire contract an offset-less value is
547+
* interpreted as UTC, so we append {@code Z} when no zone is present.
548+
*/
549+
private static OffsetDateTime parseInstant(String s) {
550+
if (s.endsWith("Z") || s.contains("+")) {
551+
return OffsetDateTime.parse(s).withOffsetSameInstant(ZoneOffset.UTC);
552+
}
553+
return OffsetDateTime.parse(s + "Z");
554+
}
555+
556+
/** Format an instant as the UTC wire form yyyy-MM-ddTHH:mm:ssZ. */
557+
private static String formatInstant(Instant instant) {
558+
return INSTANT_FMT.format(instant.atOffset(ZoneOffset.UTC)) + "Z";
542559
}
543560

544561
private static Integer parseIntOrNull(String s) {

0 commit comments

Comments
 (0)