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=}{"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 8604457592e..6d6ef6f5ca1 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[]