From 5b9d695273b2fb0b615b93c5fb2c123d222bb773 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Fri, 6 Nov 2026 09:13:02 +0100 Subject: [PATCH 1/2] [OPENJPA-3028] Bind DATE values without their time of day A java.util.Date or Calendar bound for a DATE column, as the value of a @Temporal(DATE) attribute or as a parameter, and a parameter set with TemporalType.DATE were converted with new java.sql.Date(millis), which keeps the time of day, although a java.sql.Date has to be normalized to the start of its day. A java.sql.Date holding a time of day was bound as it was. Most drivers drop the time when binding a date, but the Oracle driver stores and compares it as part of the DATE value, so a date attribute or parameter holding a time of day never equalled a stored date. Every date bound for a DATE column, or as a parameter without a known column, whether it is a java.util.Date, a Calendar or a java.sql.Date, is now bound as the java.sql.Date of its day in the default time zone, so that the same value binds the same way in an INSERT, in a query parameter and in a primary key lookup. A java.sql.Date bound with an explicit Calendar, and a java.sql.Date attribute mapped to a column that is not of type DATE, are bound as before. Behaviour change on Oracle: the time of day of a date attribute mapped to a DATE column (a java.sql.Date, or a java.util.Date or Calendar with @Temporal(DATE)) is no longer stored, and such values and TemporalType.DATE parameters are bound as midnight. The time of day of the value held by the entity in memory is unchanged. Rows written by earlier versions keep the time of day they were stored with, and no longer compare equal to the same value bound now: a query on such a column, em.find on such an id, or an update or delete of a row keyed by such a column does not find the row. Normalize those columns, for example with UPDATE SET = TRUNC(), before upgrading. Databases whose DATE type has no time of day are not affected. --- .../apache/openjpa/jdbc/sql/DBDictionary.java | 16 ++- .../org/apache/openjpa/kernel/Filters.java | 16 +++ .../query/TestDateTemporalTypeParameter.java | 132 ++++++++++++++++++ .../openjpa/persistence/AbstractQuery.java | 5 +- 4 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestDateTemporalTypeParameter.java diff --git a/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/DBDictionary.java b/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/DBDictionary.java index bfc52086e9..f668f3353d 100644 --- a/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/DBDictionary.java +++ b/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/DBDictionary.java @@ -1376,23 +1376,27 @@ public void setClobString(PreparedStatement stmnt, int idx, String val, */ public void setDate(PreparedStatement stmnt, int idx, Date val, Column col) throws SQLException { - if (col != null && col.getType() == Types.DATE) + if (col != null && col.getType() == Types.DATE) { setDate(stmnt, idx, new java.sql.Date(val.getTime()), null, col); - else if (col != null && col.getType() == Types.TIME) + } else if (col != null && col.getType() == Types.TIME) { setTime(stmnt, idx, new Time(val.getTime()), null, col); - else if (val instanceof Timestamp) - setTimestamp(stmnt, idx,(Timestamp) val, null, col); - else + } else if (val instanceof Timestamp timestamp) { + setTimestamp(stmnt, idx, timestamp, null, col); + } else { setTimestamp(stmnt, idx, new Timestamp(val.getTime()), null, col); + } } /** * Set the given value as a parameter to the statement. + * Without a calendar, a value for a DATE column or for no known column is bound as the date of its day, + * normalized to the start of the day as JDBC requires: drivers such as Oracle's store and compare the + * time of day of a {@link java.sql.Date} otherwise. */ public void setDate(PreparedStatement stmnt, int idx, java.sql.Date val, Calendar cal, Column col) throws SQLException { if (cal == null) { - stmnt.setDate(idx, val); + stmnt.setDate(idx, col == null || col.getType() == Types.DATE ? Filters.toSqlDate(val) : val); } else { stmnt.setDate(idx, val, cal); diff --git a/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/Filters.java b/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/Filters.java index 9533465915..8eb067c2d2 100644 --- a/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/Filters.java +++ b/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/Filters.java @@ -38,6 +38,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.GregorianCalendar; import java.util.List; import org.apache.openjpa.enhance.Reflection; @@ -496,6 +497,21 @@ private static Instant toInstant(Date date) { return (date instanceof Timestamp) ? date.toInstant() : Instant.ofEpochMilli(date.getTime()); } + /** + * The {@link java.sql.Date} of the day of the given date in the default time zone, normalized to the + * start of that day as JDBC requires. {@code new java.sql.Date(millis)} keeps the time of day, which + * drivers such as Oracle's bind as part of a DATE value. + */ + public static java.sql.Date toSqlDate(Date date) { + Calendar day = new GregorianCalendar(); + day.setTime(date); + Calendar start = new GregorianCalendar(); + start.clear(); + start.set(Calendar.ERA, day.get(Calendar.ERA)); + start.set(day.get(Calendar.YEAR), day.get(Calendar.MONTH), day.get(Calendar.DAY_OF_MONTH)); + return new java.sql.Date(start.getTimeInMillis()); + } + private static boolean allowNumericConversion(Class actual, Class target, boolean strict) { if (!strict || actual == target) return true; diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestDateTemporalTypeParameter.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestDateTemporalTypeParameter.java new file mode 100644 index 0000000000..b75351461a --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestDateTemporalTypeParameter.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.openjpa.persistence.query; + +import java.time.LocalDate; +import java.util.Calendar; +import java.util.List; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Parameter; +import jakarta.persistence.Query; +import jakarta.persistence.TemporalType; + +import org.apache.openjpa.persistence.detach.Entity20; +import org.apache.openjpa.persistence.simple.TemporalFieldTypes; +import org.apache.openjpa.persistence.test.SingleEMFTestCase; + +/** + * A Calendar, java.util.Date or java.sql.Date bound for a DATE column or with TemporalType.DATE, as a query + * parameter or as the value of a DATE attribute, is bound as a java.sql.Date without its time of day, so that + * dates compare equal also on databases such as Oracle whose DATE values have a time of day. + */ +public class TestDateTemporalTypeParameter extends SingleEMFTestCase { + + @Override + public void setUp() { + setUp(CLEAR_TABLES, Entity20.class, TemporalFieldTypes.class); + } + + public void testCalendarAndDateParametersAreBoundWithoutTimeOfDay() { + Calendar now = Calendar.getInstance(); + now.set(Calendar.HOUR_OF_DAY, 13); + java.sql.Date today = java.sql.Date.valueOf(LocalDate.of(now.get(Calendar.YEAR), + now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH))); + persistEntity20(today); + + try (EntityManager em = emf.createEntityManager()) { + Query q = em.createQuery("SELECT e FROM Entity20 e WHERE e.sqlDate = ?1") + .setParameter(1, now, TemporalType.DATE); + assertEquals(today, q.getParameterValue(1)); + assertIds(q.getResultList()); + + q = em.createQuery("SELECT e FROM Entity20 e WHERE e.sqlDate = :d") + .setParameter("d", now.getTime(), TemporalType.DATE); + assertEquals(today, q.getParameterValue("d")); + assertIds(q.getResultList()); + + // the parameter is of the type of the attribute, a Calendar is bound to it with a TemporalType + q = em.createQuery("SELECT e FROM Entity20 e WHERE e.sqlDate = :d"); + @SuppressWarnings("unchecked") + Parameter d = (Parameter) q.getParameter("d"); + q.setParameter(d, now, TemporalType.DATE); + assertSame(now, q.getParameterValue("d")); + assertIds(q.getResultList()); + } + } + + public void testDateAttributesAreStoredWithoutTimeOfDay() { + Calendar now = Calendar.getInstance(); + now.set(Calendar.HOUR_OF_DAY, 13); + java.sql.Date today = java.sql.Date.valueOf(LocalDate.of(now.get(Calendar.YEAR), + now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH))); + + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + TemporalFieldTypes e = new TemporalFieldTypes(); + e.setDateDateField(now.getTime()); + e.setCalendarDateField((Calendar) now.clone()); + em.persist(e); + em.getTransaction().commit(); + em.clear(); + + assertEquals(1, em.createQuery("SELECT t FROM TemporalFieldTypes t WHERE t.dateDateField = :d") + .setParameter("d", now, TemporalType.DATE).getResultList().size()); + assertEquals(1, em.createQuery("SELECT t FROM TemporalFieldTypes t WHERE t.calendarDateField = :d") + .setParameter("d", now, TemporalType.DATE).getResultList().size()); + assertEquals(1, em.createQuery("SELECT t FROM TemporalFieldTypes t WHERE t.dateDateField = :d") + .setParameter("d", today).getResultList().size()); + assertEquals(1, em.createQuery("SELECT t FROM TemporalFieldTypes t WHERE t.calendarDateField = :d") + .setParameter("d", today).getResultList().size()); + } + } + + public void testSqlDateWithTimeOfDayIsBoundWithoutIt() { + Calendar now = Calendar.getInstance(); + now.set(Calendar.HOUR_OF_DAY, 13); + java.sql.Date today = java.sql.Date.valueOf(LocalDate.of(now.get(Calendar.YEAR), + now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH))); + java.sql.Date withTimeOfDay = new java.sql.Date(now.getTimeInMillis()); + persistEntity20(withTimeOfDay); + + try (EntityManager em = emf.createEntityManager()) { + assertIds(em.createQuery("SELECT e FROM Entity20 e WHERE e.sqlDate = :d") + .setParameter("d", today).getResultList()); + assertIds(em.createQuery("SELECT e FROM Entity20 e WHERE e.sqlDate = :d") + .setParameter("d", withTimeOfDay).getResultList()); + assertIds(em.createQuery("SELECT e FROM Entity20 e WHERE e.sqlDate = :d") + .setParameter("d", now, TemporalType.DATE).getResultList()); + } + } + + private void persistEntity20(java.sql.Date date) { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + Entity20 e = new Entity20(1); + e.setDate(date); + em.persist(e); + em.getTransaction().commit(); + } + } + + private static void assertIds(List result) { + assertEquals(1, result.size()); + assertEquals(Integer.valueOf(1), ((Entity20) result.get(0)).getId()); + } +} diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AbstractQuery.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AbstractQuery.java index 374b09239f..3182472a43 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AbstractQuery.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AbstractQuery.java @@ -196,11 +196,14 @@ public OpenJPAQuery setParameter(int position, Date value, TemporalType type) /** * Converts the given Date to a value corresponding to given temporal type. + * A {@link java.sql.Date} is the date of the day of the value, normalized to the start of the day as JDBC + * requires: drivers such as Oracle's bind the time of day of a date otherwise, and the value then never + * equals a stored date. */ Object convertTemporalType(Date value, TemporalType type) { switch (type) { case DATE: - return new java.sql.Date(value.getTime()); + return Filters.toSqlDate(value); case TIME: return new Time(value.getTime()); case TIMESTAMP: From b2a79783be3ce63294d63ff7bdd6eaffdd92acdf Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Fri, 6 Nov 2026 09:13:02 +0100 Subject: [PATCH 2/2] [OPENJPA-3028] Bind times at the precision of DATE columns on Oracle Times are mapped to DATE columns on Oracle, which drop the fraction of the second when a value is stored. A LocalTime or OffsetTime parameter with a fraction of a second was bound with it and so never equalled the value stored for the same time. A time bound for a DATE column is now bound without the fraction; columns that can hold it, such as TIMESTAMP columns, are not affected. --- .../openjpa/jdbc/sql/OracleDictionary.java | 19 +++++- .../simple/TestTimeParameterPrecision.java | 68 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/simple/TestTimeParameterPrecision.java diff --git a/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/OracleDictionary.java b/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/OracleDictionary.java index 767699a9e5..f084e75c0b 100644 --- a/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/OracleDictionary.java +++ b/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/sql/OracleDictionary.java @@ -787,9 +787,26 @@ public LocalDate getLocalDate(ResultSet rs, int column) throws SQLException { return rs.getObject(column, LocalDate.class); } + /** + * A time held in a DATE column, the default mapping of times on Oracle, loses the fraction of its + * second when it is stored. A time bound for such a column is bound at that precision, so that a + * time parameter equals the value stored for the same time. + */ @Override public void setLocalTime(PreparedStatement stmnt, int idx, LocalTime val, Column col) throws SQLException { - stmnt.setObject(idx, val); + stmnt.setObject(idx, isTimeInDateColumn(col) ? val.withNano(0) : val); + } + + private boolean isTimeInDateColumn(Column col) { + if (col == null) { + return false; + } + String typeName = col.getTypeName(); + if (typeName != null) { + return typeName.toUpperCase(Locale.ENGLISH).startsWith("DATE"); + } + return (col.getType() == Types.TIME && "DATE".equalsIgnoreCase(timeTypeName)) + || (col.getType() == Types.TIME_WITH_TIMEZONE && "DATE".equalsIgnoreCase(timeWithZoneTypeName)); } @Override diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/simple/TestTimeParameterPrecision.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/simple/TestTimeParameterPrecision.java new file mode 100644 index 0000000000..81b774fc79 --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/simple/TestTimeParameterPrecision.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.openjpa.persistence.simple; + +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZoneOffset; +import java.util.List; + +import jakarta.persistence.EntityManager; + +import org.apache.openjpa.persistence.test.SingleEMFTestCase; + +/** + * A time parameter with a fraction of a second finds the entity stored with the same time, also where + * the time column cannot hold the fraction, such as the DATE column a time is mapped to on Oracle. + */ +public class TestTimeParameterPrecision extends SingleEMFTestCase { + + private static final LocalTime TIME = LocalTime.of(10, 11, 12, 345_678_000); + private static final OffsetTime OFFSET_TIME = OffsetTime.of(TIME, ZoneOffset.UTC); + + @Override + public void setUp() { + setUp(CLEAR_TABLES, Java8TimeTypes.class); + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + Java8TimeTypes e = new Java8TimeTypes(); + e.setId(1); + e.setLocalTimeField(TIME); + e.setOffsetTimeField(OFFSET_TIME); + em.persist(e); + em.getTransaction().commit(); + } + } + + public void testFindByLocalTime() { + try (EntityManager em = emf.createEntityManager()) { + List ids = em.createQuery("SELECT e.id FROM Java8TimeTypes e WHERE e.localTimeField = :t", + Integer.class).setParameter("t", TIME).getResultList(); + assertEquals(List.of(1), ids); + } + } + + public void testFindByOffsetTime() { + try (EntityManager em = emf.createEntityManager()) { + List ids = em.createQuery("SELECT e.id FROM Java8TimeTypes e WHERE e.offsetTimeField = :t", + Integer.class).setParameter("t", OFFSET_TIME).getResultList(); + assertEquals(List.of(1), ids); + } + } +}