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