Skip to content

feat: prefetch aliased fields with filters/ordering#882

Open
rcybulski1122012 wants to merge 9 commits intostrawberry-graphql:mainfrom
rcybulski1122012:optimize-aliased-fields-with-fields-and-ordering
Open

feat: prefetch aliased fields with filters/ordering#882
rcybulski1122012 wants to merge 9 commits intostrawberry-graphql:mainfrom
rcybulski1122012:optimize-aliased-fields-with-fields-and-ordering

Conversation

@rcybulski1122012
Copy link
Member

@rcybulski1122012 rcybulski1122012 commented Mar 4, 2026

Adjust the optimizer so it works if someone uses aliases to retrieve a field (with default resolver) few times with different argument

Description

Types of Changes

  • Core
  • Bugfix
  • New feature
  • Enhancement/optimization
  • Documentation

Issues Fixed or Closed by This PR

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

Summary by Sourcery

Support optimized prefetching for aliased Django relation fields with differing filters and ordering in GraphQL queries.

New Features:

  • Enable optimizer to prefetch aliased relation fields that use different filters or ordering by mapping each alias to a dedicated attribute.

Enhancements:

  • Adjust optimizer hint generation to avoid conflicting prefetches on connection/paginated fields when alias-based prefetching is requested.

Tests:

  • Add integration tests covering aliased milestones with different/same filters, ordering, and combined filters plus ordering to verify optimized query counts and results.

Summary by Sourcery

Optimize Django relation prefetching for aliased GraphQL fields that reuse the same underlying field with different arguments.

New Features:

  • Support optimized prefetching for aliased relation fields that use distinct filters or ordering by assigning each alias a dedicated to_attr-backed prefetch.

Enhancements:

  • Skip optimizer-based prefetching for aliased connection or paginated fields when to_attr would conflict with their expected prefetch cache usage.
  • Update field resolution to read from alias-specific prefetched attributes when no custom resolver is defined.
  • Add release metadata documenting the optimizer behavior change for aliased fields with different arguments.

Tests:

  • Extend optimizer integration tests to cover aliased milestones with different/same filters, ordering, and combined filters plus ordering, asserting both query counts and results.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Mar 4, 2026

Reviewer's Guide

Extends the Django optimizer to support prefetching aliased relation fields that use different filters and/or ordering by assigning each alias a dedicated to_attr, while ensuring connection/paginated fields are not incorrectly optimized, and updates field resolution plus tests accordingly.

File-Level Changes

Change Details Files
Support alias-specific prefetching for Django relation fields with differing arguments via optimizer to_attr handling.
  • Introduce ALIAS_PREFIX constant used to build alias-specific attribute names for prefetched data.
  • Extend _get_hints_from_django_relation and _get_hints_from_django_field to accept an optional to_attr parameter and wire it through when building Prefetch objects.
  • Update _get_model_hints to group aliased field selections by name, compare their arguments, and when arguments differ assign a unique to_attr per alias instead of merging them.
  • Skip optimization for connection/paginated fields when a to_attr is requested to avoid conflicts with expected _prefetched_objects_cache behavior.
strawberry_django/optimizer.py
Resolve aliased fields from alias-specific prefetched attributes when available.
  • Update field.get_result to compute the current response key from GraphQL info.path and attempt to read a corresponding alias-specific attribute (using ALIAS_PREFIX) from the source model instance before falling back to the standard resolution path.
strawberry_django/fields/field.py
Add coverage for aliased milestones queries with different/same filters and ordering under pagination.
  • Add tests that query projectsPaginated.results with multiple aliases of the milestones field using different filters, a mix of alias and non-aliased fields with differing filters, same filters, different ordering, and combined filters plus ordering.
  • Assert both the returned data structure and the expected query counts when the DjangoOptimizerExtension is enabled vs disabled.
tests/test_optimizer.py
Document the optimizer behavior change as a minor release entry.
  • Add RELEASE.md entry specifying a minor release adjusting optimizer handling for aliased selections of the same field with different arguments and no custom resolver.
RELEASE.md

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@botberry
Copy link
Member

botberry commented Mar 4, 2026

Thanks for adding the RELEASE.md file!

Below is the changelog that will be used for the release.


Adjust the optimizer to handle if the same field with different arguments using aliases is selected and the field doesn't have a custom resolver.

This release was contributed by @rcybulski1122012 in #882

@codecov
Copy link

codecov bot commented Mar 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.62%. Comparing base (bbe5aec) to head (f82de03).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #882      +/-   ##
==========================================
+ Coverage   91.59%   91.62%   +0.02%     
==========================================
  Files          51       51              
  Lines        4618     4631      +13     
==========================================
+ Hits         4230     4243      +13     
  Misses        388      388              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rcybulski1122012 rcybulski1122012 marked this pull request as ready for review March 8, 2026 16:58
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In Field.get_result, the lookup for aliased prefetch uses info._raw_info.path.key and a hard-coded "_strawberry_alias_" prefix; consider centralizing this convention (e.g., a small helper shared with the optimizer) and avoiding direct reliance on _raw_info internals if possible, so changes in the underlying GraphQL library or path structure don’t break the mapping.
  • In _get_hints_from_django_relation, when to_attr is requested for connection/paginated fields you skip optimization entirely; it may be worth narrowing this condition or falling back to a non-to_attr prefetch where safe so that alias-based prefetching doesn’t silently disable otherwise valid optimizations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `Field.get_result`, the lookup for aliased prefetch uses `info._raw_info.path.key` and a hard-coded `"_strawberry_alias_"` prefix; consider centralizing this convention (e.g., a small helper shared with the optimizer) and avoiding direct reliance on `_raw_info` internals if possible, so changes in the underlying GraphQL library or path structure don’t break the mapping.
- In `_get_hints_from_django_relation`, when `to_attr` is requested for connection/paginated fields you skip optimization entirely; it may be worth narrowing this condition or falling back to a non-`to_attr` prefetch where safe so that alias-based prefetching doesn’t silently disable otherwise valid optimizations.

## Individual Comments

### Comment 1
<location path="tests/test_optimizer.py" line_range="1233" />
<code_context>
     }


