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..e075f7c21d86
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/component/MatchedQueriesComponent.java
@@ -0,0 +1,175 @@
+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..f5739f2a801d 100644
--- a/solr/core/src/java/org/apache/solr/search/QueryParsing.java
+++ b/solr/core/src/java/org/apache/solr/search/QueryParsing.java
@@ -53,6 +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
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 d7865f2a73aa..48733efbff79 100644
--- a/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml
+++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml
@@ -383,6 +383,14 @@
+
+
+
+
+ matchedQueriesComponent
+
+
+
@@ -519,7 +527,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..9e1e19a6b5ca
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/component/TestMatchedQueriesComponent.java
@@ -0,0 +1,305 @@
+/*
+ * 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..f8f784738643 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,21 @@ 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..eebb1a1ee19c 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,28 @@ 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..348c8b4c9a7d 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,54 @@ 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.