From 74fba00a77dce1d12cafad722064db2f5aeff84e Mon Sep 17 00:00:00 2001 From: bog-walk <82039410+bog-walk@users.noreply.github.com> Date: Fri, 28 Jul 2023 09:49:34 -0400 Subject: [PATCH] test: Fix failing datetime tests in MariaDB (#1805) * test: Fix failing datetime tests in MariaDB The following tests in exposed-java-time and exposed-kotlin-datetime fail when run using MariaDB: KotlinTimeTests/'test string LocalDateTime with nanos'() JavaTimeTests/'test string LocalDateTime with nanos'() - Fails with the message `Failed on microseconds` because Exposed's assertion functions for the fractional part are using `roundToMicro()` to compare nanoseconds. In other databases (DB), including MySQL, the nanoseconds retrieved from the DB is rounded to the internal precision using RoundingMode.HALF_UP. MariaDB always rounds down. - These tests are flaky because they rely on Clock.System.now() (and the java equivalent) and add a set amount of nanoseconds to a dynamic datetime value. This means any resulting value on the lower half of a microsecond will be round down and pass. - The assertion has been changed to floor the nanoseconds value instead and the test now takes 2 values with constant nanoseconds to evaluate what happens when the value is low versus high. --- .../org/jetbrains/exposed/JavaTimeTests.kt | 55 ++++++++++++------- .../sql/kotlin/datetime/KotlinTimeTests.kt | 55 ++++++++++++------- 2 files changed, 70 insertions(+), 40 deletions(-) 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 fa8e527afa..599f8c486b 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 @@ -96,18 +96,27 @@ open class JavaTimeBaseTest : DatabaseTestsBase() { } @Test - fun `test storing LocalDateTime with nanos`() { + fun testStoringLocalDateTimeWithNanos() { val testDate = object : IntIdTable("TestLocalDateTime") { val time = datetime("time") } + withTables(testDate) { - val dateTimeWithNanos = LocalDateTime.now().withNano(123) + val dateTime = LocalDateTime.now() + val nanos = 111111 + // insert 2 separate nanosecond constants to ensure test's rounding mode matches DB precision + val dateTimeWithFewNanos = dateTime.withNano(nanos) + val dateTimeWithManyNanos = dateTime.withNano(nanos * 7) + testDate.insert { + it[time] = dateTimeWithFewNanos + } testDate.insert { - it[time] = dateTimeWithNanos + it[time] = dateTimeWithManyNanos } - val dateTimeFromDB = testDate.selectAll().single()[testDate.time] - assertEqualDateTime(dateTimeWithNanos, dateTimeFromDB) + val dateTimesFromDB = testDate.selectAll().map { it[testDate.time] } + assertEqualDateTime(dateTimeWithFewNanos, dateTimesFromDB[0]) + assertEqualDateTime(dateTimeWithManyNanos, dateTimesFromDB[1]) } } @@ -442,25 +451,29 @@ fun assertEqualDateTime(d1: T?, d2: T?) { } private fun assertEqualFractionalPart(nano1: Int, nano2: Int) { - when (currentDialectTest) { - // nanoseconds (H2, Oracle & Sqlite could be here) - // assertEquals(nano1, nano2, "Failed on nano ${currentDialectTest.name}") + val dialect = currentDialectTest + val db = dialect.name + when (dialect) { // accurate to 100 nanoseconds - is SQLServerDialect -> assertEquals(roundTo100Nanos(nano1), roundTo100Nanos(nano2), "Failed on 1/10th microseconds ${currentDialectTest.name}") + is SQLServerDialect -> + assertEquals(roundTo100Nanos(nano1), roundTo100Nanos(nano2), "Failed on 1/10th microseconds $db") // microseconds - is H2Dialect, is MariaDBDialect, is PostgreSQLDialect, is PostgreSQLNGDialect -> - assertEquals(roundToMicro(nano1), roundToMicro(nano2), "Failed on microseconds ${currentDialectTest.name}") - is MysqlDialect -> - if ((currentDialectTest as? MysqlDialect)?.isFractionDateTimeSupported() == true) { - // this should be uncommented, but mysql has different microseconds between save & read -// assertEquals(roundToMicro(nano1), roundToMicro(nano2), "Failed on microseconds ${currentDialectTest.name}") - } else { - // don't compare fractional part + is MariaDBDialect -> + assertEquals(floorToMicro(nano1), floorToMicro(nano2), "Failed on microseconds $db") + is H2Dialect, is PostgreSQLDialect, is MysqlDialect -> { + when ((dialect as? MysqlDialect)?.isFractionDateTimeSupported()) { + null, true -> { + assertEquals(roundToMicro(nano1), roundToMicro(nano2), "Failed on microseconds $db") + } + else -> {} // don't compare fractional part } + } // milliseconds - is OracleDialect -> assertEquals(roundToMilli(nano1), roundToMilli(nano2), "Failed on milliseconds ${currentDialectTest.name}") - is SQLiteDialect -> assertEquals(floorToMilli(nano1), floorToMilli(nano2), "Failed on milliseconds ${currentDialectTest.name}") - else -> fail("Unknown dialect ${currentDialectTest.name}") + is OracleDialect -> + assertEquals(roundToMilli(nano1), roundToMilli(nano2), "Failed on milliseconds $db") + is SQLiteDialect -> + assertEquals(floorToMilli(nano1), floorToMilli(nano2), "Failed on milliseconds $db") + else -> fail("Unknown dialect $db") } } @@ -472,6 +485,8 @@ private fun roundToMicro(nanos: Int): Int { return BigDecimal(nanos).divide(BigDecimal(1_000), RoundingMode.HALF_UP).toInt() } +private fun floorToMicro(nanos: Int): Int = nanos / 1_000 + private fun roundToMilli(nanos: Int): Int { return BigDecimal(nanos).divide(BigDecimal(1_000_000), RoundingMode.HALF_UP).toInt() } 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 3164f542ff..fdfba6fc6f 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 @@ -87,18 +87,27 @@ open class KotlinTimeBaseTest : DatabaseTestsBase() { } @Test - fun `test storing LocalDateTime with nanos`() { + fun testStoringLocalDateTimeWithNanos() { val testDate = object : IntIdTable("TestLocalDateTime") { val time = datetime("time") } + withTables(testDate) { - val dateTimeWithNanos = Clock.System.now().plus(DateTimeUnit.NANOSECOND * 123).toLocalDateTime(TimeZone.currentSystemDefault()) + val dateTime = Instant.parse("2023-05-04T05:04:00.000Z") // has 0 nanoseconds + val nanos = DateTimeUnit.NANOSECOND * 111111 + // insert 2 separate constants to ensure test's rounding mode matches DB precision + val dateTimeWithFewNanos = dateTime.plus(nanos).toLocalDateTime(TimeZone.currentSystemDefault()) + val dateTimeWithManyNanos = dateTime.plus(nanos * 7).toLocalDateTime(TimeZone.currentSystemDefault()) + testDate.insert { + it[testDate.time] = dateTimeWithFewNanos + } testDate.insert { - it[testDate.time] = dateTimeWithNanos + it[testDate.time] = dateTimeWithManyNanos } - val dateTimeFromDB = testDate.selectAll().single()[testDate.time] - assertEqualDateTime(dateTimeWithNanos, dateTimeFromDB) + val dateTimesFromDB = testDate.selectAll().map { it[testDate.time] } + assertEqualDateTime(dateTimeWithFewNanos, dateTimesFromDB[0]) + assertEqualDateTime(dateTimeWithManyNanos, dateTimesFromDB[1]) } } @@ -437,25 +446,29 @@ fun assertEqualDateTime(d1: T?, d2: T?) { } private fun assertEqualFractionalPart(nano1: Int, nano2: Int) { - when (currentDialectTest) { - // nanoseconds (H2, Oracle & Sqlite could be here) - // assertEquals(nano1, nano2, "Failed on nano ${currentDialectTest.name}") + val dialect = currentDialectTest + val db = dialect.name + when (dialect) { // accurate to 100 nanoseconds - is SQLServerDialect -> assertEquals(roundTo100Nanos(nano1), roundTo100Nanos(nano2), "Failed on 1/10th microseconds ${currentDialectTest.name}") + is SQLServerDialect -> + assertEquals(roundTo100Nanos(nano1), roundTo100Nanos(nano2), "Failed on 1/10th microseconds $db") // microseconds - is H2Dialect, is MariaDBDialect, is PostgreSQLDialect, is PostgreSQLNGDialect -> - assertEquals(roundToMicro(nano1), roundToMicro(nano2), "Failed on microseconds ${currentDialectTest.name}") - is MysqlDialect -> - if ((currentDialectTest as? MysqlDialect)?.isFractionDateTimeSupported() == true) { - // this should be uncommented, but mysql has different microseconds between save & read -// assertEquals(roundToMicro(nano1), roundToMicro(nano2), "Failed on microseconds ${currentDialectTest.name}") - } else { - // don't compare fractional part + is MariaDBDialect -> + assertEquals(floorToMicro(nano1), floorToMicro(nano2), "Failed on microseconds $db") + is H2Dialect, is PostgreSQLDialect, is MysqlDialect -> { + when ((dialect as? MysqlDialect)?.isFractionDateTimeSupported()) { + null, true -> { + assertEquals(roundToMicro(nano1), roundToMicro(nano2), "Failed on microseconds $db") + } + else -> {} // don't compare fractional part } + } // milliseconds - is OracleDialect -> assertEquals(roundToMilli(nano1), roundToMilli(nano2), "Failed on milliseconds ${currentDialectTest.name}") - is SQLiteDialect -> assertEquals(floorToMilli(nano1), floorToMilli(nano2), "Failed on milliseconds ${currentDialectTest.name}") - else -> fail("Unknown dialect ${currentDialectTest.name}") + is OracleDialect -> + assertEquals(roundToMilli(nano1), roundToMilli(nano2), "Failed on milliseconds $db") + is SQLiteDialect -> + assertEquals(floorToMilli(nano1), floorToMilli(nano2), "Failed on milliseconds $db") + else -> fail("Unknown dialect $db") } } @@ -467,6 +480,8 @@ private fun roundToMicro(nanos: Int): Int { return BigDecimal(nanos).divide(BigDecimal(1_000), RoundingMode.HALF_UP).toInt() } +private fun floorToMicro(nanos: Int): Int = nanos / 1_000 + private fun roundToMilli(nanos: Int): Int { return BigDecimal(nanos).divide(BigDecimal(1_000_000), RoundingMode.HALF_UP).toInt() }