[email protected]_db(transaction=True)
+def test_query_prefetch_aliases_with_different_filters(
+    db, gql_client: GraphQLTestClient
</code_context>
<issue_to_address>
**issue (testing):** Add tests covering aliased fields on connection/paginated relations where optimization is intentionally skipped

The optimizer now intentionally skips `to_attr` when fields are connections/paginated, but the current tests only cover `milestones` on `projectsPaginated.results`. They don’t cover a connection/paginated field that is itself aliased with different filters/ordering.

Please add an integration test that:
- Uses a connection/paginated relation as the aliased field, with different filters/ordering on the same Django relation.
- Verifies each alias returns the correct results.
- Asserts query counts to confirm we are *not* merging these into a single prefetch (i.e., it’s acceptable if query counts are higher with the optimizer enabled).

This will directly exercise the new `is_connection`/`is_paginated` conditional and protect against regressions when alias-based prefetching is used on connection/paginated fields.
</issue_to_address>

### Comment 2
<location path="tests/test_optimizer.py" line_range="1345-1347" />
<code_context>
+
+
[email protected]_db(transaction=True)
+def test_query_prefetch_aliases_with_same_filters(db, gql_client: GraphQLTestClient):
+    # even though these have the same filters, query is not optimized
+    query = """
+      query TestQuery {
</code_context>
<issue_to_address>
**suggestion (testing):** Clarify and/or assert the intended optimization behavior for aliases with identical filters

The test comment states that queries are not optimized for identical filters, but the test only asserts result shape and reuses the standard `assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5)` pattern. This doesn’t actually demonstrate whether optimization is disabled here or if some merging still occurs.

To better capture the intent, please either:
- Change the expected query count in the `enabled` case to match the “no optimization/merging” requirement, or
- Reword the comment to describe the expected optimization behavior and, ideally, add a companion test that contrasts query counts with an equivalent non-aliased query.

As written, the test may give a misleading impression of what is or isn’t optimized.

```suggestion
@pytest.mark.django_db(transaction=True)
def test_query_prefetch_aliases_with_same_filters(db, gql_client: GraphQLTestClient):
    # Aliased milestones with identical filters are currently treated as separate fields.
    # This test guards the existing query count so that any future merging/optimization is explicit.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@rcybulski1122012 rcybulski1122012 marked this pull request as draft March 8, 2026 17:05
@rcybulski1122012 rcybulski1122012 marked this pull request as ready for review March 8, 2026 17:18
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 5 issues, and left some high level feedback:

  • The alias-based to_attr naming (ALIAS_PREFIX + alias) is global per model instance, so reusing the same alias for different relations on the same model will result in attribute collisions; consider including the field name or path in the generated to_attr to keep them unique per field.
  • In test_query_prefetch_aliases_with_same_filters the inline comment says the query is not optimized, but the assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5) expectation implies optimization still applies; it would be clearer to align the comment or assertion with the intended behavior so readers understand whether this case should be optimized or not.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The alias-based `to_attr` naming (`ALIAS_PREFIX + alias`) is global per model instance, so reusing the same alias for different relations on the same model will result in attribute collisions; consider including the field name or path in the generated `to_attr` to keep them unique per field.
- In `test_query_prefetch_aliases_with_same_filters` the inline comment says the query is not optimized, but the `assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5)` expectation implies optimization still applies; it would be clearer to align the comment or assertion with the intended behavior so readers understand whether this case should be optimized or not.

## Individual Comments

### Comment 1
<location path="strawberry_django/optimizer.py" line_range="1061-1072" />
<code_context>
+    # expect data in _prefetched_objects_cache, not as a plain list attribute.
+    # If to_attr was requested but can't be used, skip optimization entirely
+    # to avoid merging conflicting prefetches for the same path.
+    if to_attr and (
+        getattr(field, "is_connection", False) or getattr(field, "is_paginated", False)
+    ):
+        return store
+    field_prefetch = Prefetch(path, queryset=field_qs, to_attr=to_attr)
     field_prefetch._optimizer_sentinel = _sentinel  # type: ignore
</code_context>
<issue_to_address>
**suggestion (performance):** Skipping the entire optimization when `to_attr` is incompatible drops non-prefetch hints as well.

For connection/paginated fields, this early return prevents adding the prefetch but also discards other collected optimizations in `field_store` (`only`, `select_related`, nested prefetches that don’t rely on `to_attr`). If the goal is just to avoid conflicting `to_attr` prefetches, consider still building `field_qs` and merging its non-`to_attr` hints into `store`, while omitting only the `Prefetch(..., to_attr=...)` for this field.

```suggestion
    field_qs = field_store.apply(base_qs, info=field_info, config=config)
    # Don't use to_attr for connection/paginated fields - their resolvers
    # expect data in _prefetched_objects_cache, not as a plain list attribute.
    # If to_attr was requested but can't be used, still apply other
    # optimizations but omit the to_attr argument from the Prefetch so we
    # don't create conflicting prefetches for the same path.
    use_to_attr = to_attr and not (
        getattr(field, "is_connection", False) or getattr(field, "is_paginated", False)
    )
    if use_to_attr:
        field_prefetch = Prefetch(path, queryset=field_qs, to_attr=to_attr)
    else:
        field_prefetch = Prefetch(path, queryset=field_qs)
    field_prefetch._optimizer_sentinel = _sentinel  # type: ignore
    store.prefetch_related.append(field_prefetch)
```
</issue_to_address>

### Comment 2
<location path="strawberry_django/fields/field.py" line_range="227-231" />
<code_context>
+
+            # Check for to_attr-based prefetch from optimizer (aliased field with filters)
+            if info is not None:
+                response_key = info._raw_info.path.key
+                alias_attr = f"{ALIAS_PREFIX}{response_key}"
+                prefetched = getattr(source, alias_attr, None)
+                if prefetched is not None:
+                    return prefetched
+
             attr = getattr(source.__class__, attname, None)
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `path.key` for alias lookup may not distinguish between identical aliases at different nesting levels.

Because `alias_attr` uses only `path.key`, different fields at different depths that share the same response key (aliased or not) will all use the same `_strawberry_alias_<key>` attribute. Since the prefetch is stored on the Django model instance, these nested selections can overwrite or read each other’s prefetched values. To avoid collisions, consider basing `to_attr` / `alias_attr` on a stable representation of the full GraphQL path rather than just the leaf key.
</issue_to_address>

### Comment 3
<location path="tests/test_optimizer.py" line_range="1289-1317" />
<code_context>
+
+
[email protected]_db(transaction=True)
+def test_query_prefetch_alias_and_field_with_different_filters(
+    db, gql_client: GraphQLTestClient
+):
+    query = """
+      query TestQuery {
+        projectsPaginated{
+          results {
+            id
+            milestones(filters: { name: {contains: "a"}}) {
+              id
+            }
+            b: milestones(filters: { name: {contains: "b"}}) {
+              id
+            }
+          }
+        }
+      }
+    """
+
+    project_1 = ProjectFactory.create()
+    milestone_1a = MilestoneFactory.create(project=project_1, name="a")
+    milestone_1b = MilestoneFactory.create(project=project_1, name="b")
+    project_2 = ProjectFactory.create()
+    milestone_2a = MilestoneFactory.create(project=project_2, name="a")
+    milestone_2b = MilestoneFactory.create(project=project_2, name="b")
+
+    with assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5):
+        res = gql_client.query(query)
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test case where one alias has filters and another has no filters to cover mixed filtered/unfiltered aliases

We already test alias vs alias with different filters and alias vs field with different filters. What’s missing is a case where one alias uses arguments (filters/order) and another alias or the base field uses none (the default relation). Please add a test like `milestones` (no filters) plus `a: milestones(filters: ...)` to verify `to_attr` handling and prefetch merging when filtered and unfiltered variants of the same field are requested together.

```suggestion
@pytest.mark.django_db(transaction=True)
def test_query_prefetch_alias_and_field_with_different_filters(
    db, gql_client: GraphQLTestClient
):
    query = """
      query TestQuery {
        projectsPaginated{
          results {
            id
            milestones(filters: { name: {contains: "a"}}) {
              id
            }
            b: milestones(filters: { name: {contains: "b"}}) {
              id
            }
          }
        }
      }
    """

    project_1 = ProjectFactory.create()
    milestone_1a = MilestoneFactory.create(project=project_1, name="a")
    milestone_1b = MilestoneFactory.create(project=project_1, name="b")
    project_2 = ProjectFactory.create()
    milestone_2a = MilestoneFactory.create(project=project_2, name="a")
    milestone_2b = MilestoneFactory.create(project=project_2, name="b")

    with assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5):
        res = gql_client.query(query)

    assert res.data["projectsPaginated"]["results"] == [
        {
            "id": to_base64("ProjectType", project_1.pk),
            "milestones": [
                {"id": to_base64("MilestoneType", milestone_1a.pk)},
            ],
            "b": [
                {"id": to_base64("MilestoneType", milestone_1b.pk)},
            ],
        },
        {
            "id": to_base64("ProjectType", project_2.pk),
            "milestones": [
                {"id": to_base64("MilestoneType", milestone_2a.pk)},
            ],
            "b": [
                {"id": to_base64("MilestoneType", milestone_2b.pk)},
            ],
        },
    ]


@pytest.mark.django_db(transaction=True)
def test_query_prefetch_mixed_filtered_and_unfiltered_aliases(
    db, gql_client: GraphQLTestClient
):
    query = """
      query TestQuery {
        projectsPaginated {
          results {
            id
            milestones {
              id
            }
            a: milestones(filters: { name: { contains: "a" } }) {
              id
            }
          }
        }
      }
    """

    project_1 = ProjectFactory.create()
    milestone_1a = MilestoneFactory.create(project=project_1, name="a")
    milestone_1b = MilestoneFactory.create(project=project_1, name="b")
    project_2 = ProjectFactory.create()
    milestone_2a = MilestoneFactory.create(project=project_2, name="a")
    milestone_2b = MilestoneFactory.create(project=project_2, name="b")

    with assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5):
        res = gql_client.query(query)

    assert res.data["projectsPaginated"]["results"] == [
        {
            "id": to_base64("ProjectType", project_1.pk),
            "milestones": [
                {"id": to_base64("MilestoneType", milestone_1a.pk)},
                {"id": to_base64("MilestoneType", milestone_1b.pk)},
            ],
            "a": [
                {"id": to_base64("MilestoneType", milestone_1a.pk)},
            ],
        },
        {
            "id": to_base64("ProjectType", project_2.pk),
            "milestones": [
                {"id": to_base64("MilestoneType", milestone_2a.pk)},
                {"id": to_base64("MilestoneType", milestone_2b.pk)},
            ],
            "a": [
                {"id": to_base64("MilestoneType", milestone_2a.pk)},
            ],
        },
    ]
```
</issue_to_address>

### Comment 4
<location path="tests/test_optimizer.py" line_range="1346-1347" />
<code_context>
+
+
[email protected]_db(transaction=True)
+def test_query_prefetch_aliases_with_same_filters(db, gql_client: GraphQLTestClient):
+    # even though these have the same filters, query is not optimized
+    query = """
+      query TestQuery {
</code_context>
<issue_to_address>
**issue (testing):** The comment states queries are not optimized, but the test only checks counts; consider asserting optimizer behavior more explicitly or rephrasing

The test only checks `assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5)` and the response payload, which doesn’t actually show whether alias merging occurred. Please either make the assertions demonstrate the lack of optimization (e.g., show a higher query count than in optimized cases or explicitly assert optimizer state if possible), or reword the inline comment so it doesn’t claim behavior we don’t verify. Otherwise, future readers may assume the test guarantees something it doesn’t.
</issue_to_address>

### Comment 5
<location path="RELEASE.md" line_range="5" />
<code_context>
+release type: minor
+---
+
+Adjust the optimizer to handle if the same filed with different arguments using aliases is selected and the field doesn't have a custom resolver.
</code_context>
<issue_to_address>
**issue (typo):** Fix the typo "filed" → "field" and consider slightly rephrasing the sentence for clarity.

You could rephrase to something like: "Adjust the optimizer to handle cases where the same field with different arguments using aliases is selected and the field doesn't have a custom resolver."
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants