Skip to content
New issue

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

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

Already on GitHub? # to your account

Support multi-level nested create/update with model full_clean() #659

Conversation

philipstarkey
Copy link
Contributor

@philipstarkey philipstarkey commented Nov 15, 2024

Description

This implements the core functionality of #604, allowing multi-level create/update of nested resources. It also ensures that model.full_clean() is called before creating a new resource, which ensures that internal information about constraint names doesn't leak out to the client via IntegrityError messages (see #398).

To solve this, we allow update_m2m to recursively call create(), ensuring that the logic for creation of records uses a consistent code path regardless of what depth in the nested data it is created at.

Notes

This is not everything from #604, nor does it fix all of #603. It does fix all of #398. It seemed like #604 was getting too large to review though, so I'm hoping this approach of breaking it up into smaller chunks will work better.

Part of the reason why this approach was a bit easier was that I didn't try to preserve the change from #362. This change didn't come with any tests, has probably been superseded by the key_attr functionality that was added after the fix was merged, and it seems risky to assume that the default behaviour should be to reuse an existing model instance just because some fields match (as pointed out by @bellini666 previously in this comment).

From the changes in this PR, I see several other subsequent bugfixes/improvements that could be made to address the remaining issues from #603 and comments raised in the previous PR #604:

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

Implement support for multi-level nested create and update operations with model full_clean() to enhance data integrity and prevent constraint name leaks. Add tests to verify the new functionality.

New Features:

  • Implement multi-level nested create and update functionality for resources, allowing recursive calls to create nested resources.

Bug Fixes:

Enhancements:

  • Add support for excluding certain many-to-many fields from processing during create and update operations to prevent circular references.

Tests:

  • Add tests for multi-level nested creation and update mutations to ensure the new functionality works as expected.

Copy link
Contributor

sourcery-ai bot commented Nov 15, 2024

Reviewer's Guide by Sourcery

This PR implements multi-level nested create/update functionality for resources and ensures proper model validation through full_clean(). The implementation focuses on allowing nested resource creation at any depth while maintaining consistent code paths and proper validation.

Sequence diagram for multi-level nested create/update

sequenceDiagram
    actor User
    participant Client
    participant Server
    participant Database

    User->>Client: Initiate create/update request
    Client->>Server: Send GraphQL mutation
    Server->>Database: Validate data with full_clean()
    alt Validation passes
        Server->>Database: Create/update nested resources
        Database-->>Server: Return success
        Server-->>Client: Return success response
    else Validation fails
        Server-->>Client: Return validation error
    end
Loading

Updated class diagram for mutation resolvers

classDiagram
    class MutationResolver {
        +create(info, model, data, key_attr, full_clean, pre_save_hook, exclude_m2m)
        +update(info, model, data, key_attr, full_clean, pre_save_hook, exclude_m2m)
        +_create(info, manager, data, key_attr, full_clean, pre_save_hook, exclude_m2m)
        +update_m2m(info, instance, field, value, key_attr)
    }
    class ProjectInputPartial {
        +name
        +milestones
    }
    class MilestoneInputPartial {
        +name
        +issues
    }
    class MilestoneIssueInputPartial {
        +name
        +tags
    }
    MutationResolver --> ProjectInputPartial
    ProjectInputPartial --> MilestoneInputPartial
    MilestoneInputPartial --> MilestoneIssueInputPartial
Loading

File-Level Changes

Change Details Files
Added support for multi-level nested resource creation and updates
  • Modified update_m2m to recursively handle nested resource creation
  • Added reference instance data to prevent circular references
  • Implemented consistent creation path through _create helper function
  • Added exclude_m2m parameter to prevent circular references in nested creation
strawberry_django/mutations/resolvers.py
Added model validation through full_clean()
  • Ensured full_clean is called before creating new resources
  • Added validation error handling to prevent leaking internal constraint names
