Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
13 changes: 13 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
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 (see the [optimizer guide](https://strawberry-graphql.github.io/strawberry-django/guide/optimizer/) for examples):

- `optimizer_hint_key(info)`: a unique, deterministic attribute name for the current field selection (based on the alias), stable between the hint callable and the resolver.
- `get_hint_value(source, info, default_attr=None, *, default=...)`: reads the value produced by an optimizer hint for the current selection.
- `get_field_arguments(info)`: resolves the coerced argument values (including variables and schema defaults) of the current field selection, for use inside hint callables.

Note: inside an optimizer hint callable (and any `get_queryset` reached through the optimizer's prefetch path), `info.path.key` now reports the selection's response key — the alias when the field is aliased — rather than always the field name. This is what makes the per-alias hints deterministic, but code that keys cache or permission logic on `info.path` inside a hint callable should account for it.
116 changes: 116 additions & 0 deletions docs/guide/optimizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,122 @@ 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)` names the annotation or
`Prefetch(to_attr=...)` inside the hint callable.
- `strawberry_django.get_hint_value(...)` reads that value back inside the
resolver.
- `strawberry_django.get_field_arguments(info)` resolves the current
selection's coerced arguments inside the hint callable.

See each helper's docstring for the exact arguments and lookup order.

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.

> [!NOTE]
> Aliases of a callable-hint field are collapsed into a single annotation /
> `Prefetch` when they are called with identical arguments **and** resolve to
> the same target - a shared annotation label, or a `Prefetch` with a fixed
> `to_attr` (or a plain relation string). Aliases with different arguments, or
> whose `to_attr` is derived from `optimizer_hint_key(info)` (which is unique
> per alias), each get their own annotation / prefetch.
>
> In particular, a `prefetch_related` that names its `to_attr` with
> `optimizer_hint_key` targets a distinct attribute per alias, so every such
> alias costs one additional query - even when the arguments are identical.
> If you want identical-argument aliases to share a single prefetch, give the
> `Prefetch` a fixed `to_attr` instead.

## 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 (
Expand Down Expand Up @@ -70,13 +71,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
14 changes: 13 additions & 1 deletion strawberry_django/fields/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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,
get_hint_value,
is_optimized_by_prefetching,
)
from strawberry_django.ordering import (
ORDER_ARG,
ORDERING_ARG,
Expand Down Expand Up @@ -95,6 +99,8 @@

_T = TypeVar("_T")

_sentinel = object()


class StrawberryDjangoField(
StrawberryDjangoPagination,
Expand Down Expand Up @@ -233,6 +239,12 @@ 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

if (self.is_list or self.store) and info is not None:
value = get_hint_value(source, info, default=_sentinel)
if value is not _sentinel:
return value

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

def get_cached_result():
Expand Down
Loading
Loading