This is an automated email from the ASF dual-hosted git repository. aadamchik pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/cayenne.git
The following commit(s) were added to refs/heads/master by this push: new a3cf68b16 CAY-2701 DST-related LocalDateTime issues a3cf68b16 is described below commit a3cf68b163d67352ae6661563d15a536cf38e553 Author: Andrus Adamchik <and...@objectstyle.com> AuthorDate: Thu Jan 30 17:35:37 2025 -0500 CAY-2701 DST-related LocalDateTime issues --- RELEASE-NOTES.txt | 1 + .../org/apache/cayenne/dba/mysql/MySQLAdapter.java | 24 +- .../cayenne/dba/mysql/MySQLLocalDateTimeType.java | 53 ++++ .../cayenne/access/types/DateTimeTypesIT.java | 330 ++++++++++++--------- .../testcontainers/MysqlContainerProvider.java | 4 +- pom.xml | 9 +- 6 files changed, 271 insertions(+), 150 deletions(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 21757a41c..ddeee5276 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -18,6 +18,7 @@ CAY-2873 Change Orderings.orderedList() to accept a Collection rather than a Lis Bug Fixes: +CAY-2701 MySQL DST-related LocalDateTime issues CAY-2871 QualifierTranslator breaks on a relationship with a compound FK CAY-2872 CayenneModeler "Documentation" link is broken diff --git a/cayenne/src/main/java/org/apache/cayenne/dba/mysql/MySQLAdapter.java b/cayenne/src/main/java/org/apache/cayenne/dba/mysql/MySQLAdapter.java index 4301ee40a..3aec4a1bd 100644 --- a/cayenne/src/main/java/org/apache/cayenne/dba/mysql/MySQLAdapter.java +++ b/cayenne/src/main/java/org/apache/cayenne/dba/mysql/MySQLAdapter.java @@ -19,16 +19,6 @@ package org.apache.cayenne.dba.mysql; -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.sql.Types; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Comparator; -import java.util.Iterator; -import java.util.List; - import org.apache.cayenne.access.DataNode; import org.apache.cayenne.access.sqlbuilder.sqltree.SQLTreeProcessor; import org.apache.cayenne.access.translator.ParameterBinding; @@ -49,7 +39,6 @@ import org.apache.cayenne.configuration.Constants; import org.apache.cayenne.configuration.RuntimeProperties; import org.apache.cayenne.dba.DefaultQuotingStrategy; import org.apache.cayenne.dba.JdbcAdapter; -import org.apache.cayenne.dba.PkGenerator; import org.apache.cayenne.dba.QuotingStrategy; import org.apache.cayenne.dba.TypesMapping; import org.apache.cayenne.di.Inject; @@ -59,6 +48,16 @@ import org.apache.cayenne.query.Query; import org.apache.cayenne.query.SQLAction; import org.apache.cayenne.resource.ResourceLocator; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; + /** * DbAdapter implementation for <a href="http://www.mysql.com">MySQL RDBMS</a>. * <h3> @@ -149,6 +148,9 @@ public class MySQLAdapter extends JdbcAdapter { protected void configureExtendedTypes(ExtendedTypeMap map) { super.configureExtendedTypes(map); + // TODO: this may need to be made universal across adapters + map.registerType(new MySQLLocalDateTimeType()); + // must handle CLOBs as strings, otherwise there // are problems with NULL clobs that are treated // as empty strings... somehow this doesn't happen diff --git a/cayenne/src/main/java/org/apache/cayenne/dba/mysql/MySQLLocalDateTimeType.java b/cayenne/src/main/java/org/apache/cayenne/dba/mysql/MySQLLocalDateTimeType.java new file mode 100644 index 000000000..a85b3db45 --- /dev/null +++ b/cayenne/src/main/java/org/apache/cayenne/dba/mysql/MySQLLocalDateTimeType.java @@ -0,0 +1,53 @@ +package org.apache.cayenne.dba.mysql; + +import org.apache.cayenne.access.types.ExtendedType; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.LocalDateTime; + +/** + * An extended type for DST-safe LocalDateTime handling that bypasses timezone resolution by reading and writing + * timestamps directly as LocalDateTime. + * + * @since 5.0 + */ +// TODO: only used by MySQL now. Test compatibility on other engines, and make it a universal approach +class MySQLLocalDateTimeType implements ExtendedType<LocalDateTime> { + + @Override + public String getClassName() { + return LocalDateTime.class.getName(); + } + + @Override + public LocalDateTime materializeObject(ResultSet rs, int index, int type) throws Exception { + return rs.getObject(index, LocalDateTime.class); + } + + @Override + public LocalDateTime materializeObject(CallableStatement rs, int index, int type) throws Exception { + return rs.getObject(index, LocalDateTime.class); + } + + @Override + public void setJdbcObject( + PreparedStatement st, + LocalDateTime val, + int pos, + int type, + int precision) throws Exception { + + if (val == null) { + st.setNull(pos, type); + } else { + st.setObject(pos, val, type); + } + } + + @Override + public String toString(LocalDateTime value) { + return String.valueOf(value); + } +} diff --git a/cayenne/src/test/java/org/apache/cayenne/access/types/DateTimeTypesIT.java b/cayenne/src/test/java/org/apache/cayenne/access/types/DateTimeTypesIT.java index a1769bc00..ab9a4b210 100644 --- a/cayenne/src/test/java/org/apache/cayenne/access/types/DateTimeTypesIT.java +++ b/cayenne/src/test/java/org/apache/cayenne/access/types/DateTimeTypesIT.java @@ -22,12 +22,15 @@ package org.apache.cayenne.access.types; import org.apache.cayenne.access.DataContext; import org.apache.cayenne.di.Inject; import org.apache.cayenne.query.ObjectSelect; +import org.apache.cayenne.query.SQLExec; +import org.apache.cayenne.query.SQLSelect; import org.apache.cayenne.test.jdbc.DBHelper; import org.apache.cayenne.testdo.datetime.DurationTestEntity; import org.apache.cayenne.testdo.datetime.LocalDateTestEntity; import org.apache.cayenne.testdo.datetime.LocalDateTimeTestEntity; import org.apache.cayenne.testdo.datetime.LocalTimeTestEntity; import org.apache.cayenne.testdo.datetime.PeriodTestEntity; +import org.apache.cayenne.unit.MySQLUnitDbAdapter; import org.apache.cayenne.unit.UnitDbAdapter; import org.apache.cayenne.unit.di.runtime.CayenneProjects; import org.apache.cayenne.unit.di.runtime.RuntimeCase; @@ -43,170 +46,227 @@ import java.time.LocalTime; import java.time.Period; import java.time.temporal.ChronoField; import java.time.temporal.TemporalField; +import java.util.TimeZone; import static org.junit.Assert.*; @UseCayenneRuntime(CayenneProjects.DATE_TIME_PROJECT) public class DateTimeTypesIT extends RuntimeCase { - @Inject - private DataContext context; + @Inject + private DataContext context; - @Inject - private UnitDbAdapter unitDbAdapter; + @Inject + private UnitDbAdapter unitDbAdapter; - @Inject - private DBHelper dbHelper; + @Inject + private DBHelper dbHelper; - @Before - public void before() throws SQLException { - dbHelper.deleteAll("LOCAL_DATE_TEST"); - dbHelper.deleteAll("LOCAL_DATETIME_TEST"); - dbHelper.deleteAll("LOCAL_TIME_TEST"); - dbHelper.deleteAll("DURATION_TEST"); - dbHelper.deleteAll("PERIOD_TEST"); - } + @Before + public void before() throws SQLException { + dbHelper.deleteAll("LOCAL_DATE_TEST"); + dbHelper.deleteAll("LOCAL_DATETIME_TEST"); + dbHelper.deleteAll("LOCAL_TIME_TEST"); + dbHelper.deleteAll("DURATION_TEST"); + dbHelper.deleteAll("PERIOD_TEST"); + } - @Test - public void testLocalDate_Null() { - LocalDateTestEntity localDateTestEntity = context.newObject(LocalDateTestEntity.class); - localDateTestEntity.setDate(null); + @Test + public void testLocalDate_Null() { + LocalDateTestEntity localDateTestEntity = context.newObject(LocalDateTestEntity.class); + localDateTestEntity.setDate(null); - context.commitChanges(); + context.commitChanges(); - LocalDateTestEntity testRead = ObjectSelect.query(LocalDateTestEntity.class).selectOne(context); + LocalDateTestEntity testRead = ObjectSelect.query(LocalDateTestEntity.class).selectOne(context); - assertNull(testRead.getDate()); - } + assertNull(testRead.getDate()); + } - @Test - public void testLocalDate() { - LocalDateTestEntity localDateTestEntity = context.newObject(LocalDateTestEntity.class); - LocalDate localDate = LocalDate.now(); - localDateTestEntity.setDate(localDate); + @Test + public void testLocalDate() { + LocalDateTestEntity localDateTestEntity = context.newObject(LocalDateTestEntity.class); + LocalDate localDate = LocalDate.now(); + localDateTestEntity.setDate(localDate); - context.commitChanges(); + context.commitChanges(); - LocalDateTestEntity testRead = ObjectSelect.query(LocalDateTestEntity.class).selectOne(context); + LocalDateTestEntity testRead = ObjectSelect.query(LocalDateTestEntity.class).selectOne(context); - assertNotNull(testRead.getDate()); - assertEquals(LocalDate.class, testRead.getDate().getClass()); - assertEquals(localDate, testRead.getDate()); - } + assertNotNull(testRead.getDate()); + assertEquals(LocalDate.class, testRead.getDate().getClass()); + assertEquals(localDate, testRead.getDate()); + } - @Test - public void testLocalTime() { - LocalTimeTestEntity localTimeTestEntity = context.newObject(LocalTimeTestEntity.class); - LocalTime localTime = LocalTime.now(); - localTimeTestEntity.setTime(localTime); + @Test + public void testLocalTime() { + LocalTimeTestEntity localTimeTestEntity = context.newObject(LocalTimeTestEntity.class); + LocalTime localTime = LocalTime.now(); + localTimeTestEntity.setTime(localTime); - context.commitChanges(); + context.commitChanges(); - LocalTimeTestEntity testRead = ObjectSelect.query(LocalTimeTestEntity.class).selectOne(context); + LocalTimeTestEntity testRead = ObjectSelect.query(LocalTimeTestEntity.class).selectOne(context); - TemporalField testValue = unitDbAdapter.supportsPreciseTime() - ? ChronoField.MILLI_OF_DAY - : ChronoField.SECOND_OF_DAY; + TemporalField testValue = unitDbAdapter.supportsPreciseTime() + ? ChronoField.MILLI_OF_DAY + : ChronoField.SECOND_OF_DAY; - assertNotNull(testRead.getTime()); - assertEquals(LocalTime.class, testRead.getTime().getClass()); - assertEquals(localTime.toSecondOfDay(), testRead.getTime().toSecondOfDay()); - assertEquals(localTime.get(testValue), testRead.getTime().get(testValue)); - } + assertNotNull(testRead.getTime()); + assertEquals(LocalTime.class, testRead.getTime().getClass()); + assertEquals(localTime.toSecondOfDay(), testRead.getTime().toSecondOfDay()); + assertEquals(localTime.get(testValue), testRead.getTime().get(testValue)); + } - @Test - public void testLocalDateTime() { - LocalDateTimeTestEntity localDateTimeTestEntity = context.newObject(LocalDateTimeTestEntity.class); - // round up seconds fraction - // reason: on MySQL field should be defined as TIMESTAMP(fractionSecondsPrecision) to support it - LocalDateTime localDateTime = LocalDateTime.now().with(ChronoField.NANO_OF_SECOND, 0); - localDateTimeTestEntity.setTimestamp(localDateTime); + @Test + public void testLocalDateTime() { + LocalDateTimeTestEntity localDateTimeTestEntity = context.newObject(LocalDateTimeTestEntity.class); + // round up seconds fraction + // reason: on MySQL field should be defined as TIMESTAMP(fractionSecondsPrecision) to support it + LocalDateTime localDateTime = LocalDateTime.now().with(ChronoField.NANO_OF_SECOND, 0); + localDateTimeTestEntity.setTimestamp(localDateTime); + + context.commitChanges(); + + LocalDateTimeTestEntity testRead = ObjectSelect.query(LocalDateTimeTestEntity.class).selectOne(context); - context.commitChanges(); + assertNotNull(testRead.getTimestamp()); + assertEquals(LocalDateTime.class, testRead.getTimestamp().getClass()); + assertEquals(localDateTime, testRead.getTimestamp()); - LocalDateTimeTestEntity testRead = ObjectSelect.query(LocalDateTimeTestEntity.class).selectOne(context); + } + + @Test + public void testLocalDateTime_DST_Select() { - assertNotNull(testRead.getTimestamp()); - assertEquals(LocalDateTime.class, testRead.getTimestamp().getClass()); - assertEquals(localDateTime, testRead.getTimestamp()); + if (!(unitDbAdapter instanceof MySQLUnitDbAdapter)) { + // skip other DB's for now until we can test across all of them + return; + } + + // not using TableHelper to avoid PreparedStatement parameter interactions for this timestamp + SQLExec.query("insert into LOCAL_DATETIME_TEST values (1, '2021-03-14 02:35:00')").execute(context); - } + // set the TZ where this time doesn't exist because of the DST switchover at 2 AM + TimeZone defaultTz = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")); - @Test - public void columnSelectWithLocalDateTime() { - // round up seconds fraction - // reason: on MySQL field should be defined as TIMESTAMP(fractionSecondsPrecision) to support it - LocalDateTime localDateTime = LocalDateTime.now().with(ChronoField.NANO_OF_SECOND, 0); + try { + // LocalDateTime is TZ-agnostic, so the timestamp should not be rewound to 3 AM just because it happens to + // match the DST "gap" in US timezones + LocalDateTime value = ObjectSelect.query(LocalDateTimeTestEntity.class) + .column(LocalDateTimeTestEntity.TIMESTAMP) + .selectOne(context); + + assertEquals(LocalDateTime.of(2021, 3, 14, 2, 35, 0), value); + } finally { + TimeZone.setDefault(defaultTz); + } + } + + @Test + public void testLocalDateTime_DST_Insert() { + + if (!(unitDbAdapter instanceof MySQLUnitDbAdapter)) { + // skip other DB's for now until we can test across all of them + return; + } + + // set the TZ where this time doesn't exist because of the DST switchover at 2 AM + TimeZone defaultTz = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")); - LocalDateTimeTestEntity localDateTimeTestEntity = context.newObject(LocalDateTimeTestEntity.class); - localDateTimeTestEntity.setTimestamp(localDateTime); + try { - context.commitChanges(); - - LocalDateTime value = ObjectSelect.query(LocalDateTimeTestEntity.class) - .column(LocalDateTimeTestEntity.TIMESTAMP) - .selectOne(context); - assertEquals(localDateTime, value); - - LocalDateTime value2 = ObjectSelect.query(LocalDateTimeTestEntity.class) - .min(LocalDateTimeTestEntity.TIMESTAMP) - .selectOne(context); - assertEquals(localDateTime, value2); - } - - @Test - public void testDuration() { - DurationTestEntity durationTestEntity = context.newObject(DurationTestEntity.class); - Duration duration = Duration.ofDays(10); - durationTestEntity.setDurationBigInt(duration); - durationTestEntity.setDurationDecimal(duration); - durationTestEntity.setDurationInt(duration); - durationTestEntity.setDurationLongVarchar(duration); - durationTestEntity.setDurationNumeric(duration); - durationTestEntity.setDurationVarchar(duration); - - context.commitChanges(); - - DurationTestEntity testRead = ObjectSelect.query(DurationTestEntity.class).selectOne(context); - - assertNotNull(testRead.getDurationBigInt()); - assertEquals(Duration.class, testRead.getDurationBigInt().getClass()); - assertEquals(duration, testRead.getDurationBigInt()); - - assertNotNull(testRead.getDurationDecimal()); - assertEquals(Duration.class, testRead.getDurationDecimal().getClass()); - assertEquals(duration, testRead.getDurationDecimal()); - - assertNotNull(testRead.getDurationInt()); - assertEquals(Duration.class, testRead.getDurationInt().getClass()); - assertEquals(duration, testRead.getDurationInt()); - - assertNotNull(testRead.getDurationLongVarchar()); - assertEquals(Duration.class, testRead.getDurationLongVarchar().getClass()); - assertEquals(duration, testRead.getDurationLongVarchar()); - - assertNotNull(testRead.getDurationNumeric()); - assertEquals(Duration.class, testRead.getDurationNumeric().getClass()); - assertEquals(duration, testRead.getDurationNumeric()); - - assertNotNull(testRead.getDurationVarchar()); - assertEquals(Duration.class, testRead.getDurationVarchar().getClass()); - assertEquals(duration, testRead.getDurationVarchar()); - } - - @Test - public void testPeriod() { - PeriodTestEntity periodTestEntity = context.newObject(PeriodTestEntity.class); - Period period = Period.of(100, 10, 5); - periodTestEntity.setPeriodField(period); - - context.commitChanges(); - - PeriodTestEntity testRead = ObjectSelect.query(PeriodTestEntity.class).selectOne(context); - - assertNotNull(testRead.getPeriodField()); - assertEquals(Period.class, testRead.getPeriodField().getClass()); - assertEquals(period, testRead.getPeriodField()); - } + LocalDateTimeTestEntity o = context.newObject(LocalDateTimeTestEntity.class); + o.setTimestamp(LocalDateTime.of(2021, 3, 14, 2, 35, 0)); + context.commitChanges(); + + String val = SQLSelect + .scalarQuery("select CAST(TimestampField as CHAR) from LOCAL_DATETIME_TEST", String.class) + .selectOne(context); + + assertEquals("2021-03-14 02:35:00", val); + } finally { + TimeZone.setDefault(defaultTz); + } + } + + @Test + public void columnSelectWithLocalDateTime() { + // round up seconds fraction + // reason: on MySQL field should be defined as TIMESTAMP(fractionSecondsPrecision) to support it + LocalDateTime localDateTime = LocalDateTime.now().with(ChronoField.NANO_OF_SECOND, 0); + + LocalDateTimeTestEntity localDateTimeTestEntity = context.newObject(LocalDateTimeTestEntity.class); + localDateTimeTestEntity.setTimestamp(localDateTime); + + context.commitChanges(); + + LocalDateTime value = ObjectSelect.query(LocalDateTimeTestEntity.class) + .column(LocalDateTimeTestEntity.TIMESTAMP) + .selectOne(context); + assertEquals(localDateTime, value); + + LocalDateTime value2 = ObjectSelect.query(LocalDateTimeTestEntity.class) + .min(LocalDateTimeTestEntity.TIMESTAMP) + .selectOne(context); + assertEquals(localDateTime, value2); + } + + @Test + public void testDuration() { + DurationTestEntity durationTestEntity = context.newObject(DurationTestEntity.class); + Duration duration = Duration.ofDays(10); + durationTestEntity.setDurationBigInt(duration); + durationTestEntity.setDurationDecimal(duration); + durationTestEntity.setDurationInt(duration); + durationTestEntity.setDurationLongVarchar(duration); + durationTestEntity.setDurationNumeric(duration); + durationTestEntity.setDurationVarchar(duration); + + context.commitChanges(); + + DurationTestEntity testRead = ObjectSelect.query(DurationTestEntity.class).selectOne(context); + + assertNotNull(testRead.getDurationBigInt()); + assertEquals(Duration.class, testRead.getDurationBigInt().getClass()); + assertEquals(duration, testRead.getDurationBigInt()); + + assertNotNull(testRead.getDurationDecimal()); + assertEquals(Duration.class, testRead.getDurationDecimal().getClass()); + assertEquals(duration, testRead.getDurationDecimal()); + + assertNotNull(testRead.getDurationInt()); + assertEquals(Duration.class, testRead.getDurationInt().getClass()); + assertEquals(duration, testRead.getDurationInt()); + + assertNotNull(testRead.getDurationLongVarchar()); + assertEquals(Duration.class, testRead.getDurationLongVarchar().getClass()); + assertEquals(duration, testRead.getDurationLongVarchar()); + + assertNotNull(testRead.getDurationNumeric()); + assertEquals(Duration.class, testRead.getDurationNumeric().getClass()); + assertEquals(duration, testRead.getDurationNumeric()); + + assertNotNull(testRead.getDurationVarchar()); + assertEquals(Duration.class, testRead.getDurationVarchar().getClass()); + assertEquals(duration, testRead.getDurationVarchar()); + } + + @Test + public void testPeriod() { + PeriodTestEntity periodTestEntity = context.newObject(PeriodTestEntity.class); + Period period = Period.of(100, 10, 5); + periodTestEntity.setPeriodField(period); + + context.commitChanges(); + + PeriodTestEntity testRead = ObjectSelect.query(PeriodTestEntity.class).selectOne(context); + + assertNotNull(testRead.getPeriodField()); + assertEquals(Period.class, testRead.getPeriodField().getClass()); + assertEquals(period, testRead.getPeriodField()); + } } diff --git a/cayenne/src/test/java/org/apache/cayenne/unit/testcontainers/MysqlContainerProvider.java b/cayenne/src/test/java/org/apache/cayenne/unit/testcontainers/MysqlContainerProvider.java index aabfbe6e3..e52c98434 100644 --- a/cayenne/src/test/java/org/apache/cayenne/unit/testcontainers/MysqlContainerProvider.java +++ b/cayenne/src/test/java/org/apache/cayenne/unit/testcontainers/MysqlContainerProvider.java @@ -18,14 +18,14 @@ ****************************************************************/ package org.apache.cayenne.unit.testcontainers; -import java.util.Calendar; - import org.apache.cayenne.dba.JdbcAdapter; import org.apache.cayenne.dba.mysql.MySQLAdapter; import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import java.util.Calendar; + public class MysqlContainerProvider extends TestContainerProvider { @Override diff --git a/pom.xml b/pom.xml index 3923d68e3..2330a835a 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ <slf4j.version>1.7.36</slf4j.version> <ant.version>1.10.12</ant.version> <testcontainers.version>1.20.1</testcontainers.version> + <mysql.connector.version>8.0.29</mysql.connector.version> <maven.compiler.target>11</maven.compiler.target> <maven.compiler.source>11</maven.compiler.source> @@ -399,6 +400,12 @@ <version>10.14.2.0</version> <scope>test</scope> </dependency> + <dependency> + <groupId>mysql</groupId> + <artifactId>mysql-connector-java</artifactId> + <version>${mysql.connector.version}</version> + <scope>test</scope> + </dependency> <dependency> <groupId>org.xmlunit</groupId> <artifactId>xmlunit-matchers</artifactId> @@ -1031,7 +1038,6 @@ <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> - <version>8.0.29</version> <scope>test</scope> </dependency> </dependencies> @@ -1048,7 +1054,6 @@ <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> - <version>8.0.29</version> <scope>test</scope> </dependency> </dependencies>