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

New-Style Serializers #101

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

claytondaley
Copy link
Contributor

@claytondaley claytondaley commented Feb 1, 2020

This is a PR of my increasingly-mature patch (discussed in #57) that provides a new style of Serializer. Instead of depending on the parent serializer to make Create/Update decisions, each nested serializer manages its own behavior. This makes it possible to use different semantics (e.g. Get/Create) for different nested serializers and to control the fields that are used in matching (i.e. match_on).

New-style Serializers use the following semantics:

  • Get: Find a match (or fail) without updating
  • Update: Find a match (or fail) and update
  • Create: Create a new object (or fail)
  • Combined serializers (e.g. UpdateOrCreate) are also available

The current implementation correctly handles forward- and reverse- foreign key relationships as well as serialization of ManyToMany fields. A cursory skim of issues suggests that it may address #19, #26, #28, #41, #85, #89, #90, and #99. Due to design decisions I think it also fixes #48, #49, and #88.

The only thing I haven't worked out is backwards compatibility with the existing classes (e.g. preventing default behavior when the nested serializer inherits from new-style classes).

@codecov-io
Copy link

codecov-io commented Feb 1, 2020

Codecov Report

Merging #101 (7a82fb7) into master (87a1476) will decrease coverage by 9.92%.
The diff coverage is 82.31%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #101      +/-   ##
==========================================
- Coverage   98.59%   88.66%   -9.93%     
==========================================
  Files           3        3              
  Lines         213      538     +325     
==========================================
+ Hits          210      477     +267     
- Misses          3       61      +58     
Impacted Files Coverage Δ
drf_writable_nested/mixins.py 88.40% <82.31%> (-10.11%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 87a1476...608b299. Read the comment docs.

@claytondaley
Copy link
Contributor Author

I removed the last comment. It looks like I've done the same thing by default (i.e. passing the entire kwargs to each instance).

@ruscoder
Copy link
Member

ruscoder commented Feb 5, 2020

Hello @claytondaley! Thanks for the contributions, I see a lot of work have been done. I'll try to find time to review your PR.

@claytondaley
Copy link
Contributor Author

claytondaley commented Feb 5, 2020

Thanks. No urgency on my behalf. I had to get back into the package to add a more complex matching behavior for my company which prompted me to revisit/refactor the base functionality. Starting to cover your test cases helped me verify functionality during the rewrite and fix a couple bugs (e.g. field_name vs. field.source).

It has also drawn my attention to several feature gaps that will need to be resolved or documented:

  • partial
  • GenericRelation
  • some OneToOne behaviors

@claytondaley
Copy link
Contributor Author

claytondaley commented Feb 7, 2020

I went ahead and committed all of the tests from test_writable_nested_model_serializer, re-implemented using new-style serializers (found in tests/serializers.py). I currently raise an Exception for partial and GenericRelation to make it clear why those tests (6x) are failing. Technically, one of the partial tests already passes as-is since the only thing I don't do is propagate partial to nested serializers. I'll take a stab at that after I implement the work-requirement that got me back into this.

I also made a small change to test_update_raise_protected_error to get it passing. The test expects a ValidationError when trying to remove a protected relation, My current implementation raises the original ProtectedError. By contrast, for test_nested_partial_update_failed_with_empty_direct_fk_object I retain the existing logic that converts TypeError and ValueError to ValidationError.

I could catch and convert ProtectedError to ValidationError as well, but I'm surprised that serializer.save() is raising ValidationError; I expect that out of is_valid(). I wonder if you'd be open to permitting Type/Value/Protected to pass unaltered from save() as part of the documentation around using the new-style serializers.

@claytondaley
Copy link
Contributor Author

I just had a work requirement that partial might have solved so I was thinking through it. I'm not sure it makes sense to propagate partial in new style serializers. Specifically, the fields on a serializer are configured when the class is defined. To make a nested serializer partial, you'd have to do one of the following:

  1. Do it when it's defined. This is valid, but isn't dynamically configurable (e.g. the current test).
  2. Reconfigure when constructing. Unfortunately, this would affect any other (non-partial) users of the serializer.
  3. Reconfigure when calling. Unfortunately super().save() (which I use) doesn't have a kwarg for this right now.

The feature certainly could be added/supported, but I wonder if you'd agree that it's not a test worth supporting as-is.

@tybritten
Copy link

Just came across this and it looks like it could address my issue- i'm using unique_together which is breaking the curent drf-writeable-nested. I'm not sure if it would also fix my other issue- i have M:M relations between two of the sub-objects which then fails on initial creation (does not exist)

@claytondaley
Copy link
Contributor Author

If you'd like to test it out for your case, I'm happy to provide guidance and bug fixes on the brach in my repo (which will also update this PR).

@TJHeeringa
Copy link

I have many serializer in which I would like one related field to be writable, but not the other related fields. From the description this seems to fix that. Is there any indication for when this is ready/going to be merged?

@claytondaley
Copy link
Contributor Author

Yes it should work for your use case. Unless the sentiment of the maintainers have changed, the only functional gap is the ability to mix these with the legacy classes. It doesn't sounds like that matters to you. If you wanted to test it and find it satisfactory, I can try to figure out what it would take to fully integrate the two if that was the blocker. My guess is "not much".

@TJHeeringa
Copy link

Thank you for your quick response. I have installed the latest version of your branch in my venv using

pip install git+https://github.com/claytondaley/drf-writable-nested.git@da8dc88f09faa074e99d4113cbb9c100aa65fc86#egg=drf-writable-nested

(posting the command as reference for others if they want to test your branch too.)

In the next few days I should have some time to update my serializers using the new-style-serializers and test it for my use case (stuff mentioned above and #89).

@TJHeeringa
Copy link

TJHeeringa commented Dec 21, 2020

Hi @claytondaley,

I have tried to convert my setting to your new style serializer but have not succeeded.

Not everything I have is completely relevant, so I will try to reduce it to a minimal version.

Models

class Profile(models.Model):
    slug = models.SlugField(default=uuid.uuid4)
    name = models.CharField()
    associations = models.manyToMany(Association, through=Membership)

class Membership(models.Model):
    slug = models.SlugField(default=uuid.uuid4)
    profile = models.Foreignkey(Profile)
    association = models.Foreignkey(Association)

class Association(models.Model):
    slug = models.SlugField(default=uuid.uuid4)

class Data(models.Models):
    slug = models.SlugField(default=uuid.uuid4)
    value = models.CharField()
    membership = models.Foreignkey(Membership)
    association = models.Foreignkey(Association)

Serializers:

class ProfileImportSerializer(mixins.CreateOnlyNestedSerializer, HyperlinkedModelSerializer):
    class Meta:
        model=Profile
        extra_kwargs = {
            'url': {'lookup_field': 'slug'},
        }

class DataImportSerializer(mixins.CreateOnlyNestedSerializer, HyperlinkedModelSerializer):
    class Meta:
        model=Data
        extra_kwargs = {
            'url': {'lookup_field': 'slug'},
            'membership': {'lookup_field': 'slug'},
            'association': {'lookup_field': 'slug'},
        }

class MembershipImportSerializer(mixins.RelatedSaveMixin, HyperlinkedModelSerializer):
    profile = ProfileImportSerializer()
    data = DataImportSerializer(many=True)

    class Meta:
        model=Membership
        extra_kwargs = {
            'url': {'lookup_field': 'slug'},
            'profile': {'lookup_field': 'slug'},
            'association': {'lookup_field': 'slug'},
        }

I pass something like

data = [
{
    "association": "/associations/<slug>"
    "data": [
        {"value": <str1>, "association": "/associations/<slug>"},
        {"value": <str2>, "association": "/associations/<slug>"}
    ],
    "profile": {"name": <name1>}
},
{
    "association": "/associations/<slug>"
    "data": [
        {"value": <str3>, "association": "/associations/<slug>"},
        {"value": <str4>, "association": "/associations/<slug>"}
    ]
    "profile": {"name": <name2>}
}
]

to the serializer using

serializer = MembershipImportSerializer(many=True, data=data)

So far so good. When calling serializer.is_valid(raise_exception=True) no errors are triggered, but when calling serializer.save() the following error is raised:
image
image

ValueError: Cannot assign "OrderedDict([("name": <name1>)])": "Membership.profile" must be a "Profile" instance.

Not sure what goes wrong here.

--- EDIT ---

Changed HyperlinkedIdentitySerializer to HyperlinkedModelSerializer

@claytondaley
Copy link
Contributor Author

@TJHeeringa can you give me the full stack? I don't see any lines from drf-writeable-nested.

And what is HyperlinkedIdentitySerializer?

@TJHeeringa
Copy link

TJHeeringa commented Dec 26, 2020

HyperlinkedIdentitySerializerHyperlinkedModelSerializer is a default Django Rest Framework serializer that provides an url attribute based on the lookup_field.

Seems the RelatedSaveMixin doesn't get called properly when using many=True. When I change the serializer call from

serializer = MembershipImportSerializer(many=True, data=data)

to

serializer = MembershipImportSerializer(data=data[0])

then drf-writable-nested is called. I got another error, but that turned out to be that I didn't have the membership included in the fields in the Data serializer. Then it works.

Upon closer inspection of the RelatedSaveMixin I saw that there was no many_init. The NestedSaveSerializer does, so I change

class MembershipImportSerializer(mixins.RelatedSaveMixin, HyperlinkedModelSerializer):
    profile = ProfileImportSerializer()
    data = DataImportSerializer(many=True)

    class Meta:
        model=Membership
        extra_kwargs = {
            'url': {'lookup_field': 'slug'},
            'profile': {'lookup_field': 'slug'},
            'association': {'lookup_field': 'slug'},
        }

to

class MembershipImportSerializer(mixins.CreateOnlyNestedSerializerMixin, HyperlinkedModelSerializer):
    profile = ProfileImportSerializer()
    data = DataImportSerializer(many=True)

    class Meta:
        model=Membership
        extra_kwargs = {
            'url': {'lookup_field': 'slug'},
            'profile': {'lookup_field': 'slug'},
            'association': {'lookup_field': 'slug'},
        }

and then calling the serializer like

serializer = MembershipImportSerializer(many=True, data=data)

fully works.

@claytondaley
Copy link
Contributor Author

Thanks. Yes it looks like the RelatedSaveMixin needs to have a custom ListSerializer as well. I'll add a test case and figure out how much of NestedSaveListSerializer (perhaps all) needs to be included there.

@TJHeeringa
Copy link

Also seems that the ReadOnlyFields from Django Rest Framework are breaking. I added a ReadOnlyField on the Data serializer, and got
image
.

@claytondaley
Copy link
Contributor Author

claytondaley commented Dec 26, 2020

Based on the location in the code, it looks like you're matching on a read only field. In that case, the system needs a way to convert the JSON value into an internal value for the purposes of matching.

@claytondaley
Copy link
Contributor Author

OK so it looks like you might hit this code even if you don't match_on that specific field so I'll ignore the exception knowing that it will result in an error later if you attempt to match on it.

@claytondaley
Copy link
Contributor Author

claytondaley commented Dec 26, 2020

@TJHeeringa that's should be a quick fix for your read only fields. Still need to add a test. The Related ListSerializer will take me longer.

@rollue
Copy link

rollue commented Jan 11, 2021

Really looking forward to this, as we use id and uuid in models. We keep the django default id, but use uuid to expose the data in our external API's.
This will fix #19.

@claytondaley Do you have any idea how long this might take? We can wait a few weeks, but anything longer than that we have to resort to using the method described in the DRF doc.

@claytondaley
Copy link
Contributor Author

@mhoonjeon more of a question for maintainers. I'm currently unable to run/write test cases as I noted in #132. I'm not clear what the maintainers would need to actually merge this in.

@TJHeeringa
Copy link

@mhoonjeon more of a question for maintainers. I'm currently unable to run/write test cases as I noted in #132. I'm not clear what the maintainers would need to actually merge this in.

@ruscoder @ir4y Could you help us out by making clear what is needed in order to merge this very useful PR?

@ruscoder
Copy link
Member

ruscoder commented Feb 1, 2021

Hello! Sorry for the long response from our side. @claytondaley What about backward compatibility for now?

@claytondaley
Copy link
Contributor Author

@ruscoder I'm happy to work on backwards compat, I was trying to verify that some behaviors were acceptable (e.g. starting here). If it wasn't going to be accepted without improvement, compat wasn't a priority.

What's the ideal compat result?

  • A nested new-style serializer preempts default create behavior?
  • Do we need to support the reverse case i.e. old nested in new (which honestly might just work)?

FYI I have a broken bone in my elbow. I seem to type without too much pain, but it makes everything else in my day slower so I will have less time to make progress on this.

@auvipy
Copy link

auvipy commented Apr 1, 2021

I think an easier up-gradation path is the only concern. great work.

@ruscoder
Copy link
Member

ruscoder commented Apr 1, 2021

@claytondaley For existing users of the library it will be difficult to upgrade to the new style serializers but we will need to support the old-style serializers too. The code has a lot of differences and it can take much time to support both versions in one package.

So, I suggest you publish and maintain new style serializers as a separate package. We'll make links in README to the new style serializers package with a description of why it can be useful.

What do you think?

@claytondaley
Copy link
Contributor Author

claytondaley commented Apr 1, 2021

I can certainly make a new library and contribute it to something like JazzBand, but was trying to avoid splitting the base of users/contributors. I actually started with your library. I kept adding features based on our needs (often mirrored by issues here). Hoping to contribute it back, I even tried to preserve methods like field type definitions, but ended up needing enough local workarounds that it was simpler to refactor the method.

@claytondaley
Copy link
Contributor Author

claytondaley commented Apr 1, 2021

I've offered to develop a migration path to help keep the user/contributor bases together, but need a clear idea of what you would expect backwards compatibility to support.

EDIT: It may even be possible to simulate the old-style syntax while using the new-style serializers. All you really need to do is automatically create nested serializers that follow the default rules of the existing serializers. A BackwardsCompat mixin or metaclass might work for everything but the unsupported edge cases like partial and GenericRelation.

@auvipy
Copy link

auvipy commented Apr 1, 2021

documenting the migration path will be great

@TJHeeringa
Copy link

I don't know how I can add to this, but I think it a good idea to make a decision.

As far as I can see there are 3 choices:

  • include in this package; requires migration path, but not multiple packages floating around doing the same thing
  • move it to jazzband;
  • move it to drf-extentions

Not sure what the pro's and cons are for jazzband and drf-extentions, but both time the code base is split.

@DudeRandom21
Copy link

Is there any update on the approval/denial status?? I'd love to see this merged in as it fixes several issues that I've been running into (M2M fields, unique_together constraints). I understand that there's a migration consideration but seeing as this has the potential to resolved about 20% of the open issues in this repo I think there's a lot of value to it.

As for integrating this here or publishing it somewhere else I'd be inclined to agree that a unified code base/development effort has several benefits, but I'd be happy with either solution.

@codecov-commenter
Copy link

codecov-commenter commented Jul 13, 2021

Codecov Report

Merging #101 (7a82fb7) into master (b3fff32) will decrease coverage by 9.52%.
The diff coverage is 82.31%.

❗ Current head 7a82fb7 differs from pull request most recent head 74044b7. Consider uploading reports for the commit 74044b7 to get more accurate results
Impacted file tree graph

@@            Coverage Diff             @@
##           master     #101      +/-   ##
==========================================
- Coverage   98.18%   88.66%   -9.53%     
==========================================
  Files           3        3              
  Lines         220      538     +318     
==========================================
+ Hits          216      477     +261     
- Misses          4       61      +57     
Impacted Files Coverage Δ
drf_writable_nested/mixins.py 88.40% <82.31%> (-9.68%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b3fff32...74044b7. Read the comment docs.

@claytondaley
Copy link
Contributor Author

At least part of the hold-up was migration instructions. I've pushed a commit adding some -- and drawing attention to tests since all of the existing tests are migrated/re-implemented using new style serializers.

@claytondaley
Copy link
Contributor Author

You reviewed too fast lol... was trying to fix my typos without creating commit noise.

@ruscoder
Copy link
Member

It may even be possible to simulate the old-style syntax while using the new-style serializers. All you really need to do is automatically create nested serializers that follow the default rules of the existing serializers. A BackwardsCompat mixin or metaclass might work for everything but the unsupported edge cases like partial and GenericRelation.

@claytondaley have you managed to implement old style serialisers using new ones? I believe if we have backward compatibility for existing users using new code base even without some edge cases it will be great.

@claytondaley
Copy link
Contributor Author

Yes. All of the test cases have been recreated using new serializers. There are two specific cases where they don't work as noted in this thread: partial behaviors and generic relations.

Current partial behavior is hard to replicate for architectural reasons mentioned above. Generic relations are unsupported because I'm not familiar with them and wasn't using them.

@ir4y
Copy link
Member

ir4y commented Jul 14, 2021

Hi @claytondaley

Thank you for your contribution. You did a lot and we can merge these changes once it meets all requirements.

  1. Backward compatibility.
    All existing tests should pass. Since Generic relations already supported we can't drop it.
    Generic relations are unsupported because I'm not familiar with them and wasn't using them.
    You can see it as an opportunity to work with new tools.
    Regarding partial behaviour, please delete old tests and add new for current behaviour.
  2. Good code coverage.
    All new code should have 100% coverage.
  3. Up to date documentation.
    Make sure that README is up to date and documentation doesn't contain obsolete examples.

Can you please do these things?

@claytondaley
Copy link
Contributor Author

Is there a good way to allow others who benefit from the new features to help? I'm not actually a developer so, for example, I will never benefit from any learnings related to GenericRelation. The incremental test coverage is a more reasonable ask since I'm far more likely to know the purpose of uncovered code to design an appropriate test.

@auvipy
Copy link

auvipy commented Aug 21, 2021

Is there a good way to allow others who benefit from the new features to help? I'm not actually a developer so, for example, I will never benefit from any learnings related to GenericRelation. The incremental test coverage is a more reasonable ask since I'm far more likely to know the purpose of uncovered code to design an appropriate test.

you should push further, I think you are almost there

Copy link

@auvipy auvipy left a comment

Choose a reason for hiding this comment

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

one questions, how they or do they work wel with hypelinkedmodel serializers and rest-framework-json-api? and drf-gis?

############


class NewAvatarSerializer(UpdateOrCreateNestedSerializerMixin, serializers.ModelSerializer):
Copy link

Choose a reason for hiding this comment

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

do we really need the name Mixin ?

@claytondaley
Copy link
Contributor Author

claytondaley commented Oct 15, 2021

We're using this code with drf-hal-json which makes heavy use of hyperlinked serialization so I don't expect an issue. I don't necessarily care about naming/styling (the package drop 2.x support?), but the mixins inherit from serializers.Serializer rather than serializers.ModelSerializer (as BaseNestedModelSerializer does) so a distinction seems warranted. A bunch of legacy classes included Mixin so it also seemed like the package norm.

@claytondaley
Copy link
Contributor Author

100% test coverage (which I can work on) and support for GenericRelation which will require an interested party to help.

@TJHeeringa
Copy link

one questions, how they or do they work wel with hypelinkedmodel serializers and rest-framework-json-api? and drf-gis?

@auvipy I have noticed that they do not work well together nicely. Because the saving is different, the get_url method of the HyperlinkedModel gets an unexpected input. It expects an instance, but gets a dict. This causes a condition to an if statement to fail, which should have passed. This can be fixed, but then other issues will appear. I have no solutions for the new issues, nor clear descriptions of what they are.

A little more detail on the get_url 'fix':

The HyperlinkedRelatedField has a method called get_url. This expects a model instance. The check that checks for unsaved objects fails.

class HyperlinkedRelatedField(RelatedField):
    def get_url(self, obj, view_name, request, format):
        """
        Given an object, return the URL that hyperlinks to the object.

        May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
        attributes are not configured to correctly match the URL conf.
        """
        # Unsaved objects will not yet have a valid URL.
        if hasattr(obj, 'pk') and obj.pk in (None, ''):
            return None

        lookup_value = getattr(obj, self.lookup_field)
        kwargs = {self.lookup_url_kwarg: lookup_value}
        return self.reverse(view_name, kwargs=kwargs, request=request, format=format)

To get around this specific error you could for example create the following serializer. You would then use this DrfWritableSupportedHyperlinkedModelSerializer instead of the HyperlinkedModelSerializer.

class DrfWritableSupportedHyperlinkMixin(object):
    def get_url(self, obj, view_name, request, format):
        """
        Given an object, return the URL that hyperlinks to the object.

        May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
        attributes are not configured to correctly match the URL conf.
        """
        # Unsaved objects will not yet have a valid URL.
        if hasattr(obj, 'pk') and obj.pk in (None, ''):
            return None
        # Drf-writable doesn't save things properly, so we get a dict instead of obj. This causes the previous check to
        # fail even though it should pass
        if type(obj) is dict and obj.get("pk") in (None, ""):
            return None

        lookup_value = getattr(obj, self.lookup_field)
        kwargs = {self.lookup_url_kwarg: lookup_value}
        return self.reverse(view_name, kwargs=kwargs, request=request, format=format)

class DrfWritableSupportedHyperlinkedIdentityField(DrfWritableSupportedHyperlinkMixin, serializers.HyperlinkedIdentityField):
    pass


class DrfWritableSupportedHyperlinkedRelatedField(DrfWritableSupportedHyperlinkMixin, serializers.HyperlinkedRelatedField):
    pass

class DrfWritableSupportedHyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
    serializer_related_field = DrfWritableSupportedHyperlinkedRelatedField
    serializer_url_field = DrfWritableSupportedHyperlinkedIdentityField

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.

Atomic transaction
10 participants