You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@openjpa.apache.org by rz...@apache.org on 2026/09/02 18:08:41 UTC

(openjpa) branch OPENJPA-2964 created (now 17da2c470)

This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a change to branch OPENJPA-2964
in repository https://gitbox.apache.org/repos/asf/openjpa.git


      at 17da2c470 [OPENJPA-2964] Honour the query range on set operations

This branch includes the following new commits:

     new 17da2c470 [OPENJPA-2964] Honour the query range on set operations

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(openjpa) 01/01: [OPENJPA-2964] Honour the query range on set operations

Posted by rz...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch OPENJPA-2964
in repository https://gitbox.apache.org/repos/asf/openjpa.git

commit 17da2c470eb7f5dc8e6c1ccecc1aa2458bd078d9
Author: Richard Zowalla <rz...@apache.org>
AuthorDate: Wed Sep 2 19:48:01 2026 +0200

    [OPENJPA-2964] Honour the query range on set operations
    
    executeSetOperatorQuery() received the StoreQuery.Range but never used it,
    so setFirstResult()/setMaxResults() were silently ignored on a UNION,
    INTERSECT or EXCEPT query.
    
    The range is applied in memory over the compound result, through
    RangeResultObjectProvider, which is the same fallback the ordinary path
    uses when the dictionary cannot express a range in SQL. It cannot be
    pushed into the statement: DBDictionary.toSelect() renders the first
    operand in full, including any limit derived from the select's start and
    end index, and only then appends the set operator buffer, so a range on
    the main select would bind to the first operand instead of to the
    compound result. A javadoc on the method records that, since the
    optimisation is tempting and silently wrong.
    
    testSetOperatorRange covers UNION ALL, UNION, INTERSECT and EXCEPT, and
    both provider branches. It asserts cardinality only: JPQL attaches an
    ORDER BY to an individual select, so the row order of a compound set
    operation is undefined.
---
 .../apache/openjpa/jdbc/kernel/JDBCStoreQuery.java |  32 ++++-
 .../jpql/functions/TestEJBQLFunction.java          | 132 +++++++++++++++++++++
 2 files changed, 161 insertions(+), 3 deletions(-)

diff --git a/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/kernel/JDBCStoreQuery.java b/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/kernel/JDBCStoreQuery.java
index 0a0c1f19a..b0f0ac7b1 100644
--- a/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/kernel/JDBCStoreQuery.java
+++ b/openjpa-jdbc/src/main/java/org/apache/openjpa/jdbc/kernel/JDBCStoreQuery.java
@@ -1183,6 +1183,17 @@ public class JDBCStoreQuery
         return null;
     }
 
