Skip to content

Commit

Permalink
fix: EXPOSED-281 Timestamp with time zone column default falsely trig…
Browse files Browse the repository at this point in the history
…gers ALTER statement

-`KotlinOffsetDateTimeColumnType`, `JavaOffsetDateTimeColumnType`, and `DateTimeWithTimeZoneColumnType` now override `nonNullValueAsDefaultString` to match the default value obtained from the metadata for PostgreSQL, MySQL, and H2 Oracle.
  • Loading branch information
joc-a committed Feb 9, 2024
1 parent f79012d commit 65fe85b
Show file tree
Hide file tree
Showing 9 changed files with 160 additions and 0 deletions.
1 change: 1 addition & 0 deletions exposed-java-time/api/exposed-java-time.api
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ public final class org/jetbrains/exposed/sql/javatime/JavaOffsetDateTimeColumnTy
public static final field Companion Lorg/jetbrains/exposed/sql/javatime/JavaOffsetDateTimeColumnType$Companion;
public fun <init> ()V
public fun getHasTimePart ()Z
public fun nonNullValueAsDefaultString (Ljava/lang/Object;)Ljava/lang/String;
public fun nonNullValueToString (Ljava/lang/Object;)Ljava/lang/String;
public fun notNullValueToDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun readObject (Ljava/sql/ResultSet;I)Ljava/lang/Object;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import org.jetbrains.exposed.sql.vendors.*
import java.sql.ResultSet
import java.time.*
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatter.ISO_LOCAL_DATE
import java.time.format.DateTimeFormatter.ISO_LOCAL_TIME
import java.time.format.DateTimeFormatterBuilder
import java.util.*

internal val DEFAULT_DATE_STRING_FORMATTER by lazy {
Expand Down Expand Up @@ -71,6 +74,23 @@ internal val ORACLE_OFFSET_DATE_TIME_FORMATTER by lazy {
)
}

// Example result: 2023-07-07 14:42:29.343
internal val POSTGRESQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER by lazy {
DateTimeFormatterBuilder()
.parseCaseInsensitive()
.append(ISO_LOCAL_DATE)
.appendLiteral(' ')
.append(ISO_LOCAL_TIME)
.toFormatter(Locale.ROOT)
}

internal val MYSQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER by lazy {
DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss.SSSSSS",
Locale.ROOT
).withZone(ZoneId.of("UTC"))
}

internal val DEFAULT_OFFSET_DATE_TIME_FORMATTER by lazy {
DateTimeFormatter.ISO_OFFSET_DATE_TIME.withLocale(Locale.ROOT)
}
Expand Down Expand Up @@ -402,6 +422,20 @@ class JavaOffsetDateTimeColumnType : ColumnType(), IDateColumnType {
else -> error("Unexpected value: $value of ${value::class.qualifiedName}")
}

override fun nonNullValueAsDefaultString(value: Any): String = when (value) {
is OffsetDateTime -> {
when {
currentDialect is PostgreSQLDialect -> // +00 appended because PostgreSQL stores it in UTC time zone
"'${value.format(POSTGRESQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER)}+00'::timestamp with time zone"
currentDialect is H2Dialect && currentDialect.h2Mode == H2Dialect.H2CompatibilityMode.Oracle ->
"'${value.format(POSTGRESQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER)}'"
currentDialect is MysqlDialect -> "'${value.format(MYSQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER)}'"
else -> super.nonNullValueAsDefaultString(value)
}
}
else -> super.nonNullValueAsDefaultString(value)
}

companion object {
internal val INSTANCE = JavaOffsetDateTimeColumnType()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -540,4 +540,29 @@ class DefaultsTest : DatabaseTestsBase() {
assertEquals(0, statements.size)
}
}

