From ac36ffe189648262fbc7dec815a140695ddc0ec7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:48:00 +0000 Subject: [PATCH 01/12] Initial plan From fa8df7995e052ec5f7f41fb57c81fb5ebd36e8f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:06:57 +0000 Subject: [PATCH 02/12] SOLR-14687: Add parentPath param to {!parent} and {!child} query parsers Co-authored-by: dsmiley <377295+dsmiley@users.noreply.github.com> --- .../SOLR-14687-parentPath-param.yml | 6 + .../search/join/BlockJoinChildQParser.java | 63 +++++++ .../search/join/BlockJoinParentQParser.java | 161 ++++++++++++++++++ .../update/TestNestedUpdateProcessor.java | 18 +- 4 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 changelog/unreleased/SOLR-14687-parentPath-param.yml diff --git a/changelog/unreleased/SOLR-14687-parentPath-param.yml b/changelog/unreleased/SOLR-14687-parentPath-param.yml new file mode 100644 index 000000000000..caa1e2aa7862 --- /dev/null +++ b/changelog/unreleased/SOLR-14687-parentPath-param.yml @@ -0,0 +1,6 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: The {!parent} and {!child} query parsers now support a parentPath local param that automatically derives the correct parent filter using the _nest_path_ field, making nested document queries easier to write correctly. +type: added # added, changed, fixed, deprecated, removed, dependency_update, security, other +links: + - name: SOLR-14687 + url: https://issues.apache.org/jira/browse/SOLR-14687 diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java index bb6c80db07a8..90f2b6034318 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java @@ -16,13 +16,17 @@ */ package org.apache.solr.search.join; +import org.apache.lucene.index.Term; import org.apache.lucene.search.BooleanClause.Occur; import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.FieldExistsQuery; import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.Query; +import org.apache.lucene.search.TermQuery; import org.apache.lucene.search.join.ToChildBlockJoinQuery; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.IndexSchema; import org.apache.solr.search.SyntaxError; public class BlockJoinChildQParser extends BlockJoinParentQParser { @@ -52,4 +56,63 @@ protected Query noClausesQuery() throws SyntaxError { .build(); return new BitSetProducerQuery(getBitSetProducer(notParents)); } + + /** + * Parses the query using the {@code parentPath} localparam for the child parser. + * + *

For the {@code child} parser with {@code parentPath="/a/b/c"}: + * + *

NEW: q={!child parentPath="/a/b/c"}p_title:dad
+   *
+   * OLD: q={!child of=$ff v=$vv}
+   *      ff=(*:* -{prefix f="_nest_path_" v="/a/b/c/"})
+   *      vv=(+p_title:dad +{field f="_nest_path_" v="/a/b/c"})
+ * + *

For {@code parentPath="/"}: + * + *

