Skip to content

Improve support for type inheritance from other mapped types #253

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

Ckk3
Copy link
Contributor

@Ckk3 Ckk3 commented Jun 11, 2025

Description

Ensures consistent behavior when extending mapped types, resolving conflicts between inherited and model-defined fields more predictably.

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

Improve support for type inheritance by centralizing generated resolver handling, merging annotations appropriately for different Python versions, and updating documentation and tests to cover inheritance, exclusion, and conflict resolution

Bug Fixes:

  • Ensure consistent inheritance behavior for mapped types by resolving conflicts between inherited and model-defined fields and merging annotations predictably across Python versions

Enhancements:

  • Introduce a helper to centralize wrapping of generated resolvers from both mapped and original types

Documentation:

  • Add a Type Inheritance section to the README with examples on inheritance, exclusion, conflict resolution, and overrides
  • Include RELEASE.md detailing patch release notes for inheritance improvements

Tests:

  • Add tests covering inheritance scenarios including basic inheritance, exclusion of fields, duplicate field conflicts, and explicit overrides

@Ckk3 Ckk3 self-assigned this Jun 11, 2025
Copy link

codecov bot commented Jun 11, 2025

Codecov Report

Attention: Patch coverage is 94.85294% with 7 lines in your changes missing coverage. Please review.

Project coverage is 90.16%. Comparing base (fa4eb67) to head (0aa5033).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #253      +/-   ##
==========================================
+ Coverage   89.72%   90.16%   +0.44%     
==========================================
  Files          17       18       +1     
  Lines        1936     2065     +129     
  Branches      145      159      +14     
==========================================
+ Hits         1737     1862     +125     
+ Misses        125      123       -2     
- Partials       74       80       +6     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link

codspeed-hq bot commented Jun 11, 2025

CodSpeed Performance Report

Merging #253 will not alter performance

Comparing Ckk3:issue-249 (0aa5033) with main (fa4eb67)

Summary

✅ 1 untouched benchmarks

@Ckk3 Ckk3 changed the title FIx MissingReturnAnotationError Improve support for type inheritance from other mapped types Jun 20, 2025
@Ckk3 Ckk3 marked this pull request as ready for review June 20, 2025 18:45
@Ckk3 Ckk3 requested review from bellini666 and patrick91 June 20, 2025 18:45
@botberry
Copy link
Member

botberry commented Jun 20, 2025

Thanks for adding the RELEASE.md file!

Here's a preview of the changelog:


This release improves how types inherit fields from other mapped types using @mapper.type(...).
You can now safely inherit from another mapped type, and the resulting GraphQL type will include all expected fields with predictable conflict resolution.

Some examples:

  • Basic Inheritance:
@mapper.type(ModelA)
class ApiA:
    pass


@mapper.type(ModelB)
class ApiB(ApiA):
    # ApiB inherits all fields declared in ApiA
    pass
  • The __exclude__ option continues working:
@mapper.type(ModelA)
class ApiA:
    __exclude__ = ["relationshipB_id"]


@mapper.type(ModelB)
class ApiB(ApiA):
    # ApiB will have all fields declared in ApiA, except "relationshipB_id"
    pass
  • If two SQLAlchemy models define fields with the same name, the field from the model inside .type(...) takes precedence:
class ModelA(base):
    __tablename__ = "a"

    id = Column(String, primary_key=True)
    example_field = Column(String(50))


class ModelB(base):
    __tablename__ = "b"

    id = Column(String, primary_key=True)
    example_field = Column(Integer, autoincrement=True)


@mapper.type(ModelA)
class ApiA:
    # example_field will be a String
    pass


@mapper.type(ModelB)
class ApiB(ApiA):
    # example_field will be taken from ModelB and will be an Integer
    pass
  • If a field is explicitly declared in the mapped type, it will override any inherited or model-based definition:
class ModelA(base):
    __tablename__ = "a"

    id = Column(String, primary_key=True)
    example_field = Column(String(50))


class ModelB(base):
    __tablename__ = "b"

    id = Column(String, primary_key=True)
    example_field = Column(Integer, autoincrement=True)


@mapper.type(ModelA)
class ApiA:
    pass


@mapper.type(ModelB)
class ApiB(ApiA):
    # example_field will be a Float
    example_field: float = strawberry.field(name="exampleField")

Copy link
Contributor

sourcery-ai bot commented Jun 20, 2025

Reviewer's Guide

This PR refactors the mapping conversion logic to better support inheritance of mapped types by extracting resolver handling into a helper, refining annotation merging based on Python version, and updating both documentation and tests to cover predictable conflict resolution between inherited, model-defined, and manually declared fields.

Class diagram for mapped type inheritance and conflict resolution

classDiagram
    class ModelA {
        +id: String
        +common_field: String
    }
    class ModelB {
        +id: String
        +common_field: Integer
        +extra_field: String
    }
    class ApiA {
        <<mapped type>>
        __exclude__: List[str]
        +common_field
    }
    class ApiB {
        <<mapped type>>
        +common_field
        +extra_field: float
    }
    ModelA <|-- ApiA
    ModelB <|-- ApiB
    ApiA <|-- ApiB
    note for ApiB "common_field comes from ModelB, extra_field is manually overridden as float"
Loading

Class diagram for annotation merging and field resolution in convert()

classDiagram
    class Mapper {
        +_handle_columns()
    }
    class ConvertHelper {
        +_get_generated_field_keys(type_, old_annotations) Tuple[List[str], Dict[str, Any]]
        +convert(type_)
    }
    Mapper <.. ConvertHelper : uses
    ConvertHelper : - _get_generated_field_keys(type_, old_annotations)
    ConvertHelper : - convert(type_)
    ConvertHelper : - Handles merging of annotations based on Python version
