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

thomasm pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git


The following commit(s) were added to refs/heads/trunk by this push:
     new b9cb0d8c92 OAK-11949: Sort union queries without "order-by" by score 
(#2540)
b9cb0d8c92 is described below

commit b9cb0d8c920ccc2ff503368f75ef17651158d733
Author: Marvin <[email protected]>
AuthorDate: Mon Oct 20 15:14:40 2025 +0200

    OAK-11949: Sort union queries without "order-by" by score (#2540)
    
    * OAK-11949: Sort Union Queries without order-by by score
    
    * OAK-11936: restructure if-clause, move import and one additional test
    
    * OAK-11949: use double for Null protection
    
    ---------
    
    Co-authored-by: marvinw <[email protected]>
---
 .../main/java/org/apache/jackrabbit/oak/Oak.java   |   8 +
 .../jackrabbit/oak/query/QueryEngineSettings.java  |  12 ++
 .../jackrabbit/oak/query/UnionQueryImpl.java       |  58 +++++-
 .../jackrabbit/oak/query/UnionQueryTest.java       | 214 +++++++++++++++++++++
 4 files changed, 290 insertions(+), 2 deletions(-)

diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java 
b/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java
index 8af5260fbc..3f39239168 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java
@@ -589,6 +589,10 @@ public class Oak {
             LOG.info("Registered optimize in restrictions for functions 
feature: " + QueryEngineSettings.FT_OPTIMIZE_IN_RESTRICTIONS_FOR_FUNCTIONS);
             closer.register(optimizeInRestrictionsForFunctions);
             
queryEngineSettings.setOptimizeInRestrictionsForFunctions(optimizeInRestrictionsForFunctions);
+            Feature sortUnionQueryByScoreFeature = 
newFeature(QueryEngineSettings.FT_SORT_UNION_QUERY_BY_SCORE, whiteboard);
+            LOG.info("Registered sort union query by score feature: " + 
QueryEngineSettings.FT_SORT_UNION_QUERY_BY_SCORE);
+            closer.register(sortUnionQueryByScoreFeature);
+            
queryEngineSettings.setSortUnionQueryByScoreFeature(sortUnionQueryByScoreFeature);
         }
 
         return this;
@@ -1000,6 +1004,10 @@ public class Oak {
             settings.setOptimizeInRestrictionsForFunctions(feature);
         }
 
+        public void setSortUnionQueryByScoreFeature(@Nullable Feature feature) 
{
+            settings.setSortUnionQueryByScoreFeature(feature);
+        }
+
         @Override
         public void setQueryValidatorPattern(String key, String pattern, 
String comment, boolean failQuery) {
             settings.getQueryValidator().setPattern(key, pattern, comment, 
failQuery);
diff --git 
a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java
 
b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java
index 9527ddb927..9a143f91ec 100644
--- 
a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java
+++ 
b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java
@@ -67,6 +67,8 @@ public class QueryEngineSettings implements 
QueryEngineSettingsMBean, QueryLimit
 
     public static final String FT_OPTIMIZE_IN_RESTRICTIONS_FOR_FUNCTIONS = 
"FT_OAK-11214";
 
+    public static final String FT_SORT_UNION_QUERY_BY_SCORE = "FT_OAK-11949";
+
     public static final int DEFAULT_PREFETCH_COUNT = 
Integer.getInteger(OAK_QUERY_PREFETCH_COUNT, -1);
 
     public static final String OAK_QUERY_FAIL_TRAVERSAL = 
"oak.queryFailTraversal";
@@ -124,6 +126,7 @@ public class QueryEngineSettings implements 
QueryEngineSettingsMBean, QueryLimit
     private Feature prefetchFeature;
     private Feature improvedIsNullCostFeature;
     private Feature optimizeInRestrictionsForFunctions;
+    private Feature sortUnionQueryByScoreFeature;
 
     private String autoOptionsMappingJson = "{}";
     private QueryOptions.AutomaticQueryOptionsMapping autoOptionsMapping = new 
QueryOptions.AutomaticQueryOptionsMapping(autoOptionsMappingJson);
@@ -248,6 +251,15 @@ public class QueryEngineSettings implements 
QueryEngineSettingsMBean, QueryLimit
         return optimizeInRestrictionsForFunctions == null || 
optimizeInRestrictionsForFunctions.isEnabled();
     }
 
+    public void setSortUnionQueryByScoreFeature(@Nullable Feature feature) {
+        this.sortUnionQueryByScoreFeature = feature;
+    }
+
+    public boolean isSortUnionQueryByScoreEnabled() {
+        // disable if the feature toggle is not used
+        return sortUnionQueryByScoreFeature != null && 
sortUnionQueryByScoreFeature.isEnabled();
+    }
+
     public String getStrictPathRestriction() {
         return strictPathRestriction.name();
     }
diff --git 
a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/UnionQueryImpl.java 
b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/UnionQueryImpl.java
index 894fb6d9e7..321a2aa4e1 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/UnionQueryImpl.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/UnionQueryImpl.java
@@ -313,7 +313,7 @@ public class UnionQueryImpl implements Query {
         FacetMerger facetMerger = new FacetMerger(left, right);
 
         Iterator<ResultRowImpl> it;
-        final Iterator<ResultRowImpl> leftRows = 
facetMerger.getLeftIterator();;
+        final Iterator<ResultRowImpl> leftRows = facetMerger.getLeftIterator();
         final Iterator<ResultRowImpl> rightRows = 
facetMerger.getRightIterator();
         Iterator<ResultRowImpl> leftIter = leftRows;
         Iterator<ResultRowImpl> rightIter = rightRows;
@@ -324,7 +324,24 @@ public class UnionQueryImpl implements Query {
             rightIter = ((MeasuringIterator) rightRows).getDelegate();
         }
         if (orderBy == null) {
-            it = IteratorUtils.chainedIterator(leftIter, rightIter);
+            if(!settings.isSortUnionQueryByScoreEnabled()) {
+                // Default old behavior
+                it = IteratorUtils.chainedIterator(leftIter, rightIter);
+            } else {
+                boolean leftHasScore = isScorePresent(left);
+                boolean rightHasScore = isScorePresent(right);
+                if (leftHasScore && rightHasScore) {
+                    // Both sides have scores => merge
+                    Comparator<ResultRowImpl> scoreComparator = 
createScoreBasedComparator();
+                    it = IteratorUtils.mergeSorted(List.of(leftIter, 
rightIter), scoreComparator);
+                } else if (rightHasScore) {
+                    // Only right has score => start with right
+                    it = IteratorUtils.chainedIterator(rightIter, leftIter);
+                } else {
+                    // only left has score, or neither has scores => start 
with left
+                    it = IteratorUtils.chainedIterator(leftIter, rightIter);
+                }
+            }
         } else {
             // This would suggest either the sub queries are sorted by index 
or explicitly by QueryImpl (in case of traversing index)
             // So use mergeSorted here.
@@ -527,4 +544,41 @@ public class UnionQueryImpl implements Query {
     public Optional<Long> getOffset() {
         return offset;
     }
+
+    /**
+     * @param query the query to check.
+     * @return true if the query contains a jcr:score column, false otherwise.
+     */
+    private static boolean isScorePresent(Query query) {
+        return query.getColumnIndex("jcr:score") != -1;
+    }
+
+    /**
+     * @return a comparator to sort results by jcr:score in descending order.
+     * Precondition: {@link #isScorePresent(Query)} is true.
+     */
+    private Comparator<ResultRowImpl> createScoreBasedComparator() {
+        return new Comparator<ResultRowImpl>() {
+            @Override
+            public int compare(ResultRowImpl left, ResultRowImpl right) {
+                return Double.compare(getScoreFromRow(right), 
getScoreFromRow(left));
+            }
+        };
+    }
+
+    /**
+     * @param row the result row
+     * @return the jcr:score as a double
+     * Precondition: {@link #isScorePresent(Query)} must be true. If the row 
lacks a jcr:score, 0.0 is returned and
+     * the issue is logged.
+     */
+    private double getScoreFromRow(ResultRowImpl row) {
+        try {
+            PropertyValue scoreValue = row.getValue(QueryConstants.JCR_SCORE);
+            return scoreValue.getValue(Type.DOUBLE);
+        } catch (IllegalArgumentException e) {
+            LOG.warn("Failed to get jcr:score for path={}", row.getPath(), e);
+            return 0.0;
+        }
+    }
 }
diff --git 
a/oak-core/src/test/java/org/apache/jackrabbit/oak/query/UnionQueryTest.java 
b/oak-core/src/test/java/org/apache/jackrabbit/oak/query/UnionQueryTest.java
index 969c164f32..3b8ae9455e 100644
--- a/oak-core/src/test/java/org/apache/jackrabbit/oak/query/UnionQueryTest.java
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/query/UnionQueryTest.java
@@ -21,18 +21,22 @@ import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
 
 import org.apache.jackrabbit.oak.InitialContent;
 import org.apache.jackrabbit.oak.Oak;
 import org.apache.jackrabbit.oak.api.ContentRepository;
+import org.apache.jackrabbit.oak.api.PropertyValue;
 import org.apache.jackrabbit.oak.api.QueryEngine;
 import org.apache.jackrabbit.oak.api.Result;
 import org.apache.jackrabbit.oak.api.ResultRow;
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.plugins.memory.PropertyValues;
 import org.apache.jackrabbit.oak.commons.collections.ListUtils;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
 import org.apache.jackrabbit.oak.namepath.impl.GlobalNameMapper;
@@ -50,6 +54,7 @@ import org.apache.jackrabbit.oak.query.ast.SelectorImpl;
 import org.apache.jackrabbit.oak.query.ast.SourceImpl;
 import org.apache.jackrabbit.oak.query.stats.QueryStatsData;
 import org.apache.jackrabbit.oak.spi.security.OpenSecurityProvider;
+import org.apache.jackrabbit.oak.spi.toggle.Feature;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -64,6 +69,8 @@ public class UnionQueryTest extends AbstractQueryTest {
     protected ContentRepository createRepository() {
         store = new MemoryNodeStore();
         qeSettings = new QueryEngineSettings();
+        Feature sortFeature = createFeature(true);
+        qeSettings.setSortUnionQueryByScoreFeature(sortFeature);
 
         return new Oak(store)
                 .with(new OpenSecurityProvider())
@@ -362,6 +369,108 @@ public class UnionQueryTest extends AbstractQueryTest {
         }
     }
 
+    @Test
+    public void testUnionMergingBothQueryHaveScores() throws Exception {
+        MockQueryBuilder leftQuery = new MockQueryBuilder(true)
+                .addResult("/left/doc1", 0.8)
+                .addResult("/left/doc2", 0.7);
+        MockQueryBuilder rightQuery = new MockQueryBuilder(true)
+                .addResult("/right/doc1", 0.9)
+                .addResult("/right/doc2", 0.6);
+
+        UnionQueryImpl unionQuery = new UnionQueryImpl(true, 
leftQuery.build(), rightQuery.build(), qeSettings);
+        List<ScoredResult> results = 
executeUnionAndGetScoredResults(unionQuery);
+        assertPathOrder(results, new String[]{"/right/doc1", "/left/doc1", 
"/left/doc2", "/right/doc2"});
+    }
+
+    @Test
+    public void testUnionMergingOnlyRightQueryHasScores() throws Exception {
+        MockQueryBuilder leftQuery = new MockQueryBuilder(false)
+                .addResult("/left/doc1")
+                .addResult("/left/doc2");
+        MockQueryBuilder rightQuery = new MockQueryBuilder(true)
+                .addResult("/right/doc1", 0.9)
+                .addResult("/right/doc2", 0.6);
+
+        UnionQueryImpl unionQuery = new UnionQueryImpl(true, 
leftQuery.build(), rightQuery.build(), qeSettings);
+        List<ScoredResult> results = 
executeUnionAndGetScoredResults(unionQuery);
+        assertPathOrder(results, new String[]{"/right/doc1", "/right/doc2", 
"/left/doc1", "/left/doc2"});
+    }
+
+    @Test
+    public void testUnionMergingOnlyLeftQueryHasScores() throws Exception {
+        MockQueryBuilder leftQuery = new MockQueryBuilder(true)
+            .addResult("/left/doc1", 0.8)
+            .addResult("/left/doc2", 0.7);
+        MockQueryBuilder rightQuery = new MockQueryBuilder(false)
+            .addResult("/right/doc1")
+            .addResult("/right/doc2");
+
+        UnionQueryImpl unionQuery = new UnionQueryImpl(true, 
leftQuery.build(), rightQuery.build(), qeSettings);
+        List<ScoredResult> results = 
executeUnionAndGetScoredResults(unionQuery);
+        assertPathOrder(results, new String[]{"/left/doc1", "/left/doc2", 
"/right/doc1", "/right/doc2"});
+    }
+
+    @Test
+    public void testUnionMergingNeitherQueryHasScores() throws Exception {
+        MockQueryBuilder leftQuery = new MockQueryBuilder(false)
+            .addResult("/left/doc1")
+            .addResult("/left/doc2");
+        MockQueryBuilder rightQuery = new MockQueryBuilder(false)
+            .addResult("/right/doc1")
+            .addResult("/right/doc2");
+
+        UnionQueryImpl unionQuery = new UnionQueryImpl(true, 
leftQuery.build(), rightQuery.build(), qeSettings);
+        List<ScoredResult> results = 
executeUnionAndGetScoredResults(unionQuery);
+        assertPathOrder(results, new String[]{"/left/doc1", "/left/doc2", 
"/right/doc1", "/right/doc2"});
+    }
+
+    @Test
+    public void testUnionMergingEmptyIterators() throws Exception {
+        MockQueryBuilder leftQuery = new MockQueryBuilder(true);
+        MockQueryBuilder rightQuery = new MockQueryBuilder(true)
+            .addResult("/right/doc1", 0.9);
+
+        UnionQueryImpl unionQuery = new UnionQueryImpl(true, 
leftQuery.build(), rightQuery.build(), qeSettings);
+        List<ScoredResult> results = 
executeUnionAndGetScoredResults(unionQuery);
+
+        assertEquals(1, results.size());
+        assertEquals("/right/doc1", results.get(0).path);
+    }
+
+    @Test
+    public void testSortUnionQueryScoreFlagDisabled() throws Exception {
+        QueryEngineSettings disabledSettings = new QueryEngineSettings();
+        Feature sortFeature = createFeature(false);
+        disabledSettings.setSortUnionQueryByScoreFeature(sortFeature);
+        MockQueryBuilder leftQuery = new MockQueryBuilder(true)
+                .addResult("/left/doc1", 0.8)
+                .addResult("/left/doc2", 0.7);
+        MockQueryBuilder rightQuery = new MockQueryBuilder(true)
+                .addResult("/right/doc1", 0.9)
+                .addResult("/right/doc2", 0.6);
+
+        UnionQueryImpl unionQuery = new UnionQueryImpl(true, 
leftQuery.build(), rightQuery.build(), disabledSettings);
+        List<ScoredResult> results = 
executeUnionAndGetScoredResults(unionQuery);
+        assertPathOrder(results, new String[]{"/left/doc1", "/left/doc2", 
"/right/doc1", "/right/doc2"});
+    }
+
+    @Test
+    public void testSortUnionQueryScoreFlagIsNull() throws Exception {
+        QueryEngineSettings disabledSettings = new QueryEngineSettings();
+        disabledSettings.setSortUnionQueryByScoreFeature(null);
+        MockQueryBuilder leftQuery = new MockQueryBuilder(true)
+                .addResult("/left/doc1", 0.8)
+                .addResult("/left/doc2", 0.7);
+        MockQueryBuilder rightQuery = new MockQueryBuilder(true)
+                .addResult("/right/doc1", 0.9)
+                .addResult("/right/doc2", 0.6);
+
+        UnionQueryImpl unionQuery = new UnionQueryImpl(true, 
leftQuery.build(), rightQuery.build(), disabledSettings);
+        List<ScoredResult> results = 
executeUnionAndGetScoredResults(unionQuery);
+        assertPathOrder(results, new String[]{"/left/doc1", "/left/doc2", 
"/right/doc1", "/right/doc2"});
+    }
+
     private QueryImpl createQuery (String statement, ConstraintImpl c, 
SourceImpl sImpl) throws Exception {
 
         NamePathMapper namePathMapper = new NamePathMapperImpl(new 
GlobalNameMapper(root));
@@ -369,4 +478,109 @@ public class UnionQueryTest extends AbstractQueryTest {
                 new ColumnImpl[]{new ColumnImpl("a", "jcr:path", "jcr:path")}, 
namePathMapper, qeSettings, new QueryStatsData("", "").new 
QueryExecutionStats());
 
     }
+
+    /**
+     * Builder class for creating mock queries with controllable scores
+     */
+    private static class MockQueryBuilder {
+        private final boolean hasScore;
+        private final List<ResultRowImpl> results = new ArrayList<>();
+        private final QueryImpl mockQuery;
+        private final ColumnImpl[] columns;
+
+        public MockQueryBuilder(boolean hasScore) {
+            this.hasScore = hasScore;
+            this.mockQuery = Mockito.mock(QueryImpl.class);
+
+            if (hasScore) {
+                this.columns = new ColumnImpl[] {
+                        new ColumnImpl("a", "jcr:path", "jcr:path"),
+                        new ColumnImpl("a", "jcr:score", "jcr:score")
+                };
+                // return path at position 0, and score at 1
+                
Mockito.when(mockQuery.getColumnIndex("jcr:path")).thenReturn(0);
+                
Mockito.when(mockQuery.getColumnIndex("jcr:score")).thenReturn(1);
+            } else {
+                this.columns = new ColumnImpl[] {
+                        new ColumnImpl("a", "jcr:path", "jcr:path")
+                };
+                // return path at position 0, and score can't be found
+                
Mockito.when(mockQuery.getColumnIndex("jcr:path")).thenReturn(0);
+                
Mockito.when(mockQuery.getColumnIndex("jcr:score")).thenReturn(-1);
+            }
+            Mockito.when(mockQuery.getColumns()).thenReturn(columns);
+        }
+
+        public MockQueryBuilder addResult(String path, double score) {
+            if (!hasScore) {
+                throw new IllegalStateException("Cannot provide score");
+            }
+            PropertyValue[] values = new PropertyValue[] {
+                    PropertyValues.newString(path),
+                    PropertyValues.newDouble(score)
+            };
+            results.add(new ResultRowImpl(mockQuery, null, values, null, 
null));
+            return this;
+        }
+
+        public MockQueryBuilder addResult(String path) {
+            if (hasScore) {
+                throw new IllegalStateException("Must provide score");
+            }
+            PropertyValue[] values = new PropertyValue[] {
+                    PropertyValues.newString(path)
+            };
+            results.add(new ResultRowImpl(mockQuery, null, values, null, 
null));
+            return this;
+        }
+
+        public QueryImpl build() {
+            Mockito.when(mockQuery.getRows()).thenReturn(results.iterator());
+            return mockQuery;
+        }
+    }
+
+    /**
+     * Helper class to hold path and according score
+     */
+    private static class ScoredResult {
+        final String path;
+        final Double score;
+        ScoredResult(String path, Double score) {
+            this.path = path;
+            this.score = score;
+        }
+    }
+
+    private List<ScoredResult> executeUnionAndGetScoredResults(UnionQueryImpl 
unionQuery) throws Exception {
+        Iterator<ResultRowImpl> results = unionQuery.getRows();
+        List<ScoredResult> scoredResults = new ArrayList<>();
+
+        while (results.hasNext()) {
+            ResultRowImpl row = results.next();
+            String path = row.getValue("jcr:path").getValue(Type.STRING);
+            try {
+                PropertyValue scoreValue = row.getValue("jcr:score");
+                Double  score = scoreValue.getValue(Type.DOUBLE);
+                scoredResults.add(new ScoredResult(path, score));
+            } catch (Exception e) {
+                scoredResults.add(new ScoredResult(path, null));
+            }
+        }
+
+        return scoredResults;
+    }
+
+    private void assertPathOrder(List<ScoredResult> results, String[] 
expectedPaths) {
+        assertEquals(expectedPaths.length, results.size());
+        for (int i = 0; i < expectedPaths.length; i++) {
+            assertEquals(expectedPaths[i], results.get(i).path);
+        }
+    }
+
+    private static Feature createFeature(boolean enabled) {
+        Feature feature = Mockito.mock(Feature.class);
+        Mockito.when(feature.isEnabled()).thenReturn(enabled);
+        return feature;
+    }
 }

Reply via email to