From c1e68e37882e861c6d4f1bb407dbee390c413f5f Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sun, 26 May 2024 16:55:47 +0200 Subject: [PATCH] OneToOne and ManyToMany models are nullable (#8) These foreign key relationships should be nullable and as such this should be reflected in the schema and when making a model instance into a schema. Prior to this this would throw an error, as it was not recognized as nullable. --- djantic/fields.py | 38 ++++++++++++++++++++++++++------------ tests/test_fields.py | 30 +++++++++++++++++++++++++++++- tests/test_relations.py | 9 ++++++++- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/djantic/fields.py b/djantic/fields.py index 2c09426..25608e1 100644 --- a/djantic/fields.py +++ b/djantic/fields.py @@ -2,7 +2,8 @@ from datetime import date, datetime, time, timedelta from decimal import Decimal from enum import Enum -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Union, Optional +import typing from uuid import UUID from django.utils.functional import Promise @@ -130,9 +131,6 @@ def ModelSchemaField(field: Any, schema_name: str) -> tuple: if blank or null: python_type = Union[python_type, None] - if field.related_model: - field = field.target_field - else: if field.choices: enum_choices = {} @@ -182,7 +180,11 @@ def ModelSchemaField(field: Any, schema_name: str) -> tuple: title = field.verbose_name.title() if not description: - description = field.name + related_field_name = None + if field.related_model: + related_field_name = field.target_field.name + + description = related_field_name or field.name if ( getattr(field, "get_internal_type", lambda: None)() in STR_TYPES @@ -190,13 +192,25 @@ def ModelSchemaField(field: Any, schema_name: str) -> tuple: ): max_length = field.max_length + field_info = FieldInfo( + default=default, + default_factory=default_factory, + title=title, + description=str(description), + max_length=max_length, + ) + + field_is_optional = all([ + getattr(field, "null", None), + field.is_relation, + # A list that is null, is the empty list. So there is no need + # to make it nullable. + typing.get_origin(python_type) is not list + ]) + if field_is_optional: + python_type = Optional[python_type] + return ( python_type, - FieldInfo( - default=default, - default_factory=default_factory, - title=title, - description=str(description), - max_length=max_length, - ), + field_info ) diff --git a/tests/test_fields.py b/tests/test_fields.py index 62b9e51..cf93c55 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,10 +1,38 @@ import pytest from pydantic import ConfigDict -from testapp.models import Configuration, Listing, Preference, Record, Searchable +from testapp.models import Configuration, Listing, Preference, Record, Searchable, User from djantic import ModelSchema +@pytest.mark.django_db +def test_schema_without_include_and_exclude(): + """ + Using a schema without include and exclude populates userschema correctly + Optional foreign key handled gracefully. + """ + + user = User.objects.create( + first_name="Jordan", last_name="Eremieff", email="jordan@eremieff.com" + ) + + class UserSchema(ModelSchema): + model_config = ConfigDict(model=User) + + dumped = UserSchema.from_django(user).model_dump() + # These values are here, but a hassle to use freeze gun. + dumped.pop("updated_at") + dumped.pop("created_at") + + assert dumped == { + "first_name": "Jordan", + "id": 1, + "email": "jordan@eremieff.com", + "profile": None, + "last_name": "Eremieff", + } + + @pytest.mark.django_db def test_unhandled_field_type(): class SearchableSchema(ModelSchema): diff --git a/tests/test_relations.py b/tests/test_relations.py index 1687d74..76b6774 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -364,10 +364,17 @@ class ProfileWithUserSchema(ModelSchema): "description": "A user of the application.", "properties": { "profile": { + "anyOf": [ + { + "type": "integer", + }, + { + "type": "null", + }, + ], "default": None, "description": "id", "title": "Profile", - "type": "integer", }, "id": { "anyOf": [{"type": "integer"}, {"type": "null"}],