Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes/481.feature.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions django_countries/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
10 changes: 6 additions & 4 deletions django_countries/fields.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions django_countries/serializer_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions django_countries/tests/graphql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
22 changes: 21 additions & 1 deletion django_countries/tests/graphql/test_model.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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}
12 changes: 6 additions & 6 deletions django_countries/tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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")
Expand Down
68 changes: 48 additions & 20 deletions django_countries/tests/test_null_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -264,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
"""
Expand Down
34 changes: 34 additions & 0 deletions docs/usage/field.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading