Skip to content

Commit 4fd44aa

Browse files
authored
Raise an error for nested Strawberry field metadata (#4595)
1 parent 62edaec commit 4fd44aa

13 files changed

Lines changed: 410 additions & 12 deletions

RELEASE.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
release type: patch
3+
social_messages:
4+
x: >-
5+
{project_name} {version} is out! This release reports misplaced nested
6+
strawberry.field() metadata instead of silently ignoring it.
7+
https://strawberry.rocks/release/{version}
8+
linkedin: >-
9+
{project_name} {version} is out. This release reports misplaced nested
10+
strawberry.field() metadata with a clear error instead of silently ignoring it.
11+
---
12+
13+
This release fixes silently ignored `strawberry.field()` metadata in nested type
14+
annotations.
15+
16+
Strawberry now raises a clear error when field metadata is placed below the
17+
class-field annotation, such as on a list item, and explains that it must be moved
18+
to the outermost `Annotated` metadata for the field.
19+
20+
For example, Strawberry now reports this misplaced metadata:
21+
22+
```python
23+
from typing import Annotated
24+
25+
import strawberry
26+
27+
28+
@strawberry.type
29+
class Query:
30+
names: list[Annotated[str, strawberry.field(description="A name")]]
31+
```
32+
33+
Move `strawberry.field()` to the field's outermost `Annotated` metadata:
34+
35+
```python
36+
@strawberry.type
37+
class Query:
38+
names: Annotated[list[str], strawberry.field(description="The names")]
39+
```
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
title: Invalid Strawberry Field Annotation Error
3+
---
4+
5+
# Invalid Strawberry Field Annotation Error
6+
7+
## Description
8+
9+
This error is raised when `strawberry.field()` is nested inside another type in
10+
a class-field annotation. For example, this attempts to configure a list item,
11+
which is not a GraphQL field:
12+
13+
```python
14+
from typing import Annotated
15+
16+
import strawberry
17+
18+
19+
@strawberry.type
20+
class Query:
21+
names: list[Annotated[str, strawberry.field(description="A name")]]
22+
```
23+
24+
Strawberry only uses `strawberry.field()` metadata from the outermost
25+
`Annotated` type because that node represents the class field.
26+
27+
## How to fix this error
28+
29+
Move `strawberry.field()` to the outermost `Annotated` metadata for the field:
30+
31+
```python
32+
from typing import Annotated
33+
34+
import strawberry
35+
36+
37+
@strawberry.type
38+
class Query:
39+
names: Annotated[list[str], strawberry.field(description="The names")]
40+
```

docs/types/object-types.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ Use only one `strawberry.field()` for each field. You can alternatively use the
158158
equivalent assignment syntax, such as
159159
`name: str = strawberry.field(description="The displayed name")`.
160160

161+
`strawberry.field()` must be metadata on the field's outermost `Annotated` type.
162+
Placing it inside a wrapper configures no GraphQL field, so Strawberry raises an
163+
error instead of silently ignoring it:
164+
165+
```python
166+
# Incorrect: strawberry.field() describes the list item, not `names`.
167+
names: list[Annotated[str, strawberry.field(description="A name")]]
168+
169+
# Correct: strawberry.field() describes `names`.
170+
names: Annotated[list[str], strawberry.field(description="The names")]
171+
```
172+
161173
## API
162174

163175
`@strawberry.type(name: str = None, description: str = None)`

strawberry/annotation.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,16 @@
5454
typing.AsyncIterator,
5555
)
5656

57+
_NOT_EVALUATED = object()
58+
5759

5860
class StrawberryAnnotation:
59-
__slots__ = "__resolve_cache__", "namespace", "raw_annotation"
61+
__slots__ = (
62+
"__evaluated_cache__",
63+
"__resolve_cache__",
64+
"namespace",
65+
"raw_annotation",
66+
)
6067

6168
def __init__(
6269
self,
@@ -67,6 +74,7 @@ def __init__(
6774
self.raw_annotation = annotation
6875
self.namespace = namespace
6976

77+
self.__evaluated_cache__: object = _NOT_EVALUATED
7078
self.__resolve_cache__: StrawberryType | type | None = None
7179

7280
def __eq__(self, other: object) -> bool:
@@ -93,7 +101,7 @@ def from_annotation(
93101
def annotation(self) -> object | str:
94102
"""Return evaluated type on success or fallback to raw (string) annotation."""
95103
try:
96-
return self.evaluate()
104+
return self._evaluated_annotation
97105
except NameError:
98106
# Evaluation failures can happen when importing types within a TYPE_CHECKING
99107
# block or if the type is declared later on in a module.
@@ -103,6 +111,7 @@ def annotation(self) -> object | str:
103111
def annotation(self, value: object | str) -> None:
104112
self.raw_annotation = value
105113

114+
self.__evaluated_cache__ = _NOT_EVALUATED
106115
self.__resolve_cache__ = None
107116

108117
def evaluate(self) -> type:
@@ -114,6 +123,13 @@ def evaluate(self) -> type:
114123

115124
return eval_type(annotation, self.namespace, None)
116125

126+
@property
127+
def _evaluated_annotation(self) -> object:
128+
if self.__evaluated_cache__ is _NOT_EVALUATED:
129+
self.__evaluated_cache__ = self.evaluate()
130+
131+
return self.__evaluated_cache__
132+
117133
def _get_type_with_args(
118134
self, evaled_type: type[Any]
119135
) -> tuple[type[Any], list[Any]]:
@@ -161,7 +177,7 @@ def resolve(
161177
return resolved
162178

163179
def _resolve(self) -> StrawberryType | type:
164-
evaled_type = cast("Any", self.evaluate())
180+
evaled_type = cast("Any", self._evaluated_annotation)
165181
return self._resolve_evaled_type(evaled_type)
166182

167183
def _resolve_evaled_type(self, evaled_type: Any) -> StrawberryType | type:
@@ -208,6 +224,7 @@ def set_namespace_from_field(self, field: StrawberryField) -> None:
208224
module = sys.modules[field.origin.__module__]
209225
self.namespace = module.__dict__
210226

227+
self.__evaluated_cache__ = _NOT_EVALUATED
211228
self.__resolve_cache__ = None # Invalidate cache to allow re-evaluation
212229

213230
def create_concrete_type(self, evaled_type: type) -> type:

strawberry/exceptions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .exception import StrawberryException, UnableToFindExceptionSource
1111
from .handler import setup_exception_handler
1212
from .invalid_argument_type import InvalidArgumentTypeError
13+
from .invalid_strawberry_field_annotation import InvalidStrawberryFieldAnnotationError
1314
from .invalid_superclass_interface import InvalidSuperclassInterfaceError
1415
from .invalid_union_type import InvalidTypeForUnionMergeError, InvalidUnionTypeError
1516
from .missing_arguments_annotations import MissingArgumentsAnnotationsError
@@ -176,6 +177,7 @@ def __init__(self, payload: dict[str, object] | None = None) -> None:
176177
"InvalidArgumentTypeError",
177178
"InvalidCustomContext",
178179
"InvalidDefaultFactoryError",
180+
"InvalidStrawberryFieldAnnotationError",
179181
"InvalidSuperclassInterfaceError",
180182
"InvalidTypeForUnionMergeError",
181183
"InvalidUnionTypeError",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
from functools import cached_property
4+
from typing import TYPE_CHECKING
5+
6+
from .exception import StrawberryException
7+
from .utils.source_finder import SourceFinder
8+
9+
if TYPE_CHECKING:
10+
from .exception_source import ExceptionSource
11+
12+
13+
class InvalidStrawberryFieldAnnotationError(StrawberryException):
14+
def __init__(self, field_name: str, cls: type) -> None:
15+
self.cls = cls
16+
self.field_name = field_name
17+
18+
self.message = (
19+
f"`strawberry.field()` for field `{field_name}` on type `{cls.__name__}` "
20+
"must be placed at the top level of the field annotation"
21+
)
22+
self.rich_message = (
23+
f"`strawberry.field()` for field `[underline]{field_name}[/]` on type "
24+
f"`[underline]{cls.__name__}[/]` cannot be nested inside another type"
25+
)
26+
self.annotation_message = "strawberry.field() is nested inside another type"
27+
self.suggestion = (
28+
"To fix this error, move `strawberry.field()` to the outermost "
29+
"`Annotated` metadata for the field."
30+
)
31+
32+
super().__init__(self.message)
33+
34+
@cached_property
35+
def exception_source(self) -> ExceptionSource | None:
36+
source_finder = SourceFinder()
37+
38+
return source_finder.find_class_attribute_from_object(self.cls, self.field_name)

strawberry/types/field.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,23 @@
88
from functools import cached_property
99
from typing import (
1010
TYPE_CHECKING,
11+
Annotated,
1112
Any,
1213
NoReturn,
1314
TypeAlias,
1415
TypeVar,
1516
Union,
17+
get_args,
18+
get_origin,
1619
overload,
1720
)
1821

1922
from strawberry.annotation import StrawberryAnnotation
20-
from strawberry.exceptions import InvalidArgumentTypeError, InvalidDefaultFactoryError
23+
from strawberry.exceptions import (
24+
InvalidArgumentTypeError,
25+
InvalidDefaultFactoryError,
26+
InvalidStrawberryFieldAnnotationError,
27+
)
2128
from strawberry.types.base import (
2229
StrawberryType,
2330
WithStrawberryObjectDefinition,
@@ -124,6 +131,9 @@ def __init__( # noqa: PLR0917
124131
self.python_name = python_name
125132

126133
self.type_annotation = type_annotation
134+
# Cache the exact annotation object after successful validation and
135+
# resolution so replacing `type_annotation` automatically revalidates it.
136+
self._validated_type_annotation: StrawberryAnnotation | None = None
127137

128138
self.description: str | None = description
129139
self.origin = origin
@@ -345,7 +355,14 @@ def resolve_type(
345355
with contextlib.suppress(NameError):
346356
# Prioritise the field type over the resolver return type
347357
if self.type_annotation is not None:
348-
resolved = self.type_annotation.resolve(type_definition=type_definition)
358+
type_annotation = self.type_annotation
359+
if type_annotation is not self._validated_type_annotation:
360+
self._validate_type_annotation(
361+
type_annotation._evaluated_annotation
362+
)
363+
364+
resolved = type_annotation.resolve(type_definition=type_definition)
365+
self._validated_type_annotation = type_annotation
349366
elif self.base_resolver is not None and self.base_resolver.type is not None:
350367
# Handle unannotated functions (such as lambdas)
351368
# Generics will raise MissingTypesForGenericError later
@@ -355,6 +372,17 @@ def resolve_type(
355372

356373
return resolved
357374

375+
def _validate_type_annotation(self, annotation: object) -> None:
376+
if (
377+
self.python_name is not None
378+
and isinstance(self.origin, type)
379+
and _contains_strawberry_field(annotation)
380+
):
381+
raise InvalidStrawberryFieldAnnotationError(
382+
field_name=self.python_name,
383+
cls=self.origin,
384+
)
385+
358386
def copy_with(
359387
self, type_var_map: Mapping[str, StrawberryType | builtins.type]
360388
) -> Self:
@@ -395,6 +423,29 @@ def is_async(self) -> bool:
395423
return self._has_async_base_resolver
396424

397425

426+
def _contains_strawberry_field(
427+
annotation: object,
428+
*,
429+
at_field_annotation_root: bool = True,
430+
) -> bool:
431+
if get_origin(annotation) is Annotated:
432+
annotation, *metadata = get_args(annotation)
433+
if not at_field_annotation_root and any(
434+
isinstance(item, StrawberryField) for item in metadata
435+
):
436+
return True
437+
438+
return _contains_strawberry_field(
439+
annotation,
440+
at_field_annotation_root=at_field_annotation_root,
441+
)
442+
443+
return any(
444+
_contains_strawberry_field(arg, at_field_annotation_root=False)
445+
for arg in get_args(annotation)
446+
)
447+
448+
398449
# NOTE: we are separating the sync and async resolvers because using both
399450
# in the same function will cause mypy to raise an error. Not sure if it is a bug
400451

strawberry/types/fields/resolver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,13 @@ def find(
120120
annotation = resolver.strawberry_annotations[parameter]
121121
if isinstance(annotation, StrawberryAnnotation):
122122
try:
123-
evaled_annotation: Any = annotation.evaluate()
123+
evaled_annotation: Any = annotation._evaluated_annotation
124124
except NameError:
125125
# If we fail to evaluate, check if the raw annotation string
126126
# matches this reserved type. This handles cases where types
127127
# are imported under TYPE_CHECKING or use forward references
128128
# like "strawberry.Info".
129-
raw = annotation.annotation
129+
raw = annotation.raw_annotation
130130
if isinstance(raw, str):
131131
evaled_annotation = raw
132132
else:

strawberry/types/object_type.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from strawberry.annotation import StrawberryAnnotation
2626
from strawberry.exceptions import (
27+
InvalidStrawberryFieldAnnotationError,
2728
InvalidSuperclassInterfaceError,
2829
MissingFieldAnnotationError,
2930
MissingReturnAnnotationError,
@@ -36,7 +37,7 @@
3637
from strawberry.utils.str_converters import to_camel_case
3738

3839
from .base import StrawberryObjectDefinition
39-
from .field import StrawberryField, field
40+
from .field import StrawberryField, _contains_strawberry_field, field
4041
from .type_resolver import _get_fields
4142

4243
T = TypeVar("T", bound=builtins.type)
@@ -70,10 +71,12 @@ def _process_annotated_fields(cls: T) -> dict[str, StrawberryAnnotation]:
7071
else:
7172
annotation = raw_annotation
7273

73-
if get_origin(annotation) is not Annotated:
74-
continue
74+
if get_origin(annotation) is Annotated:
75+
first, *rest = get_args(annotation)
76+
else:
77+
first = annotation
78+
rest = []
7579

76-
first, *rest = get_args(annotation)
7780
strawberry_fields = [arg for arg in rest if isinstance(arg, StrawberryField)]
7881

7982
if len(strawberry_fields) > 1 or (
@@ -82,6 +85,12 @@ def _process_annotated_fields(cls: T) -> dict[str, StrawberryAnnotation]:
8285
):
8386
raise MultipleStrawberryFieldsError(field_name=field_name, cls=cls)
8487

88+
if _contains_strawberry_field(annotation):
89+
raise InvalidStrawberryFieldAnnotationError(
90+
field_name=field_name,
91+
cls=cls,
92+
)
93+
8594
if not strawberry_fields:
8695
continue
8796

0 commit comments

Comments
 (0)