From cd7dbadf9d009d25ebbce1e91af985743c80854f Mon Sep 17 00:00:00 2001 From: Jocelyne Date: Fri, 7 Jul 2023 20:00:40 +0100 Subject: [PATCH] feat: EXPOSED-43 Add support for timestamp with time zone --- exposed-core/api/exposed-core.api | 2 + .../jetbrains/exposed/sql/vendors/Default.kt | 3 + .../org/jetbrains/exposed/sql/vendors/H2.kt | 2 + .../jetbrains/exposed/sql/vendors/Mysql.kt | 14 +++ .../exposed/sql/vendors/SQLServerDialect.kt | 6 + .../exposed/sql/vendors/SQLiteDialect.kt | 1 + exposed-java-time/api/exposed-java-time.api | 19 +++ .../sql/javatime/JavaDateColumnType.kt | 85 +++++++++++++- .../exposed/sql/javatime/JavaDateFunctions.kt | 13 +++ .../org/jetbrains/exposed/DefaultsTest.kt | 55 +++++++++ .../org/jetbrains/exposed/JavaTimeTests.kt | 108 +++++++++++++++++- .../exposed/sql/jodatime/DateColumnType.kt | 82 +++++++++++++ .../exposed/sql/jodatime/DateFunctions.kt | 8 ++ .../jetbrains/exposed/JodaTimeDefaultsTest.kt | 55 +++++++++ .../org/jetbrains/exposed/JodaTimeTests.kt | 96 ++++++++++++++++ .../api/exposed-kotlin-datetime.api | 19 +++ .../kotlin/datetime/KotlinDateColumnType.kt | 86 +++++++++++++- .../kotlin/datetime/KotlinDateFunctions.kt | 15 +++ .../sql/kotlin/datetime/DefaultsTest.kt | 58 ++++++++++ .../sql/kotlin/datetime/KotlinTimeTests.kt | 105 ++++++++++++++++- 20 files changed, 824 insertions(+), 8 deletions(-) diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index c957c1399e..aaf79d7b5a 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -3191,6 +3191,7 @@ public abstract class org/jetbrains/exposed/sql/vendors/DataTypeProvider { public fun shortType ()Ljava/lang/String; public fun textType ()Ljava/lang/String; public fun timeType ()Ljava/lang/String; + public fun timestampWithTimeZoneType ()Ljava/lang/String; public fun ubyteType ()Ljava/lang/String; public fun uintegerType ()Ljava/lang/String; public fun ulongType ()Ljava/lang/String; @@ -3497,6 +3498,7 @@ public class org/jetbrains/exposed/sql/vendors/MysqlDialect : org/jetbrains/expo public fun getSupportsTernaryAffectedRowValues ()Z public fun isAllowedAsColumnDefault (Lorg/jetbrains/exposed/sql/Expression;)Z public final fun isFractionDateTimeSupported ()Z + public final fun isTimeZoneOffsetSupported ()Z public fun setSchema (Lorg/jetbrains/exposed/sql/Schema;)Ljava/lang/String; } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt index dd272ade4e..41039f60b9 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt @@ -91,6 +91,9 @@ abstract class DataTypeProvider { /** Data type for storing both date and time without a time zone. */ open fun dateTimeType(): String = "DATETIME" + /** Data type for storing both date and time with a time zone. */ + open fun timestampWithTimeZoneType(): String = "TIMESTAMP WITH TIME ZONE" + /** Time type for storing time without a time zone. */ open fun timeType(): String = "TIME" diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt index 6e959d4b60..b26c613c3e 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt @@ -14,6 +14,8 @@ internal object H2DataTypeProvider : DataTypeProvider() { override fun uuidType(): String = "UUID" override fun dateTimeType(): String = "DATETIME(9)" + override fun timestampWithTimeZoneType(): String = "TIMESTAMP(9) WITH TIME ZONE" + override fun jsonBType(): String = "JSON" override fun hexToDb(hexString: String): String = "X'$hexString'" diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Mysql.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Mysql.kt index 5a1f6dd6f5..46fa2909a3 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Mysql.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Mysql.kt @@ -15,6 +15,17 @@ internal object MysqlDataTypeProvider : DataTypeProvider() { override fun dateTimeType(): String = if ((currentDialect as? MysqlDialect)?.isFractionDateTimeSupported() == true) "DATETIME(6)" else "DATETIME" + override fun timestampWithTimeZoneType(): String = + if ((currentDialect as? MysqlDialect)?.isTimeZoneOffsetSupported() == true) { + "TIMESTAMP(6)" + } else { + throw UnsupportedByDialectException( + "This vendor does not support timestamp with time zone data type" + + ((currentDialect as? MariaDBDialect)?.let { "" } ?: " for this version"), + currentDialect + ) + } + override fun ubyteType(): String = "TINYINT UNSIGNED" override fun ushortType(): String = "SMALLINT UNSIGNED" @@ -262,6 +273,9 @@ open class MysqlDialect : VendorDialect(dialectName, MysqlDataTypeProvider, Mysq fun isFractionDateTimeSupported(): Boolean = TransactionManager.current().db.isVersionCovers(BigDecimal("5.6")) + // Available from MySQL 8.0.19 + fun isTimeZoneOffsetSupported(): Boolean = (currentDialect !is MariaDBDialect) && isMysql8 + override fun isAllowedAsColumnDefault(e: Expression<*>): Boolean { if (super.isAllowedAsColumnDefault(e)) return true val acceptableDefaults = arrayOf("CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()", "NOW()", "CURRENT_TIMESTAMP(6)", "NOW(6)") diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt index 067edf043c..fe4fdaf94c 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt @@ -17,6 +17,12 @@ internal object SQLServerDataTypeProvider : DataTypeProvider() { override fun uuidType(): String = "uniqueidentifier" override fun uuidToDB(value: UUID): Any = value.toString() override fun dateTimeType(): String = "DATETIME2" + override fun timestampWithTimeZoneType(): String = + if ((currentDialect as? H2Dialect)?.h2Mode == H2Dialect.H2CompatibilityMode.SQLServer) { + "TIMESTAMP(9) WITH TIME ZONE" + } else { + "DATETIMEOFFSET" + } override fun booleanType(): String = "BIT" override fun booleanToStatementString(bool: Boolean): String = if (bool) "1" else "0" diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt index 5193dd0391..cc6f9926d0 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt @@ -15,6 +15,7 @@ internal object SQLiteDataTypeProvider : DataTypeProvider() { override fun floatType(): String = "SINGLE" override fun binaryType(): String = "BLOB" override fun dateTimeType(): String = "TEXT" + override fun timestampWithTimeZoneType(): String = "TEXT" override fun dateType(): String = "TEXT" override fun booleanToStatementString(bool: Boolean) = if (bool) "1" else "0" override fun jsonType(): String = "TEXT" diff --git a/exposed-java-time/api/exposed-java-time.api b/exposed-java-time/api/exposed-java-time.api index 3e6f0f1101..8f8a328c27 100644 --- a/exposed-java-time/api/exposed-java-time.api +++ b/exposed-java-time/api/exposed-java-time.api @@ -38,6 +38,7 @@ public final class org/jetbrains/exposed/sql/javatime/JavaDateColumnTypeKt { public static final fun duration (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; public static final fun time (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; public static final fun timestamp (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; + public static final fun timestampWithTimeZone (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; } public final class org/jetbrains/exposed/sql/javatime/JavaDateFunctionsKt { @@ -46,6 +47,7 @@ public final class org/jetbrains/exposed/sql/javatime/JavaDateFunctionsKt { public static final fun CustomDurationFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; public static final fun CustomTimeFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; public static final fun CustomTimeStampFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; + public static final fun CustomTimestampWithTimeZoneFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; public static final fun date (Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/javatime/Date; public static final fun dateLiteral (Ljava/time/LocalDate;)Lorg/jetbrains/exposed/sql/LiteralOp; public static final fun dateParam (Ljava/time/LocalDate;)Lorg/jetbrains/exposed/sql/Expression; @@ -62,6 +64,8 @@ public final class org/jetbrains/exposed/sql/javatime/JavaDateFunctionsKt { public static final fun timeParam (Ljava/time/LocalTime;)Lorg/jetbrains/exposed/sql/Expression; public static final fun timestampLiteral (Ljava/time/Instant;)Lorg/jetbrains/exposed/sql/LiteralOp; public static final fun timestampParam (Ljava/time/Instant;)Lorg/jetbrains/exposed/sql/Expression; + public static final fun timestampWithTimeZoneLiteral (Ljava/time/OffsetDateTime;)Lorg/jetbrains/exposed/sql/LiteralOp; + public static final fun timestampWithTimeZoneParam (Ljava/time/OffsetDateTime;)Lorg/jetbrains/exposed/sql/Expression; public static final fun year (Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/javatime/Year; } @@ -134,6 +138,21 @@ public final class org/jetbrains/exposed/sql/javatime/JavaLocalTimeColumnType : public final class org/jetbrains/exposed/sql/javatime/JavaLocalTimeColumnType$Companion { } +public final class org/jetbrains/exposed/sql/javatime/JavaOffsetDateTimeColumnType : org/jetbrains/exposed/sql/ColumnType, org/jetbrains/exposed/sql/IDateColumnType { + public static final field Companion Lorg/jetbrains/exposed/sql/javatime/JavaOffsetDateTimeColumnType$Companion; + public fun ()V + public fun getHasTimePart ()Z + 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; + public fun sqlType ()Ljava/lang/String; + public synthetic fun valueFromDB (Ljava/lang/Object;)Ljava/lang/Object; + public fun valueFromDB (Ljava/lang/Object;)Ljava/time/OffsetDateTime; +} + +public final class org/jetbrains/exposed/sql/javatime/JavaOffsetDateTimeColumnType$Companion { +} + public final class org/jetbrains/exposed/sql/javatime/Minute : org/jetbrains/exposed/sql/Function { public fun (Lorg/jetbrains/exposed/sql/Expression;)V public final fun getExpr ()Lorg/jetbrains/exposed/sql/Expression; diff --git a/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateColumnType.kt b/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateColumnType.kt index 241c52ef79..f1346ed9df 100644 --- a/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateColumnType.kt +++ b/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateColumnType.kt @@ -5,6 +5,7 @@ import org.jetbrains.exposed.sql.ColumnType import org.jetbrains.exposed.sql.IDateColumnType import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.vendors.H2Dialect +import org.jetbrains.exposed.sql.vendors.MysqlDialect import org.jetbrains.exposed.sql.vendors.OracleDialect import org.jetbrains.exposed.sql.vendors.SQLiteDialect import org.jetbrains.exposed.sql.vendors.currentDialect @@ -38,6 +39,26 @@ internal val DEFAULT_TIME_STRING_FORMATTER by lazy { DateTimeFormatter.ISO_LOCAL_TIME.withLocale(Locale.ROOT).withZone(ZoneId.systemDefault()) } +// Example result: 2023-07-07 14:42:29.343+02:00 or 2023-07-07 12:42:29.343Z +internal val SQLITE_OFFSET_DATE_TIME_FORMATTER by lazy { + DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss.SSS[XXX]", + Locale.ROOT + ) +} + +// For UTC time zone, MySQL rejects the 'Z' and will only accept the offset '+00:00' +internal val MYSQL_OFFSET_DATE_TIME_FORMATTER by lazy { + DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss.SSSSSS[xxx]", + Locale.ROOT + ) +} + +internal val DEFAULT_OFFSET_DATE_TIME_FORMATTER by lazy { + DateTimeFormatter.ISO_OFFSET_DATE_TIME.withLocale(Locale.ROOT) +} + internal fun formatterForDateString(date: String) = dateTimeWithFractionFormat(date.substringAfterLast('.', "").length) internal fun dateTimeWithFractionFormat(fraction: Int): DateTimeFormatter { val baseFormat = "yyyy-MM-d HH:mm:ss" @@ -241,6 +262,55 @@ class JavaInstantColumnType : ColumnType(), IDateColumnType { } } +class JavaOffsetDateTimeColumnType : ColumnType(), IDateColumnType { + override val hasTimePart: Boolean = true + + override fun sqlType(): String = currentDialect.dataTypeProvider.timestampWithTimeZoneType() + + override fun nonNullValueToString(value: Any): String = when (value) { + is OffsetDateTime -> { + when (currentDialect) { + is SQLiteDialect -> "'${value.format(SQLITE_OFFSET_DATE_TIME_FORMATTER)}'" + is MysqlDialect -> "'${value.format(MYSQL_OFFSET_DATE_TIME_FORMATTER)}'" + else -> "'${value.format(DEFAULT_OFFSET_DATE_TIME_FORMATTER)}'" + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + override fun valueFromDB(value: Any): OffsetDateTime = when (value) { + is OffsetDateTime -> value + is String -> { + if (currentDialect is SQLiteDialect) { + OffsetDateTime.parse(value, SQLITE_OFFSET_DATE_TIME_FORMATTER) + } else { + OffsetDateTime.parse(value) + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + override fun readObject(rs: ResultSet, index: Int): Any? = when (currentDialect) { + is SQLiteDialect -> super.readObject(rs, index) + else -> rs.getObject(index, OffsetDateTime::class.java) + } + + override fun notNullValueToDB(value: Any): Any = when (value) { + is OffsetDateTime -> { + when (currentDialect) { + is SQLiteDialect -> value.format(SQLITE_OFFSET_DATE_TIME_FORMATTER) + is MysqlDialect -> value.format(MYSQL_OFFSET_DATE_TIME_FORMATTER) + else -> value + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + companion object { + internal val INSTANCE = JavaOffsetDateTimeColumnType() + } +} + class JavaDurationColumnType : ColumnType() { override fun sqlType(): String = currentDialect.dataTypeProvider.longType() @@ -288,7 +358,7 @@ class JavaDurationColumnType : ColumnType() { fun Table.date(name: String): Column = registerColumn(name, JavaLocalDateColumnType()) /** - * A datetime column to store both a date and a time. + * A datetime column to store both a date and a time without time zone. * * @param name The column name */ @@ -305,12 +375,23 @@ fun Table.datetime(name: String): Column = registerColumn(name, J fun Table.time(name: String): Column = registerColumn(name, JavaLocalTimeColumnType()) /** - * A timestamp column to store both a date and a time. + * A timestamp column to store both a date and a time without time zone. * * @param name The column name */ fun Table.timestamp(name: String): Column = registerColumn(name, JavaInstantColumnType()) +/** + * A timestamp column to store both a date and a time with time zone. + * + * Note: PostgreSQL and MySQL always store the timestamp in UTC, thereby losing the original time zone. To preserve the + * original time zone, store the time zone information in a separate column. + * + * @param name The column name + */ +fun Table.timestampWithTimeZone(name: String): Column = + registerColumn(name, JavaOffsetDateTimeColumnType()) + /** * A date column to store a duration. * diff --git a/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt b/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt index bdbf099a8e..bc89279ade 100644 --- a/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt +++ b/exposed-java-time/src/main/kotlin/org/jetbrains/exposed/sql/javatime/JavaDateFunctions.kt @@ -12,6 +12,7 @@ import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.time.OffsetDateTime import java.time.temporal.Temporal class Date(val expr: Expression) : Function(JavaLocalDateColumnType.INSTANCE) { @@ -138,6 +139,10 @@ fun dateTimeParam(value: LocalDateTime): Expression = QueryParameter(value, JavaLocalDateTimeColumnType.INSTANCE) fun timestampParam(value: Instant): Expression = QueryParameter(value, JavaInstantColumnType.INSTANCE) + +fun timestampWithTimeZoneParam(value: OffsetDateTime): Expression = + QueryParameter(value, JavaOffsetDateTimeColumnType.INSTANCE) + fun durationParam(value: Duration): Expression = QueryParameter(value, JavaDurationColumnType.INSTANCE) fun dateLiteral(value: LocalDate): LiteralOp = LiteralOp(JavaLocalDateColumnType.INSTANCE, value) @@ -145,6 +150,8 @@ fun timeLiteral(value: LocalTime): LiteralOp = LiteralOp(JavaLocalTim fun dateTimeLiteral(value: LocalDateTime): LiteralOp = LiteralOp(JavaLocalDateTimeColumnType.INSTANCE, value) fun timestampLiteral(value: Instant): LiteralOp = LiteralOp(JavaInstantColumnType.INSTANCE, value) +fun timestampWithTimeZoneLiteral(value: OffsetDateTime): LiteralOp = + LiteralOp(JavaOffsetDateTimeColumnType.INSTANCE, value) fun durationLiteral(value: Duration): LiteralOp = LiteralOp(JavaDurationColumnType.INSTANCE, value) @Suppress("FunctionName") @@ -163,6 +170,12 @@ fun CustomDateTimeFunction(functionName: String, vararg params: Expression<*>): fun CustomTimeStampFunction(functionName: String, vararg params: Expression<*>): CustomFunction = CustomFunction(functionName, JavaInstantColumnType.INSTANCE, *params) +@Suppress("FunctionName") +fun CustomTimestampWithTimeZoneFunction( + functionName: String, + vararg params: Expression<*> +): CustomFunction = CustomFunction(functionName, JavaOffsetDateTimeColumnType.INSTANCE, *params) + @Suppress("FunctionName") fun CustomDurationFunction(functionName: String, vararg params: Expression<*>): CustomFunction = CustomFunction(functionName, JavaDurationColumnType.INSTANCE, *params) diff --git a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt index 4021bd91ec..fe67ba6533 100644 --- a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt +++ b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/DefaultsTest.kt @@ -287,6 +287,61 @@ class DefaultsTest : DatabaseTestsBase() { } } + @Test + fun testTimestampWithTimeZoneDefaults() { + // UTC time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(ZoneOffset.UTC)) + assertEquals("UTC", ZoneId.systemDefault().id) + + val nowWithTimeZone = OffsetDateTime.now() + val timestampWithTimeZoneLiteral = timestampWithTimeZoneLiteral(nowWithTimeZone) + + val testTable = object : IntIdTable("t") { + val t1 = timestampWithTimeZone("t1").default(nowWithTimeZone) + val t2 = timestampWithTimeZone("t2").defaultExpression(timestampWithTimeZoneLiteral) + } + + fun Expression<*>.itOrNull() = when { + currentDialectTest.isAllowedAsColumnDefault(this) -> + "DEFAULT ${currentDialectTest.dataTypeProvider.processForDefaultValue(this)} NOT NULL" + else -> "NULL" + } + + withDb(excludeSettings = listOf(TestDB.SQLITE, TestDB.MARIADB)) { + if (!isOldMySql()) { + SchemaUtils.create(testTable) + + val timestampWithTimeZoneType = currentDialectTest.dataTypeProvider.timestampWithTimeZoneType() + + val baseExpression = "CREATE TABLE " + addIfNotExistsIfSupported() + + "${"t".inProperCase()} (" + + "${"id".inProperCase()} ${currentDialectTest.dataTypeProvider.integerAutoincType()} PRIMARY KEY, " + + "${"t1".inProperCase()} $timestampWithTimeZoneType ${timestampWithTimeZoneLiteral.itOrNull()}, " + + "${"t2".inProperCase()} $timestampWithTimeZoneType ${timestampWithTimeZoneLiteral.itOrNull()}" + + ")" + + val expected = if (currentDialectTest is OracleDialect || + currentDialectTest.h2Mode == H2Dialect.H2CompatibilityMode.Oracle + ) { + arrayListOf( + "CREATE SEQUENCE t_id_seq START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775807", + baseExpression + ) + } else { + arrayListOf(baseExpression) + } + + assertEqualLists(expected, testTable.ddl) + + val id1 = testTable.insertAndGetId { } + + val row1 = testTable.select { testTable.id eq id1 }.single() + assertEqualDateTime(nowWithTimeZone, row1[testTable.t1]) + assertEqualDateTime(nowWithTimeZone, row1[testTable.t2]) + } + } + } + @Test fun testDefaultExpressions01() { fun abs(value: Int) = object : ExpressionWithColumnType() { diff --git a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt index b8865a28fe..20995309fe 100644 --- a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt +++ b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/JavaTimeTests.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.between import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq @@ -19,6 +20,7 @@ import org.jetbrains.exposed.sql.tests.TestDB import org.jetbrains.exposed.sql.tests.currentDialectTest import org.jetbrains.exposed.sql.tests.shared.assertEquals import org.jetbrains.exposed.sql.tests.shared.assertTrue +import org.jetbrains.exposed.sql.tests.shared.expectException import org.jetbrains.exposed.sql.vendors.* import org.junit.Assert.fail import org.junit.Test @@ -28,6 +30,8 @@ import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneId import java.time.ZoneOffset import java.time.temporal.Temporal import kotlin.test.assertEquals @@ -306,6 +310,100 @@ open class JavaTimeBaseTest : DatabaseTestsBase() { assertEquals(2, modifiedBeforeCreation[tester.modified].userId) } } + + @Test + fun testTimestampWithTimeZone() { + val testTable = object : IntIdTable("TestTable") { + val timestampWithTimeZone = timestampWithTimeZone("timestamptz-column") + } + + withDb(excludeSettings = listOf(TestDB.MARIADB)) { testDB -> + if (!isOldMySql()) { + SchemaUtils.create(testTable) + + // Cairo time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Africa/Cairo")) + assertEquals("Africa/Cairo", ZoneId.systemDefault().id) + + val cairoNow = OffsetDateTime.now(ZoneId.systemDefault()) + + val cairoId = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInCairoTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + // UTC time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(ZoneOffset.UTC)) + assertEquals("UTC", ZoneId.systemDefault().id) + + val cairoNowRetrievedInUTCTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + val utcID = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInUTCTimeZone = testTable.select { testTable.id eq utcID } + .single()[testTable.timestampWithTimeZone] + + // Tokyo time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Asia/Tokyo")) + assertEquals("Asia/Tokyo", ZoneId.systemDefault().id) + + val cairoNowRetrievedInTokyoTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + val tokyoID = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInTokyoTimeZone = testTable.select { testTable.id eq tokyoID } + .single()[testTable.timestampWithTimeZone] + + // PostgreSQL and MySQL always store the timestamp in UTC, thereby losing the original time zone. + // To preserve the original time zone, store the time zone information in a separate column. + val isOriginalTimeZonePreserved = testDB !in listOf( + TestDB.POSTGRESQL, + TestDB.POSTGRESQLNG, + TestDB.MYSQL + ) + if (isOriginalTimeZonePreserved) { + // Assert that time zone is preserved when the same value is inserted in different time zones + assertEqualDateTime(cairoNow, cairoNowInsertedInCairoTimeZone) + assertEqualDateTime(cairoNow, cairoNowInsertedInUTCTimeZone) + assertEqualDateTime(cairoNow, cairoNowInsertedInTokyoTimeZone) + + // Assert that time zone is preserved when the same record is retrieved in different time zones + assertEqualDateTime(cairoNow, cairoNowRetrievedInUTCTimeZone) + assertEqualDateTime(cairoNow, cairoNowRetrievedInTokyoTimeZone) + } else { + // Assert equivalence in UTC when the same value is inserted in different time zones + assertEqualDateTime(cairoNowInsertedInCairoTimeZone, cairoNowInsertedInUTCTimeZone) + assertEqualDateTime(cairoNowInsertedInUTCTimeZone, cairoNowInsertedInTokyoTimeZone) + + // Assert equivalence in UTC when the same record is retrieved in different time zones + assertEqualDateTime(cairoNowRetrievedInUTCTimeZone, cairoNowRetrievedInTokyoTimeZone) + } + } + } + } + + @Test + fun testTimestampWithTimeZoneThrowsExceptionForUnsupportedDialects() { + val testTable = object : IntIdTable("TestTable") { + val timestampWithTimeZone = timestampWithTimeZone("timestamptz-column") + } + + withDb(db = listOf(TestDB.MYSQL, TestDB.MARIADB)) { testDB -> + if (testDB == TestDB.MARIADB || isOldMySql()) { + expectException { + SchemaUtils.create(testTable) + } + } + } + } } fun assertEqualDateTime(d1: T?, d2: T?) { @@ -320,13 +418,21 @@ fun assertEqualDateTime(d1: T?, d2: T?) { } } d1 is LocalDateTime && d2 is LocalDateTime -> { - assertEquals(d1.toEpochSecond(ZoneOffset.UTC), d2.toEpochSecond(ZoneOffset.UTC), "Failed on epoch seconds ${currentDialectTest.name}") + assertEquals( + d1.toEpochSecond(ZoneOffset.UTC), + d2.toEpochSecond(ZoneOffset.UTC), + "Failed on epoch seconds ${currentDialectTest.name}" + ) assertEqualFractionalPart(d1.nano, d2.nano) } d1 is Instant && d2 is Instant -> { assertEquals(d1.epochSecond, d2.epochSecond, "Failed on epoch seconds ${currentDialectTest.name}") assertEqualFractionalPart(d1.nano, d2.nano) } + d1 is OffsetDateTime && d2 is OffsetDateTime -> { + assertEqualDateTime(d1.toLocalDateTime(), d2.toLocalDateTime()) + assertEquals(d1.offset, d2.offset) + } else -> assertEquals(d1, d2, "Failed on ${currentDialectTest.name}") } } diff --git a/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateColumnType.kt b/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateColumnType.kt index 73a65a36cf..27a83638dc 100644 --- a/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateColumnType.kt +++ b/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateColumnType.kt @@ -21,6 +21,18 @@ private val DEFAULT_DATE_TIME_STRING_FORMATTER = DateTimeFormat.forPattern("YYYY private val SQLITE_AND_ORACLE_DATE_TIME_STRING_FORMATTER = DateTimeFormat.forPattern("YYYY-MM-dd HH:mm:ss.SSS") private val SQLITE_DATE_STRING_FORMATTER = ISODateTimeFormat.yearMonthDay() +private val SQLITE_DATE_TIME_WITH_TIME_ZONE_FORMATTER by lazy { + DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSSZZ").withLocale(Locale.ROOT) +} + +private val MYSQL_DATE_TIME_WITH_TIME_ZONE_FORMATTER by lazy { + DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSSSSSZZ").withLocale(Locale.ROOT) +} + +private val DEFAULT_DATE_TIME_WITH_TIME_ZONE_FORMATTER by lazy { + ISODateTimeFormat.dateTime().withLocale(Locale.ROOT) +} + private fun formatterForDateTimeString(date: String) = dateTimeWithFractionFormat(date.substringAfterLast('.', "").length) private fun dateTimeWithFractionFormat(fraction: Int): DateTimeFormatter { val baseFormat = "YYYY-MM-dd HH:mm:ss" @@ -129,6 +141,66 @@ class DateColumnType(val time: Boolean) : ColumnType(), IDateColumnType { } } +class DateTimeWithTimeZoneColumnType : ColumnType(), IDateColumnType { + override val hasTimePart: Boolean = true + + override fun sqlType(): String = currentDialect.dataTypeProvider.timestampWithTimeZoneType() + + override fun nonNullValueToString(value: Any): String = when (value) { + is DateTime -> { + when (currentDialect) { + is SQLiteDialect -> "'${SQLITE_DATE_TIME_WITH_TIME_ZONE_FORMATTER.print(value)}'" + is MysqlDialect -> "'${MYSQL_DATE_TIME_WITH_TIME_ZONE_FORMATTER.print(value)}'" + else -> "'${DEFAULT_DATE_TIME_WITH_TIME_ZONE_FORMATTER.print(value)}'" + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + override fun valueFromDB(value: Any): DateTime = when { + value.javaClass == offsetDateTimeClass -> DateTime.parse(value.toString()) + value is String -> { + if (currentDialect is SQLiteDialect) { + DateTime.parse(value, SQLITE_DATE_TIME_WITH_TIME_ZONE_FORMATTER) + } else { + DateTime.parse(value) + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + override fun readObject(rs: ResultSet, index: Int): Any? = when (currentDialect) { + is SQLiteDialect -> super.readObject(rs, index) + else -> { + if (offsetDateTimeClass != null) { + rs.getObject(index, offsetDateTimeClass) + } else { + super.readObject(rs, index) + } + } + } + + override fun notNullValueToDB(value: Any): Any = when (value) { + is DateTime -> { + when (currentDialect) { + is SQLiteDialect -> SQLITE_DATE_TIME_WITH_TIME_ZONE_FORMATTER.print(value) + is MysqlDialect -> MYSQL_DATE_TIME_WITH_TIME_ZONE_FORMATTER.print(value) + else -> java.sql.Timestamp(value.millis) + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + companion object { + // https://www.baeldung.com/java-check-class-exists + private val offsetDateTimeClass = try { + Class.forName("java.time.OffsetDateTime", false, this::class.java.classLoader) + } catch (_: ClassNotFoundException) { + null + } + } +} + /** * A date column to store a date. * @@ -142,3 +214,13 @@ fun Table.date(name: String): Column = registerColumn(name, DateColumn * @param name The column name */ fun Table.datetime(name: String): Column = registerColumn(name, DateColumnType(true)) + +/** + * A timestamp column to store both a date and a time with time zone. + * + * Note: PostgreSQL and MySQL always store the timestamp in UTC, thereby losing the original time zone. To preserve the + * original time zone, store the time zone information in a separate column. + * + * @param name The column name + */ +fun Table.timestampWithTimeZone(name: String): Column = registerColumn(name, DateTimeWithTimeZoneColumnType()) diff --git a/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt b/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt index 49e3eb43b3..bbc9393d49 100644 --- a/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt +++ b/exposed-jodatime/src/main/kotlin/org/jetbrains/exposed/sql/jodatime/DateFunctions.kt @@ -116,9 +116,13 @@ fun Expression.second() = Second(this) fun dateParam(value: DateTime): Expression = QueryParameter(value, DateColumnType(false)) fun dateTimeParam(value: DateTime): Expression = QueryParameter(value, DateColumnType(true)) +fun timestampWithTimeZoneParam(value: DateTime): Expression = + QueryParameter(value, DateTimeWithTimeZoneColumnType()) fun dateLiteral(value: DateTime): LiteralOp = LiteralOp(DateColumnType(false), value) fun dateTimeLiteral(value: DateTime): LiteralOp = LiteralOp(DateColumnType(true), value) +fun timestampWithTimeZoneLiteral(value: DateTime): LiteralOp = + LiteralOp(DateTimeWithTimeZoneColumnType(), value) @Suppress("FunctionNaming") fun CustomDateTimeFunction(functionName: String, vararg params: Expression<*>) = @@ -127,3 +131,7 @@ fun CustomDateTimeFunction(functionName: String, vararg params: Expression<*>) = @Suppress("FunctionNaming") fun CustomDateFunction(functionName: String, vararg params: Expression<*>) = CustomFunction(functionName, DateColumnType(false), *params) + +@Suppress("FunctionNaming") +fun CustomTimestampWithTimeZoneFunction(functionName: String, vararg params: Expression<*>) = + CustomFunction(functionName, DateTimeWithTimeZoneColumnType(), *params) diff --git a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt index bc0df2b22b..2d0b24ccfb 100644 --- a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt +++ b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeDefaultsTest.kt @@ -212,6 +212,61 @@ class JodaTimeDefaultsTest : JodaTimeBaseTest() { } } + @Test + fun testTimestampWithTimeZoneDefaults() { + // UTC time zone + DateTimeZone.setDefault(DateTimeZone.UTC) + assertEquals("UTC", DateTimeZone.getDefault().id) + + val nowWithTimeZone = DateTime.now() + val timestampWithTimeZoneLiteral = timestampWithTimeZoneLiteral(nowWithTimeZone) + + val testTable = object : IntIdTable("t") { + val t1 = timestampWithTimeZone("t1").default(nowWithTimeZone) + val t2 = timestampWithTimeZone("t2").defaultExpression(timestampWithTimeZoneLiteral) + } + + fun Expression<*>.itOrNull() = when { + currentDialectTest.isAllowedAsColumnDefault(this) -> + "DEFAULT ${currentDialectTest.dataTypeProvider.processForDefaultValue(this)} NOT NULL" + else -> "NULL" + } + + withDb(excludeSettings = listOf(TestDB.SQLITE, TestDB.MARIADB)) { + if (! isOldMySql()) { + SchemaUtils.create(testTable) + + val timestampWithTimeZoneType = currentDialectTest.dataTypeProvider.timestampWithTimeZoneType() + + val baseExpression = "CREATE TABLE " + addIfNotExistsIfSupported() + + "${"t".inProperCase()} (" + + "${"id".inProperCase()} ${currentDialectTest.dataTypeProvider.integerAutoincType()} PRIMARY KEY, " + + "${"t1".inProperCase()} $timestampWithTimeZoneType ${timestampWithTimeZoneLiteral.itOrNull()}, " + + "${"t2".inProperCase()} $timestampWithTimeZoneType ${timestampWithTimeZoneLiteral.itOrNull()}" + + ")" + + val expected = if (currentDialectTest is OracleDialect || + currentDialectTest.h2Mode == H2Dialect.H2CompatibilityMode.Oracle + ) { + arrayListOf( + "CREATE SEQUENCE t_id_seq START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775807", + baseExpression + ) + } else { + arrayListOf(baseExpression) + } + + assertEqualLists(expected, testTable.ddl) + + val id1 = testTable.insertAndGetId { } + + val row1 = testTable.select { testTable.id eq id1 }.single() + assertEqualDateTime(nowWithTimeZone, row1[testTable.t1]) + assertEqualDateTime(nowWithTimeZone, row1[testTable.t2]) + } + } + } + @Test fun testDefaultExpressions01() { diff --git a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeTests.kt b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeTests.kt index 1a4a4d6ce8..3ec0ba3d3d 100644 --- a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeTests.kt +++ b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/JodaTimeTests.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.between import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq @@ -18,6 +19,7 @@ import org.jetbrains.exposed.sql.tests.TestDB import org.jetbrains.exposed.sql.tests.currentDialectTest import org.jetbrains.exposed.sql.tests.shared.assertEquals import org.jetbrains.exposed.sql.tests.shared.assertTrue +import org.jetbrains.exposed.sql.tests.shared.expectException import org.jetbrains.exposed.sql.vendors.* import org.joda.time.DateTime import org.joda.time.DateTimeZone @@ -221,6 +223,100 @@ open class JodaTimeBaseTest : DatabaseTestsBase() { assertEquals(2, modifiedBeforeCreation[tester.modified].userId) } } + + @Test + fun testTimestampWithTimeZone() { + val testTable = object : IntIdTable("TestTable") { + val timestampWithTimeZone = timestampWithTimeZone("timestamptz-column") + } + + withDb(excludeSettings = listOf(TestDB.MARIADB)) { testDB -> + if (!isOldMySql()) { + SchemaUtils.create(testTable) + + // Cairo time zone + DateTimeZone.setDefault(DateTimeZone.forID("Africa/Cairo")) + assertEquals("Africa/Cairo", DateTimeZone.getDefault().id) + + val cairoNow = DateTime.now(DateTimeZone.getDefault()) + + val cairoId = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInCairoTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + // UTC time zone + DateTimeZone.setDefault(DateTimeZone.UTC) + assertEquals("UTC", DateTimeZone.getDefault().id) + + val cairoNowRetrievedInUTCTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + val utcID = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInUTCTimeZone = testTable.select { testTable.id eq utcID } + .single()[testTable.timestampWithTimeZone] + + // Tokyo time zone + DateTimeZone.setDefault(DateTimeZone.forID("Asia/Tokyo")) + assertEquals("Asia/Tokyo", DateTimeZone.getDefault().id) + + val cairoNowRetrievedInTokyoTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + val tokyoID = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInTokyoTimeZone = testTable.select { testTable.id eq tokyoID } + .single()[testTable.timestampWithTimeZone] + + // PostgreSQL and MySQL always store the timestamp in UTC, thereby losing the original time zone. + // To preserve the original time zone, store the time zone information in a separate column. + val isOriginalTimeZonePreserved = testDB !in listOf( + TestDB.POSTGRESQL, + TestDB.POSTGRESQLNG, + TestDB.MYSQL + ) + if (isOriginalTimeZonePreserved) { + // Assert that time zone is preserved when the same value is inserted in different time zones + assertEqualDateTime(cairoNow, cairoNowInsertedInCairoTimeZone) + assertEqualDateTime(cairoNow, cairoNowInsertedInUTCTimeZone) + assertEqualDateTime(cairoNow, cairoNowInsertedInTokyoTimeZone) + + // Assert that time zone is preserved when the same record is retrieved in different time zones + assertEqualDateTime(cairoNow, cairoNowRetrievedInUTCTimeZone) + assertEqualDateTime(cairoNow, cairoNowRetrievedInTokyoTimeZone) + } else { + // Assert equivalence in UTC when the same value is inserted in different time zones + assertEqualDateTime(cairoNowInsertedInCairoTimeZone, cairoNowInsertedInUTCTimeZone) + assertEqualDateTime(cairoNowInsertedInUTCTimeZone, cairoNowInsertedInTokyoTimeZone) + + // Assert equivalence in UTC when the same record is retrieved in different time zones + assertEqualDateTime(cairoNowRetrievedInUTCTimeZone, cairoNowRetrievedInTokyoTimeZone) + } + } + } + } + + @Test + fun testTimestampWithTimeZoneThrowsExceptionForUnsupportedDialects() { + val testTable = object : IntIdTable("TestTable") { + val timestampWithTimeZone = timestampWithTimeZone("timestamptz-column") + } + + withDb(db = listOf(TestDB.MYSQL, TestDB.MARIADB)) { testDB -> + if (testDB == TestDB.MARIADB || isOldMySql()) { + expectException { + SchemaUtils.create(testTable) + } + } + } + } } fun assertEqualDateTime(d1: DateTime?, d2: DateTime?) { diff --git a/exposed-kotlin-datetime/api/exposed-kotlin-datetime.api b/exposed-kotlin-datetime/api/exposed-kotlin-datetime.api index dc434d4506..f13ada0450 100644 --- a/exposed-kotlin-datetime/api/exposed-kotlin-datetime.api +++ b/exposed-kotlin-datetime/api/exposed-kotlin-datetime.api @@ -20,6 +20,7 @@ public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateColumnTyp public static final fun duration (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; public static final fun time (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; public static final fun timestamp (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; + public static final fun timestampWithTimeZone (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; } public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctionsKt { @@ -28,6 +29,7 @@ public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions public static final fun CustomDurationFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; public static final fun CustomTimeFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; public static final fun CustomTimeStampFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; + public static final fun CustomTimestampWithTimeZoneFunction (Ljava/lang/String;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/CustomFunction; public static final fun InstantDateExt (Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/Function; public static final fun InstantDateFunction (Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/Function; public static final fun InstantDayExt (Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/Function; @@ -83,6 +85,8 @@ public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions public static final fun timeParam (Lkotlinx/datetime/LocalTime;)Lorg/jetbrains/exposed/sql/Expression; public static final fun timestampLiteral (Lkotlinx/datetime/Instant;)Lorg/jetbrains/exposed/sql/LiteralOp; public static final fun timestampParam (Lkotlinx/datetime/Instant;)Lorg/jetbrains/exposed/sql/Expression; + public static final fun timestampWithTimeZoneLiteral (Ljava/time/OffsetDateTime;)Lorg/jetbrains/exposed/sql/LiteralOp; + public static final fun timestampWithTimeZoneParam (Ljava/time/OffsetDateTime;)Lorg/jetbrains/exposed/sql/Expression; } public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinDurationColumnType : org/jetbrains/exposed/sql/ColumnType { @@ -154,6 +158,21 @@ public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinLocalTimeColu public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinLocalTimeColumnType$Companion { } +public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinOffsetDateTimeColumnType : org/jetbrains/exposed/sql/ColumnType, org/jetbrains/exposed/sql/IDateColumnType { + public static final field Companion Lorg/jetbrains/exposed/sql/kotlin/datetime/KotlinOffsetDateTimeColumnType$Companion; + public fun ()V + public fun getHasTimePart ()Z + 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; + public fun sqlType ()Ljava/lang/String; + public synthetic fun valueFromDB (Ljava/lang/Object;)Ljava/lang/Object; + public fun valueFromDB (Ljava/lang/Object;)Ljava/time/OffsetDateTime; +} + +public final class org/jetbrains/exposed/sql/kotlin/datetime/KotlinOffsetDateTimeColumnType$Companion { +} + public final class org/jetbrains/exposed/sql/kotlin/datetime/YearInternal : org/jetbrains/exposed/sql/Function { public fun (Lorg/jetbrains/exposed/sql/Expression;)V public final fun getExpr ()Lorg/jetbrains/exposed/sql/Expression; diff --git a/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateColumnType.kt b/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateColumnType.kt index b733b9cd13..6b2fe4ad79 100644 --- a/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateColumnType.kt +++ b/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateColumnType.kt @@ -7,11 +7,13 @@ import org.jetbrains.exposed.sql.ColumnType import org.jetbrains.exposed.sql.IDateColumnType import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.vendors.H2Dialect +import org.jetbrains.exposed.sql.vendors.MysqlDialect import org.jetbrains.exposed.sql.vendors.OracleDialect import org.jetbrains.exposed.sql.vendors.SQLiteDialect import org.jetbrains.exposed.sql.vendors.currentDialect import org.jetbrains.exposed.sql.vendors.h2Mode import java.sql.ResultSet +import java.time.OffsetDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.* @@ -48,6 +50,26 @@ private val DEFAULT_TIME_STRING_FORMATTER by lazy { DateTimeFormatter.ISO_LOCAL_TIME.withLocale(Locale.ROOT).withZone(ZoneId.systemDefault()) } +// Example result: 2023-07-07 14:42:29.343+02:00 or 2023-07-07 12:42:29.343Z +internal val SQLITE_OFFSET_DATE_TIME_FORMATTER by lazy { + DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss.SSS[XXX]", + Locale.ROOT + ) +} + +// For UTC time zone, MySQL rejects the 'Z' and will only accept the offset '+00:00' +internal val MYSQL_OFFSET_DATE_TIME_FORMATTER by lazy { + DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss.SSSSSS[xxx]", + Locale.ROOT + ) +} + +internal val DEFAULT_OFFSET_DATE_TIME_FORMATTER by lazy { + DateTimeFormatter.ISO_OFFSET_DATE_TIME.withLocale(Locale.ROOT) +} + private fun formatterForDateString(date: String) = dateTimeWithFractionFormat(date.substringAfterLast('.', "").length) private fun dateTimeWithFractionFormat(fraction: Int): DateTimeFormatter { val baseFormat = "yyyy-MM-d HH:mm:ss" @@ -249,6 +271,55 @@ class KotlinInstantColumnType : ColumnType(), IDateColumnType { } } +class KotlinOffsetDateTimeColumnType : ColumnType(), IDateColumnType { + override val hasTimePart: Boolean = true + + override fun sqlType(): String = currentDialect.dataTypeProvider.timestampWithTimeZoneType() + + override fun nonNullValueToString(value: Any): String = when (value) { + is OffsetDateTime -> { + when (currentDialect) { + is SQLiteDialect -> "'${value.format(SQLITE_OFFSET_DATE_TIME_FORMATTER)}'" + is MysqlDialect -> "'${value.format(MYSQL_OFFSET_DATE_TIME_FORMATTER)}'" + else -> "'${value.format(DEFAULT_OFFSET_DATE_TIME_FORMATTER)}'" + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + override fun valueFromDB(value: Any): OffsetDateTime = when (value) { + is OffsetDateTime -> value + is String -> { + if (currentDialect is SQLiteDialect) { + OffsetDateTime.parse(value, SQLITE_OFFSET_DATE_TIME_FORMATTER) + } else { + OffsetDateTime.parse(value) + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + override fun readObject(rs: ResultSet, index: Int): Any? = when (currentDialect) { + is SQLiteDialect -> super.readObject(rs, index) + else -> rs.getObject(index, OffsetDateTime::class.java) + } + + override fun notNullValueToDB(value: Any): Any = when (value) { + is OffsetDateTime -> { + when (currentDialect) { + is SQLiteDialect -> value.format(SQLITE_OFFSET_DATE_TIME_FORMATTER) + is MysqlDialect -> value.format(MYSQL_OFFSET_DATE_TIME_FORMATTER) + else -> value + } + } + else -> error("Unexpected value: $value of ${value::class.qualifiedName}") + } + + companion object { + internal val INSTANCE = KotlinOffsetDateTimeColumnType() + } +} + class KotlinDurationColumnType : ColumnType() { override fun sqlType(): String = currentDialect.dataTypeProvider.longType() @@ -296,7 +367,7 @@ class KotlinDurationColumnType : ColumnType() { fun Table.date(name: String): Column = registerColumn(name, KotlinLocalDateColumnType()) /** - * A datetime column to store both a date and a time. + * A datetime column to store both a date and a time without time zone. * * @param name The column name */ @@ -312,12 +383,23 @@ fun Table.datetime(name: String): Column = registerColumn(name, K fun Table.time(name: String): Column = registerColumn(name, KotlinLocalTimeColumnType()) /** - * A timestamp column to store both a date and a time. + * A timestamp column to store both a date and a time without time zone. * * @param name The column name */ fun Table.timestamp(name: String): Column = registerColumn(name, KotlinInstantColumnType()) +/** + * A timestamp column to store both a date and a time with time zone. + * + * Note: PostgreSQL and MySQL always store the timestamp in UTC, thereby losing the original time zone. To preserve the + * original time zone, store the time zone information in a separate column. + * + * @param name The column name + */ +fun Table.timestampWithTimeZone(name: String): Column = + registerColumn(name, KotlinOffsetDateTimeColumnType()) + /** * A date column to store a duration. * diff --git a/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt b/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt index b144460baf..fe9e2148be 100644 --- a/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt +++ b/exposed-kotlin-datetime/src/main/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinDateFunctions.kt @@ -13,6 +13,7 @@ import org.jetbrains.exposed.sql.vendors.MysqlDialect import org.jetbrains.exposed.sql.vendors.SQLServerDialect import org.jetbrains.exposed.sql.vendors.currentDialect import org.jetbrains.exposed.sql.vendors.h2Mode +import java.time.OffsetDateTime import kotlin.time.Duration internal class DateInternal(val expr: Expression<*>) : Function(KotlinLocalDateColumnType.INSTANCE) { @@ -235,6 +236,10 @@ fun dateParam(value: LocalDate): Expression = QueryParameter(value, K fun timeParam(value: LocalTime): Expression = QueryParameter(value, KotlinLocalTimeColumnType.INSTANCE) fun dateTimeParam(value: LocalDateTime): Expression = QueryParameter(value, KotlinLocalDateTimeColumnType.INSTANCE) fun timestampParam(value: Instant): Expression = QueryParameter(value, KotlinInstantColumnType.INSTANCE) + +fun timestampWithTimeZoneParam(value: OffsetDateTime): Expression = + QueryParameter(value, KotlinOffsetDateTimeColumnType.INSTANCE) + fun durationParam(value: Duration): Expression = QueryParameter(value, KotlinDurationColumnType.INSTANCE) fun dateLiteral(value: LocalDate): LiteralOp = LiteralOp(KotlinLocalDateColumnType.INSTANCE, value) @@ -242,6 +247,10 @@ fun timeLiteral(value: LocalTime): LiteralOp = LiteralOp(KotlinLocalT fun dateTimeLiteral(value: LocalDateTime): LiteralOp = LiteralOp(KotlinLocalDateTimeColumnType.INSTANCE, value) fun timestampLiteral(value: Instant): LiteralOp = LiteralOp(KotlinInstantColumnType.INSTANCE, value) + +fun timestampWithTimeZoneLiteral(value: OffsetDateTime): LiteralOp = + LiteralOp(KotlinOffsetDateTimeColumnType.INSTANCE, value) + fun durationLiteral(value: Duration): LiteralOp = LiteralOp(KotlinDurationColumnType.INSTANCE, value) fun CustomDateFunction(functionName: String, vararg params: Expression<*>): CustomFunction = @@ -256,5 +265,11 @@ fun CustomDateTimeFunction(functionName: String, vararg params: Expression<*>): fun CustomTimeStampFunction(functionName: String, vararg params: Expression<*>): CustomFunction = CustomFunction(functionName, KotlinInstantColumnType.INSTANCE, *params) +@Suppress("FunctionName") +fun CustomTimestampWithTimeZoneFunction( + functionName: String, + vararg params: Expression<*> +): CustomFunction = CustomFunction(functionName, KotlinOffsetDateTimeColumnType.INSTANCE, *params) + fun CustomDurationFunction(functionName: String, vararg params: Expression<*>): CustomFunction = CustomFunction(functionName, KotlinDurationColumnType.INSTANCE, *params) diff --git a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt index 53207f7f4b..78b03596c0 100644 --- a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt +++ b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/DefaultsTest.kt @@ -25,6 +25,9 @@ import org.jetbrains.exposed.sql.vendors.OracleDialect import org.jetbrains.exposed.sql.vendors.SQLServerDialect import org.jetbrains.exposed.sql.vendors.h2Mode import org.junit.Test +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZoneOffset import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -286,6 +289,61 @@ class DefaultsTest : DatabaseTestsBase() { } } + @Test + fun testTimestampWithTimeZoneDefaults() { + // UTC time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(ZoneOffset.UTC)) + assertEquals("UTC", ZoneId.systemDefault().id) + + val nowWithTimeZone = OffsetDateTime.now() + val timestampWithTimeZoneLiteral = timestampWithTimeZoneLiteral(nowWithTimeZone) + + val testTable = object : IntIdTable("t") { + val t1 = timestampWithTimeZone("t1").default(nowWithTimeZone) + val t2 = timestampWithTimeZone("t2").defaultExpression(timestampWithTimeZoneLiteral) + } + + fun Expression<*>.itOrNull() = when { + currentDialectTest.isAllowedAsColumnDefault(this) -> + "DEFAULT ${currentDialectTest.dataTypeProvider.processForDefaultValue(this)} NOT NULL" + else -> "NULL" + } + + withDb(excludeSettings = listOf(TestDB.SQLITE, TestDB.MARIADB)) { + if (!isOldMySql()) { + SchemaUtils.create(testTable) + + val timestampWithTimeZoneType = currentDialectTest.dataTypeProvider.timestampWithTimeZoneType() + + val baseExpression = "CREATE TABLE " + addIfNotExistsIfSupported() + + "${"t".inProperCase()} (" + + "${"id".inProperCase()} ${currentDialectTest.dataTypeProvider.integerAutoincType()} PRIMARY KEY, " + + "${"t1".inProperCase()} $timestampWithTimeZoneType ${timestampWithTimeZoneLiteral.itOrNull()}, " + + "${"t2".inProperCase()} $timestampWithTimeZoneType ${timestampWithTimeZoneLiteral.itOrNull()}" + + ")" + + val expected = if (currentDialectTest is OracleDialect || + currentDialectTest.h2Mode == H2Dialect.H2CompatibilityMode.Oracle + ) { + arrayListOf( + "CREATE SEQUENCE t_id_seq START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775807", + baseExpression + ) + } else { + arrayListOf(baseExpression) + } + + assertEqualLists(expected, testTable.ddl) + + val id1 = testTable.insertAndGetId { } + + val row1 = testTable.select { testTable.id eq id1 }.single() + assertEqualDateTime(nowWithTimeZone, row1[testTable.t1]) + assertEqualDateTime(nowWithTimeZone, row1[testTable.t2]) + } + } + } + @Test fun testDefaultExpressions01() { diff --git a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt index cdbc0a84a5..536f2ced1a 100644 --- a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt +++ b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/sql/kotlin/datetime/KotlinTimeTests.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.between import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq @@ -15,11 +16,14 @@ import org.jetbrains.exposed.sql.tests.TestDB import org.jetbrains.exposed.sql.tests.currentDialectTest import org.jetbrains.exposed.sql.tests.shared.assertEquals import org.jetbrains.exposed.sql.tests.shared.assertTrue +import org.jetbrains.exposed.sql.tests.shared.expectException import org.jetbrains.exposed.sql.vendors.* import org.junit.Assert.fail import org.junit.Test import java.math.BigDecimal import java.math.RoundingMode +import java.time.OffsetDateTime +import java.time.ZoneId import java.time.ZoneOffset import kotlin.test.assertEquals @@ -301,6 +305,100 @@ open class KotlinTimeBaseTest : DatabaseTestsBase() { assertEquals(2, modifiedBeforeCreation[tester.modified].userId) } } + + @Test + fun testTimestampWithTimeZone() { + val testTable = object : IntIdTable("TestTable") { + val timestampWithTimeZone = timestampWithTimeZone("timestamptz-column") + } + + withDb(excludeSettings = listOf(TestDB.MARIADB)) { testDB -> + if (!isOldMySql()) { + SchemaUtils.create(testTable) + + // Cairo time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Africa/Cairo")) + assertEquals("Africa/Cairo", ZoneId.systemDefault().id) + + val cairoNow = OffsetDateTime.now(ZoneId.systemDefault()) + + val cairoId = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInCairoTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + // UTC time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(ZoneOffset.UTC)) + assertEquals("UTC", ZoneId.systemDefault().id) + + val cairoNowRetrievedInUTCTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + val utcID = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInUTCTimeZone = testTable.select { testTable.id eq utcID } + .single()[testTable.timestampWithTimeZone] + + // Tokyo time zone + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Asia/Tokyo")) + assertEquals("Asia/Tokyo", ZoneId.systemDefault().id) + + val cairoNowRetrievedInTokyoTimeZone = testTable.select { testTable.id eq cairoId } + .single()[testTable.timestampWithTimeZone] + + val tokyoID = testTable.insertAndGetId { + it[timestampWithTimeZone] = cairoNow + } + + val cairoNowInsertedInTokyoTimeZone = testTable.select { testTable.id eq tokyoID } + .single()[testTable.timestampWithTimeZone] + + // PostgreSQL and MySQL always store the timestamp in UTC, thereby losing the original time zone. + // To preserve the original time zone, store the time zone information in a separate column. + val isOriginalTimeZonePreserved = testDB !in listOf( + TestDB.POSTGRESQL, + TestDB.POSTGRESQLNG, + TestDB.MYSQL + ) + if (isOriginalTimeZonePreserved) { + // Assert that time zone is preserved when the same value is inserted in different time zones + assertEqualDateTime(cairoNow, cairoNowInsertedInCairoTimeZone) + assertEqualDateTime(cairoNow, cairoNowInsertedInUTCTimeZone) + assertEqualDateTime(cairoNow, cairoNowInsertedInTokyoTimeZone) + + // Assert that time zone is preserved when the same record is retrieved in different time zones + assertEqualDateTime(cairoNow, cairoNowRetrievedInUTCTimeZone) + assertEqualDateTime(cairoNow, cairoNowRetrievedInTokyoTimeZone) + } else { + // Assert equivalence in UTC when the same value is inserted in different time zones + assertEqualDateTime(cairoNowInsertedInCairoTimeZone, cairoNowInsertedInUTCTimeZone) + assertEqualDateTime(cairoNowInsertedInUTCTimeZone, cairoNowInsertedInTokyoTimeZone) + + // Assert equivalence in UTC when the same record is retrieved in different time zones + assertEqualDateTime(cairoNowRetrievedInUTCTimeZone, cairoNowRetrievedInTokyoTimeZone) + } + } + } + } + + @Test + fun testTimestampWithTimeZoneThrowsExceptionForUnsupportedDialects() { + val testTable = object : IntIdTable("TestTable") { + val timestampWithTimeZone = timestampWithTimeZone("timestamptz-column") + } + + withDb(db = listOf(TestDB.MYSQL, TestDB.MARIADB)) { testDB -> + if (testDB == TestDB.MARIADB || isOldMySql()) { + expectException { + SchemaUtils.create(testTable) + } + } + } + } } fun assertEqualDateTime(d1: T?, d2: T?) { @@ -314,7 +412,6 @@ fun assertEqualDateTime(d1: T?, d2: T?) { assertEqualFractionalPart(d1.nanosecond, d2.nanosecond) } } - d1 is LocalDateTime && d2 is LocalDateTime -> { assertEquals( d1.toJavaLocalDateTime().toEpochSecond(ZoneOffset.UTC), @@ -323,12 +420,14 @@ fun assertEqualDateTime(d1: T?, d2: T?) { ) assertEqualFractionalPart(d1.nanosecond, d2.nanosecond) } - d1 is Instant && d2 is Instant -> { assertEquals(d1.epochSeconds, d2.epochSeconds, "Failed on epoch seconds ${currentDialectTest.name}") assertEqualFractionalPart(d1.nanosecondsOfSecond, d2.nanosecondsOfSecond) } - + d1 is OffsetDateTime && d2 is OffsetDateTime -> { + assertEqualDateTime(d1.toLocalDateTime().toKotlinLocalDateTime(), d2.toLocalDateTime().toKotlinLocalDateTime()) + assertEquals(d1.offset, d2.offset) + } else -> assertEquals(d1, d2, "Failed on ${currentDialectTest.name}") } }