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

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


The following commit(s) were added to refs/heads/main by this push:
     new d17c00ee37e SOLR-18227: Add name= local-param with 
MatchedQueriesComponent (#4402)
d17c00ee37e is described below

commit d17c00ee37eaad6c2d84d368709802001037bae8
Author: squallsama <[email protected]>
AuthorDate: Sat May 23 09:04:15 2026 -0400

    SOLR-18227: Add name= local-param with MatchedQueriesComponent (#4402)
    
    to identify which named sub-queries matched each document.
    Uses org.apache.lucene.search.NamedMatches.
---
 .../SOLR-18227_named_queries_matched_component.yml |   7 +
 .../handler/component/MatchedQueriesComponent.java | 175 ++++++++++++
 .../src/java/org/apache/solr/search/QParser.java   |   9 +
 .../java/org/apache/solr/search/QueryParsing.java  |   1 +
 .../conf/solrconfig-matched-queries.xml            |  56 ++++
 .../component/TestMatchedQueriesComponent.java     | 301 +++++++++++++++++++++
 .../org/apache/solr/search/QueryEqualityTest.java  | 134 +++++++++
 .../solr/search/TestMmBoolQParserPlugin.java       |  22 ++
 .../query-guide/pages/common-query-parameters.adoc |   1 +
 .../pages/matched-queries-component.adoc           | 121 +++++++++
 .../modules/query-guide/pages/other-parsers.adoc   |  25 +-
 .../modules/query-guide/querying-nav.adoc          |   1 +
 12 files changed, 850 insertions(+), 3 deletions(-)

diff --git 
a/changelog/unreleased/SOLR-18227_named_queries_matched_component.yml 
b/changelog/unreleased/SOLR-18227_named_queries_matched_component.yml
new file mode 100644
index 00000000000..84f43cb169d
--- /dev/null
+++ b/changelog/unreleased/SOLR-18227_named_queries_matched_component.yml
@@ -0,0 +1,7 @@
+title: "Add `name` local parameter support and `MatchedQueriesComponent` to 
identify which named sub-queries matched each document."
+type: added
+authors:
+  - name: Dmitrii Tikhonov
+links:
+  - name: SOLR-18227
+    url: https://issues.apache.org/jira/browse/SOLR-18227
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 00000000000..56040116f9e
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/handler/component/MatchedQueriesComponent.java
@@ -0,0 +1,175 @@
+/*
+ * 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 com.carrotsearch.hppc.IntObjectHashMap;
+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.index.ReaderUtil;
+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.SolrDocumentFetcher;
+import org.apache.solr.search.SolrIndexSearcher;
+
+/**
+ * Search component that enriches the response with named-match information 
for each document in the
+ * top-N hits.
+ *
+ * <p>Activation: Add {@code matched_queries=true} (or {@code mq=true}) to the 
request.
+ *
+ * <p>Output:
+ *
+ * <ul>
+ *   <li>{@code matched_queries_per_hit}: map of unique-key value → list of 
names that matched that
+ *       document. Documents that matched no named query are absent.
+ *   <li>{@code matched_queries_summary}: map of name → ordered list of 
unique-key values of
+ *       documents it matched.
+ * </ul>
+ *
+ * <p>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. {@link
+ * org.apache.lucene.search.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();
+    String idField = searcher.getSchema().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<Integer, Set<String>> perDocNames = new LinkedHashMap<>();
+    // Collect: per name → list of global doc ids (preserves document order)
+    Map<String, List<Integer>> perNameDocs = new LinkedHashMap<>();
+    IntObjectHashMap<String> idCache = new IntObjectHashMap<>(docList.size());
+
+    List<LeafReaderContext> leaves = searcher.getTopReaderContext().leaves();
+    SolrDocumentFetcher docFetcher = searcher.getDocFetcher();
+
+    DocIterator it = docList.iterator();
+    while (it.hasNext()) {
+      int globalDoc = it.nextDoc();
+
+      LeafReaderContext leaf = leaves.get(ReaderUtil.subIndex(globalDoc, 
leaves));
+      int leafDoc = globalDoc - leaf.docBase;
+
+      Matches matches = matchesWeight.matches(leaf, leafDoc);
+      if (matches == null) {
+        continue;
+      }
+      List<NamedMatches> named = NamedMatches.findNamedMatches(matches);
+      if (named.isEmpty()) {
+        continue;
+      }
+
+      Set<String> names = new LinkedHashSet<>();
+      for (NamedMatches nm : named) {
+        names.add(nm.getName());
+      }
+      perDocNames.put(globalDoc, names);
+      idCache.put(globalDoc, readUniqueKeyValue(docFetcher, 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<Object> perHit = new SimpleOrderedMap<>();
+    for (Map.Entry<Integer, Set<String>> e : perDocNames.entrySet()) {
+      perHit.add(idCache.get(e.getKey()), new ArrayList<>(e.getValue()));
+    }
+
+    // Summary: name → [id1, id2, ...]
+    SimpleOrderedMap<Object> summary = new SimpleOrderedMap<>();
+    for (Map.Entry<String, List<Integer>> e : perNameDocs.entrySet()) {
+      List<String> ids = new ArrayList<>(e.getValue().size());
+      for (Integer luceneId : e.getValue()) {
+        ids.add(idCache.get(luceneId));
+      }
+      summary.add(e.getKey(), ids);
+    }
+
+    NamedList<Object> response = rb.rsp.getValues();
+    response.add("matched_queries_per_hit", perHit);
+    response.add("matched_queries_summary", summary);
+  }
+
+  private String readUniqueKeyValue(SolrDocumentFetcher docFetcher, String 
idField, int globalDoc)
+      throws IOException {
+    return docFetcher.doc(globalDoc, Set.of(idField)).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/QParser.java 
b/solr/core/src/java/org/apache/solr/search/QParser.java
index 4d207b19144..8f0977f7e00 100644
--- a/solr/core/src/java/org/apache/solr/search/QParser.java
+++ b/solr/core/src/java/org/apache/solr/search/QParser.java
@@ -26,6 +26,7 @@ import org.apache.lucene.queries.function.FunctionQuery;
 import org.apache.lucene.queries.function.FunctionScoreQuery;
 import org.apache.lucene.queries.function.ValueSource;
 import org.apache.lucene.queries.function.valuesource.QueryValueSource;
+import org.apache.lucene.search.NamedMatches;
 import org.apache.lucene.search.Query;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.CommonParams;
@@ -196,6 +197,14 @@ public abstract class QParser {
       query = parse();
 
       if (localParams != null) {
+        // MUST come before extendedQuery() calls below: NamedMatches is not 
an ExtendedQuery,
+        // so wrapping must happen first so that extendedQuery() can wrap it 
in a WrappedQuery
+        // that preserves the cache/cost settings as the outermost layer.
+        String name = localParams.get(QueryParsing.NAME);
+        if (name != null && !name.isBlank() && query != null) {
+          query = NamedMatches.wrapQuery(name, query);
+        }
+
         String cacheStr = localParams.get(CommonParams.CACHE);
         if (cacheStr != null) {
           if (CommonParams.FALSE.equals(cacheStr)) {
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 98029a68e03..07575857da3 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/test-files/solr/collection1/conf/solrconfig-matched-queries.xml 
b/solr/core/src/test-files/solr/collection1/conf/solrconfig-matched-queries.xml
new file mode 100644
index 00000000000..95cca5d940f
--- /dev/null
+++ 
b/solr/core/src/test-files/solr/collection1/conf/solrconfig-matched-queries.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" ?>
+
+<!--
+ 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.
+-->
+
+<config>
+
+  <dataDir>${solr.data.dir:}</dataDir>
+
+  <directoryFactory name="DirectoryFactory"
+                    
class="${solr.directoryFactory:solr.NRTCachingDirectoryFactory}"/>
+  <schemaFactory class="ClassicIndexSchemaFactory"/>
+
+  <luceneMatchVersion>${tests.luceneMatchVersion:LATEST}</luceneMatchVersion>
+
+  <xi:include href="solrconfig.snippet.randomindexconfig.xml" 
xmlns:xi="http://www.w3.org/2001/XInclude"/>
+
+  <updateHandler class="solr.DirectUpdateHandler2">
+    <commitWithin>
+      <softCommit>${solr.commitwithin.softcommit:true}</softCommit>
+    </commitWithin>
+  </updateHandler>
+
+  <requestHandler name="/select" class="solr.SearchHandler">
+    <lst name="defaults">
+      <str name="echoParams">explicit</str>
+      <str name="df">text</str>
+    </lst>
+  </requestHandler>
+
+  <searchComponent name="matched_queries" 
class="org.apache.solr.handler.component.MatchedQueriesComponent"/>
+
+  <requestHandler name="/matched-queries" class="solr.SearchHandler">
+    <arr name="last-components">
+      <str>matched_queries</str>
+    </arr>
+    <lst name="defaults">
+      <str name="df">text</str>
+    </lst>
+  </requestHandler>
+
+</config>
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 00000000000..a0a4b891742
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/component/TestMatchedQueriesComponent.java
@@ -0,0 +1,301 @@
+/*
+ * 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-matched-queries.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/[0]=='1'",
+        "/matched_queries_summary/fantasy_cat/[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/[0]=='1'");
+  }
+
+  /**
+   * 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/[3]=='4'",
+        "/matched_queries_summary/scifi_cat/[2]=='7'");
+  }
+
+  /** 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/[3]=='4'",
+        "/matched_queries_summary/childrens_cat/[0]=='2'",
+        "/matched_queries_summary/childrens_cat/[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/[0]=='1'",
+        "/matched_queries_summary/genre_all/[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/[6]=='7'",
+        // inner names split correctly
+        "/matched_queries_summary/fantasy_cat/[3]=='4'",
+        "/matched_queries_summary/scifi_cat/[2]=='7'",
+        // 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/[3]=='4'",
+        "/matched_queries_summary/scifi_cat/[2]=='7'");
+  }
+
+  /**
+   * {@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/[0]=='2'",
+        "/matched_queries_summary/childrens_cat/[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/[3]=='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/[3]=='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/[2]=='7'",
+        "/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/QueryEqualityTest.java 
b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
index b687d826077..ecad83f39de 100644
--- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
+++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
@@ -20,9 +20,16 @@ import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.BooleanClause;
+import org.apache.lucene.search.BooleanQuery;
 import org.apache.lucene.search.FuzzyQuery;
+import org.apache.lucene.search.NamedMatches;
 import org.apache.lucene.search.Query;
+import org.apache.lucene.search.TermInSetQuery;
+import org.apache.lucene.search.TermQuery;
 import org.apache.lucene.tests.search.QueryUtils;
+import org.apache.lucene.util.BytesRef;
 import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.common.SolrInputDocument;
 import org.apache.solr.request.SolrQueryRequest;
@@ -31,6 +38,7 @@ import org.apache.solr.response.SolrQueryResponse;
 import org.apache.solr.search.numericrange.NumericRangeQParserPlugin;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Test;
 
 /**
  * Sanity checks that queries (generated by the QParser and ValueSourceParser 
framework) are
@@ -1859,6 +1867,132 @@ public class QueryEqualityTest extends SolrTestCaseJ4 {
                 "bool", "{!bool must='{!lucene}foo_s:a'}", "{!bool 
should='{!lucene}foo_s:a'}"));
   }
 
+  /**
+   * The {@code name} local parameter is handled centrally in {@link 
QParser#getQuery()} and wraps
+   * the parsed query with {@link NamedMatches}. Verify that the wrapping is 
consistent (equals +
+   * hashCode) across the parsers.
+   */
+  public void testNameLocalParam() throws Exception {
+    assertQueryEquals(
+        "term", "{!term name=my_label f=foo_s}hello", "{!term name=my_label 
f=foo_s v=hello}");
+
+    assertQueryEquals(
+        "prefix", "{!prefix name=my_label f=foo_s}hel", "{!prefix 
name=my_label f=foo_s v=hel}");
+
+    assertQueryEquals(
+        "fuzzy", "{!fuzzy name=my_label f=foo_s}hello", "{!fuzzy name=my_label 
f=foo_s v=hello}");
+
+    assertQueryEquals(
+        "terms", "{!terms name=my_label f=foo_s}a,b", "{!terms name=my_label 
f=foo_s v='a,b'}");
+
+    assertQueryEquals(
+        "bool", "{!bool name=my_label must=foo_s:hello}", "{!bool 
name=my_label must=foo_s:hello}");
+
+    assertQueryEquals(
+        "dismax",
+        "{!dismax name=my_label qf=foo_s}hello",
+        "{!dismax name=my_label qf=foo_s v=hello}");
+
+    assertQueryEquals(
+        "edismax",
+        "{!edismax name=my_label qf=foo_s}hello",
+        "{!edismax name=my_label qf=foo_s v=hello}");
+  }
+
+  @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 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 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 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);
+  }
+
+  @Test
+  public void testNamedBoolQuery() throws Exception {
+    Query actual =
+        QParser.getParser("{!bool name=my_bool must=name:foo 
should=name:bar}", req()).getQuery();
+
+    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 =
+        QParser.getParser(
+                "{!bool name=at_least_two should=name:foo should=name:bar 
should=name:qux mm=2}",
+                req())
+            .getQuery();
+
+    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
+    SolrQueryRequest req =
+        req(
+            "ref", "{!tag=t1}foo",
+            "ref", "{!tag=t2}bar",
+            "df", "name");
+
+    Query actual =
+        QParser.getParser("{!bool name=my_ref must=$ref excludeTags=t2}", 
req).getQuery();
+    BooleanQuery inner =
+        new BooleanQuery.Builder()
+            .add(new TermQuery(new Term("name", "foo")), 
BooleanClause.Occur.MUST)
+            .build();
+
+    assertEquals(NamedMatches.wrapQuery("my_ref", inner), actual);
+  }
+
   public void testHashRangeQuery() throws Exception {
     assertQueryEquals(
         "hash_range",
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 988fa6767d4..3a2d976a97c 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 @@ package org.apache.solr.search;
 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;
@@ -158,4 +159,25 @@ public class TestMmBoolQParserPlugin extends 
SolrTestCaseJ4 {
         NumberFormatException.class,
         () -> parseQuery(req("q", "{!bool should=name:foo mm=2.9}")));
   }
+
+  /** cache and cost local params must survive alongside name: WrappedQuery 
wraps NamedMatches. */
+  @Test
+  public void testNameWithCacheAndCostPreserved() throws Exception {
+    Query actual = parseQuery(req("q", "{!bool name=my_bool cache=false 
cost=200 must=name:foo}"));
+
+    assertTrue("expected WrappedQuery", actual instanceof WrappedQuery);
+    WrappedQuery wrapped = (WrappedQuery) actual;
+    assertFalse("cache=false must be preserved", wrapped.getCache());
+    assertEquals("cost=200 must be preserved", 200, wrapped.getCost());
+
+    BooleanQuery inner =
+        new BooleanQuery.Builder()
+            .add(new TermQuery(new Term("name", "foo")), 
BooleanClause.Occur.MUST)
+            .setMinimumNumberShouldMatch(0)
+            .build();
+    assertEquals(
+        "WrappedQuery must wrap NamedMatches",
+        NamedMatches.wrapQuery("my_bool", inner),
+        wrapped.getWrappedQuery());
+  }
 }
diff --git 
a/solr/solr-ref-guide/modules/query-guide/pages/common-query-parameters.adoc 
b/solr/solr-ref-guide/modules/query-guide/pages/common-query-parameters.adoc
index 4e69ef4855f..7acd8024fee 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/common-query-parameters.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/common-query-parameters.adoc
@@ -288,6 +288,7 @@ The `debug` parameter can be specified multiple times and 
supports the following
 * `debug=timing`: return debug information about how long the query took to 
process.
 * `debug=results`: return debug information about the score results (also 
known as "explain").
 ** By default, score explanations are returned as large string values, using 
newlines and tab indenting for structure & readability, but an additional 
`debug.explain.structured=true` parameter may be specified to return this 
information as nested data structures native to the response format requested 
by `wt`.
+** To see which named sub-queries matched each document (rather than score 
breakdowns), see xref:matched-queries-component.adoc[].
 * `debug=all`: return all available debug information about the request 
request.
 An alternative usage is `debug=true`.
 
diff --git 
a/solr/solr-ref-guide/modules/query-guide/pages/matched-queries-component.adoc 
b/solr/solr-ref-guide/modules/query-guide/pages/matched-queries-component.adoc
new file mode 100644
index 00000000000..92f4ddaa0e9
--- /dev/null
+++ 
b/solr/solr-ref-guide/modules/query-guide/pages/matched-queries-component.adoc
@@ -0,0 +1,121 @@
+= Matched Queries Component
+// 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 Matched Queries component enriches search responses with named-match 
information:
+for each top-N hit it reports which named sub-queries matched that document,
+and provides a summary across all hits.
+
+This is useful when a query is composed of multiple named logical conditions 
and you want to know, per document, which conditions fired.
+
+== Activation
+
+Add `matched_queries=true` (or the short alias `mq=true`) as a request 
parameter.
+Without this parameter the component is a no-op and adds nothing to the 
response.
+
+== Naming queries with `name`
+
+Any query parser accepts the `name` local parameter.
+When present, every document matched by that sub-query is tagged with the 
given name.
+
+[source,text]
+----
+{!term name=fantasy_cat f=cat}fantasy
+----
+
+Named queries may be freely composed:
+
+[source,text]
+----
+({!term name=fantasy_cat f=cat}fantasy) OR ({!term name=scifi_cat f=cat}scifi)
+----
+
+[source,text]
+----
+{!bool name=all_genres
+  should='{!term name=fantasy_cat f=cat}fantasy'
+  should='{!term name=scifi_cat  f=cat}scifi'}
+----
+
+== Response
+
+When the component is active and at least one named query matches, two 
top-level keys are added to the response:
+
+`matched_queries_per_hit`::
+A map from each matching document's unique-key value to the list of names that 
matched it.
+Documents that matched no named query are absent from this map.
+
+`matched_queries_summary`::
+A map from each name that fired to the ordered list of unique-key values of 
documents it matched.
+
+=== Example Response
+
+Request:
+
+[source,text]
+----
+q=({!term name=fantasy_cat f=cat}fantasy) OR ({!term name=scifi_cat 
f=cat}scifi)
+&matched_queries=true&rows=3&sort=id asc
+----
+
+Response (abbreviated):
+
+[source,json]
+----
+{
+  "response": { "numFound": 7, "docs": [ ... ] },
+  "matched_queries_per_hit": {
+    "1": ["fantasy_cat"],
+    "2": ["fantasy_cat"],
+    "5": ["scifi_cat"]
+  },
+  "matched_queries_summary": {
+    "fantasy_cat": ["1", "2", "3", "4"],
+    "scifi_cat":   ["5", "6", "7"]
+  }
+}
+----
+
+Documents that matched only an unnamed clause (e.g., a plain MUST filter) 
appear in `response/docs` as normal but are absent from both output maps.
+
+== Parameters
+
+`matched_queries` (or `mq`)::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: `false`
+|===
++
+Set to `true` to activate the component for this request.
+Both `matched_queries=true` and `mq=true` are equivalent.
+
+== Configuration
+
+Register the component in the request handler in `solrconfig.xml`:
+
+[source,xml]
+----
+<requestHandler name="/matched-queries" class="solr.SearchHandler">
+  <arr name="components">
+    <str>query</str>
+    <str>matched_queries</str>
+  </arr>
+</requestHandler>
+----
+
+The component name `matched_queries` is pre-registered by Solr and does not 
require an explicit `<searchComponent>` declaration.
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 e293a42509f..7a8b0bd0918 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
@@ -1220,8 +1220,17 @@ Find all documents with the phrase "foo bar" where term 
"foo" has a payload grea
 `PrefixQParser` extends the `QParserPlugin` by creating a prefix query from 
the input value.
 Currently, no analysis or value transformation is done to create this prefix 
query.
 
-The parameter is `f`, the field.
-The string after the prefix declaration is treated as a wildcard query.
+*Parameters*
+
+`f`::
++
+[%autowidth,frame=none]
+|===
+|Required |Default: none
+|===
++
+The field on which to run the prefix query.
+The value that follows the local params declaration is treated as the prefix.
 
 Example:
 
@@ -1438,7 +1447,17 @@ Using the example configuration below, clients can 
optionally specify the custom
 
 `TermQParser` extends the `QParserPlugin` by creating a single term query from 
the input value equivalent to `readableToIndexed()`.
 This is useful for generating filter queries from the external human-readable 
terms returned by the faceting or terms components.
-The only parameter is `f`, for the field.
+
+*Parameters*
+
+`f`::
++
+[%autowidth,frame=none]
+|===
+|Required |Default: none
+|===
++
+The field on which to run the term query.
 
 Example:
 
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 5628a825699..d83ab083a83 100644
--- a/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc
@@ -54,6 +54,7 @@
 ** xref:terms-component.adoc[]
 ** xref:term-vector-component.adoc[]
 ** xref:stats-component.adoc[]
+** xref:matched-queries-component.adoc[]
 
 * Controlling Results
 ** xref:faceting.adoc[]


Reply via email to