Skip to content

fix asdict to handle Some and UNSET values#4231

Open
Mr-Neutr0n wants to merge 3 commits intostrawberry-graphql:mainfrom
Mr-Neutr0n:fix-asdict-some-unset
Open

fix asdict to handle Some and UNSET values#4231
Mr-Neutr0n wants to merge 3 commits intostrawberry-graphql:mainfrom
Mr-Neutr0n:fix-asdict-some-unset

Conversation

@Mr-Neutr0n
Copy link

@Mr-Neutr0n Mr-Neutr0n commented Feb 14, 2026

strawberry.asdict was just delegating to dataclasses.asdict, which doesn't know about Some or UNSET. This meant Some(value) would end up as-is in the dict instead of being unwrapped, and UNSET fields would leak through instead of being excluded.

Fixed by replacing the plain dataclasses.asdict call with a custom recursive implementation that:

  • unwraps Some(value) to just value
  • skips fields that are UNSET
  • still recursively handles nested dataclasses, lists, tuples, and dicts the same way dataclasses.asdict does

Fixes #4141
Related: #3265

Summary by Sourcery

Update strawberry.asdict to correctly handle framework-specific sentinel and wrapper types while maintaining recursive conversion behavior.

Bug Fixes:

  • Ensure asdict unwraps Some(value) instances to their inner values so they serialize as plain data instead of wrapper objects.
  • Exclude fields set to UNSET from asdict output to prevent leaking unset sentinel values in serialized dictionaries.

Documentation:

  • Document the updated asdict behavior in the public API docstring and release notes, including handling of Some and UNSET values.

Tests:

  • Add tests covering Some unwrapping, UNSET field exclusion, None preservation inside Some, and nested dataclass handling for Maybe fields.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 14, 2026

Reviewer's Guide

Implements a custom recursive strawberry.asdict that understands Some and UNSET, updating tests and release notes to ensure optional/maybe fields are correctly unwrapped or excluded while preserving dataclasses.asdict-like behavior for nested structures.

File-Level Changes

Change Details Files
Replace simple dataclasses.asdict wrapper with custom recursive asdict that unwraps Some, skips UNSET, and preserves list/tuple/dict and nested dataclass handling.
  • Document new behavior of asdict regarding Some wrappers and UNSET fields in the function docstring.
  • Introduce an internal _asdict_inner helper that recursively processes values, unwrapping Some(value) to its inner value.
  • Skip dataclass fields whose value is UNSET when building the result dictionary.
  • Recreate list/tuple instances by mapping _asdict_inner over their elements, preserving the original concrete type.
  • Recreate dicts by mapping _asdict_inner over both keys and values, matching dataclasses.asdict-style recursion.
strawberry/types/object_type.py
Extend test coverage for asdict to cover Some and UNSET semantics, including nested and None cases.
  • Import UNSET and Some into the test module for use in new test cases.
  • Add test verifying that Some(value) is unwrapped to value in the output dict.
  • Add test verifying that fields defaulted to UNSET are omitted from the output dict.
  • Add test verifying that Some(None) yields a key with value None rather than being dropped or wrapped.
  • Add test verifying that nested dataclasses wrapped in Some are correctly unwrapped and converted recursively.
tests/types/test_convert_to_dictionary.py
Document the behavior change as a patch release in the project’s release notes.
  • Add a new RELEASE.md describing the patch-level change to strawberry.asdict handling of Some and UNSET.
RELEASE.md

Assessment against linked issues

Issue Objective Addressed Explanation
#4141 Change strawberry.asdict so that it correctly handles strawberry.types.maybe.Some values (including nested dataclasses), unwrapping Some(value) to value in the resulting dictionary.
#4141 Update strawberry.asdict so that fields whose value is UNSET are excluded from the resulting dictionary.

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

Thanks for adding the RELEASE.md file!

Here's a preview of the changelog:


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.

Here's the tweet text:

🆕 Release (next) is out! Thanks to Harikrishna KP for the PR 👏

Get it here 👉 https://strawberry.rocks/release/(next)

@botberry
Copy link
Member

Thanks for adding the RELEASE.md file!

Here's a preview of the changelog:


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.

Here's the tweet text:

🆕 Release (next) is out! Thanks to Harikrishna KP for the PR 👏

Get it here 👉 https://strawberry.rocks/release/(next)

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 1 issue, and left some high level feedback:

  • The custom _asdict_inner no longer has the cycle-detection logic that dataclasses.asdict provides, so consider adding memoization to avoid infinite recursion on self-referential dataclasses.
  • The new implementation now also transforms dictionary keys (not just values), which differs from dataclasses.asdict behavior; double-check whether this change is intentional for Strawberry objects or if keys should be left untouched.
  • UNSET handling currently only applies to dataclass fields; if UNSET can appear inside lists, tuples, or dicts, you may want to clarify or extend how those occurrences should be treated (e.g., pruned vs. preserved).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The custom `_asdict_inner` no longer has the cycle-detection logic that `dataclasses.asdict` provides, so consider adding memoization to avoid infinite recursion on self-referential dataclasses.
