🙆

【DRF】drf-writable-nestedはvalidated_dataの値を参照しない

2023/08/11に公開

はじめに

Django REST frameworkで、リレーションされたデータを生成しようと考えた時、公式ドキュメントには
https://www.django-rest-framework.org/api-guide/relations/#writable-nested-serializers

と示されています。ふむ。

また、他の選択肢としては以下のサードパーティライブラリがあります
https://github.com/beda-software/drf-writable-nested

前者・後者ともやりたいことは実現できる為、drf-writable-nestedを採用した結果、リクエストで受け取った値以外を含めての作成・更新に苦労しましたってお話しです

そもそも何をしたかったの?

上記のdrf-writable-nestedを用いて、リレーション先のデータ作成・更新を行う際に、serializerで値を変更して作成・更新を行いたかったのですが
drf-writable-nestedはリレーション先のデータを作成・更新する際には渡されるvalidated_dataではなくinitial_dataを見て作成・更新する為どちゃめちゃに嵌ってしまいました

どんな感じなのか今回は実際にBookとCommonDetailを作ったため、それを踏まえて、備忘録として残します

環境

  • python 3.11.4
  • django 4.2.1
  • drf 3.14.0
  • drf-writable-nested 0.7.0

ベースとなる諸々

Book・CommonDetailのModel・Serialiser・ViewSetを定義します

model

models.py
class Book(models.Model):
    title = models.CharField("名前", max_length=256)
    page_size = models.PositiveSmallIntegerField("ページ数")
    version = models.PositiveSmallIntegerField("版")

class CommonDetail(models.Model):
    description = models.TextField("説明", blank=True, null=True)
    creator = models.CharField("作者", max_length=256)

    book = models.OneToOneField(
        "api.Book",
        on_delete=models.CASCADE,
        verbose_name="本",
        related_name="detail",
        blank=True,
        null=True,
    )

BookとCommonDetailは1to1で繋がっています

serializer

続いてSerializer(WritableNestedModelSerializerでの実装は後ほど)

serializers.py
class CommonDetailSerializer(serializers.ModelSerializer):
    class Meta:
        model = CommonDetail
        fields = (
            "id",
            "description",
            "creator",
        )

class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = (
            "id",
            "title",
            "page_size",
            "version",
        )
        extra_kwargs = {
            "version": {
                "read_only": True,
            },
        }

    def create(self, validated_data):
        validated_data["version"] = Book.objects.filter(title__icontains=validated_data.get("title")).count()
        return super().create(validated_data)

Bookのversionはread_onlyで定義し、createをoverrideしserializerにて値を設定するようにしています
(これが後の混乱を生むミソpointでした...)

viewset

最後にViewSetです

views.py
class BookAPIViewSet(viewsets.ModelViewSet):
    serializer_class = BookSerializer
    queryset = Book.objects.all()

一旦ここまででgetとpostを試してみようと思いまっす

$ curl -w'\n' -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/books/ -d '{"title": "test", "page_size": "100"}'
{"id":1,"title":"test","page_size":100,"version":0}

$ curl -w'\n' http://127.0.0.1:8000/api/books/
{"count":1,"next":null,"previous":null,"results":[{"id":1,"title":"test","page_size":100,"version":0}]}

serializerで値を埋め込んでいるversionもしっかりと反映されていて感じですね

全体のディレクトリ構成はこんな感じです

├── api
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   └── __init__.py
│   ├── models.py
│   ├── serializers.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── config
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

ここからdetailのdescriptionに対して、値がなかったらtitleの値を反映したいと思います

drf-writable-nestedを適用

まずは特殊なことはせずに使用してみようと思います

serializerを以下の通りに変更します

serializers.py
class BookSerializer(WritableNestedModelSerializer):
    detail = CommonDetailSerializer()
    class Meta:
        model = Book
        fields = (
            "id",
            "title",
            "page_size",
            "version",
            "detail",
        )
        extra_kwargs = {
            "version": {
                "read_only": True,
            },
        }
    ...

これでBookを作成・更新する際にdetailも一緒に作成・更新されるようになりました
確認してみましょー

$ curl -w'\n' -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/books/ -d '{"title": "test", "page_size": "100", "detail": {"description": "test description" , "creator": "user"}}'
{"id":2,"title":"test","page_size":100,"version":1,"detail":{"id":1,"description":"test description","creator":"user"}}

