diff --git a/changelog/unreleased/SOLR-14687-parentPath-param.yml b/changelog/unreleased/SOLR-14687-parentPath-param.yml
new file mode 100644
index 000000000000..24e4eadf9340
--- /dev/null
+++ b/changelog/unreleased/SOLR-14687-parentPath-param.yml
@@ -0,0 +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
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..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
@@ -25,6 +25,7 @@
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.search.SyntaxError;
+/** Matches child documents based on parent doc criteria. */
public class BlockJoinChildQParser extends BlockJoinParentQParser {
public BlockJoinChildQParser(
@@ -33,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
@@ -52,4 +53,58 @@ protected Query noClausesQuery() throws SyntaxError {
.build();
return new BitSetProducerQuery(getBitSetProducer(notParents));
}
+
+ /**
+ * Parses the query using the {@code parentPath} local-param 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_:*)
+ *
+ * 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, String childPath) throws SyntaxError {
+
+ final BooleanQuery parsedParentQuery = parseImpl();
+
+ if (parsedParentQuery.clauses().isEmpty()) { // i.e. match all parents
+ // no block-join needed; just filter to certain children
+ 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);
+ // matches all children of matching parents
+ if (childPath == null) {
+ 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 1d73bbd78aa7..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
@@ -20,13 +20,21 @@
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.ConstantScoreQuery;
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,18 +42,42 @@
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;
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";
+ /**
+ * 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
+ * path. When specified, the {@code which} param must not also be specified.
+ *
+ * @see SOLR-14687
+ */
+ public static final String PARENT_PATH_PARAM = "parentPath";
+
+ /**
+ * 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"
+ * 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";
}
@@ -60,6 +92,175 @@ 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);
+ }
+
+ 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);
+ }
+ 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 "/")
+ * @param childPath optional path constraining the children relative to parentPath
+ */
+ protected Query parseUsingParentPath(String parentPath, String childPath) throws SyntaxError {
+ final BooleanQuery parsedChildQuery = parseImpl();
+
+ 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_"
+ // v="/"})
+ final Query constrainedChildQuery =
+ wrapWithChildPathConstraint(parentPath, childPath, 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(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(String parentPath) {
+ final Query excludeQuery;
+ if (parentPath.equals("/")) {
+ 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 {@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 {
+ 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();
+ }
+
+ /**
+ * 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 wrapWithChildPathConstraint(
+ String parentPath, String childPath, Query subQuery) {
+ final Query nestPathConstraint;
+ 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 =
+ new PrefixQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath + "/"));
+ }
+ if (subQuery instanceof MatchAllDocsQuery) {
+ return new ConstantScoreQuery(nestPathConstraint);
+ }
+ return new BooleanQuery.Builder()
+ .add(subQuery, Occur.MUST)
+ .add(nestPathConstraint, Occur.FILTER)
+ .build();
+ }
+
protected Query parseParentFilter() throws SyntaxError {
String filter = localParams.get(getParentFilterLocalParamName());
QParser parentParser = subQuery(filter, null);
@@ -76,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")
@@ -105,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;
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..b89a7c2974f4 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,97 @@ 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");
+ // 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;
+ }
+ }
+
// 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)
@@ -545,7 +636,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 +731,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) {
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..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
@@ -51,10 +51,40 @@ 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`
-* 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
+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=}
+
+Key points about `parentPath`:
+
+* Must start with `/`.
+* 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 `skus` children of root documents matching a description query (excluding other child types):
+
+[source,text]
+q={!child parentPath="/" childPath="skus"}description_t:staplers
+
+=== Using the `of` Parameter
+
+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.
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 +141,40 @@ More precisely, `q={!child of=}` is equivalent to `q=\*:* -}`.
+=== Using `parentPath`
+
+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={!parent parentPath=}
+
+Key points about `parentPath`:
+
+* Must start with `/`.
+* 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 one-page _manuals_ (only manuals, not other sku children):
+
+[source,text]
+q={!parent parentPath="/skus" childPath="manuals"}pages_i:1
+
+=== Using the `which` Parameter
+
+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
+* 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..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
@@ -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' --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 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 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' --data-urlencode 'q={!child parentPath="/skus"}price_i:[* TO 50]'
{
"response":{"numFound":1,"start":0,"maxScore":1.0,"numFoundExact":true,"docs":[
{
@@ -165,25 +165,6 @@ $ 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/"})'
-----
-====
-
=== 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.
@@ -217,11 +198,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' --data-urlencode 'q={!parent parentPath="/"}pages_i:1'
{
"response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[
{
@@ -237,14 +218,15 @@ $ 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 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.
+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 which="*:* -_nest_path_:\\/skus\\/*"}(+_nest_path_:\/skus\/manuals +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":[
{
@@ -260,11 +242,6 @@ $ 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.
-====
-
=== 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.
@@ -279,7 +256,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":[
{