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-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-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-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); + } + } +} 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: