Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3c6fdfd
feat: prefetch aliased fields with filters/ordering
rcybulski1122012 Mar 3, 2026
5b0b59a
Merge branch 'main' into optimize-aliased-fields-with-fields-and-orde…
rcybulski1122012 Mar 8, 2026
f4c03c6
fix: query formatting in tests
rcybulski1122012 Mar 8, 2026
c7d2908
add another test
rcybulski1122012 Mar 8, 2026
60c2c8a
add RELASE.md
rcybulski1122012 Mar 8, 2026
6f660ca
move prefix to varaible
rcybulski1122012 Mar 8, 2026
98cd77b
add test for mixed filtering
rcybulski1122012 Mar 8, 2026
1b68df0
fix a typo
rcybulski1122012 Mar 8, 2026
f82de03
fix: test typing
rcybulski1122012 Mar 8, 2026
f302415
Merge branch 'strawberry-graphql:main' into optimize-aliased-fields-w…
rcybulski1122012 Jun 21, 2026
864aa7d
fix: RELEASE.md
rcybulski1122012 Jun 21, 2026
6cf9b61
fix: comment
rcybulski1122012 Jun 21, 2026
57bca84
test: add two more cases
rcybulski1122012 Jun 21, 2026
b60c589
optimize _get_field_args
rcybulski1122012 Jun 21, 2026
c20a244
style: use hassatttr instead of getattr
rcybulski1122012 Jun 21, 2026
970f0da
feat: support aliasing custom fields with optimizer hint callables
rcybulski1122012 Jul 3, 2026
2f400d7
feat: alias-scope dict-form annotate labels so resolvers can combine …
rcybulski1122012 Jul 3, 2026
eade6b3
refactor: address review comments
rcybulski1122012 Jul 3, 2026
2369023
docs: adjust docstring
rcybulski1122012 Jul 18, 2026
529116b
Merge branch 'main' into optimizer-hint-key-for-aliased-custom-fields
rcybulski1122012 Sep 1, 2026
4e3d9dc
fix: rename a const to match convention
rcybulski1122012 Sep 1, 2026
09fe07f
docs: simplify
rcybulski1122012 Sep 1, 2026
b7ed3e8
docs: shorten the release description
rcybulski1122012 Sep 1, 2026
f666486
test: assert sql in test_query_aliased_annotate_callable_with_same_ar…
rcybulski1122012 Sep 1, 2026
17bb07d
test: improve test_query_prefetch_aliases_with_different_pagination
rcybulski1122012 Sep 1, 2026
a79a32d
perf: optimize resolver-less field resoltion
rcybulski1122012 Sep 1, 2026
9b4af3c
fix: handle aliases strating with _ or containing __
rcybulski1122012 Sep 1, 2026
33afe5f
perf: one prefetch if arguments and target are the same
rcybulski1122012 Sep 1, 2026
3c894cf
test: annotate field aliases with the same args
rcybulski1122012 Sep 1, 2026
e1ff781
fix: move guard earlier
rcybulski1122012 Sep 1, 2026
6b11808
fix: simplify _get_field_arguments
rcybulski1122012 Sep 2, 2026
bbb3b23
test: missing get_hint_value test case
rcybulski1122012 Sep 2, 2026
61bd040
docs/test: get_field_arguments improvements
rcybulski1122012 Sep 2, 2026
ef694e0
refactor: _get_hints_from_field
rcybulski1122012 Sep 2, 2026
fabc5eb
docs: remove unnecessary comments
rcybulski1122012 Sep 2, 2026
3baf600
refactor: _get_model_hints
rcybulski1122012 Sep 2, 2026
a9aed79
docs: update RELEASE.md
rcybulski1122012 Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
release type: minor
---

Comment thread
rcybulski1122012 marked this conversation as resolved.
Adjust the optimizer to handle when the same field is selected multiple times via aliases with different arguments and the field doesn't have a custom resolver.

Also add support for aliasing custom fields with `annotate`/`prefetch_related` optimizer hint callables, together with three new public helpers:

- `optimizer_hint_key(info)`: returns a unique, deterministic attribute name for the current field selection (based on the response key, i.e. the alias). It returns the same value inside an optimizer hint callable and inside the field's resolver, so it can be used to name a `Prefetch(to_attr=...)` in the hint and read the value back in the resolver.
- `get_hint_value(source, info, default_attr=None, *, default=...)`: reads the value produced by an optimizer hint for the current selection, checking the alias-scoped attribute/label first and falling back to `default_attr`/`default`. Dict-form annotations with multiple callable labels are supported, so a resolver can combine several per-alias annotated values.
- `get_field_arguments(info)`: resolves the argument values of the current field selection (including variables), usable both inside hint callables and resolvers.

Example:

```python
@strawberry_django.type(Milestone)
class MilestoneType:
@strawberry_django.field(
annotate=lambda info: Count(
"issue",
filter=Q(issue__name__contains=get_field_arguments(info)["nameContains"]),
),
)
def issues_count_filtered(self, root, info, name_contains: str) -> int:
return get_hint_value(root, info, "issues_count_filtered")
```

```graphql
{
milestoneList {
foo: issuesCountFiltered(nameContains: "foo")
bar: issuesCountFiltered(nameContains: "bar")
}
}
```

Each alias resolves the hint callable with its own scoped `info`, and callable annotations are stored under an alias-scoped label so they don't clash with each other.
105 changes: 105 additions & 0 deletions docs/guide/optimizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,111 @@ The following options are accepted for optimizer hints:
or a callable in the format of `Callable[[Info], BaseExpression]`
(e.g. `annotate={"total": lambda info: Sum(...)}`)

## Aliased fields with optimization hint callables

A field whose hints are callables can produce argument-dependent optimizations,
which means the same field can be selected multiple times via
[aliases](https://graphql.org/learn/queries/#aliases) with different arguments:

```graphql
query {
orderItems {
cheap: totalFiltered(maxPrice: 10)
expensive: totalFiltered(maxPrice: 1000)
}
}
```

Each alias resolves the hint callables with its own `info`, scoped to that
Comment thread
rcybulski1122012 marked this conversation as resolved.
specific selection. Since every annotation and `Prefetch(to_attr=...)` needs a
unique name, three helpers keep the hint and the resolver in sync:

- `strawberry_django.optimizer_hint_key(info)`: returns a unique attribute name
for the current selection, derived from its response key (the alias if the
field is aliased). It returns the same value inside a hint callable and
inside the resolver.
- `strawberry_django.get_hint_value(source, info, default_attr=None, *, default=...)`:
reads the value produced by a hint for the current selection. It checks the
alias-scoped attribute first, then `default_attr` (e.g. the plain annotation
label used when the field is not aliased), then returns `default` if given
(useful when the optimizer is turned off), and raises `AttributeError` otherwise.
- `strawberry_django.get_field_arguments(info)`: resolves the argument values of
the current selection (keyed by their GraphQL names, with variables resolved).

For annotations, callable values are automatically stored under the
alias-scoped name when the field is aliased, so the resolver only needs
`get_hint_value`:

```python title="types.py"
@strawberry_django.type(models.Order)
class Order:
@strawberry_django.field(
annotate=lambda info: Sum(
"items__price",
filter=Q(items__price__lte=get_field_arguments(info)["maxPrice"]),
),
)
def total_filtered(self, root: models.Order, info: Info, max_price: int) -> int:
return get_hint_value(root, info, "total_filtered", default=0)
```

For prefetches, name the `to_attr` with `optimizer_hint_key` and read it back
the same way:

```python title="types.py"
@strawberry_django.type(models.Order)
class Order:
@strawberry_django.field(
prefetch_related=lambda info: Prefetch(
"items",
queryset=models.OrderItem.objects.filter(
price__lte=get_field_arguments(info)["maxPrice"],
),
to_attr=optimizer_hint_key(info),
),
)
def items_filtered(
self, root: models.Order, info: Info, max_price: int
) -> list[OrderItem]:
value = get_hint_value(root, info, default=None)
if value is None:
# The optimizer is turned off, resolve the value directly
value = list(root.items.filter(price__lte=max_price))
return value
```

A field can also annotate multiple values and combine them in the resolver.

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.

Claude raised this concern to me:

This third example reteaches what the two above it already showed, with a different aggregate. The annotate example, the prefetch example and the static-annotation note carry the section on their own.

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.

Not sure about it.

I think a more complex example with usage of the default_attr argument is actually helpful. WDYT?

Declare the annotations as a dict with custom labels and read each one back
with `get_hint_value` passing the label as `default_attr` - when the field is
aliased, callable annotations are stored under an alias-scoped variant of the
label, and `get_hint_value` checks that variant first:

```python title="types.py"
@strawberry_django.type(models.Order)
class Order:
@strawberry_django.field(
annotate={
"_matching_count": lambda info: Count(
"items",
filter=Q(items__price__lte=get_field_arguments(info)["maxPrice"]),
),
"_matching_total": lambda info: Sum(
"items__price",
filter=Q(items__price__lte=get_field_arguments(info)["maxPrice"]),
),
},
)
def items_summary(self, root: models.Order, info: Info, max_price: int) -> str:
count = get_hint_value(root, info, "_matching_count")
total = get_hint_value(root, info, "_matching_total")
return f"{count} items, {total} total"
```

> [!NOTE]
> Static (non-callable) annotate expressions can't depend on the field's
> arguments, so they keep their shared label and are annotated only once, no
> matter how many aliases select the field.

## Optimization hints on model (ModelProperty)

It is also possible to include type hints directly in the models' `@property`
Expand Down
4 changes: 4 additions & 0 deletions strawberry_django/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from .filters import filter_type, process_filters
from .mutations.mutations import input_mutation, mutation
from .optimizer import get_field_arguments, get_hint_value, optimizer_hint_key
from .ordering import Ordering, order, order_type, process_order
from .resolvers import django_resolver
from .type import input, interface, partial, type # noqa: A004
Expand Down Expand Up @@ -63,13 +64,16 @@
"filter_field",
"filter_type",
"filters",
"get_field_arguments",
"get_hint_value",
"input",
"input_mutation",
"interface",
"mutation",
"mutations",
"node",
"offset_paginated",
"optimizer_hint_key",
"order",
"order_field",
"order_type",
Expand Down
13 changes: 12 additions & 1 deletion strawberry_django/fields/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@
from strawberry_django.descriptors import ModelProperty
from strawberry_django.fields.base import StrawberryDjangoFieldBase
from strawberry_django.filters import FILTERS_ARG, StrawberryDjangoFieldFilters
from strawberry_django.optimizer import OptimizerStore, is_optimized_by_prefetching
from strawberry_django.optimizer import (
OptimizerStore,
is_optimized_by_prefetching,
optimizer_hint_key,
)
from strawberry_django.ordering import (
ORDER_ARG,
ORDERING_ARG,
Expand Down Expand Up @@ -232,6 +236,13 @@ def get_result(
# sync_to_async context if the value is already cached, since it will not
# hit the db anymore
attname = self.django_name or self.python_name

# Check for to_attr-based prefetch from optimizer (aliased field with filters)
Comment thread
rcybulski1122012 marked this conversation as resolved.
Outdated
if info is not None:
alias_attr = optimizer_hint_key(info)
if hasattr(source, alias_attr):
return getattr(source, alias_attr)
Comment thread
rcybulski1122012 marked this conversation as resolved.
Outdated

attr = getattr(source.__class__, attname, None)

def get_cached_result():
Expand Down
Loading
Loading