Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Release type: patch

`strawberry.asdict` now correctly handles `Some` and `UNSET` values.
`Some(value)` is unwrapped to its inner value, and fields set to `UNSET`
are excluded from the resulting dictionary.
29 changes: 27 additions & 2 deletions strawberry/types/object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,11 @@ class MyNode:
def asdict(obj: Any) -> dict[str, object]:
"""Convert a strawberry object into a dictionary.

This wraps the dataclasses.asdict function to strawberry.
This wraps the dataclasses.asdict function with additional handling
for strawberry-specific types like ``Some`` and ``UNSET``.

- Fields set to ``UNSET`` are excluded from the result.
- ``Some(value)`` wrappers are unwrapped to their inner value.

Args:
obj: The object to convert into a dictionary.
Expand All @@ -486,7 +490,28 @@ class User:
# {"name": "Lorem", "age": 25}
```
"""
return dataclasses.asdict(obj)
from strawberry.types.maybe import Some
from strawberry.types.unset import UNSET
Comment on lines +493 to +494
Copy link
Member

Choose a reason for hiding this comment

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

Are those necessary for preventing import error? If not, we can move then to module level


def _asdict_inner(obj: Any) -> Any:
if isinstance(obj, Some):
return _asdict_inner(obj.value)
if dataclasses.is_dataclass(obj) and not isinstance(obj, builtins.type):
Copy link
Member

Choose a reason for hiding this comment

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

what happens when dataclasses.is_dataclass(obj) and also isinstance(obj, builtins.type)?

result = {}
for f in dataclasses.fields(obj):
value = getattr(obj, f.name)
if value is UNSET:
continue
Comment on lines +503 to +504
Copy link
Member

@rcybulski1122012 rcybulski1122012 Feb 14, 2026

Choose a reason for hiding this comment

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

When you use Maybe annotation, and value is not provided it's going to be None - https://strawberry.rocks/docs/types/maybe

You need to check if the field annotation is maybe - AFAIR there is such a function already in strawberry. If so and the value is None, exclude it from the result.

This is the implementation I've been using in my project - it's not perfect, but may come in handy

from dataclasses import fields, is_dataclass

def strawberry_to_dict(obj) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for field in fields(obj):
        value = getattr(obj, field.name)
        is_maybe = _annotation_is_maybe(field.type)

        if isinstance(value, Some):
            if is_dataclass(value.value):
                result[field.name] = _strawberry_to_dict(value.value)
            elif isinstance(value.value, list):
                result[field.name] = [_strawberry_to_dict(item) if is_dataclass(item) else item for item in value.value]
            else:
                result[field.name] = value.value
        elif value is None and not is_maybe:
            result[field.name] = None
        elif value is not UNSET and not is_maybe:
            if is_dataclass(value):
                result[field.name] = _strawberry_to_dict(value)
            elif isinstance(value, list):
                result[field.name] = [_strawberry_to_dict(item) if is_dataclass(item) else item for item in value]
            else:
                result[field.name] = value

    return result


_maybe_re = re.compile(r"^(?:strawberry\.)?Maybe\[(.+)\]$")


def _annotation_is_maybe(annotation: Any) -> bool:
    # copied from strawberry
    if isinstance(annotation, str):
        # Ideally we would try to evaluate the annotation, but the args inside
        # may still not be available, as the module is still being constructed.
        # Checking for the pattern should be good enough for now.
        return _maybe_re.match(annotation) is not None

    orig = typing.get_origin(annotation)
    if orig is typing.Annotated:
        return _annotation_is_maybe(typing.get_args(annotation)[0])
    return orig is Maybe

result[f.name] = _asdict_inner(value)
Comment on lines +496 to +505
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Self-referential objects can now cause infinite recursion compared to dataclasses.asdict’s cycle protection.

dataclasses.asdict tracks visited objects to prevent infinite recursion on self- or mutually-referential structures. This implementation doesn’t, so inputs that previously worked may now hit a RecursionError. Please add identity-based cycle tracking (or otherwise align with dataclasses.asdict’s behavior) to avoid this regression.

return result
if isinstance(obj, (list, tuple)):
cls = builtins.type(obj)
return cls(_asdict_inner(v) for v in obj)
Comment on lines +507 to +509
Copy link
Contributor

Choose a reason for hiding this comment

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

NamedTuple reconstruction will break

When obj is a NamedTuple instance, isinstance(obj, tuple) is True, so this branch is taken. However, cls(generator) passes the generator as the first positional argument rather than unpacking it, which will fail for NamedTuples with more than one field. The standard library's dataclasses._asdict_inner uses type(obj)(*[...]) to correctly unpack.

While this is unlikely to be hit with typical strawberry types (which are dataclasses), it's a subtle regression from the previous dataclasses.asdict behavior. Consider using unpacking to match the standard library:

Suggested change
if isinstance(obj, (list, tuple)):
cls = builtins.type(obj)
return cls(_asdict_inner(v) for v in obj)
if isinstance(obj, (list, tuple)):
cls = builtins.type(obj)
return cls(*[_asdict_inner(v) for v in obj])

Note: using *[list_comp] instead of *(generator) ensures NamedTuple constructors receive individual positional arguments.

if isinstance(obj, dict):
return {_asdict_inner(k): _asdict_inner(v) for k, v in obj.items()}
return obj

return _asdict_inner(obj)


__all__ = [
Expand Down
62 changes: 61 additions & 1 deletion tests/types/test_convert_to_dictionary.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from enum import Enum

import strawberry
from strawberry import asdict
from strawberry import UNSET, asdict
from strawberry.types.maybe import Some


def test_convert_simple_type_to_dictionary():
Expand Down Expand Up @@ -62,3 +63,62 @@ class QnaInput:
"description": description,
"tags": None,
}


def test_convert_some_values_are_unwrapped():
@strawberry.type
class User:
name: str
age: strawberry.Maybe[int]

user = User(name="Alex", age=Some(30))

assert asdict(user) == {
"name": "Alex",
"age": 30,
}


def test_convert_unset_fields_are_excluded():
@strawberry.input
class UserInput:
name: str
age: int | None = UNSET

user = UserInput(name="Alex")

assert asdict(user) == {
"name": "Alex",
}


def test_convert_some_none_is_preserved():
@strawberry.type
class User:
name: str
age: strawberry.Maybe[int]

user = User(name="Alex", age=Some(None))

assert asdict(user) == {
"name": "Alex",
"age": None,
}


def test_convert_nested_some_values():
@strawberry.type
class Address:
city: str

@strawberry.type
class User:
name: str
address: strawberry.Maybe[Address]

user = User(name="Alex", address=Some(Address(city="NYC")))

assert asdict(user) == {
"name": "Alex",
"address": {"city": "NYC"},
}
Loading