NEW: q={!child parentPath="/"}p_title:dad
+   *
+   * OLD: q={!child of=$ff v=$vv}
+   *      ff=(*:* -_nest_path_:*)
+   *      vv=(+p_title:dad -_nest_path_:*)
+ * + * @param parentPath the normalized parent path (starts with "/", no trailing slash except for + * root "/") + */ + @Override + protected Query parseUsingParentPath(String parentPath) throws SyntaxError { + final boolean isRoot = parentPath.equals("/"); + + // allParents filter: (*:* -{prefix f="_nest_path_" v="/"}) + // For root: (*:* -_nest_path_:*) + final Query allParentsFilter = buildAllParentsFilterFromPath(isRoot, parentPath); + + final BooleanQuery parsedParentQuery = parseImpl(); + + if (parsedParentQuery.clauses().isEmpty()) { + // no parent query: return all children of parents at this level + final BooleanQuery notParents = + new BooleanQuery.Builder() + .add(new MatchAllDocsQuery(), Occur.MUST) + .add(allParentsFilter, Occur.MUST_NOT) + .build(); + return new BitSetProducerQuery(getBitSetProducer(notParents)); + } + + // constrain the parent query to only match docs at exactly parentPath + // (+ +{field f="_nest_path_" v=""}) + // For root: (+ -_nest_path_:*) + final BooleanQuery.Builder constrainedBuilder = + new BooleanQuery.Builder().add(parsedParentQuery, Occur.MUST); + if (isRoot) { + constrainedBuilder.add( + new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME), Occur.MUST_NOT); + } else { + constrainedBuilder.add( + new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.MUST); + } + final Query constrainedParentQuery = constrainedBuilder.build(); + + return createQuery(allParentsFilter, constrainedParentQuery, null); + } } diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index 1d73bbd78aa7..4aa5f6dbdcbf 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -20,13 +20,20 @@ import java.io.UncheckedIOException; import java.util.Objects; import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.BooleanClause.Occur; +import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.ConstantScoreScorer; import org.apache.lucene.search.ConstantScoreWeight; import org.apache.lucene.search.DocIdSetIterator; +import org.apache.lucene.search.FieldExistsQuery; import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.PrefixQuery; import org.apache.lucene.search.Query; import org.apache.lucene.search.QueryVisitor; import org.apache.lucene.search.ScorerSupplier; +import org.apache.lucene.search.TermQuery; import org.apache.lucene.search.Weight; import org.apache.lucene.search.join.BitSetProducer; import org.apache.lucene.search.join.QueryBitSetProducer; @@ -34,8 +41,10 @@ import org.apache.lucene.search.join.ToParentBlockJoinQuery; import org.apache.lucene.util.BitSet; import org.apache.lucene.util.BitSetIterator; +import org.apache.solr.common.SolrException; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.IndexSchema; import org.apache.solr.search.ExtendedQueryBase; import org.apache.solr.search.QParser; import org.apache.solr.search.SolrCache; @@ -46,6 +55,17 @@ public class BlockJoinParentQParser extends FiltersQParser { /** implementation detail subject to change */ public static final String CACHE_NAME = "perSegFilter"; + /** + * Optional localparam that, when specified, makes this parser natively aware of the {@link + * IndexSchema#NEST_PATH_FIELD_NAME} field to automatically derive the parent filter (the {@code + * which} param). The value must be an absolute path starting with {@code /} using {@code /} as + * separator, e.g. {@code /} for root-level parents or {@code /skus} for parents nested at that + * path. When specified, the {@code which} param must not also be specified. + * + * @see SOLR-14687 + */ + public static final String PARENT_PATH_PARAM = "parentPath"; + protected String getParentFilterLocalParamName() { return "which"; } @@ -60,6 +80,147 @@ protected String getFiltersParamName() { super(qstr, localParams, params, req); } + @Override + public Query parse() throws SyntaxError { + String parentPath = localParams.get(PARENT_PATH_PARAM); + if (parentPath != null) { + if (localParams.get(getParentFilterLocalParamName()) != null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + PARENT_PATH_PARAM + + " and " + + getParentFilterLocalParamName() + + " local params are mutually exclusive"); + } + if (!parentPath.startsWith("/")) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, PARENT_PATH_PARAM + " must start with '/'"); + } + // strip trailing slash (except for root "/") + if (parentPath.length() > 1 && parentPath.endsWith("/")) { + parentPath = parentPath.substring(0, parentPath.length() - 1); + } + return parseUsingParentPath(parentPath); + } + return super.parse(); + } + + /** + * Parses the query using the {@code parentPath} localparam to automatically derive the parent + * filter and child query constraints from {@link IndexSchema#NEST_PATH_FIELD_NAME}. + * + *

For the {@code parent} parser with {@code parentPath="/a/b/c"}: + * + *

NEW: q={!parent parentPath="/a/b/c"}c_title:son
+   *
+   * OLD: q=(+{!field f="_nest_path_" v="/a/b/c"} +{!parent which=$ff v=$vv})
+   *      ff=(*:* -{prefix f="_nest_path_" v="/a/b/c/"})
+   *      vv=(+c_title:son +{prefix f="_nest_path_" v="/a/b/c/"})
+ * + *

For {@code parentPath="/"}: + * + *

NEW: q={!parent parentPath="/"}c_title:son
+   *
+   * OLD: q=(+(*:* -_nest_path_:*) +{!parent which=$ff v=$vv})
+   *      ff=(*:* -_nest_path_:*)
+   *      vv=(+c_title:son +_nest_path_:*)
+ * + * @param parentPath the normalized parent path (starts with "/", no trailing slash except for + * root "/") + */ + protected Query parseUsingParentPath(String parentPath) throws SyntaxError { + final boolean isRoot = parentPath.equals("/"); + + // allParents filter: (*:* -{prefix f="_nest_path_" v="/"}) + // For root: (*:* -_nest_path_:*) + final Query allParentsFilter = buildAllParentsFilterFromPath(isRoot, parentPath); + + final BooleanQuery parsedChildQuery = parseImpl(); + + if (parsedChildQuery.clauses().isEmpty()) { + // no child query: return all "parent" docs at this level + return wrapWithParentPathConstraint(isRoot, parentPath, allParentsFilter); + } + + // constrain child query: (+ +{prefix f="_nest_path_" v="/"}) + // For root: (+ +_nest_path_:*) + final Query constrainedChildQuery = + buildChildQueryWithPathConstraint(isRoot, parentPath, parsedChildQuery); + + final String scoreMode = localParams.get("score", ScoreMode.None.name()); + final Query parentJoinQuery = createQuery(allParentsFilter, constrainedChildQuery, scoreMode); + + // wrap result: (+ +{field f="_nest_path_" v=""}) + // For root: (+ -_nest_path_:*) + return wrapWithParentPathConstraint(isRoot, parentPath, parentJoinQuery); + } + + /** + * Builds the "all parents" filter query from the given {@code parentPath}. This query matches all + * documents that are NOT strictly below (nested inside) the given path. This includes: + * + *
    + *
  • documents without any {@code _nest_path_} (root-level, non-nested docs) + *
  • documents at the same level as {@code parentPath} (i.e. with exactly that path) + *
  • documents at levels above {@code parentPath} + *
  • documents at completely orthogonal paths (e.g. {@code /x/y/z} when parentPath is {@code + * /a/b/c}) + *
+ * + *

Equivalent to: {@code (*:* -{prefix f="_nest_path_" v="/"})} For root ({@code + * /}): {@code (*:* -_nest_path_:*)} + */ + protected static Query buildAllParentsFilterFromPath(boolean isRoot, String parentPath) { + final Query excludeQuery; + if (isRoot) { + excludeQuery = new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME); + } else { + excludeQuery = new PrefixQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath + "/")); + } + return new BooleanQuery.Builder() + .add(new MatchAllDocsQuery(), Occur.MUST) + .add(excludeQuery, Occur.MUST_NOT) + .build(); + } + + /** + * Wraps the given query with a constraint ensuring only docs at exactly the {@code parentPath} + * level are matched. For root, this excludes docs that have a {@code _nest_path_} value. For + * non-root, this requires an exact match on {@code _nest_path_}. + */ + private static Query wrapWithParentPathConstraint( + boolean isRoot, String parentPath, Query query) { + final BooleanQuery.Builder builder = new BooleanQuery.Builder().add(query, Occur.MUST); + if (isRoot) { + builder.add(new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME), Occur.MUST_NOT); + } else { + builder.add( + new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.MUST); + } + return builder.build(); + } + + /** + * Constrains the child query to only match docs strictly below the given {@code parentPath}. For + * the parent parser, the child query must match docs with a {@code _nest_path_} that is a + * sub-path of the parent path (i.e. starts with {@code parentPath/}). For root, any doc with a + * {@code _nest_path_} is a "child". + */ + private static Query buildChildQueryWithPathConstraint( + boolean isRoot, String parentPath, Query childQuery) { + final Query nestPathConstraint; + if (isRoot) { + nestPathConstraint = new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME); + } else { + nestPathConstraint = + new PrefixQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath + "/")); + } + return new BooleanQuery.Builder() + .add(childQuery, Occur.MUST) + .add(nestPathConstraint, Occur.MUST) + .build(); + } + protected Query parseParentFilter() throws SyntaxError { String filter = localParams.get(getParentFilterLocalParamName()); QParser parentParser = subQuery(filter, null); diff --git a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java index c91cdf272578..c5e9b679df72 100644 --- a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java +++ b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java @@ -545,7 +545,14 @@ public int recursiveCheckParentQueryOfAllChildren(List parent_path) { */ private SolrParams parentQueryMaker(String parent_path, String inner_child_query) { assertValidPathSyntax(parent_path); - final boolean verbose = random().nextBoolean(); + final int variant = random().nextInt(3); + + if (variant == 2) { + // new parentPath sugar + return params("q", "{!parent parentPath='" + parent_path + "'}" + inner_child_query); + } // else old-style with explicit which/of... + + final boolean verbose = variant == 1; if (parent_path.equals("/")) { if (verbose) { @@ -633,7 +640,14 @@ public int recursiveCheckChildQueryOfAllParents(List parent_path) { */ private SolrParams childQueryMaker(String parent_path, String inner_parent_query) { assertValidPathSyntax(parent_path); - final boolean verbose = random().nextBoolean(); + final int variant = random().nextInt(3); + + if (variant == 2) { + // new parentPath sugar + return params("q", "{!child parentPath='" + parent_path + "'}" + inner_parent_query); + } // else old-style with explicit which/of... + + final boolean verbose = variant == 1; if (parent_path.equals("/")) { if (verbose) { From b95bb7a052b936ef4fd8fdfabfb0efc2bed1b68f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:21:36 +0000 Subject: [PATCH 03/12] SOLR-14687: Address review feedback - remove isRoot var, use Occur.FILTER for path constraints Co-authored-by: dsmiley <377295+dsmiley@users.noreply.github.com> --- .../search/join/BlockJoinChildQParser.java | 8 ++---- .../search/join/BlockJoinParentQParser.java | 28 ++++++++----------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java index 90f2b6034318..3b714ffd0067 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java @@ -81,11 +81,9 @@ protected Query noClausesQuery() throws SyntaxError { */ @Override protected Query parseUsingParentPath(String parentPath) throws SyntaxError { - final boolean isRoot = parentPath.equals("/"); - // allParents filter: (*:* -{prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) - final Query allParentsFilter = buildAllParentsFilterFromPath(isRoot, parentPath); + final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); final BooleanQuery parsedParentQuery = parseImpl(); @@ -104,12 +102,12 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { // For root: (+ -_nest_path_:*) final BooleanQuery.Builder constrainedBuilder = new BooleanQuery.Builder().add(parsedParentQuery, Occur.MUST); - if (isRoot) { + if (parentPath.equals("/")) { constrainedBuilder.add( new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME), Occur.MUST_NOT); } else { constrainedBuilder.add( - new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.MUST); + new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.FILTER); } final Query constrainedParentQuery = constrainedBuilder.build(); diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index 4aa5f6dbdcbf..62df54286741 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -129,30 +129,28 @@ public Query parse() throws SyntaxError { * root "/") */ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { - final boolean isRoot = parentPath.equals("/"); - // allParents filter: (*:* -{prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) - final Query allParentsFilter = buildAllParentsFilterFromPath(isRoot, parentPath); + final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); final BooleanQuery parsedChildQuery = parseImpl(); if (parsedChildQuery.clauses().isEmpty()) { // no child query: return all "parent" docs at this level - return wrapWithParentPathConstraint(isRoot, parentPath, allParentsFilter); + return wrapWithParentPathConstraint(parentPath, allParentsFilter); } // constrain child query: (+ +{prefix f="_nest_path_" v="/"}) // For root: (+ +_nest_path_:*) final Query constrainedChildQuery = - buildChildQueryWithPathConstraint(isRoot, parentPath, parsedChildQuery); + buildChildQueryWithPathConstraint(parentPath, parsedChildQuery); final String scoreMode = localParams.get("score", ScoreMode.None.name()); final Query parentJoinQuery = createQuery(allParentsFilter, constrainedChildQuery, scoreMode); // wrap result: (+ +{field f="_nest_path_" v=""}) // For root: (+ -_nest_path_:*) - return wrapWithParentPathConstraint(isRoot, parentPath, parentJoinQuery); + return wrapWithParentPathConstraint(parentPath, parentJoinQuery); } /** @@ -170,9 +168,9 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { *

Equivalent to: {@code (*:* -{prefix f="_nest_path_" v="/"})} For root ({@code * /}): {@code (*:* -_nest_path_:*)} */ - protected static Query buildAllParentsFilterFromPath(boolean isRoot, String parentPath) { + protected static Query buildAllParentsFilterFromPath(String parentPath) { final Query excludeQuery; - if (isRoot) { + if (parentPath.equals("/")) { excludeQuery = new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME); } else { excludeQuery = new PrefixQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath + "/")); @@ -188,14 +186,13 @@ protected static Query buildAllParentsFilterFromPath(boolean isRoot, String pare * level are matched. For root, this excludes docs that have a {@code _nest_path_} value. For * non-root, this requires an exact match on {@code _nest_path_}. */ - private static Query wrapWithParentPathConstraint( - boolean isRoot, String parentPath, Query query) { + private static Query wrapWithParentPathConstraint(String parentPath, Query query) { final BooleanQuery.Builder builder = new BooleanQuery.Builder().add(query, Occur.MUST); - if (isRoot) { + if (parentPath.equals("/")) { builder.add(new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME), Occur.MUST_NOT); } else { builder.add( - new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.MUST); + new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.FILTER); } return builder.build(); } @@ -206,10 +203,9 @@ private static Query wrapWithParentPathConstraint( * sub-path of the parent path (i.e. starts with {@code parentPath/}). For root, any doc with a * {@code _nest_path_} is a "child". */ - private static Query buildChildQueryWithPathConstraint( - boolean isRoot, String parentPath, Query childQuery) { + private static Query buildChildQueryWithPathConstraint(String parentPath, Query childQuery) { final Query nestPathConstraint; - if (isRoot) { + if (parentPath.equals("/")) { nestPathConstraint = new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME); } else { nestPathConstraint = @@ -217,7 +213,7 @@ private static Query buildChildQueryWithPathConstraint( } return new BooleanQuery.Builder() .add(childQuery, Occur.MUST) - .add(nestPathConstraint, Occur.MUST) + .add(nestPathConstraint, Occur.FILTER) .build(); } From 0b28c9470384a42d105bc6c59d23df1b2db32468 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:21:25 +0000 Subject: [PATCH 04/12] SOLR-14687: Document parentPath param in ref guide (block-join-query-parser.adoc, searching-nested-documents.adoc) Co-authored-by: dsmiley <377295+dsmiley@users.noreply.github.com> --- .../pages/block-join-query-parser.adoc | 68 +++++++++++++++++-- .../pages/searching-nested-documents.adoc | 52 +++++--------- 2 files changed, 79 insertions(+), 41 deletions(-) diff --git a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc index 2eed97216392..12768cb5a040 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc @@ -51,10 +51,38 @@ The example usage of the query parsers below assumes the following documents hav This parser wraps a query that matches some parent documents and returns the children of those documents. -The syntax for this parser is: `q={!child of=}`. +=== Using `parentPath` (Recommended for `_nest_path_`-based Nesting) -* The inner subordinate query string (`someParents`) must be a query that will match some parent documents -* The `of` parameter must be a query string to use as a <> -- typically a query that matches the set of all possible parent documents +When using xref:indexing-guide:indexing-nested-documents.adoc[`_nest_path_`-based nested documents], the `parentPath` parameter is the simplest and most reliable way to use this parser. +Instead of constructing a manual Block Mask with escaped `_nest_path_` prefixes, you simply specify the path at which the parent documents live: + +[source,text] +q={!child parentPath=} + +Key points about `parentPath`: + +* Must start with `/`. +* Use `parentPath="/"` to treat root-level documents (those without a `_nest_path_`) as the parents. +* A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`). +* `parentPath` and `of` are mutually exclusive; specifying both returns a `400 Bad Request` error. +* The parser automatically derives the Block Mask and constrains the subordinate query to the correct `_nest_path_` level — no manual escaping of `/` characters is required. + +For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns all children of root-level product documents that match a description query: + +[source,text] +q={!child parentPath="/"}description_t:staplers + +To return only children of `skus` documents with a price under 50: + +[source,text] +q={!child parentPath="/skus"}price_i:[* TO 50] + +=== Using the `of` Parameter + +The traditional syntax for this parser is: `q={!child of=}`. + +* The inner subordinate query string (`someParents`) must be a query that will match some parent documents. +* The `of` parameter must be a query string to use as a <> -- typically a query that matches the set of all possible parent documents. The resulting query will match all documents which do _not_ match the `` query and are children (or descendents) of the documents matched by ``. @@ -111,10 +139,38 @@ More precisely, `q={!child of=}` is equivalent to `q=\*:* -}`. +=== Using `parentPath` (Recommended for `_nest_path_`-based Nesting) + +When using xref:indexing-guide:indexing-nested-documents.adoc[`_nest_path_`-based nested documents], the `parentPath` parameter is the simplest and most reliable way to use this parser. +Instead of constructing a manual Block Mask with escaped `_nest_path_` prefixes, you simply specify the path at which the parent documents live: + +[source,text] +q={!parent parentPath=} + +Key points about `parentPath`: + +* Must start with `/`. +* Use `parentPath="/"` to treat root-level documents (those without a `_nest_path_`) as the parents. +* A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`). +* `parentPath` and `which` are mutually exclusive; specifying both returns a `400 Bad Request` error. +* The parser automatically derives the Block Mask and constrains the subordinate query to the correct `_nest_path_` level — no manual escaping of `/` characters is required. + +For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns the root-level product documents that are ancestors of manuals with exactly one page: + +[source,text] +q={!parent parentPath="/"}pages_i:1 + +To instead return the `skus` that are ancestors of those same one-page manuals: + +[source,text] +q={!parent parentPath="/skus"}(+_nest_path_:"/skus/manuals" +pages_i:1) + +=== Using the `which` Parameter + +The traditional syntax for this parser is: `q={!parent which=}`. -* The inner subordinate query string (`someChildren`) must be a query that will match some child documents -* The `which` parameter must be a query string to use as a <> -- typically a query that matches the set of all possible parent documents +* The inner subordinate query string (`someChildren`) must be a query that will match some child documents. +* The `which` parameter must be a query string to use as a <> -- typically a query that matches the set of all possible parent documents. The resulting query will match all documents which _do_ match the `` query and are parents (or ancestors) of the documents matched by ``. diff --git a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc index 83b2e35f54cd..f3e283f2bcc9 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc @@ -108,11 +108,11 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select?omitHeader=true&q=descr The `{!child}` query parser can be used to search for the _descendent_ documents of parent documents matching a wrapped query. For a detailed explanation of this parser, see the section xref:block-join-query-parser.adoc#block-join-children-query-parser[Block Join Children Query Parser]. -Let's consider again the `description_t:staplers` query used above -- if we wrap that query in a `{!child}` query parser then instead of "matching" & returning the product level documents, we instead match all of the _descendent_ child documents of the original query: +Let's consider again the `description_t:staplers` query used above -- if we wrap that query in a `{!child}` query parser with `parentPath="/"` then instead of "matching" & returning the product level documents, we instead match all of the _descendent_ child documents of the original query: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!child of="*:* -_nest_path_:*"}description_t:staplers' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!child parentPath="/"}description_t:staplers' { "response":{"numFound":5,"start":0,"maxScore":0.30136836,"numFoundExact":true,"docs":[ { @@ -146,14 +146,14 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -In this example we've used `\*:* -\_nest_path_:*` as our xref:block-join-query-parser.adoc#block-mask[`of` parameter] to indicate we want to consider all documents which don't have a nest path -- i.e., all "root" level document -- as the set of possible parents. +In this example `parentPath="/"` indicates we want to consider all root-level documents (those with no `_nest_path_`) as the set of possible parents. -By changing the `of` parameter to match ancestors at specific `\_nest_path_` levels, we can narrow down the list of children we return. -In the query below, we search for all descendants of `skus` (using an `of` parameter that identifies all documents that do _not_ have a `\_nest_path_` with the prefix `/skus/*`) with a `price_i` less than `50`: +By changing the `parentPath` to a specific `_nest_path_` level, we can narrow down the list of children we return. +In the query below, we search for all children of `skus` with a `price_i` less than `50`: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child of="*:* -_nest_path_:\\/skus\\/*"}(+price_i:[* TO 50] +_nest_path_:\/skus)' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!child parentPath="/skus"}price_i:[* TO 50]' { "response":{"numFound":1,"start":0,"maxScore":1.0,"numFoundExact":true,"docs":[ { @@ -165,24 +165,8 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -[#double-escaping-nest-path-slashes] -[CAUTION] -.Double Escaping `\_nest_path_` slashes in `of` -==== -Note that in the above example, the `/` characters in the `\_nest_path_` were "double escaped" in the `of` parameter: - -* One level of `\` escaping is necessary to prevent the `/` from being interpreted as a {lucene-javadocs}/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#Regexp_Searches[Regex Query] -* An additional level of "escaping the escape character" is necessary because the `of` local parameter is a quoted string; so we need a second `\` to ensure the first `\` is preserved and passed as is to the query parser. - -(You can see that only a single level of `\` escaping is needed in the body of the query string -- to prevent the Regex syntax -- because it's not a quoted string local param). - -You may find it more convenient to use xref:local-params.adoc#parameter-dereferencing[parameter references] in conjunction with xref:other-parsers.adoc[other parsers] that do not treat `/` as a special character to express the same query in a more verbose form: - -[source,text] ----- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child of=$block_mask}(+price_i:[* TO 50] +{!field f="_nest_path_" v="/skus"})' --data-urlencode 'block_mask=(*:* -{!prefix f="_nest_path_" v="/skus/"})' ----- -==== +The `parentPath` parameter handles `_nest_path_` construction and escaping automatically. +The traditional `of` parameter approach (constructing a Block Mask manually with escaped `_nest_path_` prefixes) continues to work for cases where `_nest_path_` is not in use or for greater control. === Parent Query Parser @@ -217,11 +201,11 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select?omitHeader=true&q=pages }} ---- -We can wrap that query in a `{!parent}` query to return the details of all products that are ancestors of these manuals: +We can wrap that query in a `{!parent}` query with `parentPath="/"` to return the details of all root-level products that are ancestors of these manuals: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent which="*:* -_nest_path_:*"}(+_nest_path_:\/skus\/manuals +pages_i:1)' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!parent parentPath="/"}pages_i:1' { "response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[ { @@ -237,14 +221,14 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -In this example we've used `\*:* -\_nest_path_:*` as our xref:block-join-query-parser.adoc#block-mask[`which` parameter] to indicate we want to consider all documents which don't have a nest path -- i.e., all "root" level document -- as the set of possible parents. +In this example `parentPath="/"` indicates we want root-level documents (those with no `_nest_path_`) to be the parents. -By changing the `which` parameter to match ancestors at specific `\_nest_path_` levels, we can change the type of ancestors we return. -In the query below, we search for `skus` (using an `which` parameter that identifies all documents that do _not_ have a `\_nest_path_` with the prefix `/skus/*`) that are the ancestors of `manuals` with exactly `1` page: +By changing `parentPath` to a specific path, we can change the type of ancestors we return. +In the query below, we search for the `skus` that are the ancestors of `manuals` with exactly `1` page: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent which="*:* -_nest_path_:\\/skus\\/*"}(+_nest_path_:\/skus\/manuals +pages_i:1)' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!parent parentPath="/skus"}pages_i:1' { "response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[ { @@ -260,10 +244,8 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -[CAUTION] -==== -Note that in the above example, the `/` characters in the `\_nest_path_` were "double escaped" in the `which` parameter, for the <> regarding the `{!child} pasers `of` parameter. -==== +The `parentPath` parameter handles `_nest_path_` construction and escaping automatically. +The traditional `which` parameter approach (constructing a Block Mask manually with escaped `_nest_path_` prefixes) continues to work for cases where `_nest_path_` is not in use or for greater control. === Combining Block Join Query Parsers with Child Doc Transformer @@ -279,7 +261,7 @@ Here for example is a query where: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'fq=color_s:RED' --data-urlencode 'q={!child of="*:* -_nest_path_:*" filters=$parent_fq}' --data-urlencode 'parent_fq={!parent which="*:* -_nest_path_:*"}(+_nest_path_:"/manuals" +content_t:"lifetime guarantee")' -d 'fl=*,[child]' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'fq=color_s:RED' --data-urlencode 'q={!child parentPath="/" filters=$parent_fq}' --data-urlencode 'parent_fq={!parent parentPath="/"}content_t:"lifetime guarantee"' -d 'fl=*,[child]' { "response":{"numFound":1,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[ { From 4fb16a306d49626acab6dd95bf95735139c97816 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:47:10 +0000 Subject: [PATCH 05/12] SOLR-14687: Refine parentPath ref guide doc per review feedback Co-authored-by: dsmiley <377295+dsmiley@users.noreply.github.com> --- .../pages/block-join-query-parser.adoc | 26 ++++++++++--------- .../pages/searching-nested-documents.adoc | 18 +++++-------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc index 12768cb5a040..835a76f2e69f 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc @@ -51,10 +51,10 @@ The example usage of the query parsers below assumes the following documents hav This parser wraps a query that matches some parent documents and returns the children of those documents. -=== Using `parentPath` (Recommended for `_nest_path_`-based Nesting) +=== Using `parentPath` -When using xref:indexing-guide:indexing-nested-documents.adoc[`_nest_path_`-based nested documents], the `parentPath` parameter is the simplest and most reliable way to use this parser. -Instead of constructing a manual Block Mask with escaped `_nest_path_` prefixes, you simply specify the path at which the parent documents live: +If your schema supports xref:indexing-guide:indexing-nested-documents.adoc[nested documents], you _should_ specify `parentPath`. +Specify the path at which the parent documents live: [source,text] q={!child parentPath=} @@ -62,10 +62,9 @@ q={!child parentPath=} Key points about `parentPath`: * Must start with `/`. -* Use `parentPath="/"` to treat root-level documents (those without a `_nest_path_`) as the parents. +* Use `parentPath="/"` to treat root-level documents as the parents. * A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`). * `parentPath` and `of` are mutually exclusive; specifying both returns a `400 Bad Request` error. -* The parser automatically derives the Block Mask and constrains the subordinate query to the correct `_nest_path_` level — no manual escaping of `/` characters is required. For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns all children of root-level product documents that match a description query: @@ -79,7 +78,9 @@ q={!child parentPath="/skus"}price_i:[* TO 50] === Using the `of` Parameter -The traditional syntax for this parser is: `q={!child of=}`. +This approach is used with anonymous child documents (schemas without `_nest_path_`). +It is more verbose and has some <>. +The syntax is: `q={!child of=}`. * The inner subordinate query string (`someParents`) must be a query that will match some parent documents. * The `of` parameter must be a query string to use as a <> -- typically a query that matches the set of all possible parent documents. @@ -139,10 +140,10 @@ More precisely, `q={!child of=}` is equivalent to `q=\*:* -} @@ -150,10 +151,9 @@ q={!parent parentPath=} Key points about `parentPath`: * Must start with `/`. -* Use `parentPath="/"` to treat root-level documents (those without a `_nest_path_`) as the parents. +* Use `parentPath="/"` to treat root-level documents as the parents. * A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`). * `parentPath` and `which` are mutually exclusive; specifying both returns a `400 Bad Request` error. -* The parser automatically derives the Block Mask and constrains the subordinate query to the correct `_nest_path_` level — no manual escaping of `/` characters is required. For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns the root-level product documents that are ancestors of manuals with exactly one page: @@ -167,7 +167,9 @@ q={!parent parentPath="/skus"}(+_nest_path_:"/skus/manuals" +pages_i:1) === Using the `which` Parameter -The traditional syntax for this parser is: `q={!parent which=}`. +This approach is used with anonymous child documents (schemas without `_nest_path_`). +It is more verbose and has some <>. +The syntax is: `q={!parent which=}`. * The inner subordinate query string (`someChildren`) must be a query that will match some child documents. * The `which` parameter must be a query string to use as a <> -- typically a query that matches the set of all possible parent documents. diff --git a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc index f3e283f2bcc9..c1165f631f87 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc @@ -112,7 +112,7 @@ Let's consider again the `description_t:staplers` query used above -- if we wrap [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!child parentPath="/"}description_t:staplers' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child parentPath="/"}description_t:staplers' { "response":{"numFound":5,"start":0,"maxScore":0.30136836,"numFoundExact":true,"docs":[ { @@ -146,14 +146,14 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -In this example `parentPath="/"` indicates we want to consider all root-level documents (those with no `_nest_path_`) as the set of possible parents. +In this example `parentPath="/"` indicates we want to consider all root-level documents as the set of possible parents. By changing the `parentPath` to a specific `_nest_path_` level, we can narrow down the list of children we return. In the query below, we search for all children of `skus` with a `price_i` less than `50`: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!child parentPath="/skus"}price_i:[* TO 50]' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child parentPath="/skus"}price_i:[* TO 50]' { "response":{"numFound":1,"start":0,"maxScore":1.0,"numFoundExact":true,"docs":[ { @@ -165,9 +165,6 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -The `parentPath` parameter handles `_nest_path_` construction and escaping automatically. -The traditional `of` parameter approach (constructing a Block Mask manually with escaped `_nest_path_` prefixes) continues to work for cases where `_nest_path_` is not in use or for greater control. - === Parent Query Parser The inverse of the `{!child}` query parser is the `{!parent}` query parser, which lets you search for the _ancestor_ documents of some child documents matching a wrapped query. @@ -205,7 +202,7 @@ We can wrap that query in a `{!parent}` query with `parentPath="/"` to return th [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!parent parentPath="/"}pages_i:1' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent parentPath="/"}pages_i:1' { "response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[ { @@ -221,14 +218,14 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -In this example `parentPath="/"` indicates we want root-level documents (those with no `_nest_path_`) to be the parents. +In this example `parentPath="/"` indicates we want root-level documents to be the parents. By changing `parentPath` to a specific path, we can change the type of ancestors we return. In the query below, we search for the `skus` that are the ancestors of `manuals` with exactly `1` page: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!parent parentPath="/skus"}pages_i:1' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent parentPath="/skus"}pages_i:1' { "response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[ { @@ -244,9 +241,6 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - }} ---- -The `parentPath` parameter handles `_nest_path_` construction and escaping automatically. -The traditional `which` parameter approach (constructing a Block Mask manually with escaped `_nest_path_` prefixes) continues to work for cases where `_nest_path_` is not in use or for greater control. - === Combining Block Join Query Parsers with Child Doc Transformer The combination of these two parsers with the `[child]` transformer enables seamless creation of very powerful queries. From 568bf4c6b5934c6be845fe70e753c9323b2f1a8e Mon Sep 17 00:00:00 2001 From: David Smiley Date: Tue, 10 Mar 2026 15:53:11 -0400 Subject: [PATCH 06/12] credit me and hossman --- changelog/unreleased/SOLR-14687-parentPath-param.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog/unreleased/SOLR-14687-parentPath-param.yml b/changelog/unreleased/SOLR-14687-parentPath-param.yml index caa1e2aa7862..24e4eadf9340 100644 --- a/changelog/unreleased/SOLR-14687-parentPath-param.yml +++ b/changelog/unreleased/SOLR-14687-parentPath-param.yml @@ -1,6 +1,9 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc title: The {!parent} and {!child} query parsers now support a parentPath local param that automatically derives the correct parent filter using the _nest_path_ field, making nested document queries easier to write correctly. type: added # added, changed, fixed, deprecated, removed, dependency_update, security, other +authors: + - name: David Smiley + - name: hossman links: - name: SOLR-14687 url: https://issues.apache.org/jira/browse/SOLR-14687 From f785ff4a3bfe4ceb0b69b6f6b8ee304a3afb239b Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 11 Mar 2026 08:13:46 -0400 Subject: [PATCH 07/12] dedup code by calling wrapWithParentPathConstraint --- .../search/join/BlockJoinChildQParser.java | 24 +++++-------------- .../search/join/BlockJoinParentQParser.java | 15 ++++++------ 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java index 3b714ffd0067..64adab84ddfc 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java @@ -16,19 +16,16 @@ */ package org.apache.solr.search.join; -import org.apache.lucene.index.Term; import org.apache.lucene.search.BooleanClause.Occur; import org.apache.lucene.search.BooleanQuery; -import org.apache.lucene.search.FieldExistsQuery; import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.Query; -import org.apache.lucene.search.TermQuery; import org.apache.lucene.search.join.ToChildBlockJoinQuery; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.schema.IndexSchema; import org.apache.solr.search.SyntaxError; +/** Matches child documents based on parent doc criteria. */ public class BlockJoinChildQParser extends BlockJoinParentQParser { public BlockJoinChildQParser( @@ -65,8 +62,8 @@ protected Query noClausesQuery() throws SyntaxError { *

NEW: q={!child parentPath="/a/b/c"}p_title:dad
    *
    * OLD: q={!child of=$ff v=$vv}
-   *      ff=(*:* -{prefix f="_nest_path_" v="/a/b/c/"})
-   *      vv=(+p_title:dad +{field f="_nest_path_" v="/a/b/c"})
+ * ff=(*:* -{!prefix f="_nest_path_" v="/a/b/c/"}) + * vv=(+p_title:dad +{!field f="_nest_path_" v="/a/b/c"}) * *

For {@code parentPath="/"}: * @@ -81,7 +78,7 @@ protected Query noClausesQuery() throws SyntaxError { */ @Override protected Query parseUsingParentPath(String parentPath) throws SyntaxError { - // allParents filter: (*:* -{prefix f="_nest_path_" v="/"}) + // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); @@ -98,18 +95,9 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { } // constrain the parent query to only match docs at exactly parentPath - // (+ +{field f="_nest_path_" v=""}) + // (+ +{!field f="_nest_path_" v=""}) // For root: (+ -_nest_path_:*) - final BooleanQuery.Builder constrainedBuilder = - new BooleanQuery.Builder().add(parsedParentQuery, Occur.MUST); - if (parentPath.equals("/")) { - constrainedBuilder.add( - new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME), Occur.MUST_NOT); - } else { - constrainedBuilder.add( - new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.FILTER); - } - final Query constrainedParentQuery = constrainedBuilder.build(); + Query constrainedParentQuery = wrapWithParentPathConstraint(parentPath, parsedParentQuery); return createQuery(allParentsFilter, constrainedParentQuery, null); } diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index 62df54286741..83718ae22277 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -51,6 +51,7 @@ import org.apache.solr.search.SyntaxError; import org.apache.solr.util.SolrDefaultScorerSupplier; +/** Matches parent documents based on child doc criteria. */ public class BlockJoinParentQParser extends FiltersQParser { /** implementation detail subject to change */ public static final String CACHE_NAME = "perSegFilter"; @@ -114,8 +115,8 @@ public Query parse() throws SyntaxError { *

NEW: q={!parent parentPath="/a/b/c"}c_title:son
    *
    * OLD: q=(+{!field f="_nest_path_" v="/a/b/c"} +{!parent which=$ff v=$vv})
-   *      ff=(*:* -{prefix f="_nest_path_" v="/a/b/c/"})
-   *      vv=(+c_title:son +{prefix f="_nest_path_" v="/a/b/c/"})
+ * ff=(*:* -{!prefix f="_nest_path_" v="/a/b/c/"}) + * vv=(+c_title:son +{!prefix f="_nest_path_" v="/a/b/c/"}) * *

For {@code parentPath="/"}: * @@ -129,7 +130,7 @@ public Query parse() throws SyntaxError { * root "/") */ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { - // allParents filter: (*:* -{prefix f="_nest_path_" v="/"}) + // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); @@ -140,7 +141,7 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { return wrapWithParentPathConstraint(parentPath, allParentsFilter); } - // constrain child query: (+ +{prefix f="_nest_path_" v="/"}) + // constrain child query: (+ +{!prefix f="_nest_path_" v="/"}) // For root: (+ +_nest_path_:*) final Query constrainedChildQuery = buildChildQueryWithPathConstraint(parentPath, parsedChildQuery); @@ -148,7 +149,7 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { final String scoreMode = localParams.get("score", ScoreMode.None.name()); final Query parentJoinQuery = createQuery(allParentsFilter, constrainedChildQuery, scoreMode); - // wrap result: (+ +{field f="_nest_path_" v=""}) + // wrap result: (+ +{!field f="_nest_path_" v=""}) // For root: (+ -_nest_path_:*) return wrapWithParentPathConstraint(parentPath, parentJoinQuery); } @@ -165,7 +166,7 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { * /a/b/c}) * * - *

Equivalent to: {@code (*:* -{prefix f="_nest_path_" v="/"})} For root ({@code + *

Equivalent to: {@code (*:* -{!prefix f="_nest_path_" v="/"})} For root ({@code * /}): {@code (*:* -_nest_path_:*)} */ protected static Query buildAllParentsFilterFromPath(String parentPath) { @@ -186,7 +187,7 @@ protected static Query buildAllParentsFilterFromPath(String parentPath) { * level are matched. For root, this excludes docs that have a {@code _nest_path_} value. For * non-root, this requires an exact match on {@code _nest_path_}. */ - private static Query wrapWithParentPathConstraint(String parentPath, Query query) { + protected static Query wrapWithParentPathConstraint(String parentPath, Query query) { final BooleanQuery.Builder builder = new BooleanQuery.Builder().add(query, Occur.MUST); if (parentPath.equals("/")) { builder.add(new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME), Occur.MUST_NOT); From 68df5784aee6a57d6e4be043dd3ed46b7e223887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:32:22 +0000 Subject: [PATCH 08/12] SOLR-14687: Add childPath param to {!parent parentPath=...} Co-authored-by: dsmiley <377295+dsmiley@users.noreply.github.com> --- .../search/join/BlockJoinChildQParser.java | 6 +++ .../search/join/BlockJoinParentQParser.java | 43 +++++++++++++-- .../update/TestNestedUpdateProcessor.java | 54 +++++++++++++++++++ .../pages/block-join-query-parser.adoc | 5 +- .../pages/searching-nested-documents.adoc | 5 +- 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java index 64adab84ddfc..db0dc521d51f 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java @@ -21,6 +21,7 @@ import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.Query; import org.apache.lucene.search.join.ToChildBlockJoinQuery; +import org.apache.solr.common.SolrException; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.search.SyntaxError; @@ -78,6 +79,11 @@ protected Query noClausesQuery() throws SyntaxError { */ @Override protected Query parseUsingParentPath(String parentPath) throws SyntaxError { + if (localParams.get(CHILD_PATH_PARAM) != null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + CHILD_PATH_PARAM + " is not supported by the {!child} parser"); + } // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index 83718ae22277..a593ff24b245 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -67,6 +67,16 @@ public class BlockJoinParentQParser extends FiltersQParser { */ public static final String PARENT_PATH_PARAM = "parentPath"; + /** + * Optional localparam, only valid together with {@link #PARENT_PATH_PARAM} on the {@code parent} + * parser. When specified, the subordinate (child) query is constrained to docs at exactly the + * path formed by concatenating {@code parentPath + "/" + childPath}, instead of the default + * behavior of matching all descendants. For example, {@code parentPath="/skus" + * childPath="manuals"} constrains children to docs whose {@code _nest_path_} is exactly {@code + * /skus/manuals}. + */ + public static final String CHILD_PATH_PARAM = "childPath"; + protected String getParentFilterLocalParamName() { return "which"; } @@ -103,6 +113,10 @@ public Query parse() throws SyntaxError { } return parseUsingParentPath(parentPath); } + if (localParams.get(CHILD_PATH_PARAM) != null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " requires " + PARENT_PATH_PARAM); + } return super.parse(); } @@ -134,6 +148,18 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { // For root: (*:* -_nest_path_:*) final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); + String childPath = localParams.get(CHILD_PATH_PARAM); + if (childPath != null) { + // strip leading slash if present + if (childPath.startsWith("/")) { + childPath = childPath.substring(1); + } + if (childPath.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not be empty"); + } + } + final BooleanQuery parsedChildQuery = parseImpl(); if (parsedChildQuery.clauses().isEmpty()) { @@ -143,8 +169,10 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { // constrain child query: (+ +{!prefix f="_nest_path_" v="/"}) // For root: (+ +_nest_path_:*) + // If childPath specified: (+ +{!term f="_nest_path_" + // v="/"}) final Query constrainedChildQuery = - buildChildQueryWithPathConstraint(parentPath, parsedChildQuery); + buildChildQueryWithPathConstraint(parentPath, childPath, parsedChildQuery); final String scoreMode = localParams.get("score", ScoreMode.None.name()); final Query parentJoinQuery = createQuery(allParentsFilter, constrainedChildQuery, scoreMode); @@ -202,11 +230,18 @@ protected static Query wrapWithParentPathConstraint(String parentPath, Query que * Constrains the child query to only match docs strictly below the given {@code parentPath}. For * the parent parser, the child query must match docs with a {@code _nest_path_} that is a * sub-path of the parent path (i.e. starts with {@code parentPath/}). For root, any doc with a - * {@code _nest_path_} is a "child". + * {@code _nest_path_} is a "child". If {@code childPath} is non-null, the constraint is an exact + * term match on {@code parentPath/childPath} instead of a prefix query. */ - private static Query buildChildQueryWithPathConstraint(String parentPath, Query childQuery) { + private static Query buildChildQueryWithPathConstraint( + String parentPath, String childPath, Query childQuery) { final Query nestPathConstraint; - if (parentPath.equals("/")) { + if (childPath != null) { + String effectiveChildPath = + parentPath.equals("/") ? "/" + childPath : parentPath + "/" + childPath; + nestPathConstraint = + new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, effectiveChildPath)); + } else if (parentPath.equals("/")) { nestPathConstraint = new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME); } else { nestPathConstraint = diff --git a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java index c5e9b679df72..e49a2e54e409 100644 --- a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java +++ b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java @@ -439,6 +439,60 @@ public void checkParentAndChildQueriesOfEachDocument() { "//result/@numFound=1", "//doc/str[@name='id'][.='" + ancestorId + "']"); + // additionally test childPath: find the immediate parent of descendentId and use + // childPath to constrain to that exact child path level + final String directParentPath = + doc_path.contains("/") + ? (doc_path.lastIndexOf("/") == 0 + ? "/" + : doc_path.substring(0, doc_path.lastIndexOf("/"))) + : "/"; + final String childSegment = doc_path.substring(doc_path.lastIndexOf("/") + 1); + // find the ancestor ID whose path is directParentPath + for (Object candAncestorId : allAncestorIds) { + final String candPath = + allDocs.get(candAncestorId.toString()).getFieldValue("test_path_s").toString(); + if (candPath.equals(directParentPath)) { + // childPath constrains the child query to exactly doc_path, so we should find + // the direct parent + assertQ( + req( + params( + "q", + "{!parent parentPath='" + + directParentPath + + "' childPath='" + + childSegment + + "'}id:" + + descendentId), + "_trace_childPath_tested", + directParentPath + "/" + childSegment, + "fl", + "id", + "indent", + "true"), + "//result/@numFound=1", + "//doc/str[@name='id'][.='" + candAncestorId + "']"); + // a childPath that doesn't match descendentId's path should return 0 results + assertQ( + req( + params( + "q", + "{!parent parentPath='" + + directParentPath + + "' childPath='xxx_yyy'}id:" + + descendentId), + "_trace_childPath_tested", + directParentPath + "/xxx_yyy", + "fl", + "id", + "indent", + "true"), + "//result/@numFound=0"); + break; + } + } + // meanwhile, a 'child' query wrapped around a query for the ancestorId, using the // ancestor_path, should match all of its descendents (for simplicity we'll check just // the numFound and the 'descendentId' we started with) diff --git a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc index 835a76f2e69f..65e665f4a827 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc @@ -154,16 +154,17 @@ Key points about `parentPath`: * Use `parentPath="/"` to treat root-level documents as the parents. * A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`). * `parentPath` and `which` are mutually exclusive; specifying both returns a `400 Bad Request` error. +* Optionally, use `childPath` to constrain the child query to docs at exactly `parentPath/childPath`. Without `childPath`, all descendants of `parentPath` are eligible as children. For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns the root-level product documents that are ancestors of manuals with exactly one page: [source,text] q={!parent parentPath="/"}pages_i:1 -To instead return the `skus` that are ancestors of those same one-page manuals: +To instead return the `skus` that are ancestors of one-page _manuals_ (only manuals, not other sku children): [source,text] -q={!parent parentPath="/skus"}(+_nest_path_:"/skus/manuals" +pages_i:1) +q={!parent parentPath="/skus" childPath="manuals"}pages_i:1 === Using the `which` Parameter diff --git a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc index c1165f631f87..15979f8ac320 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc @@ -221,11 +221,12 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' - In this example `parentPath="/"` indicates we want root-level documents to be the parents. By changing `parentPath` to a specific path, we can change the type of ancestors we return. -In the query below, we search for the `skus` that are the ancestors of `manuals` with exactly `1` page: +In the query below, we search for the `skus` that are the ancestors of `manuals` with exactly `1` page. +Adding `childPath="manuals"` constrains the child query to only docs nested at `/skus/manuals`, preventing pages from other child types from matching: [source,text] ---- -$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent parentPath="/skus"}pages_i:1' +$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent parentPath="/skus" childPath="manuals"}pages_i:1' { "response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[ { From 58e660b5974c9f3d757ff77a490125af2b613dbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:18:05 +0000 Subject: [PATCH 09/12] SOLR-14687: childPath starting with / is BAD_REQUEST; empty childPath == no childPath Co-authored-by: dsmiley <377295+dsmiley@users.noreply.github.com> --- .../apache/solr/search/join/BlockJoinParentQParser.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index a593ff24b245..c46742fa34d6 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -150,13 +150,12 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { String childPath = localParams.get(CHILD_PATH_PARAM); if (childPath != null) { - // strip leading slash if present if (childPath.startsWith("/")) { - childPath = childPath.substring(1); + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not start with '/'"); } if (childPath.isEmpty()) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not be empty"); + childPath = null; // treat empty as not specified } } From a8478be8b5019ef8a4cb0b2f1e21f7450f951f88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:54:06 +0000 Subject: [PATCH 10/12] SOLR-14687: Add childPath support to {!child} parser; narrows returned children to exact path Co-authored-by: dsmiley <377295+dsmiley@users.noreply.github.com> --- .../search/join/BlockJoinChildQParser.java | 28 +++++++++++--- .../search/join/BlockJoinParentQParser.java | 2 +- .../update/TestNestedUpdateProcessor.java | 37 +++++++++++++++++++ .../pages/block-join-query-parser.adoc | 5 ++- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java index db0dc521d51f..3611ecd2f6bc 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java @@ -74,15 +74,23 @@ protected Query noClausesQuery() throws SyntaxError { * ff=(*:* -_nest_path_:*) * vv=(+p_title:dad -_nest_path_:*) * + *

The optional {@code childPath} localparam narrows the returned children to docs at exactly + * {@code parentPath/childPath}. + * * @param parentPath the normalized parent path (starts with "/", no trailing slash except for * root "/") */ @Override protected Query parseUsingParentPath(String parentPath) throws SyntaxError { - if (localParams.get(CHILD_PATH_PARAM) != null) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - CHILD_PATH_PARAM + " is not supported by the {!child} parser"); + String childPath = localParams.get(CHILD_PATH_PARAM); + if (childPath != null) { + if (childPath.startsWith("/")) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not start with '/'"); + } + if (childPath.isEmpty()) { + childPath = null; // treat empty as not specified + } } // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) @@ -97,7 +105,11 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { .add(new MatchAllDocsQuery(), Occur.MUST) .add(allParentsFilter, Occur.MUST_NOT) .build(); - return new BitSetProducerQuery(getBitSetProducer(notParents)); + Query result = new BitSetProducerQuery(getBitSetProducer(notParents)); + if (childPath != null) { + result = buildChildQueryWithPathConstraint(parentPath, childPath, result); + } + return result; } // constrain the parent query to only match docs at exactly parentPath @@ -105,6 +117,10 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { // For root: (+ -_nest_path_:*) Query constrainedParentQuery = wrapWithParentPathConstraint(parentPath, parsedParentQuery); - return createQuery(allParentsFilter, constrainedParentQuery, null); + Query joinQuery = createQuery(allParentsFilter, constrainedParentQuery, null); + if (childPath != null) { + return buildChildQueryWithPathConstraint(parentPath, childPath, joinQuery); + } + return joinQuery; } } diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index c46742fa34d6..7f764a12d086 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -232,7 +232,7 @@ protected static Query wrapWithParentPathConstraint(String parentPath, Query que * {@code _nest_path_} is a "child". If {@code childPath} is non-null, the constraint is an exact * term match on {@code parentPath/childPath} instead of a prefix query. */ - private static Query buildChildQueryWithPathConstraint( + protected static Query buildChildQueryWithPathConstraint( String parentPath, String childPath, Query childQuery) { final Query nestPathConstraint; if (childPath != null) { diff --git a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java index e49a2e54e409..b89a7c2974f4 100644 --- a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java +++ b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java @@ -489,6 +489,43 @@ public void checkParentAndChildQueriesOfEachDocument() { "indent", "true"), "//result/@numFound=0"); + // childPath for {!child}: constrain returned children to exactly doc_path + assertQ( + req( + params( + "q", + "{!child parentPath='" + + directParentPath + + "' childPath='" + + childSegment + + "'}id:" + + candAncestorId), + "_trace_child_childPath_tested", + directParentPath + "/" + childSegment, + "rows", + "9999", + "fl", + "id", + "indent", + "true"), + "count(//doc)>=1", + "//doc/str[@name='id'][.='" + descendentId + "']"); + // a childPath that doesn't match should return 0 results + assertQ( + req( + params( + "q", + "{!child parentPath='" + + directParentPath + + "' childPath='xxx_yyy'}id:" + + candAncestorId), + "_trace_child_childPath_tested", + directParentPath + "/xxx_yyy", + "fl", + "id", + "indent", + "true"), + "//result/@numFound=0"); break; } } diff --git a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc index 65e665f4a827..0eb7bcc39c7f 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc @@ -65,16 +65,17 @@ Key points about `parentPath`: * Use `parentPath="/"` to treat root-level documents as the parents. * A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`). * `parentPath` and `of` are mutually exclusive; specifying both returns a `400 Bad Request` error. +* Optionally, use `childPath` to narrow the returned children to docs at exactly `parentPath/childPath`. Without `childPath`, all descendants of parents at `parentPath` are returned. For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns all children of root-level product documents that match a description query: [source,text] q={!child parentPath="/"}description_t:staplers -To return only children of `skus` documents with a price under 50: +To return only `skus` children of root documents matching a description query (excluding other child types): [source,text] -q={!child parentPath="/skus"}price_i:[* TO 50] +q={!child parentPath="/" childPath="skus"}description_t:staplers === Using the `of` Parameter From 8eb16f7319c030424fda1417408f94c1f9dbd67d Mon Sep 17 00:00:00 2001 From: David Smiley Date: Thu, 12 Mar 2026 10:20:34 -0400 Subject: [PATCH 11/12] dedup/streamline --- .../search/join/BlockJoinChildQParser.java | 40 +++------ .../search/join/BlockJoinParentQParser.java | 82 ++++++++++++------- 2 files changed, 64 insertions(+), 58 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java index 3611ecd2f6bc..08f65a6517fd 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java @@ -21,7 +21,6 @@ import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.Query; import org.apache.lucene.search.join.ToChildBlockJoinQuery; -import org.apache.solr.common.SolrException; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.search.SyntaxError; @@ -35,8 +34,8 @@ public BlockJoinChildQParser( } @Override - protected Query createQuery(Query parentListQuery, Query query, String scoreMode) { - return new ToChildBlockJoinQuery(query, getBitSetProducer(parentListQuery)); + protected Query createQuery(Query parentListQuery, Query fromQuery, String scoreMode) { + return new ToChildBlockJoinQuery(fromQuery, getBitSetProducer(parentListQuery)); } @Override @@ -56,7 +55,7 @@ protected Query noClausesQuery() throws SyntaxError { } /** - * Parses the query using the {@code parentPath} localparam for the child parser. + * Parses the query using the {@code parentPath} local-param for the child parser. * *

For the {@code child} parser with {@code parentPath="/a/b/c"}: * @@ -74,42 +73,25 @@ protected Query noClausesQuery() throws SyntaxError { * ff=(*:* -_nest_path_:*) * vv=(+p_title:dad -_nest_path_:*) * - *

The optional {@code childPath} localparam narrows the returned children to docs at exactly + *

The optional {@code childPath} local-param narrows the returned children to docs at exactly * {@code parentPath/childPath}. * * @param parentPath the normalized parent path (starts with "/", no trailing slash except for * root "/") + * @param childPath optional path constraining the children relative to parentPath */ @Override - protected Query parseUsingParentPath(String parentPath) throws SyntaxError { - String childPath = localParams.get(CHILD_PATH_PARAM); - if (childPath != null) { - if (childPath.startsWith("/")) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not start with '/'"); - } - if (childPath.isEmpty()) { - childPath = null; // treat empty as not specified - } - } + protected Query parseUsingParentPath(String parentPath, String childPath) throws SyntaxError { + // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); final BooleanQuery parsedParentQuery = parseImpl(); - if (parsedParentQuery.clauses().isEmpty()) { - // no parent query: return all children of parents at this level - final BooleanQuery notParents = - new BooleanQuery.Builder() - .add(new MatchAllDocsQuery(), Occur.MUST) - .add(allParentsFilter, Occur.MUST_NOT) - .build(); - Query result = new BitSetProducerQuery(getBitSetProducer(notParents)); - if (childPath != null) { - result = buildChildQueryWithPathConstraint(parentPath, childPath, result); - } - return result; + if (parsedParentQuery.clauses().isEmpty()) { // i.e. match all parents + // no block-join needed; just filter to certain children + return wrapWithChildPathConstraint(parentPath, childPath, new MatchAllDocsQuery()); } // constrain the parent query to only match docs at exactly parentPath @@ -119,7 +101,7 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { Query joinQuery = createQuery(allParentsFilter, constrainedParentQuery, null); if (childPath != null) { - return buildChildQueryWithPathConstraint(parentPath, childPath, joinQuery); + return wrapWithChildPathConstraint(parentPath, childPath, joinQuery); } return joinQuery; } diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index 7f764a12d086..8af2157210f7 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -23,6 +23,7 @@ import org.apache.lucene.index.Term; import org.apache.lucene.search.BooleanClause.Occur; import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.ConstantScoreQuery; import org.apache.lucene.search.ConstantScoreScorer; import org.apache.lucene.search.ConstantScoreWeight; import org.apache.lucene.search.DocIdSetIterator; @@ -111,8 +112,22 @@ public Query parse() throws SyntaxError { if (parentPath.length() > 1 && parentPath.endsWith("/")) { parentPath = parentPath.substring(0, parentPath.length() - 1); } - return parseUsingParentPath(parentPath); + + String childPath = localParams.get(CHILD_PATH_PARAM); + if (childPath != null) { + if (childPath.startsWith("/")) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not start with '/'"); + } + if (childPath.isEmpty()) { + childPath = null; // treat empty as not specified + } + } + return parseUsingParentPath(parentPath, childPath); } + + // NO parentPath; use classic/advanced/DIY code path: + if (localParams.get(CHILD_PATH_PARAM) != null) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " requires " + PARENT_PATH_PARAM); @@ -142,23 +157,13 @@ public Query parse() throws SyntaxError { * * @param parentPath the normalized parent path (starts with "/", no trailing slash except for * root "/") + * @param childPath optional path constraining the children relative to parentPath */ - protected Query parseUsingParentPath(String parentPath) throws SyntaxError { + protected Query parseUsingParentPath(String parentPath, String childPath) throws SyntaxError { // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) // For root: (*:* -_nest_path_:*) final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); - String childPath = localParams.get(CHILD_PATH_PARAM); - if (childPath != null) { - if (childPath.startsWith("/")) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not start with '/'"); - } - if (childPath.isEmpty()) { - childPath = null; // treat empty as not specified - } - } - final BooleanQuery parsedChildQuery = parseImpl(); if (parsedChildQuery.clauses().isEmpty()) { @@ -171,7 +176,7 @@ protected Query parseUsingParentPath(String parentPath) throws SyntaxError { // If childPath specified: (+ +{!term f="_nest_path_" // v="/"}) final Query constrainedChildQuery = - buildChildQueryWithPathConstraint(parentPath, childPath, parsedChildQuery); + wrapWithChildPathConstraint(parentPath, childPath, parsedChildQuery); final String scoreMode = localParams.get("score", ScoreMode.None.name()); final Query parentJoinQuery = createQuery(allParentsFilter, constrainedChildQuery, scoreMode); @@ -210,30 +215,31 @@ protected static Query buildAllParentsFilterFromPath(String parentPath) { } /** - * Wraps the given query with a constraint ensuring only docs at exactly the {@code parentPath} - * level are matched. For root, this excludes docs that have a {@code _nest_path_} value. For - * non-root, this requires an exact match on {@code _nest_path_}. + * Wraps the given query with a constraint ensuring only docs at exactly {@code parentPath} are + * matched. */ protected static Query wrapWithParentPathConstraint(String parentPath, Query query) { final BooleanQuery.Builder builder = new BooleanQuery.Builder().add(query, Occur.MUST); if (parentPath.equals("/")) { builder.add(new FieldExistsQuery(IndexSchema.NEST_PATH_FIELD_NAME), Occur.MUST_NOT); } else { - builder.add( - new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)), Occur.FILTER); + final Query constraint = + new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath)); + if (query instanceof MatchAllDocsQuery) { + return new ConstantScoreQuery(constraint); + } + builder.add(constraint, Occur.FILTER); } return builder.build(); } /** - * Constrains the child query to only match docs strictly below the given {@code parentPath}. For - * the parent parser, the child query must match docs with a {@code _nest_path_} that is a - * sub-path of the parent path (i.e. starts with {@code parentPath/}). For root, any doc with a - * {@code _nest_path_} is a "child". If {@code childPath} is non-null, the constraint is an exact - * term match on {@code parentPath/childPath} instead of a prefix query. + * Wraps the sub-query with a constraint ensuring only docs that are descendants of {@code + * parentPath} are matched. If {@code childPath} is non-null, further narrows to docs at exactly + * {@code parentPath/childPath}. */ - protected static Query buildChildQueryWithPathConstraint( - String parentPath, String childPath, Query childQuery) { + protected static Query wrapWithChildPathConstraint( + String parentPath, String childPath, Query subQuery) { final Query nestPathConstraint; if (childPath != null) { String effectiveChildPath = @@ -246,8 +252,11 @@ protected static Query buildChildQueryWithPathConstraint( nestPathConstraint = new PrefixQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath + "/")); } + if (subQuery instanceof MatchAllDocsQuery) { + return new ConstantScoreQuery(nestPathConstraint); + } return new BooleanQuery.Builder() - .add(childQuery, Occur.MUST) + .add(subQuery, Occur.MUST) .add(nestPathConstraint, Occur.FILTER) .build(); } @@ -268,19 +277,33 @@ protected Query wrapSubordinateClause(Query subordinate) throws SyntaxError { @Override protected Query noClausesQuery() throws SyntaxError { + assert false : "dead code"; return new BitSetProducerQuery(getBitSetProducer(parseParentFilter())); } - protected Query createQuery(final Query parentList, Query query, String scoreMode) + /** + * Create the block-join query, the core Query of the QParser. + * + * @param parentList the "parent" query. The result will internally be cached. + * @param fromQuery source/from query. For {!parent}, this is a child, otherwise it's a parent + * @param scoreMode see {@link ScoreMode} + * @return non-null + * @throws SyntaxError Only if scoreMode doesn't parse + */ + protected Query createQuery(final Query parentList, Query fromQuery, String scoreMode) throws SyntaxError { return new AllParentsAware( - query, getBitSetProducer(parentList), ScoreModeParser.parse(scoreMode), parentList); + fromQuery, getBitSetProducer(parentList), ScoreModeParser.parse(scoreMode), parentList); } BitSetProducer getBitSetProducer(Query query) { return getCachedBitSetProducer(req, query); } + /** + * Returns a Lucene {@link BitSetProducer}, typically cached by query. Note that BSP itself + * internally caches a per-segment {@link BitSet}. + */ public static BitSetProducer getCachedBitSetProducer( final SolrQueryRequest request, Query query) { @SuppressWarnings("unchecked") @@ -297,6 +320,7 @@ public static BitSetProducer getCachedBitSetProducer( } } + /** A {@link ToParentBlockJoinQuery} exposing the query underlying the {@link BitSetProducer}. */ static final class AllParentsAware extends ToParentBlockJoinQuery { private final Query parentQuery; From 60882263df3b11a045e0d7dcf2fb2f315e440760 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Thu, 12 Mar 2026 16:47:58 -0400 Subject: [PATCH 12/12] simplify --- .../search/join/BlockJoinChildQParser.java | 16 +++++++++------- .../search/join/BlockJoinParentQParser.java | 18 +++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java index 08f65a6517fd..637bddd9cd9b 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java @@ -83,10 +83,6 @@ protected Query noClausesQuery() throws SyntaxError { @Override protected Query parseUsingParentPath(String parentPath, String childPath) throws SyntaxError { - // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) - // For root: (*:* -_nest_path_:*) - final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); - final BooleanQuery parsedParentQuery = parseImpl(); if (parsedParentQuery.clauses().isEmpty()) { // i.e. match all parents @@ -94,15 +90,21 @@ protected Query parseUsingParentPath(String parentPath, String childPath) throws return wrapWithChildPathConstraint(parentPath, childPath, new MatchAllDocsQuery()); } + // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) + // For root: (*:* -_nest_path_:*) + final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); + // constrain the parent query to only match docs at exactly parentPath // (+ +{!field f="_nest_path_" v=""}) // For root: (+ -_nest_path_:*) Query constrainedParentQuery = wrapWithParentPathConstraint(parentPath, parsedParentQuery); Query joinQuery = createQuery(allParentsFilter, constrainedParentQuery, null); - if (childPath != null) { - return wrapWithChildPathConstraint(parentPath, childPath, joinQuery); + // matches all children of matching parents + if (childPath == null) { + return joinQuery; } - return joinQuery; + // need to constrain to certain children + return wrapWithChildPathConstraint(parentPath, childPath, joinQuery); } } diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java index 8af2157210f7..7df1257b9154 100644 --- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java @@ -58,7 +58,7 @@ public class BlockJoinParentQParser extends FiltersQParser { public static final String CACHE_NAME = "perSegFilter"; /** - * Optional localparam that, when specified, makes this parser natively aware of the {@link + * Optional local-param that, when specified, makes this parser natively aware of the {@link * IndexSchema#NEST_PATH_FIELD_NAME} field to automatically derive the parent filter (the {@code * which} param). The value must be an absolute path starting with {@code /} using {@code /} as * separator, e.g. {@code /} for root-level parents or {@code /skus} for parents nested at that @@ -69,7 +69,7 @@ public class BlockJoinParentQParser extends FiltersQParser { public static final String PARENT_PATH_PARAM = "parentPath"; /** - * Optional localparam, only valid together with {@link #PARENT_PATH_PARAM} on the {@code parent} + * Optional local-param, only valid together with {@link #PARENT_PATH_PARAM} on the {@code parent} * parser. When specified, the subordinate (child) query is constrained to docs at exactly the * path formed by concatenating {@code parentPath + "/" + childPath}, instead of the default * behavior of matching all descendants. For example, {@code parentPath="/skus" @@ -160,17 +160,17 @@ public Query parse() throws SyntaxError { * @param childPath optional path constraining the children relative to parentPath */ protected Query parseUsingParentPath(String parentPath, String childPath) throws SyntaxError { - // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) - // For root: (*:* -_nest_path_:*) - final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); - final BooleanQuery parsedChildQuery = parseImpl(); - if (parsedChildQuery.clauses().isEmpty()) { - // no child query: return all "parent" docs at this level - return wrapWithParentPathConstraint(parentPath, allParentsFilter); + if (parsedChildQuery.clauses().isEmpty()) { // i.e. all children + // no block-join needed; just return all "parent" docs at this level + return wrapWithParentPathConstraint(parentPath, new MatchAllDocsQuery()); } + // allParents filter: (*:* -{!prefix f="_nest_path_" v="/"}) + // For root: (*:* -_nest_path_:*) + final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath); + // constrain child query: (+ +{!prefix f="_nest_path_" v="/"}) // For root: (+ +_nest_path_:*) // If childPath specified: (+ +{!term f="_nest_path_"