From 5904aac5c28daa9449e22d21415ae6690aed57b5 Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhonov Date: Wed, 29 Apr 2026 19:25:09 -0400 Subject: [PATCH] Add native support for NamedQueries in Solr Adds _name local-param support to 8 core query parsers so any query clause can be labelled for post-search introspection via NamedMatches: term, terms, bool, lucene, prefix, dismax, edismax, fuzzy Introduces MatchedQueriesComponent: a SearchComponent activated by matched_queries=true (alias mq=true) that performs a lightweight second pass over the top-N hits using Weight.matches() and reports which named clauses fired, both per-document and as a summary. --- .../component/MatchedQueriesComponent.java | 183 +++++++++++ .../apache/solr/search/BoolQParserPlugin.java | 9 +- .../org/apache/solr/search/DisMaxQParser.java | 8 +- .../solr/search/ExtendedDismaxQParser.java | 5 + .../solr/search/FuzzyQParserPlugin.java | 8 +- .../org/apache/solr/search/LuceneQParser.java | 8 +- .../solr/search/PrefixQParserPlugin.java | 9 +- .../org/apache/solr/search/QueryParsing.java | 2 +- .../apache/solr/search/TermQParserPlugin.java | 13 +- .../solr/search/TermsQParserPlugin.java | 38 ++- .../solr/collection1/conf/solrconfig.xml | 10 +- .../TestMatchedQueriesComponent.java | 297 ++++++++++++++++++ .../apache/solr/search/PrefixQueryTest.java | 16 + .../solr/search/TestExtendedDismaxParser.java | 26 ++ .../solr/search/TestMmBoolQParserPlugin.java | 48 +++ .../solr/search/TestTermQParserPlugin.java | 11 + .../solr/search/TestTermsQParserPlugin.java | 17 + 17 files changed, 684 insertions(+), 24 deletions(-) create mode 100644 solr/core/src/java/org/apache/solr/handler/component/MatchedQueriesComponent.java create mode 100644 solr/core/src/test/org/apache/solr/handler/component/TestMatchedQueriesComponent.java diff --git a/solr/core/src/java/org/apache/solr/handler/component/MatchedQueriesComponent.java b/solr/core/src/java/org/apache/solr/handler/component/MatchedQueriesComponent.java new file mode 100644 index 000000000000..a3b747472926 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/component/MatchedQueriesComponent.java @@ -0,0 +1,183 @@ +package org.apache.solr.handler.component; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Matches; +import org.apache.lucene.search.NamedMatches; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.ScoreMode; +import org.apache.lucene.search.Weight; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.apache.solr.search.DocIterator; +import org.apache.solr.search.DocList; +import org.apache.solr.search.SolrIndexSearcher; + +/** + * Search component that enriches the response with named-match information + * for each document in the top-N hits. + * + * Activation: + * Add {@code matched_queries=true} (or {@code mq=true}) to the request. + * + * Output: + * - per-doc: each hit gets a "matched_queries": ["name1","name2"] field + * - response section: "matched_queries_summary": { + * "name1": {"count": 5, "docIds": ["id1","id2"]}, + * "name2": {"count": 2, "docIds": ["id3"]} + * } + * + * Implementation: + * We use the {@link Weight#matches(LeafReaderContext, int)} API which performs + * a separate, post-search pass over each requested document. {@link NamedMatches} + * become identifiable through {@link NamedMatches#findNamedMatches(Matches)} on the returned Matches tree. + * ScoreMode.COMPLETE_NO_SCORES is used for the matches Weight because matching + * does not need scoring and this lets Lucene skip score computation entirely + * for this pass. + */ +public class MatchedQueriesComponent extends SearchComponent { + + public static final String COMPONENT_NAME = "matched_queries"; + public static final String PARAM_ENABLE = "matched_queries"; + public static final String PARAM_ENABLE_SHORT = "mq"; + + + @Override + public void prepare(ResponseBuilder rb) { + // nothing to prepare + } + + @Override + public void process(ResponseBuilder rb) throws IOException { + if (!isEnabled(rb)) { + return; + } + + DocList docList = rb.getResults() == null ? null : rb.getResults().docList; + if (docList == null || docList.size() == 0) { + return; + } + + Query query = rb.getQuery(); + if (query == null) { + return; + } + + SolrIndexSearcher searcher = rb.req.getSearcher(); + // schema's unique key field — used to populate docIds in the summary + String idField = rb.req.getCore().getLatestSchema().getUniqueKeyField().getName(); + + // Build a Weight for matching only (no scoring needed) + Query rewritten = searcher.rewrite(query); + Weight matchesWeight = searcher.createWeight(rewritten, + ScoreMode.COMPLETE_NO_SCORES, 1.0f); + + // Collect: per global doc id → ordered set of names + Map> perDocNames = new LinkedHashMap<>(); + // Collect: per name → list of global doc ids (preserves document order) + Map> perNameDocs = new LinkedHashMap<>(); + // Cache unique-key values: each matching doc's stored id field is read exactly once here + // and reused in both output loops below, avoiding redundant stored-field access. + Map idCache = new LinkedHashMap<>(); + + List leaves = searcher.getTopReaderContext().leaves(); + + DocIterator it = docList.iterator(); + while (it.hasNext()) { + int globalDoc = it.nextDoc(); + + LeafReaderContext leaf = leafFor(leaves, globalDoc); + int leafDoc = globalDoc - leaf.docBase; + + Matches matches = matchesWeight.matches(leaf, leafDoc); + if (matches == null) { + continue; + } + List named = NamedMatches.findNamedMatches(matches); + if (named.isEmpty()) { + continue; + } + + Set names = new LinkedHashSet<>(); + for (NamedMatches nm : named) { + names.add(nm.getName()); + } + perDocNames.put(globalDoc, names); + idCache.put(globalDoc, readUniqueKeyValue(searcher, idField, globalDoc)); + for (String name : names) { + perNameDocs.computeIfAbsent(name, k -> new ArrayList<>()).add(globalDoc); + } + } + + if (perDocNames.isEmpty()) { + return; + } + + // Annotate each hit: we add a parallel structure (docId → matched names) + // because mutating SolrDocument inline requires DocTransformer plumbing. + // The hits-keyed map is keyed by the document's unique-key value (string) + // for client convenience. + SimpleOrderedMap perHit = new SimpleOrderedMap<>(); + for (Map.Entry> e : perDocNames.entrySet()) { + perHit.add(idCache.get(e.getKey()), new ArrayList<>(e.getValue())); + } + + // Summary: name → {count, docIds} + SimpleOrderedMap summary = new SimpleOrderedMap<>(); + for (Map.Entry> e : perNameDocs.entrySet()) { + List ids = new ArrayList<>(e.getValue().size()); + for (Integer luceneId : e.getValue()) { + ids.add(idCache.get(luceneId)); + } + SimpleOrderedMap entry = new SimpleOrderedMap<>(); + entry.add("count", ids.size()); + entry.add("docIds", ids); + summary.add(e.getKey(), entry); + } + + NamedList response = rb.rsp.getValues(); + response.add("matched_queries_per_hit", perHit); + response.add("matched_queries_summary", summary); + } + + private LeafReaderContext leafFor(List leaves, int globalDoc) { + // Standard binary search for the leaf owning a global docId + int lo = 0, hi = leaves.size() - 1; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + LeafReaderContext c = leaves.get(mid); + int max = c.docBase + c.reader().maxDoc(); + if (globalDoc < c.docBase) { + hi = mid - 1; + } else if (globalDoc >= max) { + lo = mid + 1; + } else { + return c; + } + } + throw new IllegalStateException("No leaf for global doc " + globalDoc); + } + + private String readUniqueKeyValue(IndexSearcher searcher, String idField, int globalDoc) + throws IOException { + var doc = searcher.storedFields().document(globalDoc, Set.of(idField)); + return doc.get(idField); + } + + private boolean isEnabled(ResponseBuilder rb) { + var p = rb.req.getParams(); + return p.getBool(PARAM_ENABLE, false) || p.getBool(PARAM_ENABLE_SHORT, false); + } + + @Override + public String getDescription() { + return "Adds NamedMatches information to query response"; + } +} diff --git a/solr/core/src/java/org/apache/solr/search/BoolQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/BoolQParserPlugin.java index 9d37fa590c87..14c9ce22a491 100644 --- a/solr/core/src/java/org/apache/solr/search/BoolQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/BoolQParserPlugin.java @@ -21,6 +21,7 @@ import java.util.Map; import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.solr.common.params.SolrParams; import org.apache.solr.query.FilterQuery; @@ -42,7 +43,13 @@ public QParser createParser( return new FiltersQParser(qstr, localParams, params, req) { @Override public Query parse() throws SyntaxError { - return parseImpl(); + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + Query mainQuery = parseImpl(); + + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, mainQuery); + } + return mainQuery; } @Override diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index 79756bc1090a..feb238772678 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -22,6 +22,7 @@ import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.BoostQuery; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.params.DisMaxParams; @@ -111,7 +112,12 @@ public Query parse() throws SyntaxError { addBoostQuery(query, solrParams); addBoostFunctions(query, solrParams); - return QueryUtils.build(query, this); + Query mainQuery = QueryUtils.build(query, this); + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, mainQuery); + } + return mainQuery; } protected void addBoostFunctions(BooleanQuery.Builder query, SolrParams solrParams) diff --git a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java index b1dcb910a9d0..db8cddacb35b 100644 --- a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java @@ -40,6 +40,7 @@ import org.apache.lucene.search.DisjunctionMaxQuery; import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.MultiPhraseQuery; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.PhraseQuery; import org.apache.lucene.search.Query; import org.apache.solr.analysis.TokenizerChain; @@ -210,6 +211,10 @@ public Query parse() throws SyntaxError { topQuery = FunctionScoreQuery.boostByValue(topQuery, boosts.get(0).asDoubleValuesSource()); } + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, topQuery); + } return topQuery; } diff --git a/solr/core/src/java/org/apache/solr/search/FuzzyQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/FuzzyQParserPlugin.java index fa7816fb80b3..1f33c6905cd1 100644 --- a/solr/core/src/java/org/apache/solr/search/FuzzyQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/FuzzyQParserPlugin.java @@ -19,6 +19,7 @@ import org.apache.lucene.index.Term; import org.apache.lucene.queryparser.surround.parser.QueryParser; import org.apache.lucene.search.FuzzyQuery; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.lucene.util.BytesRef; import org.apache.solr.common.params.SolrParams; @@ -82,7 +83,12 @@ public Query parse() throws SyntaxError { ? Boolean.parseBoolean(transpositionsRaw) : FuzzyQuery.defaultTranspositions; - return new FuzzyQuery(t, maxEdits, prefixLength, maxExpansions, transpositions); + Query mainQuery = new FuzzyQuery(t, maxEdits, prefixLength, maxExpansions, transpositions); + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, mainQuery); + } + return mainQuery; } protected String analyzeIfMultitermTermText(String field, String part) { diff --git a/solr/core/src/java/org/apache/solr/search/LuceneQParser.java b/solr/core/src/java/org/apache/solr/search/LuceneQParser.java index b299dd3766c5..4ab9465001a1 100644 --- a/solr/core/src/java/org/apache/solr/search/LuceneQParser.java +++ b/solr/core/src/java/org/apache/solr/search/LuceneQParser.java @@ -16,6 +16,7 @@ */ package org.apache.solr.search; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.params.SolrParams; @@ -47,8 +48,13 @@ public Query parse() throws SyntaxError { getParam(QueryParsing.SPLIT_ON_WHITESPACE), SolrQueryParser.DEFAULT_SPLIT_ON_WHITESPACE)); lparser.setAllowSubQueryParsing(true); + Query query = lparser.parse(qstr); - return lparser.parse(qstr); + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, query); + } + return query; } @Override diff --git a/solr/core/src/java/org/apache/solr/search/PrefixQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/PrefixQParserPlugin.java index 0be1de28dd81..a282625b88ad 100644 --- a/solr/core/src/java/org/apache/solr/search/PrefixQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/PrefixQParserPlugin.java @@ -16,6 +16,7 @@ */ package org.apache.solr.search; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; @@ -38,7 +39,13 @@ public QParser createParser( @Override public Query parse() { SchemaField sf = req.getSchema().getField(localParams.get(QueryParsing.F)); - return sf.getType().getPrefixQuery(this, sf, localParams.get(QueryParsing.V)); + Query query = sf.getType().getPrefixQuery(this, sf, localParams.get(QueryParsing.V)); + + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, query); + } + return query; } }; } diff --git a/solr/core/src/java/org/apache/solr/search/QueryParsing.java b/solr/core/src/java/org/apache/solr/search/QueryParsing.java index 98029a68e030..5013a9c2f87f 100644 --- a/solr/core/src/java/org/apache/solr/search/QueryParsing.java +++ b/solr/core/src/java/org/apache/solr/search/QueryParsing.java @@ -53,7 +53,7 @@ public class QueryParsing { public static final char LOCALPARAM_END = '}'; // true if the value was specified by the "v" param (i.e. v=myval, or v=$param) public static final String VAL_EXPLICIT = "__VAL_EXPLICIT__"; - + public static final String NAME = "_name"; /** * @param txt Text to parse * @param start Index into text for start of parsing diff --git a/solr/core/src/java/org/apache/solr/search/TermQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/TermQParserPlugin.java index d6f4874ffc20..0b5f2ee6d2e2 100644 --- a/solr/core/src/java/org/apache/solr/search/TermQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/TermQParserPlugin.java @@ -17,6 +17,7 @@ package org.apache.solr.search; import org.apache.lucene.index.Term; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.lucene.search.TermQuery; import org.apache.lucene.util.BytesRefBuilder; @@ -54,13 +55,21 @@ public Query parse() { } FieldType ft = req.getSchema().getFieldTypeNoEx(fname); String val = localParams.get(QueryParsing.V); + + Query mainQuery; if (ft != null) { - return ft.getFieldTermQuery(this, req.getSchema().getField(fname), val); + mainQuery = ft.getFieldTermQuery(this, req.getSchema().getField(fname), val); } else { BytesRefBuilder term = new BytesRefBuilder(); term.copyChars(val); - return new TermQuery(new Term(fname, term.get())); + mainQuery = new TermQuery(new Term(fname, term.get())); + } + + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, mainQuery); } + return mainQuery; } }; } diff --git a/solr/core/src/java/org/apache/solr/search/TermsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/TermsQParserPlugin.java index 939f2f64ede0..b2abf5e60f89 100644 --- a/solr/core/src/java/org/apache/solr/search/TermsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/TermsQParserPlugin.java @@ -35,6 +35,7 @@ import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.MultiTermQuery; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.lucene.search.ScoreMode; import org.apache.lucene.search.ScorerSupplier; @@ -161,6 +162,7 @@ public Query parse() throws SyntaxError { sepIsSpace ? qstr.split("\\s+") : qstr.split(Pattern.quote(separator), -1); assert splitVals.length > 0; + final Query mainQuery; if (ft.isPointField()) { if (localParams.get(METHOD) != null) { throw new SolrException( @@ -170,24 +172,30 @@ public Query parse() throws SyntaxError { "Method '%s' not supported in TermsQParser when using PointFields", localParams.get(METHOD))); } - return ((PointField) ft) - .getSetQuery(this, req.getSchema().getField(fname), Arrays.asList(splitVals)); - } - - BytesRef[] bytesRefs = new BytesRef[splitVals.length]; - BytesRefBuilder term = new BytesRefBuilder(); - for (int i = 0; i < splitVals.length; i++) { - String stringVal = splitVals[i]; - // logic same as TermQParserPlugin - if (ft != null) { - ft.readableToIndexed(stringVal, term); - } else { - term.copyChars(stringVal); + mainQuery = + ((PointField) ft) + .getSetQuery(this, req.getSchema().getField(fname), Arrays.asList(splitVals)); + } else { + BytesRef[] bytesRefs = new BytesRef[splitVals.length]; + BytesRefBuilder term = new BytesRefBuilder(); + for (int i = 0; i < splitVals.length; i++) { + String stringVal = splitVals[i]; + // logic same as TermQParserPlugin + if (ft != null) { + ft.readableToIndexed(stringVal, term); + } else { + term.copyChars(stringVal); + } + bytesRefs[i] = term.toBytesRef(); } - bytesRefs[i] = term.toBytesRef(); + mainQuery = method.makeFilter(fname, bytesRefs); } - return method.makeFilter(fname, bytesRefs); + String queryName = localParams != null ? localParams.get(QueryParsing.NAME) : null; + if (queryName != null && !queryName.isBlank()) { + return NamedMatches.wrapQuery(queryName, mainQuery); + } + return mainQuery; } }; } diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml index 6a960666848c..134d9b7c71a2 100644 --- a/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml +++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml @@ -387,6 +387,14 @@ + + + + + matchedQueriesComponent + + + @@ -523,7 +531,7 @@ - + text diff --git a/solr/core/src/test/org/apache/solr/handler/component/TestMatchedQueriesComponent.java b/solr/core/src/test/org/apache/solr/handler/component/TestMatchedQueriesComponent.java new file mode 100644 index 000000000000..40ac909cec27 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/component/TestMatchedQueriesComponent.java @@ -0,0 +1,297 @@ +/* + * 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.handler.component; + +import org.apache.solr.SolrTestCaseJ4; +import org.junit.BeforeClass; +import org.junit.Test; + +public class TestMatchedQueriesComponent extends SolrTestCaseJ4 { + + static final String HANDLER = "/matched-queries"; + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema.xml"); + + // 4 fantasy books (ids 1–4), 2 of which are also childrens (ids 2–3) + assertU(adoc("id", "1", "cat_s", "fantasy", "author_s1", "Lev Grossman")); + assertU(adoc("id", "2", "cat_s", "fantasy", "cat_s", "childrens", "author_s1", "Robert Jordan")); + assertU(adoc("id", "3", "cat_s", "fantasy", "cat_s", "childrens", "author_s1", "Robert Jordan")); + assertU(adoc("id", "4", "cat_s", "fantasy", "author_s1", "N.K. Jemisin")); + assertU(commit()); + // 3 scifi books (ids 5–7), in a separate segment + assertU(adoc("id", "5", "cat_s", "scifi", "author_s1", "Ursula K. Le Guin")); + assertU(adoc("id", "6", "cat_s", "scifi", "author_s1", "Ursula K. Le Guin")); + assertU(adoc("id", "7", "cat_s", "scifi", "author_s1", "Isaac Asimov")); + assertU(commit()); + } + + /** Component must be a no-op when the activation param is absent. */ + @Test + public void testNotEnabledByDefault() throws Exception { + assertJQ( + req("qt", HANDLER, "q", "{!term _name=fantasy_cat f=cat_s}fantasy", "sort", "id asc"), + "!/matched_queries_per_hit==null", + "!/matched_queries_summary==null"); + } + + /** A single named term query: all 4 matching docs appear in per_hit and summary. */ + @Test + public void testSingleNamedTermQuery() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!term _name=fantasy_cat f=cat_s}fantasy", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==4", + "/matched_queries_per_hit/1/[0]=='fantasy_cat'", + "/matched_queries_per_hit/2/[0]=='fantasy_cat'", + "/matched_queries_per_hit/3/[0]=='fantasy_cat'", + "/matched_queries_per_hit/4/[0]=='fantasy_cat'", + "/matched_queries_summary/fantasy_cat/count==4", + "/matched_queries_summary/fantasy_cat/docIds/[0]=='1'", + "/matched_queries_summary/fantasy_cat/docIds/[3]=='4'"); + } + + /** The short alias {@code mq=true} must work identically to {@code matched_queries=true}. */ + @Test + public void testShortParamAlias() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!term _name=fantasy_cat f=cat_s}fantasy", + "mq", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==4", + "/matched_queries_summary/fantasy_cat/count==4"); + } + + /** + * Boolean OR of two named term queries: fantasy docs carry "fantasy_cat", + * scifi docs carry "scifi_cat", no doc carries both. + */ + @Test + public void testTwoNamedQueriesOr() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "({!term _name=fantasy_cat f=cat_s}fantasy) OR ({!term _name=scifi_cat f=cat_s}scifi)", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==7", + "/matched_queries_per_hit/1/[0]=='fantasy_cat'", + "/matched_queries_per_hit/5/[0]=='scifi_cat'", + "/matched_queries_summary/fantasy_cat/count==4", + "/matched_queries_summary/scifi_cat/count==3"); + } + + /** An unnamed term query must produce no matched_queries output even when mq=true. */ + @Test + public void testUnnamedQueryProducesNoOutput() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!term f=cat_s}fantasy", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==4", + "!/matched_queries_per_hit==null", + "!/matched_queries_summary==null"); + } + + /** Per-hit list carries exactly the names that match for that document. */ + @Test + public void testMultiValuedFieldBothNamesPresent() throws Exception { + // docs 2 and 3 match both fantasy_cat and childrens_cat + assertJQ( + req( + "qt", HANDLER, + "q", "({!term _name=fantasy_cat f=cat_s}fantasy) OR ({!term _name=childrens_cat f=cat_s}childrens)", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==4", + "/matched_queries_summary/fantasy_cat/count==4", + "/matched_queries_summary/childrens_cat/count==2", + "/matched_queries_summary/childrens_cat/docIds/[0]=='2'", + "/matched_queries_summary/childrens_cat/docIds/[1]=='3'"); + } + + /** + * {@code {!terms}} with a single {@code _name}: all matching docs — across both index segments — + * are tagged with that name. + */ + @Test + public void testTermsNamedQuery() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!terms _name=genre_all f=cat_s}fantasy,scifi", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==7", + "/matched_queries_per_hit/1/[0]=='genre_all'", + "/matched_queries_per_hit/5/[0]=='genre_all'", + "/matched_queries_summary/genre_all/count==7", + "/matched_queries_summary/genre_all/docIds/[0]=='1'", + "/matched_queries_summary/genre_all/docIds/[6]=='7'"); + } + + /** + * Outer {@code {!bool _name=...}} plus inner named {@code {!term _name=...}} SHOULD clauses: + * the outer name appears on every hit; inner names appear only on the docs whose specific + * clause fired. All three names are independent entries in the summary. + */ + @Test + public void testBoolOuterAndInnerNamesComposed() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!bool _name=all_books" + + " should='{!term _name=fantasy_cat f=cat_s}fantasy'" + + " should='{!term _name=scifi_cat f=cat_s}scifi'}", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==7", + // every doc carries all_books (outer name) + "/matched_queries_summary/all_books/count==7", + // inner names split correctly + "/matched_queries_summary/fantasy_cat/count==4", + "/matched_queries_summary/scifi_cat/count==3", + // spot-check: doc 1 has both all_books and fantasy_cat + "/matched_queries_per_hit/1/[0]=='all_books'", + "/matched_queries_per_hit/1/[1]=='fantasy_cat'", + // spot-check: doc 5 has both all_books and scifi_cat + "/matched_queries_per_hit/5/[0]=='all_books'", + "/matched_queries_per_hit/5/[1]=='scifi_cat'"); + } + + /** + * {@code {!bool}} with multiple named SHOULD clauses: each doc is tagged only with the + * clause(s) it actually matched — same semantics as an explicit OR but exercising the + * BoolQParserPlugin / FiltersQParser code path. + */ + @Test + public void testBoolMultipleShouldNamedTerms() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!bool should='{!term _name=fantasy_cat f=cat_s}fantasy'" + + " should='{!term _name=scifi_cat f=cat_s}scifi'}", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==7", + "/matched_queries_per_hit/1/[0]=='fantasy_cat'", + "/matched_queries_per_hit/4/[0]=='fantasy_cat'", + "/matched_queries_per_hit/5/[0]=='scifi_cat'", + "/matched_queries_per_hit/7/[0]=='scifi_cat'", + "/matched_queries_summary/fantasy_cat/count==4", + "/matched_queries_summary/scifi_cat/count==3"); + } + + /** + * {@code {!bool}} with an unnamed MUST clause and a named SHOULD clause: the MUST clause + * drives which docs are returned; the named SHOULD clause fires only for the subset that + * also matches it. Docs that satisfy the MUST but not the SHOULD must be absent from + * {@code matched_queries_per_hit} and must not inflate the summary count. + */ + @Test + public void testBoolMustWithNamedShould() throws Exception { + // MUST: all 4 fantasy docs; named SHOULD: only docs 2 and 3 (childrens) + assertJQ( + req( + "qt", HANDLER, + "q", "{!bool must='{!term f=cat_s}fantasy'" + + " should='{!term _name=childrens_cat f=cat_s}childrens'}", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==4", + // docs 2 and 3 matched the named SHOULD + "/matched_queries_per_hit/2/[0]=='childrens_cat'", + "/matched_queries_per_hit/3/[0]=='childrens_cat'", + // docs 1 and 4 matched only the unnamed MUST — no entry for them + "!/matched_queries_per_hit/1==null", + "!/matched_queries_per_hit/4==null", + "/matched_queries_summary/childrens_cat/count==2", + "/matched_queries_summary/childrens_cat/docIds/[0]=='2'", + "/matched_queries_summary/childrens_cat/docIds/[1]=='3'"); + } + + /** + * {@code {!prefix}} with {@code _name}: all fantasy docs (cat_s starting with "fanta") are tagged. + */ + @Test + public void testPrefixNamedQuery() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!prefix _name=fanta_prefix f=cat_s}fanta", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==4", + "/matched_queries_summary/fanta_prefix/count==4", + "/matched_queries_per_hit/1/[0]=='fanta_prefix'", + "/matched_queries_per_hit/4/[0]=='fanta_prefix'"); + } + + /** + * {@code {!edismax}} with {@code _name}: extended DisMax query; all matching docs carry the name. + */ + @Test + public void testEdismaxNamedQuery() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!edismax _name=fantasy_edismax qf=cat_s}fantasy", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==4", + "/matched_queries_summary/fantasy_edismax/count==4", + "/matched_queries_per_hit/1/[0]=='fantasy_edismax'", + "/matched_queries_per_hit/4/[0]=='fantasy_edismax'"); + } + + /** + * {@code {!lucene}} with {@code _name}: standard Lucene query syntax; all matching docs tagged. + */ + @Test + public void testLuceneNamedQuery() throws Exception { + assertJQ( + req( + "qt", HANDLER, + "q", "{!lucene _name=scifi_lucene df=cat_s}scifi", + "matched_queries", "true", + "sort", "id asc", + "rows", "10"), + "/response/numFound==3", + "/matched_queries_summary/scifi_lucene/count==3", + "/matched_queries_per_hit/5/[0]=='scifi_lucene'", + "/matched_queries_per_hit/7/[0]=='scifi_lucene'"); + } +} diff --git a/solr/core/src/test/org/apache/solr/search/PrefixQueryTest.java b/solr/core/src/test/org/apache/solr/search/PrefixQueryTest.java index c2f5be7e0651..299f2686aef4 100644 --- a/solr/core/src/test/org/apache/solr/search/PrefixQueryTest.java +++ b/solr/core/src/test/org/apache/solr/search/PrefixQueryTest.java @@ -16,6 +16,8 @@ */ package org.apache.solr.search; +import org.apache.lucene.search.NamedMatches; +import org.apache.lucene.search.Query; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.SolrException; import org.junit.BeforeClass; @@ -116,6 +118,20 @@ public void testQuestionMarkWildcardsCountTowardsMinimumPrefix() { assertQ(req("val_s:a??*"), "//*[@numFound='4']"); // Matches all documents starting with 'a' } + @Test + public void testNamedPrefixQuery() throws Exception { + Query inner = QParser.getParser("{!prefix f=cat_s}fanta", req()).getQuery(); + Query named = QParser.getParser("{!prefix _name=fanta_cat f=cat_s}fanta", req()).getQuery(); + assertEquals(NamedMatches.wrapQuery("fanta_cat", inner), named); + } + + @Test + public void testNamedPrefixQueryDifferentField() throws Exception { + Query inner = QParser.getParser("{!prefix f=author_s1}Robert", req()).getQuery(); + Query named = QParser.getParser("{!prefix _name=robert_author f=author_s1}Robert", req()).getQuery(); + assertEquals(NamedMatches.wrapQuery("robert_author", inner), named); + } + private static String createDocWithFieldVal(String id, String fieldVal) { return "" + id diff --git a/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java b/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java index 19b5117488fd..c6030d7f792d 100644 --- a/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java +++ b/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java @@ -43,6 +43,7 @@ import org.apache.lucene.search.DisjunctionMaxQuery; import org.apache.lucene.search.FuzzyQuery; import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.lucene.search.TermQuery; import org.apache.solr.SolrTestCaseJ4; @@ -3381,4 +3382,29 @@ public void testValidateQueryFields() throws Exception { "org.apache.solr.search.SyntaxError: Query Field 'nosuchfield' is not a valid field name", exception.getMessage()); } + + @Test + public void testNamedEdismaxQuery() throws Exception { + Query inner = QParser.getParser("{!edismax qf=name}Zapp", req()).getQuery(); + Query named = QParser.getParser("{!edismax _name=edismax_q qf=name}Zapp", req()).getQuery(); + assertEquals(NamedMatches.wrapQuery("edismax_q", inner), named); + } + + @Test + public void testNamedFuzzyQuery() throws Exception { + // cat_s is a string field — no multi-term analysis, term is used verbatim + Query actual = QParser.getParser("{!fuzzy _name=cat_fuzzy f=cat_s}fantasy", req()).getQuery(); + assertEquals( + NamedMatches.wrapQuery("cat_fuzzy", new FuzzyQuery(new Term("cat_s", "fantasy"))), + actual); + } + + @Test + public void testNamedFuzzyQueryCustomMaxEdits() throws Exception { + Query actual = + QParser.getParser("{!fuzzy _name=cat_fuzzy1 f=cat_s maxEdits=1}fantasy", req()).getQuery(); + assertEquals( + NamedMatches.wrapQuery("cat_fuzzy1", new FuzzyQuery(new Term("cat_s", "fantasy"), 1)), + actual); + } } diff --git a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java index 57790c330d71..9730d6b89844 100644 --- a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java @@ -20,6 +20,7 @@ import org.apache.lucene.index.Term; import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.lucene.search.TermQuery; import org.apache.solr.SolrTestCaseJ4; @@ -100,6 +101,53 @@ public void testExcludeTags() throws Exception { assertEquals(expected, actual); } + @Test + public void testNamedBoolQuery() throws Exception { + Query actual = + parseQuery(req("q", "{!bool _name=my_bool must=name:foo should=name:bar}")); + + BooleanQuery inner = + new BooleanQuery.Builder() + .add(new TermQuery(new Term("name", "foo")), BooleanClause.Occur.MUST) + .add(new TermQuery(new Term("name", "bar")), BooleanClause.Occur.SHOULD) + .setMinimumNumberShouldMatch(0) + .build(); + assertEquals(NamedMatches.wrapQuery("my_bool", inner), actual); + } + + @Test + public void testNamedBoolQueryWithMinShouldMatch() throws Exception { + Query actual = + parseQuery( + req("q", "{!bool _name=at_least_two should=name:foo should=name:bar should=name:qux mm=2}")); + + BooleanQuery inner = + new BooleanQuery.Builder() + .add(new TermQuery(new Term("name", "foo")), BooleanClause.Occur.SHOULD) + .add(new TermQuery(new Term("name", "bar")), BooleanClause.Occur.SHOULD) + .add(new TermQuery(new Term("name", "qux")), BooleanClause.Occur.SHOULD) + .setMinimumNumberShouldMatch(2) + .build(); + assertEquals(NamedMatches.wrapQuery("at_least_two", inner), actual); + } + + @Test + public void testNamedBoolQueryWithExcludeTags() throws Exception { + // excludeTags filters one of the $ref clauses; _name wraps what remains + Query actual = + parseQuery( + req( + "q", "{!bool _name=my_ref must=$ref excludeTags=t2}", + "ref", "{!tag=t1}foo", + "ref", "{!tag=t2}bar", + "df", "name")); + BooleanQuery inner = + new BooleanQuery.Builder() + .add(new TermQuery(new Term("name", "foo")), BooleanClause.Occur.MUST) + .build(); + assertEquals(NamedMatches.wrapQuery("my_ref", inner), actual); + } + @Test public void testInvalidMinShouldMatchThrowsException() { expectThrows( diff --git a/solr/core/src/test/org/apache/solr/search/TestTermQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestTermQParserPlugin.java index be31960528b9..e0cd87ebd1f9 100644 --- a/solr/core/src/test/org/apache/solr/search/TestTermQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestTermQParserPlugin.java @@ -17,6 +17,10 @@ package org.apache.solr.search; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.NamedMatches; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.TermQuery; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.SolrException; import org.apache.solr.common.params.ModifiableSolrParams; @@ -135,6 +139,13 @@ public void testTextTermsQuery() { assertQ(req(params, "indent", "on"), "*[count(//doc)=0]"); } + @Test + public void testNamedTermQuery() throws Exception { + Query actual = QParser.getParser("{!term _name=title_left f=t_title}left", req()).getQuery(); + assertEquals( + NamedMatches.wrapQuery("title_left", new TermQuery(new Term("t_title", "left"))), actual); + } + @Test public void testMissingField() { assertQEx( diff --git a/solr/core/src/test/org/apache/solr/search/TestTermsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestTermsQParserPlugin.java index 4f755573a1bf..1ed5664f2117 100644 --- a/solr/core/src/test/org/apache/solr/search/TestTermsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestTermsQParserPlugin.java @@ -17,6 +17,11 @@ package org.apache.solr.search; +import java.util.Arrays; +import org.apache.lucene.search.NamedMatches; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.TermInSetQuery; +import org.apache.lucene.util.BytesRef; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.SolrException; import org.apache.solr.common.params.ModifiableSolrParams; @@ -185,6 +190,18 @@ public String buildQuery(String fieldName, String commaDelimitedTerms) { } } + @Test + public void testNamedTermsQuery() throws Exception { + Query actual = + QParser.getParser("{!terms _name=genre_fiction f=cat_s}fantasy,scifi", req()).getQuery(); + assertEquals( + NamedMatches.wrapQuery( + "genre_fiction", + new TermInSetQuery( + "cat_s", Arrays.asList(new BytesRef("fantasy"), new BytesRef("scifi")))), + actual); + } + @Test public void testTermsMethodEquivalency() { // Run queries with a variety of 'method' and postfilter options.