Skip to content
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

Fix nested fields update #604

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

Conversation

EvSpirit
Copy link

@EvSpirit EvSpirit commented Jul 29, 2024

Description

This PR addresses issues described in #603. Kindly ask you to review the code and share your ideas.

While working on these two major bugs I also faced another issue related to previously added Django get_or_create shortcut (see PR #362). The current implementation seems a bit inconsistent for me because get_or_create is used only in m2m relations, more than that, this shortcut relies not only on unique fields. So it definitely fixes the issue mentioned in #360 , but at the same time there may be an opposite situation when duplicated records won't be created. First I thought to identify all unique fields and constraints (e.g. using Django total_unique_constraints) to further understand if get should be used before creating an object. But it seems there are quite many caveats. So what I'm suggesting is to have the following convention: if an object input contains explicitly passed None / null pk value (not UNSET), it would indicate an intention to force create an object. If omitted, get_or_create approach will be applied (actually, it's a bit different under the hood, I refactored full_clean usage to be invoked only after get(), otherwise, full_clean may trigger unique constraint violation).

Personally I think that it should not be hardcoded somewhere in the lib and this approach looks only as a workaround for me, probably it's a good idea to further extend input types or probably integrate forms to precisely control fields cleaning and saving, that's for further discussion and may be a a good feature request (as while working on the issue I noticed that full_clean options usability may be limited in case of nested relations).

It the current idea looks good, I think it would be better to update docs respectively.

Other than proposed fixes for the issues mentioned above, there is some minor code refactoring and cleanup:

Please let me know if it is better to move out such changes to another PR.

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. (probably - to be discussed)
  • 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

This pull request fixes issues with nested fields update, refines the full_clean process, and enhances the handling of get_or_create for better consistency. It also includes new tests to cover these changes and removes redundant test code.

  • Bug Fixes:
    • Resolved issues with nested fields update, ensuring proper handling of unique constraints and nested relations.
  • Enhancements:
    • Refactored the full_clean usage to be invoked only after get() to prevent unique constraint violations.
    • Exposed full_clean options to DjangoMutationCUD for better control over validation.
    • Improved handling of get_or_create to support explicit None/null pk values for forced object creation.
  • Tests:
    • Added tests for multiple-level nested m2m creation and list input creation.
    • Included tests for nested unique together update mutation to ensure no unique constraint failures.
    • Removed unused code in tests and cleaned up inherited schema definitions.

…), add one more test to check nested object with constraint is not duplicated in case of multiple mutation invocation (see issue strawberry-graphql#360)
…function to extract nested object model properly
…ested objects creation), cleanup code, remove unused Issue `comments` field, remove duplicated inherited schema definition
… value if explicitly passed in mutation input data to force create object (workaround for previously added `get_or_create`)
Copy link
Contributor

sourcery-ai bot commented Jul 29, 2024

Reviewer's Guide by Sourcery

This pull request addresses issues related to nested fields updates and refines the handling of Django's get_or_create shortcut. The changes include bug fixes, enhancements, and code refactoring. The main approach involves using get before create to avoid unique constraint violations and introducing a convention to force object creation when a None primary key is explicitly passed. Additionally, the PR includes new tests, schema updates, and minor code cleanups.

File-Level Changes

Files Changes
tests/test_input_mutations.py
tests/projects/schema.py
tests/projects/test_schema.py
tests/projects/models.py
tests/mutations/test_mutations.py
tests/projects/schema_inherited.py
tests/types.py
Added new test cases, updated existing tests, and modified schema and model definitions to support new nested relations and input types.
strawberry_django/mutations/resolvers.py
strawberry_django/mutations/mutations.py
strawberry_django/mutations/types.py
strawberry_django/mutations/fields.py
Refactored mutation resolver functions to handle None and UNSET primary key values, introduced full_clean options, and improved nested object creation handling.

Tips
  • Trigger a new Sourcery review by commenting @sourcery-ai review on the pull request.
  • Continue your discussion with Sourcery by replying directly to review comments.
  • You can change your review settings at any time by accessing your dashboard:
    • Enable or disable the Sourcery-generated pull request summary or reviewer's guide;
    • Change the review language;
  • You can always contact us if you have any questions or feedback.

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

Overall Comments:

  • Consider refactoring the create() function in resolvers.py to improve readability, possibly by extracting some logic into separate helper functions.
  • The new approach for handling nested unique constraints and forcing object creation is good, but please add more documentation to explain this behavior to users of the library.
Here's what I looked at during the review
  • 🟡 General issues: 4 issues found
  • 🟢 Security: all looks good
  • 🟡 Testing: 3 issues 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 to tell me if it was helpful.

strawberry_django/mutations/resolvers.py Show resolved Hide resolved
strawberry_django/mutations/resolvers.py Show resolved Hide resolved
strawberry_django/mutations/resolvers.py Outdated Show resolved Hide resolved
strawberry_django/mutations/resolvers.py Show resolved Hide resolved
@@ -299,6 +299,274 @@ def test_input_create_mutation_nested_creation(db, gql_client: GraphQLTestClient
}


@pytest.mark.django_db(transaction=True)
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): Missing edge case for empty nested list

Consider adding a test case where the nested list input is empty to ensure the mutation handles it correctly.



@pytest.mark.django_db(transaction=True)
def test_input_create_mutation_multiple_level_nested_list_input_creation(
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (testing): Missing test for invalid input

Add a test case to handle invalid input scenarios, such as missing required fields or incorrect data types, to ensure robustness.

@@ -470,6 +738,55 @@ def test_input_update_mutation(db, gql_client: GraphQLTestClient):
assert set(issue.tags.all()) == set(expected_tags)


@pytest.mark.django_db(transaction=True)
def test_input_nested_unique_together_update_mutation(
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): Test for unique constraint violation

Add a test case to explicitly check for unique constraint violations to ensure the mutation handles such scenarios correctly.

Comment on lines +102 to +104
if v.pk in (None, UNSET): # noqa: PLR6201
related_model = get_model_fields(model).get(k).related_model
v = create(info, related_model, v.data or {}) # noqa: PLW2901
Copy link
Author

Choose a reason for hiding this comment

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

Seems like pk may be either UNSET or None, in both cases it's necessary to create nested object. And to further properly perform full_clean and save we should identify related model (as in test type hierarchy most models use name clashing field names, cleaning could pass somehow, but finally object of different type could be created).

strawberry_django/mutations/resolvers.py Show resolved Hide resolved
Comment on lines +379 to 392
try:
# Instead of using `get_or_create` shortcut first use `get()`
# (which will also raise `DoesNotExist` in case pk `None` value explicitly
# passed), if no records found - invoke `full_clean()` and `create`
# right after. The idea is to bypass cleaning first to allow getting
# an object by e.g. unique fields. In case `full_clean()` invoked before,
# it would raise unique constraint validation error
instance = model._default_manager.get(**create_kwargs)
except model.DoesNotExist:
# Creating the instance directly via create() without full-clean will
# raise ugly error messages. To generate user-friendly ones, we want
# full-clean() to trigger form-validation style error messages.
full_clean_options = full_clean if isinstance(full_clean, dict) else {}
dummy_instance.full_clean(**full_clean_options) # type: ignore
Copy link
Author

Choose a reason for hiding this comment

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

Instead of using get_or_create shortcut I finally came up with a decision to use get and create separately, but invoke full_clean only after get() is invoked as full_clean may raise unique constraint violation exception (e.g. in use case described in #360 )

@@ -552,16 +570,33 @@ def update_m2m(

use_remove = True
if isinstance(field, ManyToManyField):
manager = cast("RelatedManager", getattr(instance, field.attname))
remote_field_name = field.attname
reverse_field_name = field.remote_field.related_name
Copy link
Author

Choose a reason for hiding this comment

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

Have some doubts here, probably there is any other way to get reverse relationship name.

@@ -171,6 +181,21 @@ def name_with_priority(self) -> str:
return f"{self.kind}: {self.priority}"


class Version(NamedModel):
Copy link
Author

Choose a reason for hiding this comment

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

I tried not to further extend types hierarchy, but this one is required to cover #362 changes

issues: "RelatedManager[Issue]"

id = models.BigAutoField(
verbose_name="ID",
primary_key=True,
)

name = models.CharField(max_length=255, unique=True)
Copy link
Author

Choose a reason for hiding this comment

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

Also related to #362 . Not only unique_together may be the case but also a single unique field.

Comment on lines +305 to +308
@strawberry_django.input(Issue)
class IssueInputWithMilestones(IssueInput):
milestone: "MilestoneInputPartial"

Copy link
Author

Choose a reason for hiding this comment

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

Workaround not to expose milestone related fields to inherited schema (as related input types were updated to cover nested CUD operations):

def test_schema_with_inheritance(snapshot: Snapshot):
@strawberry_django.type(Project)
class ProjectTypeSubclass(ProjectType): ...
@strawberry_django.type(Milestone)
class MilestoneTypeSubclass(MilestoneType): ...
@strawberry_django.input(Issue)
class IssueInputSubclass(IssueInput): ...
@strawberry.type
class Query:
project: Optional[ProjectTypeSubclass] = strawberry_django.node()
milestone: Optional[MilestoneTypeSubclass] = strawberry_django.node()

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.

Thanks for the PR :)

I still need to take another better look at it (it is huge =P), but left some comments about some stuff that is worrying me

strawberry_django/mutations/resolvers.py Outdated Show resolved Hide resolved
strawberry_django/mutations/resolvers.py Outdated Show resolved Hide resolved
# right after. The idea is to bypass cleaning first to allow getting
# an object by e.g. unique fields. In case `full_clean()` invoked before,
# it would raise unique constraint validation error
instance = model._default_manager.get(**create_kwargs)
Copy link
Member

Choose a reason for hiding this comment

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

Is this safe? I mean, how can we ensure that create_kwargs will identify an object by unique fields?

Suppose that we have a model that has a name, and it is not unique nor something that should identify it. Then we get create_kwargs = {"name": "Foobar"}. If we already have that object, wouldn't this get that object by mistake instead of creating a new one (which is what we want)?

Copy link
Author

Choose a reason for hiding this comment

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

Yes, you are completely right, that is my main concern. Unfortunately an object with Foobar name will be returned and no new object created. Django built-inget_or_create shortcut will do exactly the same, I tried to deep-dive into Django code to find any implementation of identifying all unique fields (FK, unique prop/other unique constraints), but it seems that's tricky.

This idea comes from #360, as I tried to unify nested object creation, something breaks here, either #360 fix or new object creation. I shared my thoughts in the current PR description, could you please also have a look?

The idea to identify all unique fields seems promising, but I'm not sure that we can easily cover all possible scenarios, probably there are some DB-specific tricks also. I could not find any built-in implementation for that, Django total_unique_constraints property does not solve that problem.

I see two options here to keep both #360 fix and duplicated objects creation:

  • Introduce convention to explicitly add id=None in payload
  • Identify all unique fields and execute get() only if unique fields are in the query

Please share your thoughts. I'd personally prefer option 2, but some more research needed, have some doubts here. Option 1 looks a bit weird, but it's bullet-proof.

Probably option 3 is to remove get() and always execute full_clean followed by create, but it would break #360.

Copy link
Author

@EvSpirit EvSpirit Jul 30, 2024

Choose a reason for hiding this comment

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

I've checked Django code for unique fields validations, there are a few helpful fragments:

  1. This one covers unique_together and total_unique_constraints
    https://github.com/django/django/blob/9cf9c796be8dd53bc3b11355ff39d65c81d7be6d/django/contrib/admin/views/main.py#L473-L484
constraint_field_names = (
    *self.lookup_opts.unique_together,
    *(
        constraint.fields
        for constraint in self.lookup_opts.total_unique_constraints
    ),
)
  1. unique field + null check
    https://github.com/django/django/blob/9cf9c796be8dd53bc3b11355ff39d65c81d7be6d/django/contrib/admin/views/main.py#L441-L445
total_ordering_fields = {"pk"} | {
    field.attname
    for field in self.lookup_opts.fields
    if field.unique and not field.null
}

So what we can try to do is to extract all unique fields using these three "sources" (unique, unique_together and UniqueConstraint), what do you think? I suppose in case only one of three types is used it should be relatively simple, but in case multiple one are in, that may be tricky.

But I still have doubts, should such a logic be a part of the library? Please share your thoughts.

Copy link
Member

Choose a reason for hiding this comment

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

Although I do like the "identify the unique sources" idea, I'm worried if that could end up reusing some object where we were supposed to create a new one (and if it was unique, the user would maybe expect the code to break)

I'm thinking about a fourth option: What about expanding key_attr (or creating another field and deprecating it) to tell the list of fields/field combinations that are to be considered "keys"? Just like unique_together = [("foo", "bar"), ("bin",)] for the django model

In that example, someone could only pass "bin" to act like "get_or_create", but ("foo", "bar") would still try to only create, etc.

strawberry_django/mutations/resolvers.py Show resolved Hide resolved
result = mutation(
'{ fruits: updateFruits(data: { types: [{name: "apple"}, {name: "apple"}]}) { id types { id name }}}'
'{ fruits: updateFruits(data: { types: [{ name: "apple"}, {id: null, name: "apple"}]}) { id types { id name }}}'
Copy link
Member

Choose a reason for hiding this comment

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

This is what I was afraid in a previous comment. Should this case be forced to pass id: null? name is not an fk and neither a key that is used to reference the model, so it seems to me that we should not need this change to make this test work

Copy link
Author

Choose a reason for hiding this comment

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

Yes, unfortunately without explicit id: null duplicated object won't be created as Django ORM get(...) method would return an object found by name.

Provided more details in the previous reply: https://github.com/strawberry-graphql/strawberry-django/pull/604/files#r1696994011, let's continue discussion there if you don't mind

@@ -1116,21 +1435,21 @@ def test_mutation_full_clean_with_kwargs(db, gql_client: GraphQLTestClient):

res = gql_client.query(
query,
{"input": {"title": "ABC", "fullCleanOptions": True}},
{"input": {"pk": None, "title": "ABC", "fullCleanOptions": True}},
Copy link
Member

Choose a reason for hiding this comment

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

In those tests here, what would happen without this change?

Copy link
Author

Choose a reason for hiding this comment

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

Comment on lines -22 to -46
@strawberry_django.type(Project)
class ProjectTypeSubclass(ProjectType): ...

@strawberry_django.type(Milestone)
class MilestoneTypeSubclass(MilestoneType): ...

@strawberry_django.input(Issue)
class IssueInputSubclass(IssueInput): ...

@strawberry.type
class Query:
project: Optional[ProjectTypeSubclass] = strawberry_django.node()
milestone: Optional[MilestoneTypeSubclass] = strawberry_django.node()

@strawberry.type
class Mutation:
create_issue: IssueType = mutations.create(
IssueInputSubclass,
handle_django_errors=True,
argument_name="input",
)

schema = strawberry.Schema(query=Query, mutation=Mutation)
snapshot.snapshot_dir = SNAPSHOTS_DIR
snapshot.assert_match(str(schema), "schema_with_inheritance.gql")
Copy link
Member

Choose a reason for hiding this comment

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

Why did you have to change this?

Copy link
Author

Choose a reason for hiding this comment

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

Seems that there is a duplicated fragment of code:

@strawberry_django.type(Project)
class ProjectTypeSubclass(ProjectType): ...
@strawberry_django.type(Milestone)
class MilestoneTypeSubclass(MilestoneType): ...
@strawberry_django.type(Issue)
class IssueInputSubclass(IssueInput): ...
@strawberry.type
class Query:
project: Optional[ProjectTypeSubclass] = strawberry_django.node()
milestone: Optional[MilestoneTypeSubclass] = strawberry_django.node()
@strawberry.type
class Mutation:
create_issue: IssueType = mutations.create(
IssueInputSubclass,
handle_django_errors=True,
argument_name="input",
)
schema = strawberry.Schema(query=Query, mutation=Mutation)

and

@strawberry_django.type(Project)
class ProjectTypeSubclass(ProjectType): ...
@strawberry_django.type(Milestone)
class MilestoneTypeSubclass(MilestoneType): ...
@strawberry_django.input(Issue)
class IssueInputSubclass(IssueInput): ...
@strawberry.type
class Query:
project: Optional[ProjectTypeSubclass] = strawberry_django.node()
milestone: Optional[MilestoneTypeSubclass] = strawberry_django.node()
@strawberry.type
class Mutation:
create_issue: IssueType = mutations.create(
IssueInputSubclass,
handle_django_errors=True,
argument_name="input",
)
schema = strawberry.Schema(query=Query, mutation=Mutation)

So I tried to remove duplicated code. Or am I missing something?

Copy link
Member

Choose a reason for hiding this comment

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

Oh, that schema_inherited.py is not actually used anywhere (maybe it is a left over from some old attempt)

I think the ideal here is to remove that file and have the test "as is", as it is usually better to have everything defined inside the test itself.

I'll send a commit to main removing that file

@axieum
Copy link

axieum commented Sep 19, 2024

I'd love to see this merged. Happy to help where I can. 😄

@bellini666
Copy link
Member

I'd love to see this merged. Happy to help where I can. 😄

I'm mostly waiting for the tests to pass and the adjustment to the conflict to review this again.

Not sure if @EvSpirit is going to tackle those, but if not, I do appreciate the help to finish this PR and merge it :)

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.

3 participants