strawberry_django/mutations/resolvers.py
Added comprehensive test coverage for nested mutations
  • Added tests for multi-level nested creation
  • Added tests for multi-level nested updates
  • Added tests for nested model validation
  • Added test cases for shared resource references
tests/test_input_mutations.py
Extended schema to support nested mutations
  • Added MilestoneIssueInputPartial type for nested issue creation
  • Added create_project_with_milestones mutation
  • Updated MilestoneInputPartial to support nested issues
tests/projects/schema.py

Assessment against linked issues

Issue Objective Addressed Explanation
#398 Perform validation check on nested fields when they are updated via the parent field

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.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a 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. You can also use
    this command to specify where the summary should be inserted.

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

Here's what I looked at during the review
  • 🟢 General issues: all looks good
  • 🟢 Security: all looks good
  • 🟡 Testing: 1 issue found
  • 🟢 Complexity: all looks good
  • 🟢 Documentation: all looks good

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.

@@ -377,6 +380,479 @@ def test_input_create_with_m2m_mutation(db, gql_client: GraphQLTestClient):
}
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Consider adding negative test cases for nested creation

While the happy path is well tested, it would be valuable to add test cases that verify behavior when invalid data is provided at different nesting levels (e.g., invalid milestone data, invalid issue data, invalid tag data). This would help ensure proper error handling and validation at each level.

@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("invalid_data", [
    {"milestone": {"name": ""}},
    {"issue": {"title": ""}},
    {"tags": [{"name": " "}]}
])
def test_nested_creation_with_invalid_data(invalid_data):
    with pytest.raises(ValidationError):
        create_project_with_relations(invalid_data)

@codecov-commenter
Copy link

codecov-commenter commented Dec 8, 2024

Codecov Report

Attention: Patch coverage is 87.50000% with 3 lines in your changes missing coverage. Please review.

Project coverage is 89.09%. Comparing base (e76b3fa) to head (e719aa7).
Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
strawberry_django/mutations/resolvers.py 87.50% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #659      +/-   ##
==========================================
+ Coverage   88.98%   89.09%   +0.10%     
==========================================
  Files          41       41              
  Lines        3731     3759      +28     
==========================================
+ Hits         3320     3349      +29     
+ Misses        411      410       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@bellini666
Copy link
Member

Hi @philipstarkey ,

Thanks for the PR, and sorry for taking a while to take a look at it! I've been busy these past few weeks.

It's looking good! Could you rebase on top of main just to make sure that everything is ok after some changes that got merged there? Then it should be good to merge!

…e benefit of consistently calling `full_clean()` before creating related instances.

This does remove the `get_or_create()` calls and instead uses `create` only. The expectation here is that `key_attr` could and should be used to indicate what field should be used as the unique identifier, and not something hard coded that could have unintended side effects when creating related instances that don't have unique constraints and expect new instances to always be created.
@philipstarkey philipstarkey force-pushed the philipstarkey/603-nested-fields branch from 5230b89 to b73c0fb Compare December 10, 2024 00:43
@philipstarkey
Copy link
Contributor Author

@bellini666 No worries - I saw from a comment in another PR that you were moving so I completely understand you had more important priorities!

I've rebased and pushed - tests passed locally for me :)

@stygmate
Copy link
Contributor

stygmate commented Dec 12, 2024

@philipstarkey in the case below:

models:

class Fruit(models.Model):
    """A tasty treat"""
    name = models.CharField(
        max_length=20,
    )
    color = models.ForeignKey(
        "Color",
        on_delete=models.CASCADE,
        related_name="fruits",
        blank=True,
        null=True,
    )

class Color(models.Model):
    name = models.CharField(
        max_length=20,
        help_text="field description",
    )

    detailled_color = models.ForeignKey(
        "DetailledColor",
        on_delete=models.CASCADE,
        related_name="colors",
        blank=True,
        null=True,
    )

class DetailledColor(models.Model):
    name = models.CharField(
        max_length=20,
    )

inputs:

