diff --git a/src/changes/changes.xml b/src/changes/changes.xml index f08e57b0..5dcf7cab 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -13,7 +13,7 @@ - + Implement the 8 empty, @Disabled DefaultPrepAndExpectedTestCaseTest stub methods covering preTest, postTest, setupData, verifyData, cleanupData, and makeCompositeDataSet, using the class's existing Mockito-based connection harness. Move the test fixture's databaseTester/tc construction from field initializers to a @BeforeEach method so @Mock injection (MockitoExtension) completes before they are built. @@ -152,6 +152,9 @@ Centralize the duplicated Turkish-locale save/mutate/restore boilerplate across 13 test classes into a shared @TurkishDefaultLocale JUnit 5 extension. + + Fix TimestampDataType writing the wrong instant to TIMESTAMP WITH TIME ZONE columns: the issue #711 fix's Calendar-based setTimestamp(Timestamp, Calendar) call let some JDBC drivers tag the stored value with the JVM's default time zone instead of the dataset's own time zone, silently discarding it. Detect such columns via PreparedStatement.getParameterMetaData() and write them with setObject(OffsetDateTime) instead, which drivers supporting the SQL type map unambiguously; columns without a time zone keep the existing Calendar-based behavior from #711 unchanged. + diff --git a/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java b/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java index 8ae15354..23d73a87 100644 --- a/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java @@ -22,12 +22,15 @@ package org.dbunit.dataset.datatype; import java.math.BigInteger; +import java.sql.ParameterMetaData; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.sql.Types; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.format.DateTimeParseException; import java.util.Calendar; import java.util.TimeZone; @@ -270,13 +273,63 @@ private void setSqlValueFromString(final String value, final int column, if (timezoneMatcher.matches() && timezoneMatcher.group(2) != null) { final Calendar cal = makeCalendar(timezoneMatcher); - statement.setTimestamp(column, ts, cal); + if (isTimestampWithTimeZoneColumn(column, statement)) + { + final ZoneId zoneId = cal.getTimeZone().toZoneId(); + final OffsetDateTime offsetDateTime = + OffsetDateTime.ofInstant(ts.toInstant(), zoneId); + statement.setObject(column, offsetDateTime); + } else + { + statement.setTimestamp(column, ts, cal); + } } else { statement.setTimestamp(column, ts); } } + /** + * Determines whether a prepared-statement parameter targets a column of + * SQL type {@code TIMESTAMP WITH TIME ZONE}. + *

+ * {@link PreparedStatement#setTimestamp(int, Timestamp, Calendar)} only + * changes the wall-clock digits sent to the database. Some drivers write + * those digits into a time-zone-aware column tagged with the JVM's + * default time zone rather than the given calendar's zone, which + * silently discards the dataset's own time zone. Columns of this type + * must instead be written with + * {@link PreparedStatement#setObject(int, Object)} using an + * {@link OffsetDateTime}, which drivers supporting this SQL type map + * unambiguously. + * @param column The one-based parameter index. + * @param statement The statement the parameter belongs to. + * @return {@code true} if the parameter's declared SQL type is + * {@link Types#TIMESTAMP_WITH_TIMEZONE}, {@code false} if it is + * not, or if that could not be determined. + */ + private boolean isTimestampWithTimeZoneColumn(final int column, + final PreparedStatement statement) + { + try + { + final ParameterMetaData parameterMetaData = + statement.getParameterMetaData(); + final int parameterType = + parameterMetaData.getParameterType(column); + return parameterType == Types.TIMESTAMP_WITH_TIMEZONE; + } catch (final Exception e) + { + // Driver support for ParameterMetaData is inconsistent; treat any + // failure as "unknown" and fall back to the safe, existing + // behavior instead of failing the whole operation. + logger.debug( + "Could not determine parameter type for column {}, assuming no timezone.", + column, e); + return false; + } + } + private Calendar makeCalendar(final Matcher timezoneMatcher) { final String zoneValue = timezoneMatcher.group(2); diff --git a/src/site/asciidoc/integrationtests.adoc b/src/site/asciidoc/integrationtests.adoc index b8a8edad..aff63fe2 100644 --- a/src/site/asciidoc/integrationtests.adoc +++ b/src/site/asciidoc/integrationtests.adoc @@ -178,6 +178,10 @@ The valid feature names, their descriptions, and the databases that do not suppo |`XML_TYPE` |Oracle `XMLTYPE` column type; tests inserting and updating XML_TYPE_TABLE rows. |DB2, Derby, H2, HSQLDB, MySQL, MSSQL, PostgreSQL + +|`TIMESTAMP_WITH_TIMEZONE` +|`TIMESTAMP WITH TIME ZONE` column type; tests `TimestampDataType` preserving the dataset's own offset instead of the JVM's default time zone. +|DB2, Derby, MySQL, MSSQL, Oracle |=== == Running Integration Tests with IDEs diff --git a/src/test/java/org/dbunit/TestFeature.java b/src/test/java/org/dbunit/TestFeature.java index 6ebc7bbd..a2682913 100644 --- a/src/test/java/org/dbunit/TestFeature.java +++ b/src/test/java/org/dbunit/TestFeature.java @@ -41,6 +41,8 @@ public class TestFeature public static final TestFeature SDO_GEOMETRY = new TestFeature("SDO_GEOMETRY"); public static final TestFeature XML_TYPE = new TestFeature("XML_TYPE"); + public static final TestFeature TIMESTAMP_WITH_TIMEZONE = + new TestFeature("TIMESTAMP_WITH_TIMEZONE"); private final String _name; diff --git a/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeIT.java b/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeIT.java new file mode 100644 index 00000000..ea40717a --- /dev/null +++ b/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeIT.java @@ -0,0 +1,182 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.dataset.datatype; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.OffsetDateTime; + +import org.dbunit.AbstractDatabaseIT; +import org.dbunit.DatabaseEnvironment; +import org.dbunit.TestFeature; +import org.dbunit.database.IDatabaseConnection; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link TimestampDataType#setSqlValue} against whichever real database is + * configured by the active Maven profile (see {@link DatabaseEnvironment}), proving issue #831's + * fix for {@code TIMESTAMP WITH TIME ZONE} columns against a real JDBC driver, not just the H2 + * in-memory driver used by {@link TimestampDataTypeTest}. + * + *

+ * The zoned-column test skips itself (rather than failing) on any profile declaring + * {@link TestFeature#TIMESTAMP_WITH_TIMEZONE} unsupported via + * {@code dbunit.profile.unsupportedFeatures} - either because the SQL dialect rejects + * {@code TIMESTAMP WITH TIME ZONE} column syntax outright (e.g. Derby, MySQL, SQL Server, which + * has no equivalent type), or because the JDBC driver cannot reliably report + * {@link Types#TIMESTAMP_WITH_TIMEZONE} from {@link java.sql.ParameterMetaData} for such a column + * even though the database itself supports the type (a known limitation of Oracle's driver, not a + * dbunit defect). Declaring the gap in the profile up front - rather than discovering it by + * catching whatever exception a given driver happens to throw - means any other failure during + * setup surfaces as a real test error instead of being silently treated as unsupported. The + * plain-TIMESTAMP fallback test needs no such gating since every supported database accepts that + * syntax, though it substitutes {@code DATETIME2} on SQL Server, whose {@code TIMESTAMP} type is + * an unrelated, non-nullable row-versioning type rather than a date/time type. + * + * @since 3.4.0 + */ +class TimestampDataTypeIT +{ + private static final String ZONED_TABLE = "TIMESTAMPTYPE_TZ_IT"; + + private static final String PLAIN_TABLE = "TIMESTAMPTYPE_PLAIN_IT"; + + private static final String DATASET_VALUE = "2026-07-22 15:00:00 -0200"; + + private IDatabaseConnection connection; + + @BeforeEach + void setUp() throws Exception + { + connection = DatabaseEnvironment.getInstance().getConnection(); + } + + @AfterEach + void tearDown() throws Exception + { + if (connection != null) + { + dropTableQuietly(ZONED_TABLE); + dropTableQuietly(PLAIN_TABLE); + connection.close(); + connection = null; + } + } + + private void dropTableQuietly(final String tableName) + { + try (Statement statement = connection.getConnection().createStatement()) + { + statement.execute("DROP TABLE " + tableName); + } catch (final SQLException e) + { + // Table may not exist: either a prior run never created it, or + // the active profile does not support the column type under + // test. + } + } + + @Test + void testSetSqlValue_withOffsetDifferentFromLocalZone_writesCorrectInstantToTimestampWithTimeZoneColumn() + throws Exception + { + assumeTrue(AbstractDatabaseIT.environmentHasFeature(TestFeature.TIMESTAMP_WITH_TIMEZONE), + "Skipping: active database profile declares TIMESTAMP_WITH_TIMEZONE unsupported " + + "(see dbunit.profile.unsupportedFeatures)."); + + final Connection jdbcConnection = connection.getConnection(); + dropTableQuietly(ZONED_TABLE); + try (Statement statement = jdbcConnection.createStatement()) + { + statement.execute("CREATE TABLE " + ZONED_TABLE + + " (ID INTEGER NOT NULL PRIMARY KEY, TIM TIMESTAMP WITH TIME ZONE)"); + } + + try (PreparedStatement insert = jdbcConnection + .prepareStatement("INSERT INTO " + ZONED_TABLE + " VALUES (1, ?)")) + { + final int parameterType = insert.getParameterMetaData().getParameterType(1); + assumeTrue(parameterType == Types.TIMESTAMP_WITH_TIMEZONE, + "Skipping: active database driver does not report TIMESTAMP_WITH_TIMEZONE " + + "parameter metadata for this column."); + + new TimestampDataType().setSqlValue(DATASET_VALUE, 1, insert); + insert.executeUpdate(); + } + + try (PreparedStatement select = jdbcConnection + .prepareStatement("SELECT TIM FROM " + ZONED_TABLE + " WHERE ID = 1"); + ResultSet resultSet = select.executeQuery()) + { + resultSet.next(); + final OffsetDateTime stored = resultSet.getObject(1, OffsetDateTime.class); + assertThat(stored) + .as("a TIMESTAMP WITH TIME ZONE column must store the offset implied by " + + "the dataset's own time zone, not the JVM's default time zone.") + .isEqualTo(OffsetDateTime.parse("2026-07-22T15:00:00-02:00")); + } + } + + @Test + void testSetSqlValue_withOffsetDifferentFromLocalZone_writesLiteralDigitsToTimestampColumnWithoutTimeZone() + throws Exception + { + final Connection jdbcConnection = connection.getConnection(); + dropTableQuietly(PLAIN_TABLE); + final boolean isSqlServer = jdbcConnection.getMetaData().getDatabaseProductName() + .startsWith("Microsoft SQL Server"); + final String plainTimestampType = isSqlServer ? "DATETIME2" : "TIMESTAMP"; + try (Statement statement = jdbcConnection.createStatement()) + { + statement.execute("CREATE TABLE " + PLAIN_TABLE + + " (ID INTEGER NOT NULL PRIMARY KEY, TIM " + plainTimestampType + ")"); + } + + try (PreparedStatement insert = jdbcConnection + .prepareStatement("INSERT INTO " + PLAIN_TABLE + " VALUES (1, ?)")) + { + new TimestampDataType().setSqlValue(DATASET_VALUE, 1, insert); + insert.executeUpdate(); + } + + try (PreparedStatement select = jdbcConnection + .prepareStatement("SELECT TIM FROM " + PLAIN_TABLE + " WHERE ID = 1"); + ResultSet resultSet = select.executeQuery()) + { + resultSet.next(); + assertThat(resultSet.getTimestamp(1)) + .as("issue #711: a column without a time zone must still receive the " + + "dataset's literal wall-clock digits, regardless of the JVM's " + + "default time zone.") + .isEqualTo(Timestamp.valueOf("2026-07-22 15:00:00")); + } + } +} diff --git a/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java b/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java index 7c6feabb..493228ad 100644 --- a/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java +++ b/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java @@ -32,6 +32,7 @@ import static org.mockito.Mockito.when; import java.sql.Date; +import java.sql.ParameterMetaData; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -41,6 +42,7 @@ import java.time.Clock; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.OffsetDateTime; import java.time.temporal.ChronoUnit; import java.util.Calendar; import java.util.GregorianCalendar; @@ -70,6 +72,9 @@ class TimestampDataTypeTest extends AbstractDataTypeTest @Mock private PreparedStatement mockedStatement; + @Mock + private ParameterMetaData mockedParameterMetaData; + @Override @Test public void testToString_withDataType_returnsExpectedString() throws Exception @@ -820,6 +825,46 @@ void testSetSqlValue_withTimezoneString_passesTypeCastResultAsTimestamp() .isEqualTo(expectedTs); } + @Test + void testSetSqlValue_withTimezoneStringAndTimestampWithTimeZoneColumn_callsSetObjectWithOffsetDateTime() + throws TypeCastException, SQLException + { + when(mockedStatement.getParameterMetaData()) + .thenReturn(mockedParameterMetaData); + when(mockedParameterMetaData.getParameterType(1)) + .thenReturn(Types.TIMESTAMP_WITH_TIMEZONE); + final ArgumentCaptor odtCaptor = + ArgumentCaptor.forClass(OffsetDateTime.class); + + new TimestampDataType().setSqlValue("2026-07-22 15:00:00 -0200", 1, + mockedStatement); + + verify(mockedStatement).setObject(eq(1), odtCaptor.capture()); + verify(mockedStatement, never()).setTimestamp(anyInt(), + any(Timestamp.class), any(Calendar.class)); + assertThat(odtCaptor.getValue()) + .as("a TIMESTAMP WITH TIME ZONE column must receive the dataset's own " + + "offset rather than being routed through Calendar-based " + + "setTimestamp, which some drivers tag with the JVM default " + + "zone instead.") + .isEqualTo(OffsetDateTime.parse("2026-07-22T15:00:00-02:00")); + } + + @Test + void testSetSqlValue_withTimezoneStringAndParameterMetaDataUnavailable_fallsBackToSetTimestampWithCalendar() + throws TypeCastException, SQLException + { + when(mockedStatement.getParameterMetaData()) + .thenThrow(new SQLException("parameter metadata not supported")); + + new TimestampDataType().setSqlValue("2026-07-22 15:00:00 -0200", 1, + mockedStatement); + + verify(mockedStatement).setTimestamp(eq(1), any(Timestamp.class), + any(Calendar.class)); + verify(mockedStatement, never()).setObject(anyInt(), any()); + } + @Test void testSetSqlValue_withNoValue_callsSetTimestampWithNull() throws TypeCastException, SQLException diff --git a/src/test/resources/db2-dbunit.properties b/src/test/resources/db2-dbunit.properties index 777e6a4b..0ff24a56 100644 --- a/src/test/resources/db2-dbunit.properties +++ b/src/test/resources/db2-dbunit.properties @@ -5,5 +5,5 @@ dbunit.profile.schema=DBUNIT dbunit.profile.user=dbunit dbunit.profile.password=dbunit dbunit.profile.ddl=db2xml.sql -dbunit.profile.unsupportedFeatures=BLOB,CLOB,INSERT_IDENTITY,SDO_GEOMETRY,XML_TYPE +dbunit.profile.unsupportedFeatures=BLOB,CLOB,INSERT_IDENTITY,SDO_GEOMETRY,XML_TYPE,TIMESTAMP_WITH_TIMEZONE dbunit.profile.multiLineSupport=false diff --git a/src/test/resources/derby-dbunit.properties b/src/test/resources/derby-dbunit.properties index 3a9f741b..7393754c 100644 --- a/src/test/resources/derby-dbunit.properties +++ b/src/test/resources/derby-dbunit.properties @@ -5,5 +5,5 @@ dbunit.profile.schema=APP dbunit.profile.user=APP dbunit.profile.password=APP dbunit.profile.ddl=derby.sql -dbunit.profile.unsupportedFeatures=VARBINARY,BLOB,CLOB,INSERT_IDENTITY,SDO_GEOMETRY,XML_TYPE +dbunit.profile.unsupportedFeatures=VARBINARY,BLOB,CLOB,INSERT_IDENTITY,SDO_GEOMETRY,XML_TYPE,TIMESTAMP_WITH_TIMEZONE dbunit.profile.multiLineSupport=false diff --git a/src/test/resources/mssql2022-dbunit.properties b/src/test/resources/mssql2022-dbunit.properties index 083332a3..6b0f6046 100644 --- a/src/test/resources/mssql2022-dbunit.properties +++ b/src/test/resources/mssql2022-dbunit.properties @@ -5,5 +5,5 @@ dbunit.profile.schema=dbo dbunit.profile.user=sa dbunit.profile.password=theSaPassword1234 dbunit.profile.ddl=mssql.sql -dbunit.profile.unsupportedFeatures=BLOB,CLOB,SCROLLABLE_RESULTSET,SDO_GEOMETRY,XML_TYPE +dbunit.profile.unsupportedFeatures=BLOB,CLOB,SCROLLABLE_RESULTSET,SDO_GEOMETRY,XML_TYPE,TIMESTAMP_WITH_TIMEZONE dbunit.profile.multiLineSupport=false diff --git a/src/test/resources/mssql41-dbunit.properties b/src/test/resources/mssql41-dbunit.properties index f9504677..bbbff0d3 100644 --- a/src/test/resources/mssql41-dbunit.properties +++ b/src/test/resources/mssql41-dbunit.properties @@ -5,5 +5,5 @@ dbunit.profile.schema=dbo dbunit.profile.user=dbunit dbunit.profile.password=dbunit dbunit.profile.ddl=mssql.sql -dbunit.profile.unsupportedFeatures=BLOB,CLOB,SCROLLABLE_RESULTSET,SDO_GEOMETRY,XML_TYPE +dbunit.profile.unsupportedFeatures=BLOB,CLOB,SCROLLABLE_RESULTSET,SDO_GEOMETRY,XML_TYPE,TIMESTAMP_WITH_TIMEZONE dbunit.profile.multiLineSupport=false diff --git a/src/test/resources/mysql-dbunit.properties b/src/test/resources/mysql-dbunit.properties index 99ffe0af..9a4091d7 100644 --- a/src/test/resources/mysql-dbunit.properties +++ b/src/test/resources/mysql-dbunit.properties @@ -5,5 +5,5 @@ dbunit.profile.schema= dbunit.profile.user=dbunit dbunit.profile.password=dbunit dbunit.profile.ddl=mysql.sql -dbunit.profile.unsupportedFeatures=BLOB,CLOB,SCROLLABLE_RESULTSET,INSERT_IDENTITY,SDO_GEOMETRY,XML_TYPE +dbunit.profile.unsupportedFeatures=BLOB,CLOB,SCROLLABLE_RESULTSET,INSERT_IDENTITY,SDO_GEOMETRY,XML_TYPE,TIMESTAMP_WITH_TIMEZONE dbunit.profile.multiLineSupport=false diff --git a/src/test/resources/oracle-free-dbunit.properties b/src/test/resources/oracle-free-dbunit.properties index 09ea16b7..f84e2096 100644 --- a/src/test/resources/oracle-free-dbunit.properties +++ b/src/test/resources/oracle-free-dbunit.properties @@ -5,5 +5,5 @@ dbunit.profile.schema=DBUNIT dbunit.profile.user=dbunit dbunit.profile.password=dbunit dbunit.profile.ddl=oracle.sql -dbunit.profile.unsupportedFeatures=INSERT_IDENTITY +dbunit.profile.unsupportedFeatures=INSERT_IDENTITY,TIMESTAMP_WITH_TIMEZONE dbunit.profile.multiLineSupport=false diff --git a/src/test/resources/oracle-xe-dbunit.properties b/src/test/resources/oracle-xe-dbunit.properties index 1975fc35..202af051 100644 --- a/src/test/resources/oracle-xe-dbunit.properties +++ b/src/test/resources/oracle-xe-dbunit.properties @@ -5,5 +5,5 @@ dbunit.profile.schema=DBUNIT dbunit.profile.user=dbunit dbunit.profile.password=dbunit dbunit.profile.ddl=oracle.sql -dbunit.profile.unsupportedFeatures=INSERT_IDENTITY +dbunit.profile.unsupportedFeatures=INSERT_IDENTITY,TIMESTAMP_WITH_TIMEZONE dbunit.profile.multiLineSupport=false