Skip to content

Commit 93e6120

Browse files
dmealingclaude
andcommitted
fix(codegen-kotlin): emit instantWithTimeZone helper once per package (fix multi-table redeclaration)
The timestamp_with_tz column-level fix inlined the MetaInstantWithTimeZoneColumnType support class + instantWithTimeZone(...) extension into EVERY generated *Table.kt that had such a column, declared private (file-scoped). With ONE timestamp_with_tz table per package this compiled; with TWO OR MORE in the same package each file re-declared the same top-level symbols, producing redeclaration + private-access compile errors (a real consumer hit 162 errors across its tables). Fix: emit the support helper ONCE PER PACKAGE into a shared MetaInstantWithTimeZoneColumnType.kt file, with internal visibility, so every *Table.kt in that package + module references the single shared declaration. The generator now tracks which packages have >=1 timestamp_with_tz column during the table-emission pass and emits one support file per such package afterward. The per-file inline block + per-file Instant/Column imports are removed; table files just call the same-package internal instantWithTimeZone("col") (Column<Instant> type inferred, no import needed). Column-level behavior is UNCHANGED: still Column<Instant> data class, TIMESTAMP WITH TIME ZONE DDL, Z wire form, offset->UTC normalization. The plain field.timestamp default (datetime / LocalDateTime) is UNCHANGED. The custom type body (delegate to JavaInstantColumnType, override only sqlType()) is byte-identical to before, only its visibility (private -> internal) and emission site (per-file -> per-package) changed. No other ports touched. Tests: - Updated timestampFieldWithDbColumnTypeTimestampWithTzEmitsInstantColumn to assert the table file no longer inlines the helper and a shared per-package MetaInstantWithTimeZoneColumnType.kt (internal) carries it. - Added twoTimestampTzTablesInSamePackageShareOneSupportFile — the regression guard the prior fix lacked: two entities in one package, both with timestamp_with_tz, assert the helper class is declared exactly once (in the shared support file) and both tables call the extension. FAILS on the pre-fix code (helper inlined into both table files, no shared file), PASSES after. - codegen-kotlin: 219 tests green. integration-tests-kotlin: 69 tests green against Testcontainers Postgres + Exposed 0.55. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f9ed09d commit 93e6120

2 files changed

Lines changed: 208 additions & 52 deletions

File tree

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

Lines changed: 104 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
5454
// contribute the FK column to PostTable even when Post has no reciprocal.
5555
val fkMap = buildGlobalFkMap(loader, refDecorationMap)
5656

57+
// Packages that emit at least one table carrying a
58+
// `@dbColumnType=timestamp_with_tz` column. Each such package gets ONE
59+
// shared `MetaInstantWithTimeZoneColumnType.kt` support file (emitted in
60+
// pass 3 below) instead of inlining the helper into every table file —
61+
// multiple timestamp_with_tz tables in one package would otherwise
62+
// redeclare the top-level support class/extension and fail to compile.
63+
val packagesNeedingInstantTzHelper = sortedSetOf<String>()
64+
5765
// Pass 2: emit one Table per entity using its own metadata + the
5866
// inbound FKs accumulated in Pass 1.
5967
for (entity in loader.metaObjects) {
@@ -82,10 +90,46 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
8290
val fkColumns = if (isViewKind(sourceRdb)) emptyList() else fkMap[entity.name].orEmpty()
8391
val refDecorations =
8492
if (isViewKind(sourceRdb)) emptyMap() else refDecorationMap[entity.name].orEmpty()
85-
emit(entity, sourceRdb, outRoot, loader, fkColumns, refDecorations)
93+
val usedInstantTzHelper = emit(entity, sourceRdb, outRoot, loader, fkColumns, refDecorations)
94+
if (usedInstantTzHelper) {
95+
packagesNeedingInstantTzHelper += PackageMapping.splitFqn(entity.name).first
96+
}
97+
}
98+
99+
// Pass 3: emit ONE shared `MetaInstantWithTimeZoneColumnType.kt` per package
100+
// that has at least one `@dbColumnType=timestamp_with_tz` column. The helper
101+
// (custom `ColumnType<Instant>` + `Table.instantWithTimeZone(...)` extension) is
102+
// `internal`, so every `*Table.kt` in the same package + module shares the single
103+
// declaration with no redeclaration / private-access clash.
104+
for (pkg in packagesNeedingInstantTzHelper) {
105+
emitInstantTzSupportFile(pkg, outRoot)
86106
}
87107
}
88108