@strawberry_django.partial(models.Fruit)
class FruitInput:
    color: 'ColorInput'
    ...

@strawberry_django.partial(models.Color)
class ColorInput:
    detailled_color: 'DetailledColorInput'
    ...

@strawberry_django.partial(models.DetailledColor)
class DetailledColorInput:
    ...

does this PR covers the case of cretion of nested objects like this: fruit containing a color containing a detailled_color ?

@philipstarkey
Copy link
Contributor Author

does this PR covers the case of cretion of nested objects like this: fruit containing a color containing a detailled_color ?

Hmm, possibly not with the foreign key relations defined in that way? I have an example of creating nested tags in issues in milestones, in projects, but I think the foreign key's are on the other side of the relationships to your example. I suspect it's this line that would need to be updated to support your example (e.g. this should call the strawberry-django create function rather than using the manager directly):

value = field.related_model._default_manager.create( # noqa: PLW2901
**value_data,
)

@stygmate
Copy link
Contributor

@philipstarkey I will try this modification on your pr code and then let you know if it works.

@bellini666
Copy link
Member

@stygmate I'll wait for you to test what you want to test to see if this will need any extra modifications. When you give me an "ok", I'll proceed with the merge

@philipstarkey
Copy link
Contributor Author

philipstarkey commented Dec 16, 2024

@bellini666 @stygmate I have extended an existing test (that pre-dates this PR) to test nested creation of foreign key relations to an even deeper depth, using a schema approximately equivalent to the one detailed above. Identified a couple of issues and fixed them in e719aa7. Has the added bonus of fixing some other edge cases with full_clean()/key_attr not being passed through.

@stygmate
Copy link
Contributor

@philipstarkey don't work for me.

mutation MyMutation {
  createFruit(
    data: {
      name: "fraise", 
      color: {
        name: "ruge", 
        detailledColor: {
          name: "2"
        }
      }
    }
  ) {
    id
  }
}

result in:

{
  "data": null,
  "errors": [
    {
      "message": "Cannot assign \"{'detailled_color': {'name': '2'}, 'name': 'ruge'}\": \"Fruit.color\" must be a \"Color\" instance.",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "createFruit"
      ]
    }
  ]
}

@philipstarkey
Copy link
Contributor Author

@stygmate Ah, I see. It looks like this is a pre-existing non-relay bug. This PR might make it more likely to be hit, but I am pretty sure it was there in some variations of single-level deep nested create/update (e.g. m2m). The root cause is that only relay input types produce ParsedObject types, and thus _parse_data only recursively calls create()/update() for nested relations for those types. If you switch to relay types, I think it will work as per my test case included in this PR.

@bellini666 This is definitely a bug, but I think out-of-scope for trying to fix in this PR, as it predates my changes? I think the bug is just that these lines don't get hit for non-relay types:

if isinstance(v, ParsedObject):
if v.pk is None:
v = create(info, model, v.data or {}) # noqa: PLW2901
elif isinstance(v.pk, models.Model) and v.data:
v = update(info, v.pk, v.data, key_attr=key_attr) # noqa: PLW2901
But it's hard to know without spending a lot of time digging in to parts of the codebase I don't yet understand.

@bellini666
Copy link
Member

@philipstarkey I think so, that issue doesn't seem to be related to the scope of this PR.

@stygmate what do you think? Do you have any other concerns? We can probably track that issue you mentioned in a separated ticket

@stygmate
Copy link
Contributor

@bellini666 I would have liked to investigate a little more to give myself a more informed opinion but I don't have the time, if separating it into another ticket seems the best thing to do then here we go! 👍

@bellini666
Copy link
Member

Ok, I'm going to merge this.

There's an issue with typing, but for some reason I'm not able to push a fix to it here 🤔

I'm going to merge it and fix in a following commit

@bellini666 bellini666 merged commit 0967d04 into strawberry-graphql:main Dec 21, 2024
21 of 22 checks passed
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Perform validation check on updating nested fields
4 participants