Skip to content

Relation custom fields cause N+1 queries in list contexts with no way to optimize #4796

Description

@gabriellbui

Describe the bug

When querying a list of entities that have relation-type custom fields, Vendure runs one database query per entity to resolve each relation — the classic N+1 problem. There is no way for plugin authors to optimize this because the auto-generated custom field resolvers silently override any @ResolveField a plugin defines.

To Reproduce

  1. Define a relation custom field on an entity used in list queries:
config.customFields.OrderLine.push({
    name: 'subItems',
    type: 'relation',
    entity: SubItem,
    list: true,
});
  1. Query a list that includes the relation:
query {
  activeOrder {
    lines {
      customFields {
        subItems { id name }
      }
    }
  }
}
  1. Observe database queries — each order line triggers its own query to resolve subItems.

Expected behavior

Relation custom fields should be resolved efficiently in list contexts, either by batching queries internally or by allowing plugin authors to plug in their own DataLoader.

Actual behavior

CustomFieldRelationResolverService.resolveRelation() runs a subquery + main query per entity per relation field. For 50 order lines with one relation custom field, that's 51 queries instead of 2-3.

Two expected escape routes don't work:

  1. eager: true on the custom field config — maps to TypeORM's eager decorator option, but ListQueryBuilder uses QueryBuilder + setFindOptions(). While setFindOptions does call joinEagerRelations(), that method walks metadata.eagerRelations on the root entity. Custom field relations live on the embedded customFields entity metadata, which joinEagerRelations doesn't recurse into. Meanwhile, joinTreeRelationsDynamically explicitly skips non-tree, non-self-referencing relations. So eager: true has no practical effect on list queries.

  2. Plugging in a DataLoader via @ResolveField — The auto-generated resolvers on {Entity}CustomFields silently override plugin resolvers. NestJS GraphQL merges resolvers using a shallow spread (extend(typesResolvers, options.resolvers)), and the programmatic resolver map from generateResolvers() replaces the entire type object from decorator-based resolvers.

Workaround

The only current workaround is to mark the custom field as internal: true, then re-add the field as a schema extension on the parent entity with a @ResolveField + DataLoader. This works but requires maintaining two representations of the same field.

Docs note

The DataLoader docs state:

"CustomField relations are automatically added to the root query for the entity that they are part of."

This is not accurate for list queries. The relation is resolved lazily per entity by the auto-generated resolver.

Proposed solution

Add DataLoader-style batching inside CustomFieldRelationResolverService. The resolveRelation() method would delegate to a request-scoped DataLoader (keyed by request context + entity name + field name) that collects all entity IDs in a single tick and runs one batched query with WHERE entity.id IN (:...ids).

This is a contained change to one file. The auto-generated resolvers in generate-resolvers.ts continue calling resolveRelation() the same way — they just get batched results instead of individual queries. No plugin code changes needed.

Prior art: commit 7b57d28 added a DataLoader for the Collection → ProductVariants N+1.

Environment

  • @vendure/core version: latest (master)
  • Affects all 26+ entity types that support custom fields

Relevant files

  • packages/core/src/api/common/custom-field-relation-resolver.service.ts — per-entity query (lines 36-57)
  • packages/core/src/api/config/generate-resolvers.ts — auto-generated resolvers (lines 239-267)
  • packages/core/src/service/helpers/list-query-builder/list-query-builder.ts — uses QueryBuilder
  • packages/core/src/service/helpers/utils/tree-relations-qb-joiner.ts — skips non-tree relations (lines 100-108)
  • packages/core/src/entity/register-custom-entity-fields.ts — eager config registration (lines 45-56)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions