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: mkhl@apache.org +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.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. + * + *
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. + * + *
Local parameters: + * + *
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 void testQueryScoreJoin() throws Exception {
}
}
+ 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 f1966f0b767..0856d797e1b 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
@@ -726,6 +726,12 @@ Invoked with the syntax `{!intervals df=