$ curl -w'\n' http://127.0.0.1:8000/api/books/ | python -mjson.tool
{
    "count": 2,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 1,
            "title": "test",
            "page_size": 100,
            "version": 0,
            "detail": null
        },
        {
            "id": 2,
            "title": "test",
            "page_size": 100,
            "version": 1,
            "detail": {
                "id": 1,
                "description": "test description",
                "creator": "user"
            }
        }
    ]
}

しっかりとdetailも作成されていますねいい感じってやつです

では本題の奴行きましょう

ダメな例

まず一番安直にversionと同じようにvalidated_dataに埋め込んで作成を試してみます

serializers.py
class BookSerializer(WritableNestedModelSerializer):
    ...
    def create(self, validated_data):
        validated_data["version"] = Book.objects.filter(title__icontains=validated_data.get("title")).count()
        validated_data["detail"].setdefault("description", validated_data["title"])
        return super().create(validated_data)
$ curl -w'\n' -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/books/ -d '{"title": "test", "page_size": "100", "detail": {"creator": "user"}}'
{"id":3,"title":"test","page_size":100,"version":2,"detail":{"id":2,"description":null,"creator":"user"}}

が、ダメ!地獄いきぃ

というのも、drf-writable-nestedはvalidated_dataを見ているのではなくinitial_dataを見てリレーションデータを作成しているからです
https://github.com/beda-software/drf-writable-nested/blob/master/drf_writable_nested/mixins.py#L208

このため、埋め込むのであればvalidated_dataではなくinitial_dataに埋め込む必要があります

良い例

では実際initial_dataに適用したいと思います

class BookSerializer(WritableNestedModelSerializer):
    ...
    def create(self, validated_data):
        validated_data["version"] = Book.objects.filter(title__icontains=validated_data.get("title")).count()
        # validated_data["detail"].setdefault("description", validated_data["title"])
        self.initial_data["detail"].setdefault("description", validated_data["title"])
        return super().create(validated_data)

$ curl -w'\n' -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/books/ -d '{"title": "test", "page_size": "100", "detail": {"creator": "user"}}'
{"id":4,"title":"test","page_size":100,"version":3,"detail":{"id":3,"description":"test","creator":"user"}}

うまくdescriptionがtitleの値で更新されてますね

その他

上記ともう一点リレーション先の作成・更新で気を付けるべき箇所があります
それは、get_initialはread_onlyを対象外にしてしまうことです
https://github.com/encode/django-rest-framework/blob/master/rest_framework/serializers.py#L412

そのため、いくらinitial_dataに埋め込んだとしても値は更新されません
先ほどのdescriptionをread_onlyにして、titleを埋め込むだけにしてみましょう

class CommonDetailSerializer(serializers.ModelSerializer):
    class Meta:
        model = CommonDetail
        fields = (
            "id",
            "description",
            "creator",
        )
        extra_kwargs = {
            "description": {
                "read_only": True,
            },
        }
$ curl -w'\n' -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/books/ -d '{"title": "test", "page_size": "100", "detail": {"creator": "user"}}'
{"id":5,"title":"test","page_size":100,"version":4,"detail":{"id":4,"description":null,"creator":"user"}}

この通り先ほどと全く同じリクエストですがdescriptionがnullとなっています

まとめ

drf-writable-nestedとしてはinitial-dataを使うこと自体は仕様のようなのですが
https://github.com/beda-software/drf-writable-nested/issues/39#issuecomment-383156716

パッと見の挙動等ものすごくわかりずらかったので混乱しました...
(元々Bookのversion部分はvalidated_dataをserializerで値を埋め込んでいたことも拍車をかけました)

ここまで大変ならcreate, update時に独自でやっちゃったほうが楽だったりしそう感はありました(結局上記で実装しましたが)

他もっとよい方法等ありましたらコメントで教えていただけると僕が助かりまっす

ソースコードはgithubにあげてありますのでよろしければ
https://github.com/takap-sandbox/drf-writable-nested-not-use-validated-data

PS

気づいたら8月になっていて、前回個人ブログで記事を書いてから丸一年経っていました...
またちょくちょくこういった記事を書いていけたらなぁと思います
(気分一新zennを使って記事書いていこうかなと思います)

Discussion