Loading

File-Level Changes

Change Details Files
Extracted generated resolver handling into helper
  • Introduced _get_generated_field_keys to wrap and collect generated resolvers from a type and its __original_type
  • Replaced inline resolver wrapping loop in convert with a call to the new helper
src/strawberry_sqlalchemy_mapper/mapper.py
Enhanced annotation merging logic
  • Added version check for Python ≤3.9 to conditionally update annotations only when missing
  • Simplified default annotation update path for newer Python versions
src/strawberry_sqlalchemy_mapper/mapper.py
Documented type inheritance behavior
  • Added 'Type Inheritance' section in README.md detailing inheritance rules, exclude, conflict resolution, and manual overrides
  • Created RELEASE.md entries summarizing inheritance improvements and examples
README.md
RELEASE.md
Added inheritance-focused tests
  • Created comprehensive tests in test_mapper_inheritance.py covering basic inheritance, exclusion, duplicate field override, and manual field declaration
  • Verified schema outputs for various inheritance scenarios
tests/test_mapper_inheritance.py

Assessment against linked issues

Issue Objective Addressed Explanation
#249 Fix the MissingReturnAnnotationError when using inheritance with Strawberry models and SQLAlchemy model relationships.
#249 Ensure consistent behavior when extending mapped types, resolving conflicts between inherited and model-defined fields more predictably.

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

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 @Ckk3 - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments

### Comment 1
<location> `src/strawberry_sqlalchemy_mapper/mapper.py:656` </location>
<code_context>
+        def _get_generated_field_keys(type_, old_annotations) -> Tuple[List[str], Dict[str, Any]]:
</code_context>

<issue_to_address>
The helper function mutates its input dictionary, which could lead to side effects.

_get_generated_field_keys modifies old_annotations in place. To prevent unintended side effects, make a shallow copy of old_annotations inside the function.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
        def _get_generated_field_keys(type_, old_annotations) -> Tuple[List[str], Dict[str, Any]]:
            generated_field_keys = set()
=======
        def _get_generated_field_keys(type_, old_annotations) -> Tuple[List[str], Dict[str, Any]]:
            old_annotations = old_annotations.copy()
            generated_field_keys = set()
>>>>>>> REPLACE

</suggested_fix>

### Comment 2
<location> `README.md:192` </location>
<code_context>
+class ApiB(ApiA):
+    # Inherits fields from ApiA, except "id"
+    # "common_field" will come from ModelB, not ModelA, so it will be a Integer
+    # "extra_field" will be overrided and will be a float now instead of the String type declared in ModelB:
+    extra_field: float = strawberry.field(name="extraField")
+```
</code_context>

<issue_to_address>
Typo: 'overrided' should be 'overridden'.

Please update the comment to use 'overridden' instead of 'overrided'.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
    # "extra_field" will be overrided and will be a float now instead of the String type declared in ModelB:
=======
    # "extra_field" will be overridden and will be a float now instead of the String type declared in ModelB:
>>>>>>> REPLACE

</suggested_fix>

### Comment 3
<location> `RELEASE.md:62` </location>
<code_context>
+
[email protected](ModelB)
+class ApiB(ApiA):
+    # example_field will be taken from ModelB and will be a Integer
+    pass
+```
</code_context>

<issue_to_address>
Grammar: 'a Integer' should be 'an Integer'.

Update the comment to use 'an Integer' instead of 'a Integer'.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
@mapper.type(ModelB)
class ApiB(ApiA):
    # example_field will be taken from ModelB and will be a Integer
    pass
=======
@mapper.type(ModelB)
class ApiB(ApiA):
    # example_field will be taken from ModelB and will be an Integer
    pass
>>>>>>> REPLACE

</suggested_fix>

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.

Ckk3 and others added 3 commits June 20, 2025 15:47
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
@Ckk3
Copy link
Contributor Author

Ckk3 commented Jun 20, 2025

@bellini666 @patrick91, when you have a moment, I’d appreciate your review.
I haven’t merged this yet because I’d like a second opinion on the workaround I implemented for Python 3.8 and 3.9 compatibility.

Copy link
Member

@bellini666 bellini666 left a comment

Choose a reason for hiding this comment

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

In general LGTM. Just curious to understand what is the reason for the comment I've made

Comment on lines +824 to +831
# For Python versions <= 3.9, only update annotations that don't already exist
# because this versions handle inherance differently
if sys.version_info[:2] <= (3, 9):
for k, v in old_annotations.items():
if k not in type_.__annotations__:
type_.__annotations__[k] = v
else:
type_.__annotations__.update(old_annotations)
Copy link
Member

Choose a reason for hiding this comment

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

question: what is the exact difference between them? In theory they should be handling that the same

One thing that I know is that in previous versions, __annotations__ could not exist in the class if that class didn't have any annotations in it, something which I had to workaround on strawberry-django.

Is that it or something else?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @bellini666, sorry for the delay!
The problem isn’t directly related to __annotations__ existance, but to inconsistencies with inherited classes and this annotations.

In Python 3.10 and newer, the __annotations__ no longer includes the annotations from original_type (base class in this case), which caused some resolvers or type hints to be missing in the final class. So I had to add extra logic to extract those annotations manually.
I didn’t investigate this deeply, but I suspect it only happens because we’re using a decorator, which might interfere with how Python handles class inheritance and type resolution at that point.

On the other hand, in Python 3.8 and 3.9, the inherited annotations are already present in the subclass’s __annotations__. So thats why I only add missing keys, since its was raising a error when only using update().

Let me know if you see any better idea to fix this, this one was the most stable solution I found to make it work consistently across versions.

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.

MissingReturnAnnotationError
3 participants