Skip to content

Conversation

cikay
Copy link

@cikay cikay commented Sep 21, 2025

To prevent defining the same field for different types on the same model I defined a metaclass called PartialType that makes fields optional.

Currently, we define the same fields in multiple types for the same model since the field might be optional for one type but not for the other.

from typing import Optional


@strawberry.type
class UserCreate:
    firstname: str
    lastname: str

@strawberry.type
class UserUpdate:
    firstname: Optional[str] = strawberry.UNSET
    lastname: Optional[str] = strawberry.UNSET

@strawberry.type
class UserQuery(UserUpdate):
    id: Optional[strawberry.ID] = strawberry.UNSET

Imagine that UserCreate has a bunch of fields. One has to define all of them in the UserUpdate type. With PartialType metaclass reusing is possible.

from strawberry.tools import PartialType


@strawberry.type
class UserCreate:
    firstname: str
    lastname: str

@strawberry.type
class UserUpdate(UserCreate, metaclass=PartialType):
    pass

@strawberry.type
class UserQuery(UserCreate, metaclass=PartialType):
     id: Optional[strawberry.ID] = strawberry.UNSET

Description

Types of Changes

  • Core
  • Bugfix
  • New feature
  • Enhancement/optimization
  • Documentation

Issues Fixed or Closed by This PR

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

Summary by Sourcery

Introduce PartialType to streamline defining partial GraphQL types by inheriting existing types and making all fields optional with default UNSET

New Features:

  • Add PartialType metaclass to automatically convert inherited type fields to optional with default unset

Documentation:

  • Document PartialType usage in guides/tools

Tests:

  • Add tests verifying PartialType marks inherited fields as optional and defaults them to UNSET

Chores:

  • Expose PartialType in strawberry.tools module exports
  • Add release note for PartialType

Copy link
Contributor

sourcery-ai bot commented Sep 21, 2025

Reviewer's Guide

This PR introduces a new PartialType metaclass that automatically makes all inherited Strawberry type fields optional, integrates it into the public API, adds corresponding documentation and release notes, and provides comprehensive tests to validate its behavior.

File-Level Changes

Change Details Files
Introduce PartialType metaclass to dynamically make inherited type fields optional
  • Define PartialType class with custom new logic to copy base annotations, wrap them in Optional, and set default UNSET values
  • Import PartialType in strawberry/tools/init.py and expose it in the public API
strawberry/tools/partialtype.py
strawberry/tools/__init__.py
Add documentation and release notes for PartialType
  • Add PartialType usage guide and examples to docs/guides/tools.md
  • Update RELEASED.md with a new entry for PartialType
docs/guides/tools.md
RELEASED.md
Add tests for PartialType functionality
  • Create test suite to verify Optional wrapping and default UNSET behavior on inherited fields
tests/tools/test_partialtype.py

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

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 there - I've reviewed your changes - here's some feedback:

  • In PartialType you unconditionally set all defaults to strawberry.UNSET, which will override any explicit defaults from base classes—consider preserving user-specified defaults when copying fields.
  • The metaclass currently assumes every base has a _type_definition.fields attribute; add a guard or skip logic for bases that aren’t Strawberry types to avoid errors when mixing in non-GraphQL classes.
  • You wrap every annotation in Optional without checking if it’s already optional, which can lead to nested Optional types—add a check to avoid double-wrapping annotations that are already Optional/StrawberryOptional.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In PartialType you unconditionally set all defaults to strawberry.UNSET, which will override any explicit defaults from base classes—consider preserving user-specified defaults when copying fields.
- The metaclass currently assumes every base has a _type_definition.fields attribute; add a guard or skip logic for bases that aren’t Strawberry types to avoid errors when mixing in non-GraphQL classes.
- You wrap every annotation in Optional without checking if it’s already optional, which can lead to nested Optional types—add a check to avoid double-wrapping annotations that are already Optional/StrawberryOptional.

## Individual Comments

### Comment 1
<location> `strawberry/tools/partialtype.py:12-18` </location>
<code_context>
+        mro = super().__new__(cls, name, bases, namespaces, **kwargs).mro()
+        annotations = namespaces.get("__annotations__", {})
+        fields: list[str] = []
+        for base in mro[:-1]:  # the object class has no __annotations__ attr
+            for k, v in base.__annotations__.items():
+                # To prevent overriding the higher attr annotation
+                if k not in annotations:
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Accessing __annotations__ directly may raise AttributeError for some base classes.

Use getattr(base, "__annotations__", {}) to safely access annotations and prevent exceptions.

```suggestion
        for base in mro[:-1]:  # the object class has no __annotations__ attr
            for k, v in getattr(base, "__annotations__", {}).items():
                # To prevent overriding the higher attr annotation
                if k not in annotations:
                    annotations[k] = v

            fields.extend(field.name for field in base._type_definition.fields)
```
</issue_to_address>

### Comment 2
<location> `strawberry/tools/partialtype.py:18` </location>
<code_context>
+                if k not in annotations:
+                    annotations[k] = v
+
+            fields.extend(field.name for field in base._type_definition.fields)
+
+        for field in annotations:
</code_context>

<issue_to_address>
**suggestion:** Assumes all bases have _type_definition.fields, which may not always be true.

Use hasattr or a try/except block to avoid AttributeError when a base lacks _type_definition.

```suggestion
            if hasattr(base, "_type_definition") and hasattr(base._type_definition, "fields"):
                fields.extend(field.name for field in base._type_definition.fields)
```
</issue_to_address>

### Comment 3
<location> `strawberry/tools/partialtype.py:20-22` </location>
<code_context>
+
+            fields.extend(field.name for field in base._type_definition.fields)
+
+        for field in annotations:
+            if not field.startswith("_"):
+                annotations[field] = Optional[annotations[field]]
+
+        namespaces["__annotations__"] = annotations
</code_context>

<issue_to_address>
**suggestion:** Wrapping all non-private fields in Optional may not preserve existing Optional types.

Check if a field's annotation is already Optional before applying another Optional wrapper to avoid redundancy.

```suggestion
        from typing import get_origin

        for field, annotation in annotations.items():
            if not field.startswith("_"):
                # Check if annotation is already Optional
                origin = get_origin(annotation)
                if origin is not Optional:
                    annotations[field] = Optional[annotation]
```
</issue_to_address>

### Comment 4
<location> `tests/tools/test_partialtype.py:8-17` </location>
<code_context>
+def test_partialtype():
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding tests for edge cases such as inherited fields with default values, fields with complex types, and fields with leading underscores.

Adding these cases will help verify that the metaclass correctly handles all field scenarios, including skipping fields with leading underscores.
</issue_to_address>

### Comment 5
<location> `docs/guides/tools.md:99` </location>
<code_context>

+### `PartialType`
+
+`PartialType` metaclass is used to extend your type but makes its all field optional. Consider
+you have different types for each operation on the same model such as `UserCreate`, `UserUpdate`
+and `UserQuery`. `UserQuery` should have id field but the other does not. All fields of
</code_context>

<issue_to_address>
**issue (typo):** Correct grammar: 'makes its all field optional' should be 'makes all its fields optional.'

The correct phrasing is 'makes all its fields optional.'

```suggestion
`PartialType` metaclass is used to extend your type but makes all its fields optional. Consider
```
</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 +12 to +18
for base in mro[:-1]: # the object class has no __annotations__ attr
for k, v in base.__annotations__.items():
# To prevent overriding the higher attr annotation
if k not in annotations:
annotations[k] = v

fields.extend(field.name for field in base._type_definition.fields)
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Accessing annotations directly may raise AttributeError for some base classes.

Use getattr(base, "annotations", {}) to safely access annotations and prevent exceptions.

Suggested change
for base in mro[:-1]: # the object class has no __annotations__ attr
for k, v in base.__annotations__.items():
# To prevent overriding the higher attr annotation
if k not in annotations:
annotations[k] = v
fields.extend(field.name for field in base._type_definition.fields)
for base in mro[:-1]: # the object class has no __annotations__ attr
for k, v in getattr(base, "__annotations__", {}).items():
# To prevent overriding the higher attr annotation
if k not in annotations:
annotations[k] = v
fields.extend(field.name for field in base._type_definition.fields)

if k not in annotations:
annotations[k] = v

fields.extend(field.name for field in base._type_definition.fields)
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Assumes all bases have _type_definition.fields, which may not always be true.

Use hasattr or a try/except block to avoid AttributeError when a base lacks _type_definition.

Suggested change
fields.extend(field.name for field in base._type_definition.fields)
if hasattr(base, "_type_definition") and hasattr(base._type_definition, "fields"):
fields.extend(field.name for field in base._type_definition.fields)

Comment on lines +20 to +22
for field in annotations:
if not field.startswith("_"):
annotations[field] = Optional[annotations[field]]
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Wrapping all non-private fields in Optional may not preserve existing Optional types.

Check if a field's annotation is already Optional before applying another Optional wrapper to avoid redundancy.

Suggested change
for field in annotations:
if not field.startswith("_"):
annotations[field] = Optional[annotations[field]]
from typing import get_origin
for field, annotation in annotations.items():
if not field.startswith("_"):
# Check if annotation is already Optional
origin = get_origin(annotation)
if origin is not Optional:
annotations[field] = Optional[annotation]


### `PartialType`

`PartialType` metaclass is used to extend your type but makes its all field optional. Consider
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (typo): Correct grammar: 'makes its all field optional' should be 'makes all its fields optional.'

The correct phrasing is 'makes all its fields optional.'

Suggested change
`PartialType` metaclass is used to extend your type but makes its all field optional. Consider
`PartialType` metaclass is used to extend your type but makes all its fields optional. Consider

@cikay
Copy link
Author

