From 78e161fb567a7b8ee7b3ea626d73311d3beca525 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 19 Dec 2025 12:29:35 +1300 Subject: [PATCH 1/5] Return None for nullable CountryField when value is NULL When a CountryField has null=True and the database value is NULL, the descriptor now returns None instead of Country(code=None). This moves nullability from Country.code (str | None) to the field access itself (Country | None), resulting in cleaner typing and consistent behavior with nullable multiple fields. Fixes #481 --- changes/481.feature.md | 3 ++ django_countries/fields.py | 3 ++ django_countries/fields.pyi | 10 ++++--- django_countries/tests/test_fields.py | 12 ++++---- django_countries/tests/test_null_support.py | 32 ++++++++------------- 5 files changed, 30 insertions(+), 30 deletions(-) create mode 100644 changes/481.feature.md diff --git a/changes/481.feature.md b/changes/481.feature.md new file mode 100644 index 0000000..582515e --- /dev/null +++ b/changes/481.feature.md @@ -0,0 +1,3 @@ +Nullable `CountryField` (with `null=True`) now returns `None` instead of `Country(code=None)` when the database value is NULL. This makes the nullability explicit at the field level rather than inside the `Country` object, resulting in cleaner typing where `Country.code` is always a `str` (never `None`). + +**Breaking change:** Code that previously checked `obj.country.code is None` should now check `obj.country is None`. The common pattern `if obj.country:` continues to work unchanged. diff --git a/django_countries/fields.py b/django_countries/fields.py index a11f4ac..5f3d8ac 100644 --- a/django_countries/fields.py +++ b/django_countries/fields.py @@ -312,6 +312,9 @@ def __get__(self, instance=None, owner=None): if value is None: return None return MultipleCountriesDescriptor(self.country(code) for code in value) + # Return None for NULL values on nullable single fields + if self.field.null and value is None: + return None return self.country(value) def country(self, code): diff --git a/django_countries/fields.pyi b/django_countries/fields.pyi index 000bca5..80bb0aa 100644 --- a/django_countries/fields.pyi +++ b/django_countries/fields.pyi @@ -21,12 +21,12 @@ _CountryCode: TypeAlias = str _FlagURL: TypeAlias = str class Country: - code: _CountryCode | None + code: _CountryCode flag_url: _FlagURL | None def __init__( self, - code: _CountryCode | None, + code: _CountryCode, flag_url: _FlagURL | None = None, str_attr: str = "code", custom_countries: Countries | None = None, @@ -72,7 +72,8 @@ class CountryDescriptor: Descriptor that returns Country objects or MultipleCountriesDescriptor. Return type depends on field configuration: - - Single field (multiple=False): Returns Country (code may be None) + - Single field (multiple=False, null=False): Returns Country + - Single field (multiple=False, null=True): Returns Country | None - Multiple field (multiple=True, null=False): Returns MultipleCountriesDescriptor - Multiple field (multiple=True, null=True): @@ -99,7 +100,8 @@ class CountryField(models.CharField): A Django field for storing country codes. Type inference notes: - - When multiple=False: Instance access returns Country + - When multiple=False, null=False: Instance access returns Country + - When multiple=False, null=True: Instance access returns Country | None - When multiple=True, null=False: Instance access returns MultipleCountriesDescriptor - When multiple=True, null=True: diff --git a/django_countries/tests/test_fields.py b/django_countries/tests/test_fields.py index b41151b..c241793 100644 --- a/django_countries/tests/test_fields.py +++ b/django_countries/tests/test_fields.py @@ -165,11 +165,11 @@ def test_blank(self): self.assertEqual(person.country.code, "") def test_null(self): - person = AllowNull.objects.create(country=None) - self.assertIsNone(person.country.code) + obj = AllowNull.objects.create(country=None) + self.assertIsNone(obj.country) - person = AllowNull.objects.get(pk=person.pk) - self.assertIsNone(person.country.code) + obj = AllowNull.objects.get(pk=obj.pk) + self.assertIsNone(obj.country) def test_multi_null_country_allowed(self): """ @@ -218,8 +218,8 @@ def test_only(self): def test_nullable_deferred(self): AllowNull.objects.create(country=None) - person = AllowNull.objects.defer("country").get() - self.assertIsNone(person.country.code) + obj = AllowNull.objects.defer("country").get() + self.assertIsNone(obj.country) def test_len(self): person = Person(name="Chris Beaven", country="NZ") diff --git a/django_countries/tests/test_null_support.py b/django_countries/tests/test_null_support.py index 302d41f..503c887 100644 --- a/django_countries/tests/test_null_support.py +++ b/django_countries/tests/test_null_support.py @@ -18,21 +18,18 @@ class TestNullBehavior(TestCase): """Test that null=True fields properly handle NULL values.""" - def test_null_field_returns_country_with_none_code(self): + def test_null_field_returns_none(self): """ When a field has null=True and the database value is NULL, - the descriptor returns Country(code=None), which is falsy. + the descriptor returns None (not Country(code=None)). """ obj = AllowNull.objects.create(country=None) # Refresh from database to ensure we're testing descriptor behavior obj = AllowNull.objects.get(pk=obj.pk) - # Should return Country object with code=None - self.assertIsInstance(obj.country, Country) - self.assertIsNone(obj.country.code) - # Should be falsy for boolean checks - self.assertFalse(obj.country) + # Should return None, not Country + self.assertIsNone(obj.country) def test_null_field_with_value_returns_country(self): """ @@ -62,18 +59,15 @@ def test_set_to_none(self): obj.save() obj = AllowNull.objects.get(pk=obj.pk) - self.assertIsNone(obj.country.code) - self.assertFalse(obj.country) # Should be falsy + self.assertIsNone(obj.country) - def test_nullable_deferred_returns_country_with_none(self): - """Test that deferred loading also returns Country(code=None).""" + def test_nullable_deferred_returns_none(self): + """Test that deferred loading also returns None for null values.""" AllowNull.objects.create(country=None) obj = AllowNull.objects.defer("country").get() - # Should return Country with code=None - self.assertIsInstance(obj.country, Country) - self.assertIsNone(obj.country.code) - self.assertFalse(obj.country) + # Should return None + self.assertIsNone(obj.country) class TestNullWithUnique(TestCase): @@ -101,13 +95,11 @@ def test_multiple_nulls_allowed(self): obj1 = AllowNull.objects.create(country=None) obj2 = AllowNull.objects.create(country=None) - # Both should have Country(code=None) + # Both should return None country1 = AllowNull.objects.get(pk=obj1.pk).country country2 = AllowNull.objects.get(pk=obj2.pk).country - self.assertIsNone(country1.code) - self.assertIsNone(country2.code) - self.assertFalse(country1) - self.assertFalse(country2) + self.assertIsNone(country1) + self.assertIsNone(country2) def test_multiple_empty_strings_allowed_without_unique(self): """ From 7f9f073e5f842d7dbe0474dd240a2cbdd733bd68 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 9 Jan 2026 12:07:15 +1300 Subject: [PATCH 2/5] Handle None explicitly in DRF serializer to_representation When a nullable CountryField returns None (instead of Country(code=None)), the serializer should handle it explicitly rather than relying on alpha2() converting 'None' string to empty string. --- django_countries/serializer_fields.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/django_countries/serializer_fields.py b/django_countries/serializer_fields.py index 1993bef..d1569db 100644 --- a/django_countries/serializer_fields.py +++ b/django_countries/serializer_fields.py @@ -42,6 +42,9 @@ def __init__(self, *args, **kwargs): self._setup_spectacular_annotation() def to_representation(self, obj): + # Handle None explicitly for nullable fields + if obj is None: + return None if self.allow_null else "" code = self.countries.alpha2(obj) if not code: # Respect allow_null setting for empty values From e4f97688da2b67f27aedb4b7d95577c752c1f4d0 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 9 Jan 2026 12:08:07 +1300 Subject: [PATCH 3/5] Add GraphQL tests for nullable CountryField Verify that nullable CountryField works correctly with GraphQL: - Returns full Country type when value is present - Returns null when database value is NULL --- django_countries/tests/graphql/schema.py | 13 ++++++++++++ django_countries/tests/graphql/test_model.py | 22 +++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/django_countries/tests/graphql/schema.py b/django_countries/tests/graphql/schema.py index 1167c66..745a71d 100644 --- a/django_countries/tests/graphql/schema.py +++ b/django_countries/tests/graphql/schema.py @@ -14,9 +14,18 @@ class Meta: fields = ["name", "country"] +class AllowNullType(graphene_django.DjangoObjectType): + country = graphene.Field(CountryType) + + class Meta: + model = models.AllowNull + fields = ["id", "country"] + + class Query(graphene.ObjectType): new_zealand = graphene.Field(CountryType) people = graphene.List(Person) + allow_nulls = graphene.List(AllowNullType) @staticmethod def resolve_new_zealand(parent, info): @@ -26,5 +35,9 @@ def resolve_new_zealand(parent, info): def resolve_people(parent, info): return models.Person.objects.all() + @staticmethod + def resolve_allow_nulls(parent, info): + return models.AllowNull.objects.all() + schema = graphene.Schema(query=Query) diff --git a/django_countries/tests/graphql/test_model.py b/django_countries/tests/graphql/test_model.py index effcd32..d24cea1 100644 --- a/django_countries/tests/graphql/test_model.py +++ b/django_countries/tests/graphql/test_model.py @@ -1,7 +1,7 @@ from graphene.test import Client # type: ignore from django_countries.tests.graphql.schema import schema -from django_countries.tests.models import Person +from django_countries.tests.models import AllowNull, Person def test_country_type(db): @@ -10,3 +10,23 @@ def test_country_type(db): executed = client.execute("""{ people { name, country {name} } }""") returned_person = executed["data"]["people"][0] assert returned_person == {"name": "Skippy", "country": {"name": "Australia"}} + + +def test_nullable_country_with_value(db): + """Test that nullable CountryField with a value works in GraphQL.""" + AllowNull.objects.create(country="NZ") + client = Client(schema) + executed = client.execute("""{ allowNulls { country { code, name } } }""") + assert "errors" not in executed, executed.get("errors") + result = executed["data"]["allowNulls"][0] + assert result == {"country": {"code": "NZ", "name": "New Zealand"}} + + +def test_nullable_country_with_null(db): + """Test that nullable CountryField with NULL returns null in GraphQL.""" + AllowNull.objects.create(country=None) + client = Client(schema) + executed = client.execute("""{ allowNulls { country { code, name } } }""") + assert "errors" not in executed, executed.get("errors") + result = executed["data"]["allowNulls"][0] + assert result == {"country": None} From 9267cc9810dad4789718009c68cf10c696000ca5 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 9 Jan 2026 12:08:44 +1300 Subject: [PATCH 4/5] Add DRF serializer tests with actual database NULL values Test the full round-trip: save NULL to database, fetch it back, and serialize. This verifies the descriptor correctly returns None and the serializer handles it properly. --- django_countries/tests/test_null_support.py | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/django_countries/tests/test_null_support.py b/django_countries/tests/test_null_support.py index 503c887..d846f76 100644 --- a/django_countries/tests/test_null_support.py +++ b/django_countries/tests/test_null_support.py @@ -256,6 +256,42 @@ class Meta: self.assertEqual(serializer.data["country"], "US") + def test_serializer_null_from_database(self): + """ + Test serializer with actual NULL value from database. + + This tests the full round-trip: save NULL to DB, fetch it back, + and serialize. This verifies the descriptor returns None and + the serializer handles it correctly. + """ + + class TestSerializer(CountryFieldMixin, serializers.ModelSerializer): + class Meta: + model = AllowNull + fields = ("country",) + + # Create and save to database + obj = AllowNull.objects.create(country=None) + # Fetch fresh from database + obj = AllowNull.objects.get(pk=obj.pk) + + serializer = TestSerializer(obj) + self.assertIsNone(serializer.data["country"]) + + def test_serializer_value_from_database(self): + """Test serializer with value from database.""" + + class TestSerializer(CountryFieldMixin, serializers.ModelSerializer): + class Meta: + model = AllowNull + fields = ("country",) + + obj = AllowNull.objects.create(country="AU") + obj = AllowNull.objects.get(pk=obj.pk) + + serializer = TestSerializer(obj) + self.assertEqual(serializer.data["country"], "AU") + # Document expected behavior for unique constraints """ From 1a576466077b07dc63f22af3ac07c003012e08fe Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Fri, 9 Jan 2026 12:09:21 +1300 Subject: [PATCH 5/5] Document nullable CountryField behavior change Add documentation explaining that nullable CountryField now returns None instead of Country(code=None), with examples and migration guidance. --- docs/usage/field.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/usage/field.md b/docs/usage/field.md index f0868b9..39ec185 100644 --- a/docs/usage/field.md +++ b/docs/usage/field.md @@ -41,6 +41,40 @@ Use `blank_label` to set the label for the initial blank choice shown in forms: country = CountryField(blank_label="(select country)") ``` +### Nullable Fields + +!!! info "New in development version" + + Nullable `CountryField` now returns `None` instead of `Country(code=None)`. + +When using `null=True` on a `CountryField`, accessing the field on a model instance returns `None` when the database value is NULL: + +```python +class Person(models.Model): + country = CountryField(null=True, blank=True) + +person = Person.objects.create(country=None) +person.country # Returns None, not Country(code=None) +``` + +This allows for cleaner type checking: + +```python +# Check for null +if person.country is None: + print("No country set") +else: + print(person.country.name) + +# The common boolean check still works +if person.country: + print(person.country.name) +``` + +!!! warning "Breaking Change" + + Prior to this version, nullable fields returned `Country(code=None)`. Code that checked `obj.country.code is None` should now check `obj.country is None`. + ## Querying You can filter using the full English country names in addition to country codes, even though only the country codes are stored in the database by using the queryset lookups `contains`, `startswith`, `endswith`, `regex`, or their case insensitive versions. Use `__name` or `__iname` for the `exact`/`iexact` equivalent: