Skip to content

Commit f9ed09d

Browse files
dmealingclaude
andcommitted
fix(codegen-kotlin): timestamp_with_tz emits Column<Instant> (match Instant data class, drop OffsetDateTime coercion)
The @dbColumnType=timestamp_with_tz opt-in was internally inconsistent for the Exposed persistence path: the data-class arm emits java.time.Instant, but the Exposed column arm emitted Exposed's native timestampWithTimeZone(...), which in Exposed 0.55 is a Column<OffsetDateTime>. The generated table therefore had a Column<OffsetDateTime> column behind an Instant field, so any `it[Table.createdAt] = entity.createdAt` (Instant into Column<OffsetDateTime>) failed to compile — consumers had to hand-coerce Instant<->OffsetDateTime. Fix: emit a custom Exposed column type that is a Column<java.time.Instant> whose sqlType() is TIMESTAMP WITH TIME ZONE. It delegates ALL value/JDBC handling (read, bind, normalize, millisecond-truncate, wire string) to Exposed's tested JavaInstantColumnType and overrides only sqlType(). This matches the Instant data class (zero coercion) AND keeps the timezone-aware column, so the offset->UTC normalization persistence-conformance contract still holds. Mechanism: native Exposed has no function giving both Column<Instant> and TIMESTAMP WITH TIME ZONE (timestamp()/JavaInstantColumnType is Column<Instant> but plain TIMESTAMP; timestampWithTimeZone() is the right DDL but Column<OffsetDateTime>). The custom delegating type bridges them. The generator emits the support helper (MetaInstantWithTimeZoneColumnType + a file-local Table.instantWithTimeZone(name) extension) inline into each table file that has a timestamp_with_tz column, declared private (file-scoped) so multiple tables in one package don't clash. No new Exposed dependency is added to any metaobjects production module. Data-class type (Instant) and the wire form (Z) are UNCHANGED; the plain field.timestamp default (datetime/LocalDateTime/no-Z) is UNCHANGED. Kotlin-only: the Java/TS/Python/C# ports are DTO/string column descriptors with no Exposed column mismatch and need no change. Tests/fixtures: - KotlinTypeMapperTest: timestamp_with_tz now asserts instantWithTimeZone(...) + null import + Instant data-class type + usesInstantWithTimeZone predicate. - KotlinExposedTableGeneratorTest: asserts the emitted instantWithTimeZone column, the file-local support helper, imports, and the TIMESTAMP WITH TIME ZONE DDL. - integration-tests-kotlin reference AssetTable + sibling InstantWithTimeZoneColumnType.kt (proves the helper compiles against real Exposed 0.55), Normalization gains an Instant->UTC+Z branch, QueryScenarioRunner stops collapsing Instant to LocalDateTime, RuntimeReturnTypeTest asserts recordedAt is Instant. All run green against Testcontainers Postgres (QueryScenarioConformanceTest + RuntimeReturnTypeTest), preserving the existing `timestamp with time zone` DDL + `Z` wire fixtures unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6c7043e commit f9ed09d

10 files changed

Lines changed: 276 additions & 55 deletions

File tree

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,27 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
157157
val needsJsonbImport = objectColumns.any { it.kind == ObjectColumnKind.JSONB }
158158
val needsRefOptForDecor = refDecorations.values.any { it.hasReferenceOption }
159159

160+
// Does any column on this table use the TZ-aware `@dbColumnType=timestamp_with_tz`
161+
// opt-in? If so the file must carry the file-local `instantWithTimeZone(...)` support
162+
// helper (a custom `Column<java.time.Instant>` whose DDL is `TIMESTAMP WITH TIME
163+
// ZONE`). Walk direct fields AND flattened object sub-fields (same surfaces that
164+
// contribute columns / imports above).
165+
var needsInstantTzHelper = entity.metaFields.any {
166+
it !is ObjectField && KotlinTypeMapper.usesInstantWithTimeZone(it)
167+
}
168+
if (!needsInstantTzHelper) {
169+
for (field in entity.metaFields) {
170+
if (field !is ObjectField) continue
171+
if (readStorage(field) != STORAGE_FLATTENED) continue
172+
val ref = readObjectRef(field) ?: continue
173+
val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: continue
174+
if (target.metaFields.any { KotlinTypeMapper.usesInstantWithTimeZone(it) }) {
175+
needsInstantTzHelper = true
176+
break
177+
}
178+
}
179+
}
180+
160181
// Walk every field that will actually become a Table column and collect the
161182
// imports its column function requires. Member functions on Table (varchar,
162183
// integer, long, etc.) return null and are skipped; extension functions from
@@ -231,6 +252,12 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
231252
append("import org.jetbrains.exposed.sql.CustomFunction\n")
232253
append("import org.jetbrains.exposed.sql.UUIDColumnType\n")
233254
}
255+
// @dbColumnType=timestamp_with_tz emits a file-local `instantWithTimeZone(...)`
256+
// extension + custom `ColumnType<Instant>`; those need Column + Instant on import.
257+
if (needsInstantTzHelper) {
258+
append("import java.time.Instant\n")
259+
append("import org.jetbrains.exposed.sql.Column\n")
260+
}
234261
append("\n")
235262
if (isView) {
236263
append("/** READ-ONLY VIEW — generated from view metadata; do not insert/update/delete directly. */\n")
@@ -302,6 +329,17 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
302329
append(" }\n")
303330
}
304331
append("}\n")
332+
333+
// File-local support for `@dbColumnType=timestamp_with_tz`: a custom Exposed
334+
// column type that is a `Column<java.time.Instant>` (matches the Instant data
335+
// class — zero coercion) whose DDL is `TIMESTAMP WITH TIME ZONE` (preserves the
336+
// offset→UTC normalization persistence contract). Delegates ALL value / JDBC
337+
// handling to Exposed's tested `JavaInstantColumnType` and overrides only
338+
// `sqlType()`. Declared `private` (file-scoped) so multiple tables in one package
339+
// each carry their own copy without clashing.
340+
if (needsInstantTzHelper) {
341+
append(INSTANT_TZ_SUPPORT_BLOCK)
342+
}
305343
}
306344

307345
val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("$tableObjectName.kt")
@@ -402,6 +440,62 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
402440

403441
@JvmStatic
404442
val LOG = LoggerFactory.getLogger(KotlinExposedTableGenerator::class.java)
443+
444+
/**
445+
* File-local support emitted into a generated table file that has at least one
446+
* `@dbColumnType=timestamp_with_tz` column. Defines:
447+
*
448+
* - `MetaInstantWithTimeZoneColumnType` — a `ColumnType<Instant>` that delegates ALL
449+
* value/JDBC handling (read, bind, normalize, millisecond-truncate, wire string) to
450+
* Exposed's tested `JavaInstantColumnType`, overriding ONLY `sqlType()` to return the
451+
* dialect's `TIMESTAMP WITH TIME ZONE` (Postgres: `timestamp with time zone`). This
452+
* yields a `Column<Instant>` — matching the `Instant` data-class property (no
453+
* `Instant`↔`OffsetDateTime` coercion) — while keeping the TZ-aware column so the
454+
* seeded-offset → read-back-UTC normalization contract holds.
455+
* - `Table.instantWithTimeZone(name)` — the column-builder extension the generated
456+
* table calls (`val createdAt = instantWithTimeZone("created_at")`).
457+
*
458+
* Declared `private` (file-scoped) so two tables in the same package each carry their
459+
* own copy without a top-level name clash. Note: the GENERATED `$` lines below have no
460+
* Kotlin string templates, so this trimMargin block is emitted verbatim.
461+
*/
462+
val INSTANT_TZ_SUPPORT_BLOCK: String = """
463+
464+
|/**
465+
| * GENERATED — do not hand-edit.
466+
| * Custom Exposed column type for `@dbColumnType=timestamp_with_tz`: a
467+
| * `Column<java.time.Instant>` whose SQL DDL is `TIMESTAMP WITH TIME ZONE`.
468+
| * Delegates all value/JDBC handling to Exposed's `JavaInstantColumnType` and
469+
| * overrides only `sqlType()`, so the column type matches the `Instant` data-class
470+
| * property (no Instant↔OffsetDateTime coercion) while staying timezone-aware.
471+
| */
472+
|private class MetaInstantWithTimeZoneColumnType :
473+
| org.jetbrains.exposed.sql.ColumnType<Instant>(),
474+
| org.jetbrains.exposed.sql.IDateColumnType {
475+
| private val delegate = org.jetbrains.exposed.sql.javatime.JavaInstantColumnType()
476+
| override val hasTimePart: Boolean get() = delegate.hasTimePart
477+
| override fun sqlType(): String =
478+
| org.jetbrains.exposed.sql.vendors.currentDialect.dataTypeProvider.timestampWithTimeZoneType()
479+
| override fun valueFromDB(value: Any): Instant? = delegate.valueFromDB(value)
480+
| override fun notNullValueToDB(value: Instant): Any = delegate.notNullValueToDB(value)
481+
| override fun nonNullValueToString(value: Instant): String = delegate.nonNullValueToString(value)
482+
| override fun nonNullValueAsDefaultString(value: Instant): String =
483+
| delegate.nonNullValueAsDefaultString(value)
484+
| override fun readObject(rs: java.sql.ResultSet, index: Int): Any? = delegate.readObject(rs, index)
485+
| override fun setParameter(
486+
| stmt: org.jetbrains.exposed.sql.statements.api.PreparedStatementApi,
487+
| index: Int,
488+
| value: Any?,
489+
| ) = delegate.setParameter(stmt, index, value)
490+
|}
491+
|
492+
|/**
493+
| * Column builder for `@dbColumnType=timestamp_with_tz`: a `Column<Instant>` backed by
494+
| * a `TIMESTAMP WITH TIME ZONE` Postgres column (see [MetaInstantWithTimeZoneColumnType]).
495+
| */
496+
|private fun org.jetbrains.exposed.sql.Table.instantWithTimeZone(name: String): Column<Instant> =
497+
| registerColumn(name, MetaInstantWithTimeZoneColumnType())
498+
|""".trimMargin()
405499
}
406500

407501
// === FK column emission from relationship.composition ====================

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

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,15 @@ object KotlinTypeMapper {
7171
* - `jsonb` (on [StringField]) — emit Exposed `jsonb("col", { it }, { it })` (a real
7272
* Postgres `JSONB` column). The property stays a raw-JSON `String`; the identity
7373
* encode/decode functions pass the JSON text straight through.
74-
* - `timestamp_with_tz` (on [TimestampField]) — emit Exposed `timestampWithTimeZone("col")`
75-
* (Postgres `timestamp with time zone`). Opt-in: the default for `field.timestamp` is
76-
* plain `timestamp("col")` (Postgres `timestamp without time zone`).
74+
* - `timestamp_with_tz` (on [TimestampField]) — emit the generated, file-local
75+
* `instantWithTimeZone("col")` extension (a `Column<java.time.Instant>` whose DDL is
76+
* Postgres `timestamp with time zone`). Opt-in: the default for `field.timestamp` is
77+
* plain `datetime("col")` (Postgres `timestamp without time zone`, `LocalDateTime`).
78+
* NOTE: Exposed 0.55's native `timestampWithTimeZone(...)` is a `Column<OffsetDateTime>`,
79+
* which would MISMATCH the `Instant` data-class property emitted by [kotlinTypeName] and
80+
* force callers to hand-coerce `Instant`↔`OffsetDateTime`. We therefore emit a custom
81+
* `Column<Instant>` column whose `sqlType()` is `TIMESTAMP WITH TIME ZONE` (the helper is
82+
* emitted file-locally by [KotlinExposedTableGenerator] — see [EXPOSED_INSTANT_TZ_FN]).
7783
*
7884
* Unknown values fall through to the default mapping for the field type.
7985
*/
@@ -101,6 +107,19 @@ object KotlinTypeMapper {
101107
/** FQN of the Exposed `jsonb` extension function (raw-string open-JSON path). */
102108
private const val EXPOSED_JSONB_IMPORT = "org.jetbrains.exposed.sql.json.jsonb"
103109

110+
/**
111+
* Name of the generated, file-local Exposed extension function emitted for a
112+
* `@dbColumnType=timestamp_with_tz` [TimestampField]. It returns a
113+
* `Column<java.time.Instant>` whose `sqlType()` is `TIMESTAMP WITH TIME ZONE` — so the
114+
* column type MATCHES the `Instant` data-class property (zero `Instant`↔`OffsetDateTime`
115+
* coercion) while keeping the TZ-aware Postgres column (offset→UTC normalization, the
116+
* persistence-conformance contract). [KotlinExposedTableGenerator] emits the supporting
117+
* `ColumnType<Instant>` + this extension once per generated table file that needs it; it
118+
* lives in the table's own package, so no cross-file import is required (the column
119+
* function returns `null` from [exposedColumnImport]).
120+
*/
121+
const val EXPOSED_INSTANT_TZ_FN = "instantWithTimeZone"
122+
104123
/**
105124
* Compute the generated Kotlin enum-class name for an [EnumField] hung off [entity].
106125
*
@@ -232,14 +251,15 @@ object KotlinTypeMapper {
232251
is TimeField -> "org.jetbrains.exposed.sql.javatime.time"
233252
// Default for field.timestamp is `datetime(...)` — Postgres `timestamp without
234253
// time zone`, mapped by exposed-java-time to `java.time.LocalDateTime` (the
235-
// zone-less wall-clock shape carried on the cross-port wire). Opt-in
236-
// `@dbColumnType=timestamp_with_tz` switches to `timestampWithTimeZone(...)`
237-
// (Postgres `timestamp with time zone`, `java.time.Instant`).
254+
// zone-less wall-clock shape carried on the cross-port wire) — needs the javatime
255+
// `datetime` import. Opt-in `@dbColumnType=timestamp_with_tz` emits the file-local
256+
// `instantWithTimeZone(...)` extension instead (a `Column<java.time.Instant>` with
257+
// `TIMESTAMP WITH TIME ZONE` DDL — see [EXPOSED_INSTANT_TZ_FN]). That helper is
258+
// emitted into the table's own file by [KotlinExposedTableGenerator], so it needs
259+
// NO external import — return null for the opt-in branch.
238260
is TimestampField -> {
239-
if (timestampWithTzOptIn(field))
240-
"org.jetbrains.exposed.sql.javatime.timestampWithTimeZone"
241-
else
242-
"org.jetbrains.exposed.sql.javatime.datetime"
261+
if (timestampWithTzOptIn(field)) null
262+
else "org.jetbrains.exposed.sql.javatime.datetime"
243263
}
244264
// `@dbColumnType=jsonb` on a field.string emits the `jsonb(...)` extension, which
245265
// needs the exposed-json import. `@dbColumnType=uuid` maps to `uuid(...)`, a Table
@@ -300,9 +320,12 @@ object KotlinTypeMapper {
300320
is TimeField -> "time(\"$colName\")"
301321
// Default for field.timestamp is `datetime(...)` — Postgres `timestamp without
302322
// time zone` (`java.time.LocalDateTime`), the zone-less wall-clock wire shape.
303-
// Opt in to TZ-aware (`java.time.Instant`) via `@dbColumnType=timestamp_with_tz`.
323+
// Opt in to TZ-aware (`java.time.Instant`) via `@dbColumnType=timestamp_with_tz`,
324+
// which emits the file-local `instantWithTimeZone(...)` extension — a
325+
// `Column<java.time.Instant>` (matches the Instant data class) whose DDL is
326+
// `TIMESTAMP WITH TIME ZONE` (see [EXPOSED_INSTANT_TZ_FN]).
304327
is TimestampField -> {
305-
if (timestampWithTzOptIn(field)) "timestampWithTimeZone(\"$colName\")"
328+
if (timestampWithTzOptIn(field)) "$EXPOSED_INSTANT_TZ_FN(\"$colName\")"
306329
else "datetime(\"$colName\")"
307330
}
308331
// Currency stored as BIGINT minor units — same as Long. Separate arm for
@@ -334,6 +357,16 @@ object KotlinTypeMapper {
334357
private fun timestampWithTzOptIn(field: MetaField<*>): Boolean =
335358
dbColumnType(field) == DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ
336359

360+
/**
361+
* True iff [field] is a [TimestampField] that opted in to the TZ-aware
362+
* `@dbColumnType=timestamp_with_tz` column. [KotlinExposedTableGenerator] uses this to
363+
* decide whether a table file must carry the file-local `instantWithTimeZone(...)`
364+
* support helper (the custom `Column<java.time.Instant>` / `TIMESTAMP WITH TIME ZONE`
365+
* column type). Non-timestamp fields are always false.
366+
*/
367+
fun usesInstantWithTimeZone(field: MetaField<*>): Boolean =
368+
field is TimestampField && timestampWithTzOptIn(field)
369+
337370
/**
338371
* Best-effort read of a named string attribute (own-only) on [field]. Returns null when
339372
* the attribute is absent, throws during lookup, or isn't a [com.metaobjects.attr.MetaAttribute].

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGeneratorTest.kt

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -709,11 +709,15 @@ class KotlinExposedTableGeneratorTest {
709709
}
710710

711711
/**
712-
* Opt-in: `@dbColumnType=timestamp_with_tz` on a `field.timestamp` selects the
713-
* Postgres `timestamp with time zone` variant — emits `timestampWithTimeZone("col")`
714-
* with the matching javatime.timestampWithTimeZone import.
712+
* Opt-in: `@dbColumnType=timestamp_with_tz` on a `field.timestamp` selects a
713+
* `Column<java.time.Instant>` column whose Postgres DDL is `timestamp with time zone`.
714+
* The emitted column function is the file-local `instantWithTimeZone("col")` extension
715+
* (a custom `ColumnType<Instant>`), NOT Exposed's native `timestampWithTimeZone(...)`
716+
* (which is `Column<OffsetDateTime>` and would mismatch the `Instant` data class). The
717+
* supporting helper (`MetaInstantWithTimeZoneColumnType` + the extension) is emitted
718+
* into the SAME file, so no `javatime.timestampWithTimeZone` import is needed.
715719
*/
716-
@Test fun timestampFieldWithDbColumnTypeTimestampWithTzEmitsTzVariant() {
720+
@Test fun timestampFieldWithDbColumnTypeTimestampWithTzEmitsInstantColumn() {
717721
val tzFixture = """{
718722
"metadata.root": { "package": "x", "children": [
719723
{ "object.entity": { "name": "Event", "children": [
@@ -731,10 +735,22 @@ class KotlinExposedTableGeneratorTest {
731735
gen.execute(loadString("jt-tz", tzFixture))
732736

733737
val src = Files.readString(outDir.resolve("x/EventTable.kt"))
734-
assertTrue("import org.jetbrains.exposed.sql.javatime.timestampWithTimeZone" in src,
735-
"expected javatime.timestampWithTimeZone import for opt-in TZ-aware; saw:\n$src")
736-
assertTrue("val occurredAt = timestampWithTimeZone(\"occurred_at\")" in src,
737-
"expected timestampWithTimeZone column for opt-in TZ-aware; saw:\n$src")
738+
// The column is the file-local `instantWithTimeZone(...)` extension, NOT the native
739+
// Exposed `timestampWithTimeZone(...)` (Column<OffsetDateTime>).
740+
assertTrue("val occurredAt = instantWithTimeZone(\"occurred_at\")" in src,
741+
"expected instantWithTimeZone column for opt-in TZ-aware; saw:\n$src")
742+
assertTrue("import org.jetbrains.exposed.sql.javatime.timestampWithTimeZone" !in src,
743+
"must NOT import native timestampWithTimeZone (it is Column<OffsetDateTime>); saw:\n$src")
744+
// The file carries the self-contained support helper + needed imports.
745+
assertTrue("private class MetaInstantWithTimeZoneColumnType" in src,
746+
"expected the file-local custom ColumnType<Instant> support block; saw:\n$src")
747+
assertTrue("private fun org.jetbrains.exposed.sql.Table.instantWithTimeZone(name: String): Column<Instant>" in src,
748+
"expected the file-local instantWithTimeZone extension; saw:\n$src")
749+
assertTrue("import java.time.Instant" in src && "import org.jetbrains.exposed.sql.Column" in src,
750+
"expected Instant + Column imports for the support helper; saw:\n$src")
751+
// The custom type overrides sqlType() to the dialect's TIMESTAMP WITH TIME ZONE.
752+
assertTrue("timestampWithTimeZoneType()" in src,
753+
"custom ColumnType must produce TIMESTAMP WITH TIME ZONE DDL; saw:\n$src")
738754
} finally {
739755
outDir.toFile().deleteRecursively()
740756
}

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,16 +161,23 @@ class KotlinTypeMapperTest {
161161
assertEquals("datetime(\"created_at\")", KotlinTypeMapper.exposedColumnSpec(f))
162162
}
163163

164-
@Test fun `timestamp field with dbColumnType=timestamp_with_tz emits timestampWithTimeZone`() {
165-
// Opt-in: `@dbColumnType=timestamp_with_tz` selects Postgres `timestamp with time zone`.
164+
@Test fun `timestamp field with dbColumnType=timestamp_with_tz emits instantWithTimeZone`() {
165+
// Opt-in: `@dbColumnType=timestamp_with_tz` selects a `Column<Instant>` column whose
166+
// Postgres DDL is `timestamp with time zone`. The emitted column function is the
167+
// file-local `instantWithTimeZone(...)` extension (a custom ColumnType<Instant>), NOT
168+
// Exposed's native `timestampWithTimeZone(...)` — that one is Column<OffsetDateTime>
169+
// and would MISMATCH the `Instant` data-class property, forcing Instant↔OffsetDateTime
170+
// coercion at every callsite.
166171
val f = TimestampField("createdAt")
167172
f.addMetaAttr(StringAttribute.create("dbColumnType", "timestamp_with_tz"))
168-
assertEquals("timestampWithTimeZone(\"created_at\")", KotlinTypeMapper.exposedColumnSpec(f))
169-
// And the import switches accordingly.
170-
assertEquals(
171-
"org.jetbrains.exposed.sql.javatime.timestampWithTimeZone",
172-
KotlinTypeMapper.exposedColumnImport(f),
173-
)
173+
assertEquals("instantWithTimeZone(\"created_at\")", KotlinTypeMapper.exposedColumnSpec(f))
174+
// The helper is emitted into the table's own file by KotlinExposedTableGenerator, so the
175+
// column function needs NO external import (the javatime import is gone).
176+
assertEquals(null, KotlinTypeMapper.exposedColumnImport(f))
177+
// The data-class property type stays Instant (unchanged — the wire/DTO contract is correct).
178+
assertEquals(ClassName("java.time", "Instant"), KotlinTypeMapper.kotlinTypeName(f))
179+
// And the table generator is told this field needs the file-local support helper.
180+
assertEquals(true, KotlinTypeMapper.usesInstantWithTimeZone(f))
174181
}
175182

176183
@Test fun `timestamp field default import is javatime datetime`() {

0 commit comments

Comments
 (0)