109+
/**
110+
* Emit the per-package `MetaInstantWithTimeZoneColumnType.kt` support file for
111+
* `@dbColumnType=timestamp_with_tz` columns: an `internal` custom `ColumnType<Instant>`
112+
* (DDL `TIMESTAMP WITH TIME ZONE`) plus the `internal Table.instantWithTimeZone(name)`
113+
* column-builder extension that the generated table files call. `internal` keeps it
114+
* package+module-private (no leakage) while letting EVERY `*Table.kt` in the package use
115+
* the single shared declaration — fixing the multi-table-per-package redeclaration that the
116+
* earlier inline-per-file emission caused.
117+
*/
118+
private fun emitInstantTzSupportFile(pkg: String, outRoot: Path) {
119+
val body = buildString {
120+
if (pkg.isNotEmpty()) {
121+
append("package $pkg\n\n")
122+
}
123+
append(INSTANT_TZ_SUPPORT_FILE_IMPORTS)
124+
append("\n")
125+
append(INSTANT_TZ_SUPPORT_BLOCK)
126+
append("\n")
127+
}
128+
val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("$INSTANT_TZ_SUPPORT_FILE_NAME.kt")
129+
outFile.parent?.let { Files.createDirectories(it) }
130+
Files.writeString(outFile, body)
131+
}
132+
89133
/**
90134
* True when {@code source.rdb @kind} names a view-like construct
91135
* (view / materializedView). View-like sources are read-only: the generator
@@ -98,14 +142,20 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
98142
return k == MetaSource.KIND_VIEW || k == MetaSource.KIND_MATERIALIZED_VIEW
99143
}
100144

145+
/**
146+
* Emit one `*Table.kt` for [entity]. Returns `true` when the table carries at
147+
* least one `@dbColumnType=timestamp_with_tz` column — the caller uses that to
148+
* record the package as needing the shared `MetaInstantWithTimeZoneColumnType.kt`
149+
* support file (emitted once per package, not per table).
150+
*/
101151
private fun emit(
102152
entity: MetaObject,
103153
sourceRdb: RdbSource,
104154
outRoot: Path,
105155
loader: MetaDataLoader,
106156
fkColumns: List<FkColumnSpec>,
107157
refDecorations: Map<String, RefDecoration>,
108-
) {
158+
): Boolean {
109159
val (pkg, shortName) = PackageMapping.splitFqn(entity.name)
110160
val isView = isViewKind(sourceRdb)
111161
val tableObjectName = shortName + "Table"
@@ -158,10 +208,12 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
158208
val needsRefOptForDecor = refDecorations.values.any { it.hasReferenceOption }
159209

160210
// 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).
211+
// opt-in? If so the table calls the `instantWithTimeZone(...)` column-builder
212+
// extension, which lives in the package-shared `MetaInstantWithTimeZoneColumnType.kt`
213+
// support file (emitted once per package — see emitInstantTzSupportFile). The
214+
// extension is `internal` + same-package, so the table needs NO import for it (and the
215+
// return value's `Column<Instant>` type is inferred). Walk direct fields AND flattened
216+
// object sub-fields (same surfaces that contribute columns / imports above).
165217
var needsInstantTzHelper = entity.metaFields.any {
166218
it !is ObjectField && KotlinTypeMapper.usesInstantWithTimeZone(it)
167219
}
@@ -252,12 +304,10 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
252304
append("import org.jetbrains.exposed.sql.CustomFunction\n")
253305
append("import org.jetbrains.exposed.sql.UUIDColumnType\n")
254306
}
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-
}
307+
// @dbColumnType=timestamp_with_tz calls the `instantWithTimeZone(...)` extension
308+
// from the package-shared MetaInstantWithTimeZoneColumnType.kt support file. That
309+
// extension is `internal` + same-package, so the table file needs NO import for it
310+
// and NO Instant/Column imports (the column's `Column<Instant>` type is inferred).
261311
append("\n")
262312
if (isView) {
263313
append("/** READ-ONLY VIEW — generated from view metadata; do not insert/update/delete directly. */\n")
@@ -329,22 +379,17 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
329379
append(" }\n")
330380
}
331381
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-
}
382+
// NOTE: the `@dbColumnType=timestamp_with_tz` support helper
383+
// (MetaInstantWithTimeZoneColumnType + instantWithTimeZone extension) is NO LONGER
384+
// inlined here. It is emitted ONCE PER PACKAGE as a shared internal
385+
// MetaInstantWithTimeZoneColumnType.kt file (see emitInstantTzSupportFile), so
386+
// multiple timestamp_with_tz tables in one package don't redeclare it.
343387
}
344388

345389
val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("$tableObjectName.kt")
346390
outFile.parent?.let { Files.createDirectories(it) }
347391
Files.writeString(outFile, source)
392+
return needsInstantTzHelper
348393
}
349394

350395
// === field.object + @storage column emission ============================
@@ -442,8 +487,29 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
442487
val LOG = LoggerFactory.getLogger(KotlinExposedTableGenerator::class.java)
443488

