diff --git a/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/QueryImpl.java b/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/QueryImpl.java index 463a90bc09..961b463c33 100644 --- a/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/QueryImpl.java +++ b/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/QueryImpl.java @@ -1007,6 +1007,15 @@ public long updateAll(Map params) { /** * Return whether we should execute this query in memory. + *

+ * Section 3.10.8 of the specification scopes the duty of a + * FlushModeType.AUTO query to the updates "which could potentially + * affect the result of the query", and lets the provider discharge it "by flushing + * those entities to the database or by some other means". The access path check + * below is that other means: only an instance of a type the query reads can change + * its result, so a query is never made to flush a transaction's unrelated writes. + * The same section forbids flushing when no transaction is active, which + * {@link Broker#isActive()} covers. */ private boolean isInMemory(int operation) { // if there are any dirty instances in the current trans that are @@ -1014,24 +1023,8 @@ private boolean isInMemory(int operation) { boolean inMem = !_storeQuery.supportsDataStoreExecution() || _collection != null; int flush = _fc.getFlushBeforeQueries(); - - // Per JPA spec, FlushModeType.AUTO (FLUSH_TRUE) requires that - // pending changes are visible to queries. The access path check - // may miss dirty entities reachable via navigation (e.g., - // o.customer.spouse), so when FLUSH_TRUE is set we check for - // ANY dirty objects rather than only those in the access path. - boolean dirty; - if (flush == FLUSH_TRUE && _broker.isActive()) { - dirty = !_broker.getPersistedTypes().isEmpty() - || !_broker.getUpdatedTypes().isEmpty() - || !_broker.getDeletedTypes().isEmpty(); - } else { - dirty = isAccessPathDirty(); - } - - if (!inMem && (!_ignoreChanges || operation != OP_SELECT - || flush == FLUSH_TRUE) - && _broker.isActive() && dirty) { + if (!inMem && (!_ignoreChanges || operation != OP_SELECT) + && _broker.isActive() && isAccessPathDirty()) { if ((flush == FLUSH_TRUE || (flush == FLUSH_WITH_CONNECTION && _broker.hasConnection()) || operation != OP_SELECT diff --git a/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/exps/AbstractExpressionBuilder.java b/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/exps/AbstractExpressionBuilder.java index a659b04c7a..4a34648d42 100644 --- a/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/exps/AbstractExpressionBuilder.java +++ b/openjpa-kernel/src/main/java/org/apache/openjpa/kernel/exps/AbstractExpressionBuilder.java @@ -127,6 +127,26 @@ protected ClassMetaData addAccessPath(ClassMetaData meta) { return meta; } + /** + * Register the element and key types of the given field in the query's access path. + * A collection or map field declares Collection/List/Map as its type, which has no + * metadata of its own, so the types such a field reads are its element and key + * types. Registering them keeps the access path complete for a predicate that uses + * the field without declaring a join variable - SIZE(x.coll), x.coll IS EMPTY - and + * mirrors what the Criteria builder records for the same field. + */ + protected void addElementAndKeyAccessPath(FieldMetaData fmd) { + if (fmd == null) { + return; + } + if (fmd.getElement() != null && fmd.getElement().getDeclaredTypeMetaData() != null) { + addAccessPath(fmd.getElement().getDeclaredTypeMetaData()); + } + if (fmd.getKey() != null && fmd.getKey().getDeclaredTypeMetaData() != null) { + addAccessPath(fmd.getKey().getDeclaredTypeMetaData()); + } + } + /** * Return the recorded query access path. */ @@ -310,6 +330,12 @@ protected Value traversePath(Path path, String field, boolean pcOnly, // ex2b). Resolve via the @MapsId @ManyToOne target entity. meta = resolveMapsIdTargetMeta(fmd); } + // A collection or map field has no declared type metadata of its own - its + // declared type is Collection/List/Map - so the types it actually reads are its + // element and key types. They have to be registered for predicates that use the + // field without declaring a join variable, such as SIZE(x.coll) or + // x.coll IS EMPTY; without them a dirty element would not be flushed. + addElementAndKeyAccessPath(fmd); if (meta != null) { addAccessPath(meta); path.setMetaData(meta); diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/TestQueryFlushAccessPath.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/TestQueryFlushAccessPath.java new file mode 100644 index 0000000000..263ba2a990 --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/TestQueryFlushAccessPath.java @@ -0,0 +1,411 @@ +/* + * 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.flush; + +import java.util.List; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.FlushModeType; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; + +import org.apache.openjpa.persistence.test.SQLListenerTestCase; + +/** + * Asserts which dirty instances make a FlushModeType.AUTO query flush. + *

+ * Section 3.10.8 of the specification scopes the provider's duty to updates + * "which could potentially affect the result of the query", and lets the provider + * discharge it "by flushing those entities to the database or by some other means". + * OpenJPA's "other means" is the query access path: only a dirty instance of a type + * the query actually reads can affect its result, so only such an instance forces a + * flush. The assertions below are on emitted SQL, not on results, because a + * full-context flush returns the same results while costing an extra round trip. + */ +public class TestQueryFlushAccessPath extends SQLListenerTestCase { + + private static final String ANY_UPDATE = "(?i).*UPDATE\\s+FLUSH_.*"; + private static final String UPDATE_SPOUSE = "(?i).*UPDATE\\s+FLUSH_SPOUSE.*"; + private static final String UPDATE_CUSTOMER = "(?i).*UPDATE\\s+FLUSH_CUSTOMER.*"; + private static final String UPDATE_ORDER = "(?i).*UPDATE\\s+FLUSH_ORDER.*"; + private static final String INSERT_ORDER = "(?i).*INSERT\\s+INTO\\s+FLUSH_ORDER.*"; + private static final String DELETE_ORDER = "(?i).*DELETE\\s+FROM\\s+FLUSH_ORDER.*"; + + @Override + public void setUp() { + setUp(UnenhancedFlushSpouse.class, + UnenhancedFlushCustomer.class, + UnenhancedFlushOrder.class, + UnenhancedFlushExpressOrder.class, + "openjpa.RuntimeUnenhancedClasses", "supported", + DROP_TABLES); + + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + UnenhancedFlushCustomer cust = new UnenhancedFlushCustomer("10", "Kate P. Hudson"); + em.persist(cust); + UnenhancedFlushSpouse spouse = new UnenhancedFlushSpouse("4", "Thomas", "Mullen"); + spouse.setCustomer(cust); + em.persist(spouse); + cust.setSpouse(spouse); + em.persist(new UnenhancedFlushOrder("11", cust)); + UnenhancedFlushExpressOrder express = new UnenhancedFlushExpressOrder("12", cust); + express.setCarrier("DHL"); + em.persist(express); + em.getTransaction().commit(); + } + resetSQL(); + } + + /** + * A dirty instance of a type outside the query's access path cannot affect the + * query result, so it must not force a flush. + */ + public void testDirtyTypeOutsideAccessPathDoesNotFlush() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushSpouse.class, "4").setLastName("Miller"); + resetSQL(); + + List orders = em.createQuery( + "SELECT o FROM UnenhancedFlushOrder o WHERE o.totalPrice = 0", + UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertEquals(2, orders.size()); + assertNotSQL(ANY_UPDATE); + em.getTransaction().rollback(); + } + } + + /** + * A dirty instance of the queried type itself must be visible to the query. + */ + public void testDirtyCandidateTypeFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushOrder.class, "11").setTotalPrice(17d); + resetSQL(); + + List orders = em.createQuery( + "SELECT o FROM UnenhancedFlushOrder o WHERE o.totalPrice = 17", + UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_ORDER); + assertEquals(1, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * A dirty instance of a subclass of a type in the access path must be visible: + * the query reads the subclass rows too. + */ + public void testDirtySubclassOfCandidateTypeFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushExpressOrder.class, "12").setTotalPrice(23d); + resetSQL(); + + List orders = em.createQuery( + "SELECT o FROM UnenhancedFlushOrder o WHERE o.totalPrice = 23", + UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_ORDER); + assertEquals(1, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * A dirty instance of a type reached only by JPQL navigation is in the access + * path, so it must be visible to the query. + */ + public void testDirtyNavigatedTypeFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushSpouse.class, "4").setLastName("Miller"); + resetSQL(); + + List orders = em.createQuery( + "SELECT o FROM UnenhancedFlushOrder o " + + "WHERE o.customer.spouse.lastName = 'Miller'", + UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_SPOUSE); + assertEquals(2, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * The same navigation expressed with the Criteria API: an implicit join through + * {@code get()} must contribute to the access path exactly as the JPQL path does. + */ + public void testDirtyNavigatedTypeFlushesForCriteriaQuery() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushSpouse.class, "4").setLastName("Miller"); + resetSQL(); + + CriteriaBuilder cb = emf.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(UnenhancedFlushOrder.class); + Root order = cq.from(UnenhancedFlushOrder.class); + cq.where(cb.equal(order.get("customer").get("spouse").get("lastName"), "Miller")); + List orders = em.createQuery(cq) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_SPOUSE); + assertEquals(2, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * A dirty instance of a type that appears only in a subquery is still read by the + * query, so it must be visible. + */ + public void testDirtyTypeInSubqueryFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + List orders = em.createQuery( + "SELECT o FROM UnenhancedFlushOrder o WHERE o.customer.id IN " + + "(SELECT c.id FROM UnenhancedFlushCustomer c " + + "WHERE c.name = 'Michael Bouschen')", + UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_CUSTOMER); + assertEquals(2, orders.size()); + em.getTransaction().rollback(); + } + } + + + /** + * A collection-valued path used without a join variable - SIZE() - + * reads the element type, so a newly persisted element must be visible. The + * declared type of the field is Collection, which has no metadata of its own, so + * only the element type registered for the field puts Order into the access path. + */ + public void testDirtyElementOfCollectionPathInSizeFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + UnenhancedFlushCustomer cust = em.find(UnenhancedFlushCustomer.class, "10"); + em.persist(new UnenhancedFlushOrder("13", cust)); + resetSQL(); + + List customers = em.createQuery( + "SELECT c FROM UnenhancedFlushCustomer c WHERE SIZE(c.orders) = 3", + UnenhancedFlushCustomer.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(INSERT_ORDER); + assertEquals(1, customers.size()); + em.getTransaction().rollback(); + } + } + + /** + * The same for IS EMPTY: a removed element changes the result, so the + * delete has to be flushed first. + */ + public void testDirtyElementOfCollectionPathInIsEmptyFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.remove(em.find(UnenhancedFlushOrder.class, "11")); + em.remove(em.find(UnenhancedFlushExpressOrder.class, "12")); + resetSQL(); + + List customers = em.createQuery( + "SELECT c FROM UnenhancedFlushCustomer c WHERE c.orders IS EMPTY", + UnenhancedFlushCustomer.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(DELETE_ORDER); + assertEquals(1, customers.size()); + em.getTransaction().rollback(); + } + } + + /** + * A join of a join reads its type just as a join of the root does, so the join walk + * has to follow the whole join tree. + */ + public void testDirtyTypeBehindJoinOfJoinFlushesForCriteriaQuery() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushSpouse.class, "4").setLastName("Miller"); + resetSQL(); + + CriteriaBuilder cb = emf.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(UnenhancedFlushOrder.class); + Root order = cq.from(UnenhancedFlushOrder.class); + Join spouse = order.join("customer").join("spouse"); + cq.where(cb.equal(spouse.get("lastName"), "Miller")); + List orders = em.createQuery(cq) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_SPOUSE); + assertEquals(2, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * A fetch join reads the fetched type, so it belongs in the access path too. + */ + public void testDirtyTypeBehindFetchJoinFlushesForCriteriaQuery() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + CriteriaBuilder cb = emf.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(UnenhancedFlushOrder.class); + Root order = cq.from(UnenhancedFlushOrder.class); + order.fetch("customer"); + cq.where(cb.equal(order.get("totalPrice"), 0d)); + List orders = em.createQuery(cq) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_CUSTOMER); + assertEquals(2, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * A type read only inside a Criteria subquery must be visible: without it the + * IN-subquery misses the unflushed name and the query returns nothing. + */ + public void testDirtyTypeInCriteriaSubqueryFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + CriteriaBuilder cb = emf.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(UnenhancedFlushOrder.class); + Root order = cq.from(UnenhancedFlushOrder.class); + Subquery sq = cq.subquery(String.class); + Root cust = sq.from(UnenhancedFlushCustomer.class); + sq.select(cust.get("id")) + .where(cb.equal(cust.get("name"), "Michael Bouschen")); + cq.where(cb.in(order.get("customer").get("id")).value(sq)); + List orders = em.createQuery(cq) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_CUSTOMER); + assertEquals(2, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * A join made on a correlated join is reachable neither from the outer + * query's roots nor from the subquery's own roots, so the join walk has to start at + * the correlated joins of the enclosing subquery as well. + */ + public void testDirtyTypeBehindCorrelatedJoinFlushes() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushSpouse.class, "4").setLastName("Miller"); + resetSQL(); + + CriteriaBuilder cb = emf.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(UnenhancedFlushOrder.class); + Root order = cq.from(UnenhancedFlushOrder.class); + Join cust = order.join("customer"); + Subquery sq = cq.subquery(String.class); + Join spouse = sq.correlate(cust).join("spouse"); + sq.select(spouse.get("id")) + .where(cb.equal(spouse.get("lastName"), "Miller")); + cq.where(cb.exists(sq)); + List orders = em.createQuery(cq) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertSQL(UPDATE_SPOUSE); + assertEquals(2, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * With {@code FlushModeType.COMMIT} the effect of pending updates on queries is + * unspecified, and the provider must not flush on the query's behalf. + */ + public void testCommitFlushModeDoesNotFlush() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + em.createQuery("SELECT o FROM UnenhancedFlushOrder o " + + "WHERE o.customer.name = 'Michael Bouschen'", + UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.COMMIT) + .getResultList(); + + assertNotSQL(ANY_UPDATE); + em.getTransaction().rollback(); + } + } + + /** + * "If there is no transaction active, the persistence provider must not flush to + * the database" (section 3.10.8). + */ + public void testNoFlushWithoutActiveTransaction() { + try (EntityManager em = emf.createEntityManager()) { + UnenhancedFlushCustomer cust = em.find(UnenhancedFlushCustomer.class, "10"); + cust.setName("Michael Bouschen"); + resetSQL(); + + em.createQuery("SELECT o FROM UnenhancedFlushOrder o " + + "WHERE o.customer.name = 'Michael Bouschen'", + UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertNotSQL(ANY_UPDATE); + } + } +} diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/TestQueryFlushIgnoreChanges.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/TestQueryFlushIgnoreChanges.java new file mode 100644 index 0000000000..f254bc83f4 --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/TestQueryFlushIgnoreChanges.java @@ -0,0 +1,160 @@ +/* + * 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.flush; + +import java.util.List; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.FlushModeType; + +import org.apache.openjpa.persistence.test.SQLListenerTestCase; + +/** + * Asserts how openjpa.IgnoreChanges=true interacts with the flush mode. + *

+ * openjpa.IgnoreChanges is a documented OpenJPA option that lets a query + * run straight against the datastore without considering in-memory changes; the + * reference guide recommends it together with the default + * openjpa.FlushBeforeQueries=true as a performance setting. It therefore + * has to keep working whether the flush mode is the inherited default or + * FlushModeType.AUTO set on the query object: section 3.10.8 states the + * same duty for both triggers, and the option is a vendor opt-out from it, not a + * distinction the specification draws. Enabling it is therefore a deliberate, documented + * departure from the visibility guarantee for SELECTs, and nothing about the flush mode + * re-enables it. + */ +public class TestQueryFlushIgnoreChanges extends SQLListenerTestCase { + + private static final String ANY_UPDATE = "(?i).*UPDATE\\s+FLUSH_.*"; + private static final String UPDATE_CUSTOMER = "(?i).*UPDATE\\s+FLUSH_CUSTOMER.*"; + + private static final String JPQL = + "SELECT o FROM UnenhancedFlushOrder o WHERE o.customer.name = 'Michael Bouschen'"; + + @Override + public void setUp() { + setUp(UnenhancedFlushSpouse.class, + UnenhancedFlushCustomer.class, + UnenhancedFlushOrder.class, + UnenhancedFlushExpressOrder.class, + "openjpa.RuntimeUnenhancedClasses", "supported", + "openjpa.IgnoreChanges", "true", + DROP_TABLES); + + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + UnenhancedFlushCustomer cust = new UnenhancedFlushCustomer("10", "Kate P. Hudson"); + em.persist(cust); + em.persist(new UnenhancedFlushOrder("11", cust)); + em.getTransaction().commit(); + } + resetSQL(); + } + + /** + * With the flush mode inherited from the persistence context, IgnoreChanges keeps + * its documented effect: the query runs against the datastore and nothing is flushed. + */ + public void testIgnoreChangesSuppressesFlushForInheritedFlushMode() { + try (EntityManager em = emf.createEntityManager()) { + assertEquals(FlushModeType.AUTO, em.getFlushMode()); + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + List orders = + em.createQuery(JPQL, UnenhancedFlushOrder.class).getResultList(); + + assertNotSQL(ANY_UPDATE); + assertEquals(0, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * A flush mode of AUTO set on the query object is the mode the query already has, + * and IgnoreChanges opts out of the visibility guarantee for either trigger, so the + * query still runs against the datastore. + */ + public void testExplicitAutoOnQueryKeepsIgnoreChanges() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + List orders = + em.createQuery(JPQL, UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .getResultList(); + + assertNotSQL(ANY_UPDATE); + assertEquals(0, orders.size()); + em.getTransaction().rollback(); + } + } + + /** + * Setting a flush mode must not leave the query in a different state than it was + * created in: AUTO followed by COMMIT has to behave exactly like COMMIT alone, and + * in particular the query must still be executed against the datastore rather than + * in memory over the whole extent. + */ + public void testAutoThenCommitBehavesLikeCommitAlone() { + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + List orders = + em.createQuery(JPQL, UnenhancedFlushOrder.class) + .setFlushMode(FlushModeType.AUTO) + .setFlushMode(FlushModeType.COMMIT) + .getResultList(); + + assertNotSQL(ANY_UPDATE); + assertEquals(0, orders.size()); + // the filter has to be pushed down, not evaluated in memory over the extent + assertSQL("(?i).*SELECT.*FROM\\s+FLUSH_ORDER.*WHERE.*"); + em.getTransaction().rollback(); + } + } + + /** + * A query obtained through {@code createNamedQuery()} for a registration made with + * {@code addNamedQuery()} must not lose the option either: the registration replays + * the source query's flush mode, which is never unset. + */ + public void testIgnoreChangesSurvivesNamedQueryRoundTrip() { + emf.addNamedQuery("TestQueryFlushIgnoreChanges.byName", + emf.createEntityManager().createQuery(JPQL, UnenhancedFlushOrder.class)); + try (EntityManager em = emf.createEntityManager()) { + em.getTransaction().begin(); + em.find(UnenhancedFlushCustomer.class, "10").setName("Michael Bouschen"); + resetSQL(); + + List orders = em.createNamedQuery( + "TestQueryFlushIgnoreChanges.byName", UnenhancedFlushOrder.class) + .getResultList(); + + assertNotSQL(ANY_UPDATE); + assertEquals(0, orders.size()); + em.getTransaction().rollback(); + } + } +} diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/UnenhancedFlushExpressOrder.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/UnenhancedFlushExpressOrder.java new file mode 100644 index 0000000000..4b13473680 --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/flush/UnenhancedFlushExpressOrder.java @@ -0,0 +1,48 @@ +/* + * 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.flush; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; + +/** + * Subclass of {@link UnenhancedFlushOrder}, used to assert that a dirty instance of a + * subclass of a type in the query's access path forces a flush. + */ +@Entity +public class UnenhancedFlushExpressOrder extends UnenhancedFlushOrder { + + private String carrier; + + public UnenhancedFlushExpressOrder() { + } + + public UnenhancedFlushExpressOrder(String id, UnenhancedFlushCustomer customer) { + super(id, customer); + } + + @Column(name = "CARRIER") + public String getCarrier() { + return carrier; + } + + public void setCarrier(String carrier) { + this.carrier = carrier; + } +} diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaExpressionBuilder.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaExpressionBuilder.java index 18bb8a9def..5dbb7f2144 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaExpressionBuilder.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaExpressionBuilder.java @@ -20,6 +20,7 @@ package org.apache.openjpa.persistence.criteria; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -31,6 +32,7 @@ import jakarta.persistence.criteria.CompoundSelection; import jakarta.persistence.criteria.Expression; import jakarta.persistence.criteria.Fetch; +import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Nulls; @@ -38,8 +40,6 @@ import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Selection; import jakarta.persistence.metamodel.Attribute; -import jakarta.persistence.metamodel.PluralAttribute; -import jakarta.persistence.metamodel.Type.PersistenceType; import org.apache.openjpa.kernel.FillStrategy; import org.apache.openjpa.kernel.QueryOperations; @@ -49,6 +49,7 @@ import org.apache.openjpa.kernel.exps.QueryExpressions; import org.apache.openjpa.kernel.exps.Value; import org.apache.openjpa.meta.ClassMetaData; +import org.apache.openjpa.meta.FieldMetaData; import org.apache.openjpa.persistence.TupleFactory; import org.apache.openjpa.persistence.TupleImpl; import org.apache.openjpa.persistence.meta.AbstractManagedType; @@ -85,32 +86,68 @@ public QueryExpressions getQueryExpressions(ExpressionFactory factory, CriteriaQ exps.shape = evalResultShape(q); exps.parameterTypes = q.getParameterTypes(); exps.isAggregate(); + evalNavigatedAccessPaths(exps, q); return exps; } protected void evalAccessPaths(QueryExpressions exps, ExpressionFactory factory, CriteriaQueryImpl q) { Set metas = new HashSet<>(); - MetamodelImpl metamodel = q.getMetamodel(); for (Root root : q.getRoots()) { metas.add(((AbstractManagedType)root.getModel()).meta); - for (Join join : root.getJoins()) { - Attribute attr = join.getAttribute(); - Class cls = attr.getJavaType(); - // For plural attributes (Collection/List/Set/Map), use the - // element/value type for metadata lookup, not the collection type - if (attr instanceof PluralAttribute) { - cls = ((PluralAttribute)attr).getElementType().getJavaType(); - } - if (attr.isAssociation()) { - ClassMetaData meta = metamodel.getRepository().getMetaData(cls, null, true); - PersistenceType type = MetamodelImpl.getPersistenceType(meta); - if (type == PersistenceType.ENTITY || type == PersistenceType.EMBEDDABLE) - metas.add(meta); - } + registerJoinedTypes(q, root); + } + // A subquery's correlated joins are not reachable from its roots: correlate(Join) + // clones the parent join into the subquery without adding a root, and the joins + // the subquery then makes are made on that clone. Walk them from the delegator. + SubqueryImpl delegator = q.getDelegator(); + if (delegator != null) { + for (Join corr : delegator.getCorrelatedJoins()) { + q.addAccessPathMetaData(getFieldMetaData(corr.getAttribute())); + registerJoinedTypes(q, corr); } } - // TODO -- need to handle subqueries + // Types reached only by an implicit join (a navigated path) or by a subquery are + // added by evalNavigatedAccessPaths() once the expressions have been converted. + + exps.accessPath = metas.toArray(new ClassMetaData[metas.size()]); + } + /** + * Registers the types of the given from-element's joins and fetch joins, and of their + * own joins. A join of a join reads its type just as a join of the root does, so the + * recursion has to follow the whole join tree. + */ + private void registerJoinedTypes(CriteriaQueryImpl q, From from) { + for (Join join : from.getJoins()) { + q.addAccessPathMetaData(getFieldMetaData(join.getAttribute())); + registerJoinedTypes(q, join); + } + for (Fetch fetch : from.getFetches()) { + q.addAccessPathMetaData(getFieldMetaData(fetch.getAttribute())); + if (fetch instanceof From) { + registerJoinedTypes(q, (From) fetch); + } + } + } + + private FieldMetaData getFieldMetaData(Attribute attr) { + return attr instanceof Members.Member ? ((Members.Member) attr).fmd : null; + } + + /** + * Adds the types registered on the query while its expressions were converted to + * kernel values: the targets of implicit joins created by + * {@link jakarta.persistence.criteria.Path#get(String) get()} and the access paths of + * the query's subqueries. Both are only known after the conversion, so this runs last. + */ + protected void evalNavigatedAccessPaths(QueryExpressions exps, CriteriaQueryImpl q) { + Set navigated = q.getNavigatedAccessPathMetaDatas(); + if (navigated == null || navigated.isEmpty()) + return; + Set metas = new HashSet<>(navigated); + if (exps.accessPath != null) { + metas.addAll(Arrays.asList(exps.accessPath)); + } exps.accessPath = metas.toArray(new ClassMetaData[metas.size()]); } diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaQueryImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaQueryImpl.java index 7fe757286a..68b06ee041 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaQueryImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/CriteriaQueryImpl.java @@ -51,6 +51,8 @@ import org.apache.openjpa.kernel.exps.Value; import org.apache.openjpa.lib.util.Localizer; import org.apache.openjpa.lib.util.OrderedMap; +import org.apache.openjpa.meta.ClassMetaData; +import org.apache.openjpa.meta.FieldMetaData; import org.apache.openjpa.persistence.meta.MetamodelImpl; import org.apache.openjpa.persistence.meta.Types; @@ -87,6 +89,7 @@ class CriteriaQueryImpl implements OpenJPACriteriaQuery, AliasContext { private final TranslationLock _translationLock; private Set> _treatedRoots; + private Set _navigatedAccessPath; // AliasContext private int aliasCount = 0; @@ -499,6 +502,58 @@ Set> getTreatedRoots() { return _treatedRoots; } + /** + * Registers a type this query reads through a path traversal or a subquery. + * The roots and their joins are known statically, but a path navigated with + * {@link jakarta.persistence.criteria.Path#get(String) get()} creates an implicit + * join that is only discovered while the path is converted to a kernel value, and a + * subquery compiles its own access path. Both are collected here so that + * {@link CriteriaExpressionBuilder} can add them to + * {@link QueryExpressions#accessPath}, which decides whether a dirty instance can + * affect this query's result. + */ + void addAccessPathMetaData(ClassMetaData meta) { + if (meta == null) + return; + if (_navigatedAccessPath == null) { + _navigatedAccessPath = new LinkedHashSet<>(); + } + _navigatedAccessPath.add(meta); + } + + /** + * Registers the types the given field reads: the field's own type, and for a + * collection or map field its element and key types. + */ + void addAccessPathMetaData(FieldMetaData fmd) { + if (fmd == null) + return; + addAccessPathMetaData(fmd.isEmbedded() + ? fmd.getEmbeddedMetaData() : fmd.getDeclaredTypeMetaData()); + if (fmd.getElement() != null) + addAccessPathMetaData(fmd.getElement().getDeclaredTypeMetaData()); + if (fmd.getKey() != null) + addAccessPathMetaData(fmd.getKey().getDeclaredTypeMetaData()); + } + + /** + * Registers every type of the given access path on this query. + */ + void addAccessPathMetaDatas(ClassMetaData[] metas) { + if (metas == null) + return; + for (ClassMetaData meta : metas) { + addAccessPathMetaData(meta); + } + } + + /** + * Returns the types registered by {@link #addAccessPathMetaData}, or null if none. + */ + Set getNavigatedAccessPathMetaDatas() { + return _navigatedAccessPath; + } + /** * Affirms if selection of this query is distinct. */ diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/PathImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/PathImpl.java index 08961feda2..7f71d22253 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/PathImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/PathImpl.java @@ -161,6 +161,7 @@ public boolean isCorrelated() { public Value toValue(ExpressionFactory factory, CriteriaQueryImpl q) { if (q.isRegistered(this)) return q.getRegisteredValue(this); + recordAccessPath(q, _member == null ? null : _member.fmd); org.apache.openjpa.kernel.exps.Path path = null; SubqueryImpl subquery = q.getDelegator(); boolean allowNull = _parent != null && _parent instanceof Join @@ -180,7 +181,7 @@ public Value toValue(ExpressionFactory factory, CriteriaQueryImpl q) { path = factory.newPath(subQ); path.setMetaData(subQ.getMetaData()); path.setSchemaAlias(q.getAlias(_parent)); - traversePath(_parent, path, _member.fmd); + traversePath(q, _parent, path, _member.fmd); } else if (_parent != null) { Value val = _parent.toValue(factory, q); if (val instanceof org.apache.openjpa.kernel.exps.Path) { @@ -226,7 +227,9 @@ public boolean inSubquery(SubqueryImpl subquery) { return subquery != null && (subquery.getRoots() != null && subquery.getRoots().contains(this)); } - protected void traversePath(PathImpl parent, org.apache.openjpa.kernel.exps.Path path, FieldMetaData fmd) { + protected void traversePath(CriteriaQueryImpl q, PathImpl parent, + org.apache.openjpa.kernel.exps.Path path, FieldMetaData fmd) { + recordAccessPath(q, fmd); boolean allowNull = parent != null && parent instanceof Join && ((Join) parent).getJoinType() != JoinType.INNER; FieldMetaData fmd1 = parent._member == null ? null : parent._member.fmd; @@ -236,11 +239,23 @@ protected void traversePath(PathImpl parent, org.apache.openjpa.kernel.exp path.get(fmd, allowNull); return; } - traversePath(parent1, path, fmd1); + traversePath(q, parent1, path, fmd1); if (fmd != null) path.get(fmd, allowNull); } + /** + * Registers the type this path traverses into on the query's access path. + * A path navigated with {@link #get(String) get()} is an implicit join, and the + * types it reads must be part of the query's access path for the same reason the + * types of an explicit join are: a dirty instance of such a type can change the + * query's result and therefore has to be flushed before the query runs. + */ + private void recordAccessPath(CriteriaQueryImpl q, FieldMetaData fmd) { + if (q != null) + q.addAccessPathMetaData(fmd); + } + /** * Gets a new path that represents the given single-valued attribute from this path. */ diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/SubqueryImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/SubqueryImpl.java index 5e2787397b..2fae0f46e8 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/SubqueryImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/criteria/SubqueryImpl.java @@ -332,6 +332,8 @@ public Value toValue(ExpressionFactory factory, CriteriaQueryImpl q) { _delegate.setContexts(contexts); QueryExpressions subexp = exprBuilder.getQueryExpressions(factory, _delegate); _subq.setQueryExpressions(subexp); + // the enclosing query reads everything the subquery reads + q.addAccessPathMetaDatas(subexp.accessPath); if (subexp.projections.length > 0) JPQLExpressionBuilder.checkEmbeddable(subexp.projections[0], null); contexts.pop(); diff --git a/openjpa-project/src/doc/manual/migration_considerations.xml b/openjpa-project/src/doc/manual/migration_considerations.xml index 4b21c1234c..e33d42da3e 100644 --- a/openjpa-project/src/doc/manual/migration_considerations.xml +++ b/openjpa-project/src/doc/manual/migration_considerations.xml @@ -329,23 +329,37 @@

- FlushModeType.AUTO flushes whenever the context is dirty - - Previously a query only flushed pending changes when a dirty instance belonged to - a type in the query's access path, and never for SELECT queries - when was true. With - FlushModeType.AUTO (the default, - =true) and an active - transaction, a query now flushes whenever any instance in the persistence context - is new, dirty or deleted, regardless of the query's access path, and overrides - openjpa.IgnoreChanges. Applications with write-heavy transactions - may observe more flushes (and therefore earlier constraint or trigger evaluation). - - - To restore the previous behavior use FlushModeType.COMMIT on - the query or the EntityManager, or set - openjpa.FlushBeforeQueries to false or - with-connection. + Completed access paths for Criteria and collection-valued queries + + As before, a query with FlushModeType.AUTO (the default, + =true) flushes + pending changes only when a new, dirty or deleted instance belongs to a type in + the query's access path, that is to a type the query actually reads. This is what + section 3.10.8 of the specification asks for: the duty covers the updates "which + could potentially affect the result of the query", and the provider may discharge + it "by flushing those entities to the database or by some other means". + + + What changed is that the access paths themselves are now complete, so queries + that failed to flush a relevant change do so. For a Criteria query a type + reached through an implicit join (a path navigated with + Path.get), through a join of a join, through a fetch + join, inside a subquery or behind a join of a correlated join used to be left + out. For JPQL, a collection- or map-valued path used without a join variable - + SIZE(x.coll), x.coll IS EMPTY - did not + contribute its element and key types, because the declared type of such a field + is Collection or Map. A dirty + instance of any of those types was not flushed before the query ran, which + could yield a stale result; such a query now flushes where it did not before. + + + =true is unchanged + and keeps its documented effect for every SELECT, whatever + the flush mode of the query is. It is an OpenJPA extension that deliberately + opts a SELECT out of the visibility guarantee of section + 3.10.8; the specification states that guarantee equally for a flush mode set on + the query object and for one inherited from the persistence context, so the + option is not overridden by either.
diff --git a/openjpa-project/src/doc/manual/ref_guide_conf.xml b/openjpa-project/src/doc/manual/ref_guide_conf.xml index b3259d0f87..3e0373acd9 100644 --- a/openjpa-project/src/doc/manual/ref_guide_conf.xml +++ b/openjpa-project/src/doc/manual/ref_guide_conf.xml @@ -2091,6 +2091,14 @@ execute the query directly against the datastore. A value of false transaction affect the query, and if so to either evaluate the query in-memory or flush before running it against the datastore. + +Note that true is an OpenJPA extension that deliberately opts +every SELECT out of the guarantee of section 3.10.8 of the +specification that pending updates which could affect a query's result are visible +to it. The opt-out applies whatever the flush mode of the query is, including +FlushModeType.AUTO set on the Query +object itself. +
openjpa.Id