feat: support nested queries on has-many relationships - #17838
Open
AlessioGr wants to merge 18 commits into
Open
feat: support nested queries on has-many relationships#17838AlessioGr wants to merge 18 commits into
AlessioGr wants to merge 18 commits into
Conversation
Contributor
📦 esbuild Bundle Analysis for payloadThis analysis was generated by esbuild-bundle-analyzer. 🤖
Largest pathsThese visualization shows top 20 largest paths in the bundle.Meta file: packages/next/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js
Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js
DetailsNext to the size is how much the size has increased or decreased compared with the base branch of this PR.
|
AlessioGr
commented
Aug 18, 2026
| adapter.tableNameMap.get(`${tableName}_blocks_${toSnakeCase(block.slug)}`), | ||
| ) | ||
|
|
||
| constraintPath = `${constraintPath}${field.name}.%.` |
Member
Author
There was a problem hiding this comment.
Extracted into appendFieldToStoragePath so that new handling in parseParams can re-use
AlessioGr
commented
Aug 18, 2026
| const fieldPath = incomingSegments[0] | ||
| let locale = incomingLocale | ||
| const rootTableName = incomingRootTableName || tableName | ||
| const tableContainingField = parentAliasTable ?? aliasTable ?? adapter.tables[rootTableName] |
AlessioGr
commented
Aug 18, 2026
| // Remove top collection and reverse array | ||
| // to work backwards from top | ||
| const pathsToQuery = paths.slice(1).reverse() | ||
| const parentPaths = paths.slice(0, -1).reverse() |
Member
Author
There was a problem hiding this comment.
The previous const field = paths[0].field was incorrect.
it worked for a one-hop join, e.g. 'album.name'.
It failed for multi-hop join queries, for example artist.album.name
AlessioGr
commented
Aug 18, 2026
| * Follow this join back to its parent IDs. | ||
| * For example, matching songs yield album IDs, then artist IDs. | ||
| */ | ||
| if (parentField.type === 'join') { |
AlessioGr
marked this pull request as ready for review
August 18, 2026 22:43
AlessioGr
enabled auto-merge (squash)
August 18, 2026 22:57
…s multiple relations
r1tsuu
reviewed
Aug 20, 2026
r1tsuu
left a comment
Member
There was a problem hiding this comment.
I don't see any real blockers with this PR, just a few things to note
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR lets
contains,not_equals, andequalsaccept a nestedwherequery on non-polymorphic, has-many relationship and upload fields.With a nested query,
containsmeans at least one related document must match,not_equalsmeans no related documents may match, andequalsmeans every related document must match.This works with the Local API, REST API, GraphQL API, and MCP tools. Both Drizzle and MongoDB are supported.
The problem
Imagine a
burgerscollection with a has-many relationship to aningredientscollection:Every query below runs against
burgers, andisHealthyis a field on the relatedingredientsdocuments rather than on the burger itself.We want to find burgers that do not contain an unhealthy ingredient.
Before this PR, we could query a field on each related ingredient using dot notation:
This does not mean that every ingredient is healthy. It means that the burger has at least one ingredient that is not unhealthy:
A burger with both lettuce and an unhealthy sauce still matches because the lettuce satisfies the condition. There was no way to say that no ingredient may be unhealthy.
New API
Nested
not_equalsrequires no related ingredient to match:This means:
A burger with any unhealthy ingredient is excluded. A burger with only healthy ingredients is included. A burger with no ingredients is also included.
Nested
containsrequires at least one matching ingredient:Nested
equalsrequires every ingredient to match:An empty relationship does not match nested
contains. It does match nestednot_equalsand nestedequals, just like JavaScript'ssomeandeverymethods.The nested value is a normal Payload
wherequery. It supports field operators,and,or, IDs, and deeper relationship queries. Conditions inside one nested query must match the same related document.Backward compatibility
The new behavior is used only when the operator receives a plain query object on a supported has-many relationship or upload field.
Existing queries that pass an ID or another normal value keep their current behavior. For example,
ingredients: { contains: ingredientID }still queries the stored relationship IDs.Existing object queries on polymorphic relationships also continue to work. Singular and polymorphic relationships do not use the new nested-query behavior.
Dot-notation queries are unchanged. This query still means that at least one related ingredient is healthy:
Its explicit equivalent is:
Implementation
Payload first checks that the field is a non-polymorphic, has-many relationship or upload field and that the value is a plain query object. It then validates the nested query against the related collection. This prevents internal query values and class-based IDs from being mistaken for nested queries.
Drizzle uses a subquery to inspect the relationship rows for the current parent document. This allows it to check whether a matching row exists, whether no matching row exists, or whether no failing row exists.
MongoDB first finds the IDs of related documents that match the nested query. It then checks the parent's relationship IDs with the appropriate MongoDB condition.
Both adapters reuse their normal query builders for the nested query. This keeps existing operators,
and,or, localization, virtual fields, and deeper relationship queries working in the same way.GraphQL exposes
containson supported has-many fields.equalsandnot_equalswere already available. REST and MCP use their existing query formats.Related relationship traversal fix
This PR also fixes #16109.
The regression test follows two joins. Matching songs produces album IDs, and matching albums then produces artist IDs.
Drizzle previously used the wrong table name while walking back through these joins. This failed with custom PostgreSQL schemas and could also produce an invalid SQLite query. MongoDB had a similar problem where it looked for the root relationship field on the wrong collection.
Both adapters now follow each relationship step back to the parent collection correctly.
API-only support
This PR intentionally does not add these nested queries to the Admin UI Where Builder.
The UI would need a recursive query builder that can show fields from the related collection. Existing dot-notation relationship queries are also API-only, so UI support can be added separately.