-_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_"