From e77e22316b00526490d520d41d4a8521693d53f4 Mon Sep 17 00:00:00 2001 From: idan-david Date: Mon, 24 Jun 2024 14:07:26 +0300 Subject: [PATCH 1/5] BUGFIX: allow nullable FKS --- djantic/main.py | 23 ++++++++++++++++++++++- tests/test_fields.py | 26 +++++++++++++++++++++++++- tests/testapp/models.py | 8 ++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/djantic/main.py b/djantic/main.py index b2987d2..e61db58 100644 --- a/djantic/main.py +++ b/djantic/main.py @@ -2,6 +2,7 @@ from enum import Enum from functools import reduce from itertools import chain +from types import NoneType, UnionType from typing import Any, Dict, List, Optional, no_type_check, Union from typing_extensions import get_origin, get_args @@ -136,6 +137,16 @@ def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs): return cls +def _is_optional_field(annotation) -> bool: + args = get_args(annotation) + return ( + get_origin(annotation) is UnionType + and NoneType in args + and len(args) == 2 + and any(issubclass(arg, ModelSchema) for arg in args) + ) + + class ProxyGetterNestedObj: def __init__(self, obj: Any, schema_class): self._obj = obj @@ -199,7 +210,17 @@ def dict(self) -> dict: # Pick the underlying annotation annotation = get_args(annotation)[0] - if inspect.isclass(annotation) and issubclass(annotation, ModelSchema): + if _is_optional_field(annotation): + value = self.get(key) + if value is None: + data[key] = None + else: + non_none_type_annotation = next( + arg for arg in get_args(annotation) if arg is not NoneType + ) + data[key] = self._get_annotation_objects(value, non_none_type_annotation) + + elif inspect.isclass(annotation) and issubclass(annotation, ModelSchema): data[key] = self._get_annotation_objects(self.get(key), annotation) else: key = fieldinfo.alias if fieldinfo.alias else key diff --git a/tests/test_fields.py b/tests/test_fields.py index 8313909..1354c0f 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,6 +1,6 @@ import pytest from pydantic import ConfigDict -from testapp.models import Configuration, Listing, Preference, Record, Searchable, User +from testapp.models import Configuration, Listing, Preference, Record, Searchable, User, A, B from pydantic import ( ValidationInfo, @@ -408,3 +408,27 @@ class ListingSchema(ModelSchema): "id": None, "items": ["a", "b"], } + + +@pytest.mark.django_db +def test_nullable_fk(): + class ASchema(ModelSchema): + model_config = ConfigDict(model=A, include='value') + + class BSchema(ModelSchema): + a: ASchema | None = None + model_config = ConfigDict(model=B, include='a') + + a = A(value="test") + a.save() + model = B(a=a) + assert BSchema.from_django(model).dict() == { + "a": { + "value": "test" + } + } + + model2 = B(a=None) + assert BSchema.from_django(model2).dict() == { + "a": None + } diff --git a/tests/testapp/models.py b/tests/testapp/models.py index a37283f..f492b12 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -282,3 +282,11 @@ class Case(ExtendedModel): class Listing(models.Model): items = ArrayField(models.TextField(), size=4) content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT, blank=True, null=True) + + +class A(models.Model): + value = models.CharField(max_length=256, null=True, blank=True) + + +class B(models.Model): + a = models.ForeignKey(A, null=True, blank=True, on_delete=models.CASCADE) From d9c731fc6fcfa475687680060b60d7330219444e Mon Sep 17 00:00:00 2001 From: idan-david Date: Mon, 24 Jun 2024 19:58:06 +0300 Subject: [PATCH 2/5] Fix pr comments --- djantic/main.py | 6 +++--- tests/test_fields.py | 32 ++++++++++++++++---------------- tests/testapp/models.py | 6 +++--- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/djantic/main.py b/djantic/main.py index e61db58..2d5c73f 100644 --- a/djantic/main.py +++ b/djantic/main.py @@ -2,7 +2,7 @@ from enum import Enum from functools import reduce from itertools import chain -from types import NoneType, UnionType +from types import UnionType from typing import Any, Dict, List, Optional, no_type_check, Union from typing_extensions import get_origin, get_args @@ -141,7 +141,7 @@ def _is_optional_field(annotation) -> bool: args = get_args(annotation) return ( get_origin(annotation) is UnionType - and NoneType in args + and type(None) in args and len(args) == 2 and any(issubclass(arg, ModelSchema) for arg in args) ) @@ -216,7 +216,7 @@ def dict(self) -> dict: data[key] = None else: non_none_type_annotation = next( - arg for arg in get_args(annotation) if arg is not NoneType + arg for arg in get_args(annotation) if arg is not type(None) ) data[key] = self._get_annotation_objects(value, non_none_type_annotation) diff --git a/tests/test_fields.py b/tests/test_fields.py index 1354c0f..ae295f8 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,6 +1,6 @@ import pytest from pydantic import ConfigDict -from testapp.models import Configuration, Listing, Preference, Record, Searchable, User, A, B +from testapp.models import Configuration, Listing, Preference, Record, Searchable, User, NullableChar, NullableFK from pydantic import ( ValidationInfo, @@ -412,23 +412,23 @@ class ListingSchema(ModelSchema): @pytest.mark.django_db def test_nullable_fk(): - class ASchema(ModelSchema): - model_config = ConfigDict(model=A, include='value') - - class BSchema(ModelSchema): - a: ASchema | None = None - model_config = ConfigDict(model=B, include='a') - - a = A(value="test") - a.save() - model = B(a=a) - assert BSchema.from_django(model).dict() == { - "a": { + class NullableCharSchema(ModelSchema): + model_config = ConfigDict(model=NullableChar, include='value') + + class NullableFKSchema(ModelSchema): + nullable_char: NullableCharSchema | None = None + model_config = ConfigDict(model=NullableFK, include='nullable_char') + + nullable_char = NullableChar(value="test") + nullable_char.save() + model = NullableFK(nullable_char=nullable_char) + assert NullableFKSchema.from_django(model).dict() == { + "nullable_char": { "value": "test" } } - model2 = B(a=None) - assert BSchema.from_django(model2).dict() == { - "a": None + model2 = NullableFK(nullable_char=None) + assert NullableFKSchema.from_django(model2).dict() == { + "nullable_char": None } diff --git a/tests/testapp/models.py b/tests/testapp/models.py index f492b12..9014598 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -284,9 +284,9 @@ class Listing(models.Model): content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT, blank=True, null=True) -class A(models.Model): +class NullableChar(models.Model): value = models.CharField(max_length=256, null=True, blank=True) -class B(models.Model): - a = models.ForeignKey(A, null=True, blank=True, on_delete=models.CASCADE) +class NullableFK(models.Model): + nullable_char = models.ForeignKey(NullableChar, null=True, blank=True, on_delete=models.CASCADE) From be8db6898b6b0503ede50dc91ee9152d440e0783 Mon Sep 17 00:00:00 2001 From: idan-david Date: Tue, 25 Jun 2024 15:02:39 +0300 Subject: [PATCH 3/5] Fix pr comments 2 --- djantic/main.py | 7 ++++++- tests/test_fields.py | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/djantic/main.py b/djantic/main.py index 2d5c73f..94e0b02 100644 --- a/djantic/main.py +++ b/djantic/main.py @@ -1,4 +1,5 @@ import inspect +import sys from enum import Enum from functools import reduce from itertools import chain @@ -16,6 +17,10 @@ from pydantic.errors import PydanticUserError from pydantic._internal._model_construction import ModelMetaclass +if sys.version_info >= (3, 10): + from types import UnionType +else: + from typing import Union as UnionType from .fields import ModelSchemaField @@ -140,7 +145,7 @@ def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs): def _is_optional_field(annotation) -> bool: args = get_args(annotation) return ( - get_origin(annotation) is UnionType + (get_origin(annotation) is Union or get_origin(annotation) is UnionType) and type(None) in args and len(args) == 2 and any(issubclass(arg, ModelSchema) for arg in args) diff --git a/tests/test_fields.py b/tests/test_fields.py index ae295f8..41c17c6 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,3 +1,5 @@ +from typing import Optional + import pytest from pydantic import ConfigDict from testapp.models import Configuration, Listing, Preference, Record, Searchable, User, NullableChar, NullableFK @@ -416,7 +418,7 @@ class NullableCharSchema(ModelSchema): model_config = ConfigDict(model=NullableChar, include='value') class NullableFKSchema(ModelSchema): - nullable_char: NullableCharSchema | None = None + nullable_char: Optional[NullableCharSchema] = None model_config = ConfigDict(model=NullableFK, include='nullable_char') nullable_char = NullableChar(value="test") From 6388f60121d0e6d23981c7837ee708148c959fd4 Mon Sep 17 00:00:00 2001 From: idan-david Date: Wed, 26 Jun 2024 13:25:16 +0300 Subject: [PATCH 4/5] Fix pr comments 3 --- djantic/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/djantic/main.py b/djantic/main.py index 94e0b02..0cbae93 100644 --- a/djantic/main.py +++ b/djantic/main.py @@ -3,7 +3,6 @@ from enum import Enum from functools import reduce from itertools import chain -from types import UnionType from typing import Any, Dict, List, Optional, no_type_check, Union from typing_extensions import get_origin, get_args From cf35b0795551bbb50c0fae14df0778f5e63f388a Mon Sep 17 00:00:00 2001 From: idan-david Date: Thu, 27 Jun 2024 11:28:10 +0300 Subject: [PATCH 5/5] Fix pr comments 4 --- djantic/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djantic/main.py b/djantic/main.py index 0cbae93..2a9a733 100644 --- a/djantic/main.py +++ b/djantic/main.py @@ -147,7 +147,7 @@ def _is_optional_field(annotation) -> bool: (get_origin(annotation) is Union or get_origin(annotation) is UnionType) and type(None) in args and len(args) == 2 - and any(issubclass(arg, ModelSchema) for arg in args) + and any(inspect.isclass(arg) and issubclass(arg, ModelSchema) for arg in args) )