@Test
fun testTimestampWithTimeZoneDefaultDoesNotTriggerAlterStatement() {
val offsetDateTime = OffsetDateTime.parse("2024-02-08T20:48:04.700+09:00")

val tester = object : Table("tester") {
val timestampWithTimeZoneWithDefault = timestampWithTimeZone("timestampWithTimeZoneWithDefault").default(offsetDateTime)
}

// SQLite does not support ALTER TABLE on a column that has a default value
// MariaDB does not support TIMESTAMP WITH TIME ZONE column type
val unsupportedDatabases = listOf(TestDB.SQLITE, TestDB.MARIADB)
withDb(excludeSettings = unsupportedDatabases) {
if (!isOldMySql()) {
try {
SchemaUtils.drop(tester)
SchemaUtils.create(tester)
val statements = SchemaUtils.addMissingColumnsStatements(tester)
assertEquals(0, statements.size)
} finally {
SchemaUtils.drop(tester)
}
}
}
}
}
1 change: 1 addition & 0 deletions exposed-jodatime/api/exposed-jodatime.api
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public final class org/jetbrains/exposed/sql/jodatime/DateFunctionsKt {
public final class org/jetbrains/exposed/sql/jodatime/DateTimeWithTimeZoneColumnType : org/jetbrains/exposed/sql/ColumnType, org/jetbrains/exposed/sql/IDateColumnType {
public fun <init> ()V
public fun getHasTimePart ()Z
public fun nonNullValueAsDefaultString (Ljava/lang/Object;)Ljava/lang/String;
public fun nonNullValueToString (Ljava/lang/Object;)Ljava/lang/String;
public fun notNullValueToDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun readObject (Ljava/sql/ResultSet;I)Ljava/lang/Object;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ private val ORACLE_DATE_TIME_WITH_TIME_ZONE_FORMATTER by lazy {
DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSSSSS ZZ").withLocale(Locale.ROOT)
}

internal val MYSQL_DATE_TIME_WITH_TIME_ZONE_AS_DEFAULT_FORMATTER by lazy {
DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")
.withLocale(Locale.ROOT)
}

private val DEFAULT_DATE_TIME_WITH_TIME_ZONE_FORMATTER by lazy {
ISODateTimeFormat.dateTime().withLocale(Locale.ROOT)
}
Expand Down Expand Up @@ -216,6 +221,20 @@ class DateTimeWithTimeZoneColumnType : ColumnType(), IDateColumnType {
}
else -> error("Unexpected value: $value of ${value::class.qualifiedName}")
}

override fun nonNullValueAsDefaultString(value: Any): String = when (value) {
is DateTime -> {
when {
currentDialect is PostgreSQLDialect ->
"'${DEFAULT_DATE_TIME_STRING_FORMATTER.print(value).trimEnd('0')}+00'::timestamp with time zone"
(currentDialect as? H2Dialect)?.h2Mode == H2Dialect.H2CompatibilityMode.Oracle ->
"'${DEFAULT_DATE_TIME_STRING_FORMATTER.print(value).trimEnd('0')}'"
currentDialect is MysqlDialect -> "'${MYSQL_DATE_TIME_WITH_TIME_ZONE_AS_DEFAULT_FORMATTER.print(value)}'"
else -> super.nonNullValueAsDefaultString(value)
}
}
else -> super.nonNullValueAsDefaultString(value)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -468,4 +468,29 @@ class JodaTimeDefaultsTest : JodaTimeBaseTest() {
assertEquals(0, statements.size)
}
}

@Test
fun testTimestampWithTimeZoneDefaultDoesNotTriggerAlterStatement() {
val dateTime = DateTime.parse("2024-02-08T20:48:04.700").withZone(DateTimeZone.forID("Japan"))

val tester = object : Table("tester") {
val timestampWithTimeZoneWithDefault = timestampWithTimeZone("timestampWithTimeZoneWithDefault").default(dateTime)
}

// SQLite does not support ALTER TABLE on a column that has a default value
// MariaDB does not support TIMESTAMP WITH TIME ZONE column type
val unsupportedDatabases = listOf(TestDB.SQLITE, TestDB.MARIADB)
withDb(excludeSettings = unsupportedDatabases) {
if (!isOldMySql()) {
try {
SchemaUtils.drop(tester)
SchemaUtils.create(tester)
val statements = SchemaUtils.addMissingColumnsStatements(tester)
assertEquals(0, statements.size)
} finally {
SchemaUtils.drop(tester)
}
}
}
}
}
1 change: 1 addition & 0 deletions exposed-kotlin-datetime/api/exposed-kotlin-datetime.api
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinOffsetDateTim
public static final field Companion Lorg/jetbrains/exposed/sql/kotlin/datetime/KotlinOffsetDateTimeColumnType$Companion;
public fun <init> ()V
public fun getHasTimePart ()Z
public fun nonNullValueAsDefaultString (Ljava/lang/Object;)Ljava/lang/String;
public fun nonNullValueToString (Ljava/lang/Object;)Ljava/lang/String;
public fun notNullValueToDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun readObject (Ljava/sql/ResultSet;I)Ljava/lang/Object;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ internal val ORACLE_OFFSET_DATE_TIME_FORMATTER by lazy {
)
}

