Skip to content

feat: support nested queries on has-many relationships - #17838

Open
AlessioGr wants to merge 18 commits into
mainfrom
feat/hasmany-none-query
Open

feat: support nested queries on has-many relationships#17838
AlessioGr wants to merge 18 commits into
mainfrom
feat/hasmany-none-query

Conversation

@AlessioGr

@AlessioGr AlessioGr commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

This PR lets contains, not_equals, and equals accept a nested where query on non-polymorphic, has-many relationship and upload fields.

With a nested query, contains means at least one related document must match, not_equals means no related documents may match, and equals means 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 burgers collection with a has-many relationship to an ingredients collection:

export const Burgers: CollectionConfig = {
  slug: 'burgers',
  fields: [
    {
      name: 'ingredients',
      type: 'relationship',
      relationTo: 'ingredients',
      hasMany: true,
    },
  ],
}

export const Ingredients: CollectionConfig = {
  slug: 'ingredients',
  fields: [
    {
      name: 'name',
      type: 'text',
    },
    {
      name: 'isHealthy',
      type: 'checkbox',
    },
  ],
}

Every query below runs against burgers, and isHealthy is a field on the related ingredients documents 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:

const burgers = await payload.find({
  collection: 'burgers',
  where: {
    'ingredients.isHealthy': {
      not_equals: false,
    },
  },
})

This does not mean that every ingredient is healthy. It means that the burger has at least one ingredient that is not unhealthy:

ingredients.some((ingredient) => ingredient.isHealthy !== false)

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_equals requires no related ingredient to match:

const burgers = await payload.find({
  collection: 'burgers',
  where: {
    ingredients: {
      not_equals: {
        isHealthy: {
          equals: false,
        },
      },
    },
  },
})

This means:

!ingredients.some((ingredient) => ingredient.isHealthy === false)

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 contains requires at least one matching ingredient:

where: {
  ingredients: {
    contains: {
      isHealthy: { equals: true },
    },
  },
}

Nested equals requires every ingredient to match:

where: {
  ingredients: {
    equals: {
      isHealthy: { equals: true },
    },
  },
}

An empty relationship does not match nested contains. It does match nested not_equals and nested equals, just like JavaScript's some and every methods.

The nested value is a normal Payload where query. 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:

where: {
  'ingredients.isHealthy': {
    equals: true,
  },
}

Its explicit equivalent is:

where: {
  ingredients: {
    contains: {
      isHealthy: { equals: true },
    },
  },
}

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 contains on supported has-many fields. equals and not_equals were 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.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 213.92 KB ✅ No change
packages/payload/meta_index.json esbuild/index.js 1.40 MB ⚠️ +870 B (+0.1%)
packages/payload/meta_shared.json esbuild/exports/shared.js 213.69 KB ⚠️ +300 B (+0.1%)
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 286.50 KB ✅ No change
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 36.54 KB ✅ No change
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.95 KB ✅ No change
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▊ }}}$ 99.0%, 209.89 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.3%, 718 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.3%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.2%, 526 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 409 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████▉ }}}$ 67.6%, 944.32 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 44.38 KB
dist/collections/operations ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 42.96 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.99 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.57 KB
dist/queues/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 14.29 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.63 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.36 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.69 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.93 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.84 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.07 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.87 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.78 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.64 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.56 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.07 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.12 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 5.61 KB
dist/uploads/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 5.58 KB
(other) ${{\color{Goldenrod}{ ████████ }}}$ 32.4%, 452.30 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▉ }}}$ 71.8%, 150.13 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▎ }}}$ 5.1%, 10.69 KB
dist/fields/config ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 5.83 KB
dist/utilities/traverseFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 4.45 KB
dist/collections/config ${{\color{Goldenrod}{ ▍ }}}$ 1.6%, 3.33 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.69 KB
dist/config/client.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.69 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.41 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 1.04 KB
dist/globals/config ${{\color{Goldenrod}{ }}}$ 0.4%, 939 B
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ }}}$ 0.4%, 793 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.3%, 713 B
dist/auth/extractJWT.js ${{\color{Goldenrod}{ }}}$ 0.3%, 696 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/utilities/getSafeRedirect.js ${{\color{Goldenrod}{ }}}$ 0.3%, 632 B
dist/errors/ValidationError.js ${{\color{Goldenrod}{ }}}$ 0.3%, 577 B
(other) ${{\color{Goldenrod}{ ███████ }}}$ 28.2%, 58.98 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▎ }}}$ 13.1%, 37.20 KB
dist/lexical/ui ${{\color{Goldenrod}{ ███ }}}$ 12.1%, 34.20 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▉ }}}$ 11.7%, 33.01 KB
dist/features/table ${{\color{Goldenrod}{ ██▍ }}}$ 9.6%, 27.22 KB
dist/features/link ${{\color{Goldenrod}{ █▋ }}}$ 6.6%, 18.82 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▌ }}}$ 6.2%, 17.45 KB
dist/features/upload ${{\color{Goldenrod}{ █▎ }}}$ 5.0%, 14.28 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.9%, 11.08 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▉ }}}$ 3.5%, 10.02 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.4%, 9.61 KB
dist/features/converters ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 8.36 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.12 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.6%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.14 KB
dist/features/lists ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 3.64 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 3.28 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 2.88 KB
dist/lexical/nodes ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.66 KB
(other) ${{\color{Goldenrod}{ █████████████████████▋ }}}$ 86.9%, 246.09 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/exports/client ${{\color{Goldenrod}{ █████████████████████████ }}}$ 100.0%, 26.90 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▋ }}}$ 30.5%, 5.57 KB
../../node_modules ${{\color{Goldenrod}{ ███▌ }}}$ 14.5%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██ }}}$ 8.3%, 1.51 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.2%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 745 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.3%, 419 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/layouts/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 230 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 158 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
(other) ${{\color{Goldenrod}{ █████████████████▍ }}}$ 69.5%, 12.68 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

adapter.tableNameMap.get(`${tableName}_blocks_${toSnakeCase(block.slug)}`),
)

constraintPath = `${constraintPath}${field.name}.%.`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted into appendFieldToStoragePath so that new handling in parseParams can re-use

const fieldPath = incomingSegments[0]
let locale = incomingLocale
const rootTableName = incomingRootTableName || tableName
const tableContainingField = parentAliasTable ?? aliasTable ?? adapter.tables[rootTableName]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes #16109

// Remove top collection and reverse array
// to work backwards from top
const pathsToQuery = paths.slice(1).reverse()
const parentPaths = paths.slice(0, -1).reverse()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

* Follow this join back to its parent IDs.
* For example, matching songs yield album IDs, then artist IDs.
*/
if (parentField.type === 'join') {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mongodb fix for #16109

@AlessioGr
AlessioGr marked this pull request as ready for review August 18, 2026 22:43
@AlessioGr
AlessioGr enabled auto-merge (squash) August 18, 2026 22:57
@AlessioGr AlessioGr changed the title feat: add some, none, and every operators to has-many relationship queries feat: support nested queries on has-many relationships Aug 19, 2026

@r1tsuu r1tsuu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any real blockers with this PR, just a few things to note

Comment thread packages/db-mongodb/src/queries/buildSearchParams.ts
Comment thread docs/queries/overview.mdx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Query error when transitive join field has hasMany: true

2 participants