@@ -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