+    /**
+     * Execute a set operation (UNION, INTERSECT, EXCEPT). The operands are
+     * compiled into a single compound SQL statement: operand 0 becomes the
+     * main {@link Select}, every later operand is rendered on its own and
+     * concatenated onto it via
+     * {@link org.apache.openjpa.jdbc.sql.SelectImpl#addSetOperatorSQL}.
+     * <p>
+     * A {@link Range} is therefore applied in memory over the compound
+     * result and is never pushed into the SQL: the range of the main select
+     * would bind to the first operand only, not to the compound result.
+     */
     private ResultObjectProvider executeSetOperatorQuery(Executor ex,
         ClassMetaData base, ClassMetaData[] metas, boolean subclasses,
         ExpressionFactory[] facts, QueryExpressions[] exps,
@@ -1235,13 +1246,28 @@ public class JDBCStoreQuery
 
         localContext.remove();
 
+        ResultObjectProvider rop;
         if (mainExps.projections.length > 0) {
-            return new ProjectionResultObjectProvider(
+            rop = new ProjectionResultObjectProvider(
                 mainSel, mainExps, mainState,
                 new ExpContext(_store, params, fetch));
+        } else {
+            rop = new InstanceResultObjectProvider(
+                mainSel, mapping, _store, fetch);
         }
-        return new InstanceResultObjectProvider(
-            mainSel, mapping, _store, fetch);
+
+        // OPENJPA-2964: the range cannot be pushed into the SQL of a set
+        // operation. DBDictionary.toSelect() renders the first operand in
+        // full - including any LIMIT/OFFSET derived from the select's start
+        // and end index - and only then appends the set operator buffer, so
+        // a range set on the main select would bind to the first operand
+        // instead of to the compound result. Apply it in memory over the
+        // compound result instead; this is the same fallback executeQuery()
+        // uses when the dictionary cannot express the range in SQL.
+        if (range.start != 0 || range.end != Long.MAX_VALUE) {
+            rop = new RangeResultObjectProvider(rop, range.start, range.end);
+        }
+        return rop;
     }
 
     private void flattenSetOperator(QueryExpressions exps,
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/jpql/functions/TestEJBQLFunction.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/jpql/functions/TestEJBQLFunction.java
index c570239b0..91d2200ca 100644
--- a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/jpql/functions/TestEJBQLFunction.java
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/jpql/functions/TestEJBQLFunction.java
@@ -1405,6 +1405,138 @@ public class TestEJBQLFunction extends AbstractTestCase {
         endEm(em);
     }
 
+    /**
+     * A UNION ALL of "age &gt; 25" (Seetha, Shannon, Famzy) and "age &gt; 30"
+     * (Seetha, Shannon), i.e. 5 rows, using the given identification
+     * variable. Every execution below needs its own JPQL string, see
+     * {@link #testSetOperatorRange()}.
+     */
+    private String unionAllQuery(String var) {
+        return "SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".age > 25"
+            + " UNION ALL SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".age > 30";
+    }
+
+    /**
+     * A UNION of "age &gt; 30" (Seetha, Shannon) and "name = 'Ugo'", i.e. 3
+     * rows after duplicate elimination, using the given identification
+     * variable.
+     */
+    private String unionQuery(String var) {
+        return "SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".age > 30"
+            + " UNION SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".name = 'Ugo'";
+    }
+
+    /**
+     * An INTERSECT of "age &gt; 20" and "age &gt; 30", i.e. 2 rows (Seetha,
+     * Shannon), using the given identification variable.
+     */
+    private String intersectQuery(String var) {
+        return "SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".age > 20"
+            + " INTERSECT SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".age > 30";
+    }
+
+    /**
+     * An EXCEPT of "age &gt; 20" and "age &gt; 30", i.e. 2 rows (Famzy, Shade),
+     * using the given identification variable.
+     */
+    private String exceptQuery(String var) {
+        return "SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".age > 20"
+            + " EXCEPT SELECT " + var + ".name FROM CompUser " + var
+            + " WHERE " + var + ".age > 30";
+    }
+
+    /**
+     * The same UNION as {@link #unionQuery(String)}, but selecting the entity
+     * rather than a projection, so that the range is applied over an
+     * InstanceResultObjectProvider instead of a ProjectionResultObjectProvider.
+     */
+    private String entityUnionQuery(String var) {
+        return "SELECT " + var + " FROM CompUser " + var
+            + " WHERE " + var + ".age > 30"
+            + " UNION SELECT " + var + " FROM CompUser " + var
+            + " WHERE " + var + ".name = 'Ugo'";
+    }
+
+    /**
+     * OPENJPA-2964: setFirstResult()/setMaxResults() must be honoured for a
+     * set operation. The range cannot be pushed into the SQL of a compound
+     * statement, so it is applied in memory over the compound result.
+     * <p>
+     * Only the cardinality of the result is asserted: JPQL attaches an
+     * ORDER BY to an individual select, so the row order of a compound set
+     * operation is not defined and the identity of the rows on a given page
+     * is unspecified.
+     * <p>
+     * Every execution uses its own JPQL string (the identification variable
+     * differs) because re-executing the very same set operation string is
+     * broken by the prepared query SQL cache - an unrelated, pre-existing
+     * defect that has nothing to do with the range.
+     */
+    public void testSetOperatorRange() {
+        EntityManager em = currentEntityManager();
+
+        // UNION ALL, 3 rows + 2 rows = 5. The max-4 case is load-bearing:
+        // it is unsatisfiable if the bound had been pushed into operand 1,
+        // which yields only 3 rows.
+        assertEquals(5, em.createQuery(unionAllQuery("a1"))
+            .getResultList().size());
+        assertEquals(4, em.createQuery(unionAllQuery("a2")).setMaxResults(4)
+            .getResultList().size());
+        assertEquals(2, em.createQuery(unionAllQuery("a3")).setFirstResult(3)
+            .getResultList().size());
+        assertEquals(2, em.createQuery(unionAllQuery("a4")).setFirstResult(1)
+            .setMaxResults(2).getResultList().size());
+        assertEquals(0, em.createQuery(unionAllQuery("a5")).setFirstResult(5)
+            .getResultList().size());
+
+        // UNION (duplicate elimination), 3 distinct rows - same query as
+        // testUnionProjection. setFirstResult(2) must yield 1, proving the
+        // window is applied after the database's dedup (a window over the
+        // 5-row pre-dedup stream would yield more).
+        assertEquals(3, em.createQuery(unionQuery("b1"))
+            .getResultList().size());
+        assertEquals(2, em.createQuery(unionQuery("b2")).setMaxResults(2)
+            .getResultList().size());
+        assertEquals(1, em.createQuery(unionQuery("b3")).setFirstResult(2)
+            .getResultList().size());
+        // a range wider than the result set is a no-op
+        assertEquals(3, em.createQuery(unionQuery("b4")).setMaxResults(10)
+            .getResultList().size());
+
+        // INTERSECT, 2 rows - same query as testIntersectProjection.
+        assertEquals(1, em.createQuery(intersectQuery("c1")).setMaxResults(1)
+            .getResultList().size());
+        assertEquals(1, em.createQuery(intersectQuery("c2")).setFirstResult(1)
+            .getResultList().size());
+        assertEquals(0, em.createQuery(intersectQuery("c3")).setFirstResult(2)
+            .getResultList().size());
+
+        // EXCEPT, 2 rows - same query as testExceptProjection.
+        assertEquals(1, em.createQuery(exceptQuery("d1")).setMaxResults(1)
+            .getResultList().size());
+        assertEquals(1, em.createQuery(exceptQuery("d2")).setFirstResult(1)
+            .getResultList().size());
+        assertEquals(0, em.createQuery(exceptQuery("d3")).setFirstResult(2)
+            .getResultList().size());
+
+        // the entity, not a projection: the other provider branch.
+        assertEquals(3, em.createQuery(entityUnionQuery("e1"))
+            .getResultList().size());
+        assertEquals(2, em.createQuery(entityUnionQuery("e2")).setMaxResults(2)
+            .getResultList().size());
+        assertEquals(1, em.createQuery(entityUnionQuery("e3")).setFirstResult(2)
+            .getResultList().size());
+
+        endEm(em);
+    }
+
     public void testScalarOrderBy() {
         EntityManager em = currentEntityManager();