cikay commented Sep 21, 2025

It was closed when I deleted the fork. Here is the original PR

@botberry
Copy link
Member

botberry commented Sep 21, 2025

Hi, thanks for contributing to Strawberry 🍓!

We noticed that this PR is missing a RELEASE.md file. We use that to automatically do releases here on GitHub and, most importantly, to PyPI!

So as soon as this PR is merged, a release will be made 🚀.

Here's an example of RELEASE.md:

Release type: patch

Description of the changes, ideally with some examples, if adding a new feature.

Release type can be one of patch, minor or major. We use semver, so make sure to pick the appropriate type. If in doubt feel free to ask :)

Here's the tweet text:

🆕 Release (next) is out! Thanks to Muzaffer Cikay for the PR 👏

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

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.

Greptile Summary

This PR introduces a new PartialType metaclass to the strawberry.tools module that addresses a common code duplication problem in GraphQL schema design. The metaclass automatically converts all fields inherited from parent classes into optional fields with default values of strawberry.UNSET. This allows developers to create partial types (like UserUpdate, UserQuery) that inherit from a base type (UserCreate) without manually redefining every field as optional.

The implementation works by:

  1. Traversing the Method Resolution Order (MRO) to collect field annotations from base classes
  2. Wrapping all collected annotations with Optional[] types
  3. Setting default values to strawberry.UNSET for fields that don't already have values
  4. Integrating with Strawberry's existing type definition system

The feature is exported from strawberry.tools, includes comprehensive tests validating field properties and inheritance behavior, and provides documentation with usage examples. This fits into Strawberry's ecosystem as a developer productivity tool that reduces boilerplate code when working with related GraphQL types that have different optionality requirements.

PR Description Notes:

  • Missing import statement in the second code example (from typing import Optional)
  • Inconsistent spacing in code blocks

Confidence score: 2/5

  • This PR has significant implementation issues that could cause runtime errors and unexpected behavior in production
  • Score reflects critical flaws in the metaclass implementation including unsafe MRO traversal, potential attribute access errors, and lack of proper error handling
  • Pay close attention to strawberry/tools/partialtype.py - the core implementation needs thorough review and likely refactoring before merge

Context used:

Context - When documenting features, ensure that explanations are clear and provide context on why certain functionalities are useful, especially when dealing with integrations like Pydantic. (link)
Context - In tests involving Pydantic models, ensure that the tests accurately reflect the intended behavior by using the correct instances and values. Avoid copy-pasting code that may lead to confusion. (link)

5 files reviewed, 9 comments

Edit Code Review Bot Settings | Greptile

assert query_role.graphql_name is None
assert query_role.default is strawberry.UNSET
assert isinstance(query_role.type, StrawberryOptional)
assert query_role.type.of_type is RoleInput
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Missing newline at end of file

Comment on lines +105 to +106

```py
Copy link
Contributor

Choose a reason for hiding this comment

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

syntax: Missing required imports. The example uses Optional and strawberry.ID but doesn't import them.

Suggested change
```py
from typing import Optional
from strawberry.tools import PartialType
@strawberry.type

@strawberry.type
class UserUpdate(UserCreate, metaclass=PartialType):
pass

Copy link
Contributor

Choose a reason for hiding this comment

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

syntax: Missing default value assignment. Based on the implementation, fields should have = strawberry.UNSET when not explicitly provided.

Suggested change
id: Optional[strawberry.ID] = strawberry.UNSET

}
```

### `PartialType`
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Missing section separator. Should have --- before the PartialType section to match the formatting of other sections.

Comment on lines +123 to +124
id: Optional[strawberry.ID]
```
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Incomplete code block structure. The </CodeGrid> tag appears without proper context or corresponding GraphQL output example like the other sections.

@@ -0,0 +1,18 @@
Release type: minor

Add `PartialType` metaclass to make fields optional dynamically.
Copy link
Contributor

Choose a reason for hiding this comment

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

style: The description could be more specific about what 'dynamically' means here - that it automatically wraps inherited fields with Optional[] and sets defaults to strawberry.UNSET

Context Used: Context - When documenting features, ensure that explanations are clear and provide context on why certain functionalities are useful, especially when dealing with integrations like Pydantic. (link)

if k not in annotations:
annotations[k] = v

fields.extend(field.name for field in base._type_definition.fields)
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Accessing _type_definition may fail if base classes haven't been processed by strawberry decorators yet during MRO traversal


for field in annotations:
if not field.startswith("_"):
annotations[field] = Optional[annotations[field]]
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Already Optional fields will be double-wrapped as Optional[Optional[T]], potentially causing type resolution issues


class PartialType(ABCMeta):
def __new__(cls, name: str, bases: tuple, namespaces: dict, **kwargs):
mro = super().__new__(cls, name, bases, namespaces, **kwargs).mro()
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Creates class instance to call mro() but doesn't use the proper instance for subsequent operations

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants