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

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit a985add1db7e80e204f0dd0b37a97b6cd41156a5
Author: Mikhail Khludnev <[email protected]>
AuthorDate: Fri Sep 18 20:29:27 2026 +0300

    SOLR-18441: `{!globalOrdinalsJoin ..}..` QParser - single index, single 
field (#4881)
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
    Co-authored-by: mkhludnev <[email protected]>
    (cherry picked from commit 7af2582a075f22de6a41a6a7032172c27bca70c7)
---
 changelog/unreleased/globalOrdinalsJoin.yml        |   9 +
 .../java/org/apache/solr/search/QParserPlugin.java |   2 +
 .../join/GlobalOrdinalsJoinQParserPlugin.java      | 244 ++++++++++
 .../org/apache/solr/search/QueryEqualityTest.java  |  26 ++
 .../search/join/TestGlobalOrdinalsJoinQParser.java | 495 +++++++++++++++++++++
 .../pages/global-ordinals-join-query-parser.adoc   | 102 +++++
 .../modules/query-guide/pages/other-parsers.adoc   |   6 +
 .../modules/query-guide/querying-nav.adoc          |   1 +
 8 files changed, 885 insertions(+)

diff --git a/changelog/unreleased/globalOrdinalsJoin.yml 
b/changelog/unreleased/globalOrdinalsJoin.yml
new file mode 100644
index 00000000000..6a869ea85ea
--- /dev/null
+++ b/changelog/unreleased/globalOrdinalsJoin.yml
@@ -0,0 +1,9 @@
+title: The `{!globalOrdinalsJoin joinField=join_s_dv which="type_s:parent"}` 
query parser joins on single index by the same single-value docValues string 
field.
+type: added
+authors:
+  - name: Mikhail Khludnev
+    nick: mkhludnev
+    email: [email protected]
+links:
+  - name: GITHUB#4881
+    url: https://github.com/apache/solr/pull/4881
diff --git a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java 
b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java
index e21385592ea..8a6190c4742 100644
--- a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java
@@ -24,6 +24,7 @@ import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.search.join.BlockJoinChildQParserPlugin;
 import org.apache.solr.search.join.BlockJoinParentQParserPlugin;
 import org.apache.solr.search.join.FiltersQParserPlugin;
+import org.apache.solr.search.join.GlobalOrdinalsJoinQParserPlugin;
 import org.apache.solr.search.join.GraphQParserPlugin;
 import org.apache.solr.search.join.HashRangeQParserPlugin;
 import org.apache.solr.search.mlt.MLTContentQParserPlugin;
@@ -93,6 +94,7 @@ public abstract class QParserPlugin implements 
NamedListInitializedPlugin {
     map.put(FuzzyQParserPlugin.NAME, new FuzzyQParserPlugin());
     map.put(NumericRangeQParserPlugin.NAME, new NumericRangeQParserPlugin());
     map.put(IntervalsQParserPlugin.NAME, new IntervalsQParserPlugin());
+    map.put(GlobalOrdinalsJoinQParserPlugin.NAME, new 
GlobalOrdinalsJoinQParserPlugin());
 
     standardPlugins = Collections.unmodifiableMap(map);
   }
diff --git 
a/solr/core/src/java/org/apache/solr/search/join/GlobalOrdinalsJoinQParserPlugin.java
 
b/solr/core/src/java/org/apache/solr/search/join/GlobalOrdinalsJoinQParserPlugin.java
new file mode 100644
index 00000000000..01a27e18602
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/search/join/GlobalOrdinalsJoinQParserPlugin.java
@@ -0,0 +1,244 @@
+/*
+ * 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.solr.search.join;
+
+import java.io.IOException;
+import java.util.Objects;
+import org.apache.lucene.index.MultiDocValues;
+import org.apache.lucene.index.OrdinalMap;
+import org.apache.lucene.index.SortedDocValues;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.MatchNoDocsQuery;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.QueryVisitor;
+import org.apache.lucene.search.Weight;
+import org.apache.lucene.search.join.JoinUtil;
+import org.apache.lucene.search.join.ScoreMode;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.CommonParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.search.QParser;
+import org.apache.solr.search.QParserPlugin;
+import org.apache.solr.search.SolrIndexSearcher;
+import org.apache.solr.search.SyntaxError;
+
+/**
+ * Creates a query-time join query backed by Lucene's {@link 
JoinUtil#createJoinQuery(String, Query,
+ * Query, IndexSearcher, ScoreMode, OrdinalMap)} using global ordinals.
+ *
+ * <p>It joins documents matching a subordinate from-query to documents 
matching a target to-query
+ * (the {@code which} parameter) on the same single-valued string docValues 
field.
+ *
+ * <p>Local parameters:
+ *
+ * <ul>
+ *   <li>{@code joinField} - single-valued docValues string field to join on. 
Required.
+ *   <li>{@code which} - subquery defining the target "to" documents. Required.
+ *   <li>{@code v} - subquery defining the "from" documents (or specified as 
the query body).
+ *       Required.
+ *   <li>{@code score} - optional score mode: {@code none}, {@code avg}, 
{@code max}, {@code min},
+ *       {@code total}/{@code sum}. Defaults to {@code none}.
+ * </ul>
+ *
+ * <p>Example: {@code q={!globalOrdinalsJoin joinField=sku_id_s 
which="type:parent"
+ * score=max}color:blue}
+ */
+public class GlobalOrdinalsJoinQParserPlugin extends QParserPlugin {
+  public static final String NAME = "globalOrdinalsJoin";
+  public static final String JOIN_FIELD = "joinField";
+  public static final String WHICH = "which";
+  public static final String SCORE = "score";
+
+  @Override
+  public QParser createParser(
+      String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest 
req) {
+    return new QParser(qstr, localParams, params, req) {
+      @Override
+      public Query parse() throws SyntaxError {
+        final String joinField = localParams.get(JOIN_FIELD);
+        if (joinField == null || joinField.isBlank()) {
+          throw new SyntaxError("'" + JOIN_FIELD + "' is required for '" + 
NAME + "' query parser");
+        }
+
+        final SchemaField sf = req.getSchema().getFieldOrNull(joinField);
+        if (sf == null) {
+          throw new SolrException(
+              SolrException.ErrorCode.BAD_REQUEST,
+              "joinField '" + joinField + "' does not exist in schema");
+        }
+        if (!sf.hasDocValues()) {
+          throw new SolrException(
+              SolrException.ErrorCode.BAD_REQUEST,
+              "joinField '" + joinField + "' must have docValues enabled");
+        }
+        if (sf.multiValued()) {
+          throw new SolrException(
+              SolrException.ErrorCode.BAD_REQUEST,
+              "joinField '" + joinField + "' must be single-valued");
+        }
+        if (sf.getType().getNumberType() != null) {
+          throw new SolrException(
+              SolrException.ErrorCode.BAD_REQUEST,
+              "joinField '"
+                  + joinField
+                  + "' must be a String field, but has numeric type "
+                  + sf.getType().getNumberType());
+        }
+
+        final String whichStr = localParams.get(WHICH);
+        if (whichStr == null || whichStr.isBlank()) {
+          throw new SyntaxError("'" + WHICH + "' is required for '" + NAME + 
"' query parser");
+        }
+
+        final String fromQueryStr = localParams.get(CommonParams.VALUE);
+        final String effectiveFromStr =
+            (fromQueryStr != null && !fromQueryStr.isBlank()) ? fromQueryStr : 
qstr;
+        if (effectiveFromStr == null || effectiveFromStr.isBlank()) {
+          throw new SyntaxError("from query is required for '" + NAME + "' 
query parser");
+        }
+
+        final ScoreMode scoreMode;
+        final String scoreParam = getParam(SCORE);
+        if (scoreParam == null || scoreParam.isBlank()) {
+          scoreMode = ScoreMode.None;
+        } else {
+          scoreMode = ScoreModeParser.parse(scoreParam);
+        }
+
+        final Query fromQuery = subQuery(effectiveFromStr, null).getQuery();
+        final Query toQuery = subQuery(whichStr, null).getQuery();
+
+        return createJoinQuery(fromQuery, toQuery, joinField, scoreMode);
+      }
+    };
+  }
+
+  /**
+   * Helper method to create a {@link GlobalOrdinalsJoinQuery}.
+   *
+   * @param fromQuery the query defining matching docs on the "from" side
+   * @param toQuery the query defining candidate docs on the "to" side
+   * @param joinField the single-valued string docValues field name
+   * @param scoreMode scoring mode
+   * @return a {@link GlobalOrdinalsJoinQuery} instance
+   */
+  public static Query createJoinQuery(
+      Query fromQuery, Query toQuery, String joinField, ScoreMode scoreMode) {
+    return new GlobalOrdinalsJoinQuery(fromQuery, toQuery, joinField, 
scoreMode);
+  }
+
+  /** Query representing a join based on global ordinals across segments. */
+  public static class GlobalOrdinalsJoinQuery extends Query {
+    protected final Query fromQuery;
+    protected final Query toQuery;
+    protected final String joinField;
+    protected final ScoreMode scoreMode;
+
+    public GlobalOrdinalsJoinQuery(
+        Query fromQuery, Query toQuery, String joinField, ScoreMode scoreMode) 
{
+      this.fromQuery = Objects.requireNonNull(fromQuery, "fromQuery must not 
be null");
+      this.toQuery = Objects.requireNonNull(toQuery, "toQuery must not be 
null");
+      this.joinField = Objects.requireNonNull(joinField, "joinField must not 
be null");
+      this.scoreMode = scoreMode == null ? ScoreMode.None : scoreMode;
+    }
+
+    public Query getFromQuery() {
+      return fromQuery;
+    }
+
+    public Query getToQuery() {
+      return toQuery;
+    }
+
+    public String getJoinField() {
+      return joinField;
+    }
+
+    public ScoreMode getScoreMode() {
+      return scoreMode;
+    }
+
+    @Override
+    public Weight createWeight(
+        IndexSearcher searcher, org.apache.lucene.search.ScoreMode scoreMode, 
float boost)
+        throws IOException {
+      OrdinalMap ordinalMap = null;
+      if (searcher.getIndexReader().leaves().size() > 1) {
+        final SortedDocValues sdv;
+        if (searcher instanceof SolrIndexSearcher sis) {
+          sdv = sis.getSlowAtomicReader().getSortedDocValues(joinField);
+        } else {
+          sdv = MultiDocValues.getSortedValues(searcher.getIndexReader(), 
joinField);
+        }
+        if (sdv == null) {
+          return new MatchNoDocsQuery("No join values for " + joinField)
+              .createWeight(searcher, scoreMode, boost);
+        }
+        if (sdv instanceof MultiDocValues.MultiSortedDocValues multi) {
+          ordinalMap = multi.mapping;
+        }
+      }
+      final Query jq =
+          JoinUtil.createJoinQuery(
+              joinField, fromQuery, toQuery, searcher, this.scoreMode, 
ordinalMap);
+      return jq.rewrite(searcher).createWeight(searcher, scoreMode, boost);
+    }
+
+    @Override
+    public String toString(String field) {
+      return "GlobalOrdinalsJoinQuery [fromQuery="
+          + fromQuery
+          + ", toQuery="
+          + toQuery
+          + ", joinField="
+          + joinField
+          + ", scoreMode="
+          + scoreMode
+          + "]";
+    }
+
+    @Override
+    public int hashCode() {
+      final int prime = 31;
+      int result = classHash();
+      result = prime * result + Objects.hashCode(fromQuery);
+      result = prime * result + Objects.hashCode(toQuery);
+      result = prime * result + Objects.hashCode(joinField);
+      result = prime * result + Objects.hashCode(scoreMode);
+      return result;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      return sameClassAs(other) && equalsTo(getClass().cast(other));
+    }
+
+    private boolean equalsTo(GlobalOrdinalsJoinQuery other) {
+      return Objects.equals(fromQuery, other.fromQuery)
+          && Objects.equals(toQuery, other.toQuery)
+          && Objects.equals(joinField, other.joinField)
+          && Objects.equals(scoreMode, other.scoreMode);
+    }
+
+    @Override
+    public void visit(QueryVisitor visitor) {
+      visitor.visitLeaf(this);
+    }
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java 
b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
index 1a39024c946..695c141406e 100644
--- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
+++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
@@ -661,6 +661,32 @@ public class QueryEqualityTest extends SolrTestCaseJ4 {
     }
   }
 
+  public void testQueryGlobalOrdinalsJoin() throws Exception {
+    SolrQueryRequest req =
+        req(
+            "myVar",
+            "5",
+            "df",
+            "text",
+            "jf",
+            "foo_s_dvo",
+            "which",
+            "type_s:parent",
+            "scoreavg",
+            "avg");
+
+    try {
+      assertQueryEquals(
+          "globalOrdinalsJoin",
+          req,
+          "{!globalOrdinalsJoin joinField=foo_s_dvo which='type_s:parent' 
score=avg}asdf",
+          "{!globalOrdinalsJoin joinField=$jf which='type_s:parent' 
score=Avg}asdf",
+          "{!globalOrdinalsJoin joinField=$jf which=$which 
score=$scoreavg}text:asdf");
+    } finally {
+      req.close();
+    }
+  }
+
   public void testTerms() throws Exception {
     assertQueryEquals(
         "terms", "{!terms f=foo_i}10,20,30,-10,-20,-30", "{!terms 
f=foo_i}10,20,30,-10,-20,-30");
diff --git 
a/solr/core/src/test/org/apache/solr/search/join/TestGlobalOrdinalsJoinQParser.java
 
b/solr/core/src/test/org/apache/solr/search/join/TestGlobalOrdinalsJoinQParser.java
new file mode 100644
index 00000000000..7cd72a52516
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/search/join/TestGlobalOrdinalsJoinQParser.java
@@ -0,0 +1,495 @@
+/*
+ * 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.solr.search.join;
+
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class TestGlobalOrdinalsJoinQParser extends SolrTestCaseJ4 {
+
+  @BeforeClass
+  public static void beforeTests() throws Exception {
+    System.setProperty("solr.index.updatelog.enabled", "false");
+    System.setProperty("solr.filterCache.async", "true");
+    initCore("solrconfig-basic.xml", "schema-docValuesJoin.xml");
+  }
+
+  @Test
+  public void testBasicJoin() throws Exception {
+    clearIndex();
+
+    assertU(add(doc("id", "1", "type_s", "parent", "join_s_dv", "P1", 
"name_s", "Parent 1")));
+    assertU(add(doc("id", "2", "type_s", "parent", "join_s_dv", "P2", 
"name_s", "Parent 2")));
+    assertU(add(doc("id", "3", "type_s", "parent", "join_s_dv", "P3", 
"name_s", "Parent 3")));
+
+    assertU(
+        add(
+            doc(
+                "id",
+                "10",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "P1",
+                "child_name_s",
+                "Child 1A",
+                "skill_s",
+                "java")));
+    assertU(
+        add(
+            doc(
+                "id",
+                "11",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "P1",
+                "child_name_s",
+                "Child 1B",
+                "skill_s",
+                "python")));
+    assertU(
+        add(
+            doc(
+                "id",
+                "12",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "P2",
+                "child_name_s",
+                "Child 2A",
+                "skill_s",
+                "java")));
+    assertU(
+        add(
+            doc(
+                "id",
+                "13",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "P3",
+                "child_name_s",
+                "Child 3A",
+                "skill_s",
+                "rust")));
+
+    assertU(commit());
+
+    // Query parent via child match using query body
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\"}skill_s:python",
+            "fl",
+            "id"),
+        
"/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'1'}]}");
+
+    // Query parent via child match matching multiple parents
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\"}skill_s:java",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'1'},{'id':'2'}]}");
+
+    // Query parent via child match using 'v' local param
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv which=\"type_s:parent\" 
v=\"skill_s:rust\"}",
+            "fl",
+            "id"),
+        
"/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'3'}]}");
+
+    // Reverse join: Query children from parent match
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:child\"}name_s:\"Parent 1\"",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'10'},{'id':'11'}]}");
+  }
+
+  @Test
+  public void testScoreModes() throws Exception {
+    clearIndex();
+
+    assertU(add(doc("id", "p1", "type_s", "parent", "join_s_dv", "GRP1", 
"title_t", "parent one")));
+    assertU(add(doc("id", "p2", "type_s", "parent", "join_s_dv", "GRP2", 
"title_t", "parent two")));
+
+    // Children for GRP1: c1 (score high), c2 (score low)
+    assertU(
+        add(
+            doc(
+                "id",
+                "c1",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "GRP1",
+                "desc_t",
+                "search search search search target")));
+    assertU(
+        add(
+            doc(
+                "id",
+                "c2",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "GRP1",
+                "desc_t",
+                "search random words target")));
+
+    // Children for GRP2: c3 (score medium), c4 (score medium), c5 (score 
medium)
+    assertU(
+        add(
+            doc(
+                "id",
+                "c3",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "GRP2",
+                "desc_t",
+                "search other text target")));
+    assertU(
+        add(
+            doc(
+                "id",
+                "c4",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "GRP2",
+                "desc_t",
+                "search other text target")));
+    assertU(
+        add(
+            doc(
+                "id",
+                "c5",
+                "type_s",
+                "child",
+                "join_s_dv",
+                "GRP2",
+                "desc_t",
+                "search other text target")));
+
+    assertU(commit());
+
+    // score=None (default)
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv which=\"type_s:parent\" 
score=None}desc_t:target",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'p1'},{'id':'p2'}]}");
+
+    // score=Max: p1 max child is higher than p2
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv which=\"type_s:parent\" 
score=Max}desc_t:search",
+            "fl",
+            "id"),
+        "/response/docs/[0]/id=='p1'");
+
+    // score=Min: p2 min child (score > 0) vs p1 min child
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv which=\"type_s:parent\" 
score=Min}desc_t:search",
+            "fl",
+            "id,score"),
+        "/response/numFound==2");
+
+    // score=Total
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv which=\"type_s:parent\" 
score=Total}desc_t:target",
+            "fl",
+            "id,score"),
+        "/response/numFound==2");
+  }
+
+  @Test
+  public void testFilterAndComplexWhich() throws Exception {
+    clearIndex();
+
+    assertU(
+        add(
+            doc(
+                "id",
+                "p1",
+                "type_s",
+                "parent",
+                "active_s",
+                "yes",
+                "join_s_dv",
+                "GRP1",
+                "name_s",
+                "Parent 1")));
+    assertU(
+        add(
+            doc(
+                "id",
+                "p2",
+                "type_s",
+                "parent",
+                "active_s",
+                "no",
+                "join_s_dv",
+                "GRP2",
+                "name_s",
+                "Parent 2")));
+    assertU(add(doc("id", "c1", "type_s", "child", "join_s_dv", "GRP1", 
"skill_s", "java")));
+    assertU(add(doc("id", "c2", "type_s", "child", "join_s_dv", "GRP2", 
"skill_s", "java")));
+    assertU(commit());
+
+    // Join as filter query (fq)
+    assertJQ(
+        req(
+            "q",
+            "*:*",
+            "fq",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\"}skill_s:java",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'p1'},{'id':'p2'}]}");
+
+    // Complex 'which' condition (only active parents)
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv which=\"type_s:parent 
AND active_s:yes\"}skill_s:java",
+            "fl",
+            "id"),
+        
"/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'p1'}]}");
+
+    // 'which' with filter() syntax
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"filter(type_s:parent)\"}skill_s:java",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'p1'},{'id':'p2'}]}");
+  }
+
+  @Test
+  public void testMultiSegmentAndSearcherRotation() throws Exception {
+    clearIndex();
+
+    // Segment 1
+    assertU(add(doc("id", "1", "type_s", "parent", "join_s_dv", "P1")));
+    assertU(add(doc("id", "10", "type_s", "child", "join_s_dv", "P1", 
"text_t", "common query")));
+    assertU(commit());
+
+    // Segment 2
+    assertU(add(doc("id", "2", "type_s", "parent", "join_s_dv", "P2")));
+    assertU(add(doc("id", "20", "type_s", "child", "join_s_dv", "P2", 
"text_t", "common query")));
+    assertU(commit());
+
+    // Segment 3
+    assertU(add(doc("id", "3", "type_s", "parent", "join_s_dv", "P3")));
+    assertU(add(doc("id", "30", "type_s", "child", "join_s_dv", "P3", 
"text_t", "uncommon query")));
+    assertU(commit());
+
+    // Multi-segment search
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\"}text_t:common",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'1'},{'id':'2'}]}");
+
+    // Add more docs and commit (searcher rotation)
+    assertU(add(doc("id", "4", "type_s", "parent", "join_s_dv", "P4")));
+    assertU(add(doc("id", "40", "type_s", "child", "join_s_dv", "P4", 
"text_t", "common query")));
+    assertU(commit());
+
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\"}text_t:common",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':3,'start':0,'numFoundExact':true,'docs':[{'id':'1'},{'id':'2'},{'id':'4'}]}");
+
+    // Optimize / forceMerge down to 1 segment (tests single segment index 
where OrdinalMap is null)
+    assertU(optimize());
+
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\"}text_t:common",
+            "fl",
+            "id",
+            "sort",
+            "id asc"),
+        
"/response=={'numFound':3,'start':0,'numFoundExact':true,'docs':[{'id':'1'},{'id':'2'},{'id':'4'}]}");
+  }
+
+  @Test
+  public void testValidationErrors() {
+    // Missing joinField
+    SolrException ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(req("q", "{!globalOrdinalsJoin 
which=\"type_s:parent\"}text_t:test"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("joinField"));
+
+    // Unknown field
+    ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(
+                  req(
+                      "q",
+                      "{!globalOrdinalsJoin joinField=non_existent_field 
which=\"type_s:parent\"}text_t:test"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("non_existent_field"));
+
+    // Multi-valued field
+    ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(
+                  req(
+                      "q",
+                      "{!globalOrdinalsJoin joinField=dept_ss_dv 
which=\"type_s:parent\"}text_t:test"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("single-valued"));
+
+    // Non-docValues field
+    ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(
+                  req(
+                      "q",
+                      "{!globalOrdinalsJoin joinField=id 
which=\"type_s:parent\"}text_t:test"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("docValues"));
+
+    // Non-string field (e.g. numeric docValues)
+    ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(
+                  req(
+                      "q",
+                      "{!globalOrdinalsJoin joinField=cat_i_dv 
which=\"type_s:parent\"}text_t:test"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("must be a String field"));
+
+    // Missing which parameter
+    ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(req("q", "{!globalOrdinalsJoin 
joinField=join_s_dv}text_t:test"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("which"));
+
+    // Invalid score mode
+    ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(
+                  req(
+                      "q",
+                      "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\" score=UnknownScore}text_t:test"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("UnknownScore"));
+  }
+
+  @Test
+  public void testEmptyMatchesAndUnpopulatedField() throws Exception {
+    clearIndex();
+
+    assertU(add(doc("id", "1", "type_s", "parent", "join_s_dv", "P1")));
+    assertU(add(doc("id", "2", "type_s", "child", "join_s_dv", "P1", 
"skill_s", "java")));
+    assertU(commit());
+
+    // fromQuery matches nothing
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:parent\"}skill_s:nonexistent",
+            "fl",
+            "id"),
+        "/response=={'numFound':0,'start':0,'numFoundExact':true,'docs':[]}");
+
+    // toQuery matches nothing
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=join_s_dv 
which=\"type_s:nonexistent\"}skill_s:java",
+            "fl",
+            "id"),
+        "/response=={'numFound':0,'start':0,'numFoundExact':true,'docs':[]}");
+
+    // joinField has no doc values in entire index (e.g. dynamic field 
other_s_dv not populated in
+    // any doc)
+    assertJQ(
+        req(
+            "q",
+            "{!globalOrdinalsJoin joinField=other_s_dv 
which=\"type_s:parent\"}skill_s:java",
+            "fl",
+            "id"),
+        "/response=={'numFound':0,'start':0,'numFoundExact':true,'docs':[]}");
+  }
+}
diff --git 
a/solr/solr-ref-guide/modules/query-guide/pages/global-ordinals-join-query-parser.adoc
 
b/solr/solr-ref-guide/modules/query-guide/pages/global-ordinals-join-query-parser.adoc
new file mode 100644
index 00000000000..81ed6202034
--- /dev/null
+++ 
b/solr/solr-ref-guide/modules/query-guide/pages/global-ordinals-join-query-parser.adoc
@@ -0,0 +1,102 @@
+= Global Ordinals Join Query Parser
+// 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
+
+The `globalOrdinalsJoin` query parser allows joining documents within the same 
index using Lucene's global ordinals (`OrdinalMap`).
+It is backed by Lucene's `JoinUtil.createJoinQuery(joinField, fromQuery, 
toQuery, searcher, scoreMode, ordinalMap)`.
+
+Global ordinals joins operate on a single shared field (`joinField`) that 
contains single-valued string docValues (`StrField` with `docValues="true"`).
+The query matches source documents using a "from" subquery, maps their join 
field ordinals across segments via global ordinals, and yields target documents 
matching the "to" subquery (specified by the `which` parameter).
+
+Global ordinal mappings (`OrdinalMap`) are computed and cached per 
`SolrIndexSearcher` across search requests.
+
+== Parameters
+
+This query parser accepts the following parameters:
+
+`joinField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The name of the single-valued `StrField` with `docValues="true"` used as the 
join key between documents.
+
+`which`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+A subquery specifying the target ("to") documents to join into.
+This parameter mimics the `which` parameter from the 
xref:block-join-query-parser.adoc[Block Join Query Parser].
+
+`v`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: _query body_
+|===
++
+The subquery specifying the source ("from") documents. If not provided as a 
local parameter, the body of the query is used.
+
+`score`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: `None`
+|===
++
+Controls how scores from matching "from" documents are aggregated and applied 
to matching "to" documents.
+Acceptable values (case-insensitive) are:
+
+* `None`: (default) No scores are transferred; matching target documents have 
a constant score.
+* `Avg`: Average score of matching source documents.
+* `Max`: Maximum score among matching source documents.
+* `Min`: Minimum score among matching source documents.
+* `Total`: Sum of scores across matching source documents.
+
+== Examples
+
+=== Basic Global Ordinals Join
+
+Find parent documents whose children match `skill_s:java`:
+
+[source,text]
+----
+q={!globalOrdinalsJoin joinField=join_s_dv which="type_s:parent"}skill_s:java
+----
+
+=== With Score Aggregation
+
+Find parent documents whose children match `desc_t:laptop` and score parents 
using the maximum score of matching children:
+
+[source,text]
+----
+q={!globalOrdinalsJoin joinField=join_s_dv which="type_s:parent" 
score=Max}desc_t:laptop
+----
+
+=== Using the `v` Local Parameter in Filter Queries
+
+Use `globalOrdinalsJoin` inside a filter query (`fq`):
+
+[source,text]
+----
+fq={!globalOrdinalsJoin joinField=join_s_dv which="type_s:parent" 
v="skill_s:rust"}
+----
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc 
b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc
index 2be81f40302..771b82ff516 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc
@@ -733,6 +733,12 @@ Invoked with the syntax `{!intervals 
df=<field>}{"phrase":{"terms":["hash","map"
 
 Details of this query parser are in the section 
xref:intervals-query-parser.adoc[].
 
+== Global Ordinals Join Query Parser
+
+The Global Ordinals Join Query Parser joins documents within an index using 
Lucene's global ordinals (`OrdinalMap`) over a shared single-valued docValues 
string field.
+
+Details of this query parser are in the section 
xref:global-ordinals-join-query-parser.adoc[].
+
 == Join Query Parser
 
 The Join Query Parser allows users to run queries that normalize relationships 
between documents, similar to SQL-style joins.
diff --git a/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc 
b/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc
index dab5a2eeabb..51b6860f7ea 100644
--- a/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc
@@ -30,6 +30,7 @@
 *** xref:json-combined-query-dsl.adoc[]
 ** xref:searching-nested-documents.adoc[]
 ** xref:block-join-query-parser.adoc[]
+** xref:global-ordinals-join-query-parser.adoc[]
 ** xref:join-query-parser.adoc[]
 ** xref:spatial-search.adoc[]
 ** xref:dense-vector-search.adoc[]

Reply via email to