- The new implementation now also transforms dictionary keys (not just values), which differs from `dataclasses.asdict` behavior; double-check whether this change is intentional for Strawberry objects or if keys should be left untouched.
- UNSET handling currently only applies to dataclass fields; if UNSET can appear inside lists, tuples, or dicts, you may want to clarify or extend how those occurrences should be treated (e.g., pruned vs. preserved).

## Individual Comments

### Comment 1
<location> `strawberry/types/object_type.py:496-505` </location>
<code_context>
+    from strawberry.types.maybe import Some
+    from strawberry.types.unset import UNSET
+
+    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):
+            result = {}
+            for f in dataclasses.fields(obj):
+                value = getattr(obj, f.name)
+                if value is UNSET:
+                    continue
+                result[f.name] = _asdict_inner(value)
+            return result
+        if isinstance(obj, (list, tuple)):
+            cls = builtins.type(obj)
+            return cls(_asdict_inner(v) for v in obj)
+        if isinstance(obj, dict):
+            return {
+                _asdict_inner(k): _asdict_inner(v) for k, v in obj.items()
+            }
+        return obj
+
+    return _asdict_inner(obj)


</code_context>

<issue_to_address>
**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.
</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.

Comment on lines +496 to +505
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):
result = {}
for f in dataclasses.fields(obj):
value = getattr(obj, f.name)
if value is UNSET:
continue
result[f.name] = _asdict_inner(value)
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.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 14, 2026

Greptile Overview

Greptile Summary

Replaces the simple dataclasses.asdict delegation in strawberry.asdict with a custom recursive implementation that handles strawberry-specific types: Some(value) wrappers are unwrapped to their inner value, and fields set to UNSET are excluded from the output dictionary. The recursive logic preserves the same traversal behavior as the standard library for nested dataclasses, lists, tuples, and dicts.

  • Core change in strawberry/types/object_type.py: new _asdict_inner helper replaces dataclasses.asdict(obj) with strawberry-aware recursion
  • Four new tests cover: Some unwrapping, UNSET exclusion, Some(None) preservation, and nested Some with dataclasses
  • Downstream consumer ast_from_value.py (which calls strawberry.asdict) benefits from this fix — UNSET fields are now excluded before field lookup, preventing potential KeyError
  • Minor difference from stdlib: leaf values are no longer deep-copied (standard dataclasses.asdict uses copy.deepcopy), and tuple reconstruction uses a generator instead of unpacking (affects NamedTuple edge case)

Confidence Score: 4/5

  • This PR is safe to merge — the changes are well-scoped, fix a real bug, and include good test coverage.
  • Score of 4 reflects a clean, well-tested fix with one minor behavioral difference from the standard library (tuple/NamedTuple reconstruction and no deep-copy of leaves). These are unlikely to cause issues in practice since strawberry types are dataclasses, but they represent subtle deviations from the previous behavior.
  • strawberry/types/object_type.py — the _asdict_inner function's tuple handling differs slightly from the stdlib dataclasses.asdict behavior for NamedTuple subclasses.

Important Files Changed

Filename Overview
strawberry/types/object_type.py Replaces dataclasses.asdict delegation with custom recursive _asdict_inner that unwraps Some(value) and skips UNSET fields. One minor concern: tuple reconstruction uses a generator instead of unpacking, which differs from stdlib behavior for NamedTuples.
tests/types/test_convert_to_dictionary.py Adds four well-structured tests covering Some unwrapping, UNSET exclusion, Some(None) preservation, and nested Some with dataclasses. Good coverage of the new behavior.
RELEASE.md Standard patch release note clearly describing the new Some and UNSET handling in strawberry.asdict.

Flowchart

flowchart TD
    A["asdict(obj)"] --> B["_asdict_inner(obj)"]
    B --> C{"isinstance(obj, Some)?"}
    C -->|Yes| D["Unwrap: _asdict_inner(obj.value)"]
    D --> B
    C -->|No| E{"is_dataclass(obj)?"}
    E -->|Yes| F["Iterate fields"]
    F --> G{"value is UNSET?"}
    G -->|Yes| H["Skip field"]
    G -->|No| I["result[name] = _asdict_inner(value)"]
    I --> J["Return result dict"]
    H --> F
    E -->|No| K{"isinstance(obj, list/tuple)?"}
    K -->|Yes| L["Reconstruct with _asdict_inner per element"]
    K -->|No| M{"isinstance(obj, dict)?"}
    M -->|Yes| N["Recurse on keys and values"]
    M -->|No| O["Return obj as-is (leaf)"]
Loading

Last reviewed commit: a9158e6

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

3 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +507 to +509
if isinstance(obj, (list, tuple)):
cls = builtins.type(obj)
return cls(_asdict_inner(v) for v in obj)
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.

Comment on lines +503 to +504
if value is UNSET:
continue
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

Comment on lines +493 to +494
from strawberry.types.maybe import Some
from strawberry.types.unset import UNSET
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)?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

strawberry.asdict does not handle Some

4 participants