From 17da2c470eb7f5dc8e6c1ccecc1aa2458bd078d9 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Wed, 2 Sep 2026 19:48:01 +0200 Subject: [PATCH] [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. --- .../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 0a0c1f19a3..b0f0ac7b1e 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 static Context getThreadLocalContext(Context lctx, Context cloneFrom) { 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}. + *

+ * 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 @@ private ResultObjectProvider executeSetOperatorQuery(Executor ex, 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 c570239b09..91d2200cab 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 void testIntersectProjection() { endEm(em); } + /** + * A UNION ALL of "age > 25" (Seetha, Shannon, Famzy) and "age > 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 > 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 > 20" and "age > 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 > 20" and "age > 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. + *

+ * 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. + *

+ * 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();