444489
/**
445-
* File-local support emitted into a generated table file that has at least one
446-
* `@dbColumnType=timestamp_with_tz` column. Defines:
490+
* File name (sans `.kt`) of the per-package shared support file emitted for
491+
* `@dbColumnType=timestamp_with_tz` columns. One per package that has ≥1 such column.
492+
*/
493+
const val INSTANT_TZ_SUPPORT_FILE_NAME = "MetaInstantWithTimeZoneColumnType"
494+
495+
/**
496+
* Imports for the per-package [INSTANT_TZ_SUPPORT_BLOCK] support file. Kept here (rather
497+
* than FQN-ing every reference inline) so the emitted support file reads idiomatically.
498+
*/
499+
val INSTANT_TZ_SUPPORT_FILE_IMPORTS: String = """
500+
|import java.time.Instant
501+
|import org.jetbrains.exposed.sql.Column
502+
|import org.jetbrains.exposed.sql.ColumnType
503+
|import org.jetbrains.exposed.sql.IDateColumnType
504+
|import org.jetbrains.exposed.sql.Table
505+
|import org.jetbrains.exposed.sql.javatime.JavaInstantColumnType
506+
|import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi
507+
|import org.jetbrains.exposed.sql.vendors.currentDialect
508+
|""".trimMargin()
509+
510+
/**
511+
* Shared support body emitted ONCE PER PACKAGE (into [INSTANT_TZ_SUPPORT_FILE_NAME].kt)
512+
* for any package that has at least one `@dbColumnType=timestamp_with_tz` column. Defines:
447513
*
448514
* - `MetaInstantWithTimeZoneColumnType` — a `ColumnType<Instant>` that delegates ALL
449515
* value/JDBC handling (read, bind, normalize, millisecond-truncate, wire string) to
@@ -453,14 +519,15 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
453519
* `Instant`↔`OffsetDateTime` coercion) — while keeping the TZ-aware column so the
454520
* seeded-offset → read-back-UTC normalization contract holds.
455521
* - `Table.instantWithTimeZone(name)` — the column-builder extension the generated
456-
* table calls (`val createdAt = instantWithTimeZone("created_at")`).
522+
* tables call (`val createdAt = instantWithTimeZone("created_at")`).
457523
*
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.
524+
* Both are declared `internal` (package + module-private) so EVERY `*Table.kt` in the
525+
* same package shares the ONE declaration — multiple timestamp_with_tz tables per
526+
* package no longer redeclare a top-level class/extension (the bug the prior per-file
527+
* inline emission caused). Note: the `$` lines below have no Kotlin string templates,
528+
* so this trimMargin block is emitted verbatim.
461529
*/
462530
val INSTANT_TZ_SUPPORT_BLOCK: String = """
463-
464531
|/**
465532
| * GENERATED — do not hand-edit.
466533
| * Custom Exposed column type for `@dbColumnType=timestamp_with_tz`: a
@@ -469,21 +536,21 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
469536
| * overrides only `sqlType()`, so the column type matches the `Instant` data-class
470537
| * property (no Instant↔OffsetDateTime coercion) while staying timezone-aware.
471538
| */
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()
539+
|internal class MetaInstantWithTimeZoneColumnType :
540+
| ColumnType<Instant>(),
541+
| IDateColumnType {
542+
| private val delegate = JavaInstantColumnType()
476543
| override val hasTimePart: Boolean get() = delegate.hasTimePart
477544
| override fun sqlType(): String =
478-
| org.jetbrains.exposed.sql.vendors.currentDialect.dataTypeProvider.timestampWithTimeZoneType()
545+
| currentDialect.dataTypeProvider.timestampWithTimeZoneType()
479546
| override fun valueFromDB(value: Any): Instant? = delegate.valueFromDB(value)
480547
| override fun notNullValueToDB(value: Instant): Any = delegate.notNullValueToDB(value)
481548
| override fun nonNullValueToString(value: Instant): String = delegate.nonNullValueToString(value)
482549
| override fun nonNullValueAsDefaultString(value: Instant): String =
483550
| delegate.nonNullValueAsDefaultString(value)
484551
| override fun readObject(rs: java.sql.ResultSet, index: Int): Any? = delegate.readObject(rs, index)
485552
| override fun setParameter(
486-
| stmt: org.jetbrains.exposed.sql.statements.api.PreparedStatementApi,
553+
| stmt: PreparedStatementApi,
487554
| index: Int,
488555
| value: Any?,
489556
| ) = delegate.setParameter(stmt, index, value)
@@ -493,7 +560,7 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
493560
| * Column builder for `@dbColumnType=timestamp_with_tz`: a `Column<Instant>` backed by
494561
| * a `TIMESTAMP WITH TIME ZONE` Postgres column (see [MetaInstantWithTimeZoneColumnType]).
495562
| */
496-
|private fun org.jetbrains.exposed.sql.Table.instantWithTimeZone(name: String): Column<Instant> =
563+
|internal fun Table.instantWithTimeZone(name: String): Column<Instant> =
497564
| registerColumn(name, MetaInstantWithTimeZoneColumnType())
498565
|""".trimMargin()
499566
}

0 commit comments

Comments
 (0)