// Example result: 2023-07-07 14:42:29.343
internal val POSTGRESQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER by lazy {
DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss.SSS",
Locale.ROOT
)
}

internal val MYSQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER by lazy {
DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss.SSSSSS",
Locale.ROOT
).withZone(ZoneId.of("UTC"))
}

internal val DEFAULT_OFFSET_DATE_TIME_FORMATTER by lazy {
DateTimeFormatter.ISO_OFFSET_DATE_TIME.withLocale(Locale.ROOT)
}
Expand Down Expand Up @@ -415,6 +430,20 @@ class KotlinOffsetDateTimeColumnType : ColumnType(), IDateColumnType {
else -> error("Unexpected value: $value of ${value::class.qualifiedName}")
}

override fun nonNullValueAsDefaultString(value: Any): String = when (value) {
is OffsetDateTime -> {
when {
currentDialect is PostgreSQLDialect -> // +00 appended because PostgreSQL stores it in UTC time zone
"'${value.format(POSTGRESQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER).trimEnd('0').trimEnd('.')}+00'::timestamp with time zone"
currentDialect is H2Dialect && currentDialect.h2Mode == H2Dialect.H2CompatibilityMode.Oracle ->
"'${value.format(POSTGRESQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER).trimEnd('0').trimEnd('.')}'"
currentDialect is MysqlDialect -> "'${value.format(MYSQL_OFFSET_DATE_TIME_AS_DEFAULT_FORMATTER)}'"
else -> super.nonNullValueAsDefaultString(value)
}
}
else -> super.nonNullValueAsDefaultString(value)
}

companion object {
internal val INSTANCE = KotlinOffsetDateTimeColumnType()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -547,4 +547,29 @@ class DefaultsTest : DatabaseTestsBase() {
assertEquals(0, statements.size)
}
}

@Test
fun testTimestampWithTimeZoneDefaultDoesNotTriggerAlterStatement() {
val offsetDateTime = OffsetDateTime.parse("2023-05-04T05:04:01.700+09:00")

val tester = object : Table("tester") {
val timestampWithTimeZoneWithDefault = timestampWithTimeZone("timestampWithTimeZoneWithDefault").default(offsetDateTime)
}

// SQLite does not support ALTER TABLE on a column that has a default value
// MariaDB does not support TIMESTAMP WITH TIME ZONE column type
val unsupportedDatabases = listOf(TestDB.SQLITE, TestDB.MARIADB)
withDb(excludeSettings = unsupportedDatabases) {
if (!isOldMySql()) {
try {
SchemaUtils.drop(tester)
SchemaUtils.create(tester)
val statements = SchemaUtils.addMissingColumnsStatements(tester)
assertEquals(0, statements.size)
} finally {
SchemaUtils.drop(tester)
}
}
}
}
}

0 comments on commit 65fe85b

Please sign in to comment.