Browse Source

Fixed #29227 -- Allowed BooleanField to be null=True.

Thanks Lynn Cyrin for contributing to the patch, and Nick Pope for review.
Tim Graham 8 years ago
parent
commit
5fa4f40f45

+ 1 - 1
django/contrib/admin/filters.py

@@ -243,7 +243,7 @@ class BooleanFieldListFilter(FieldListFilter):
                 'query_string': changelist.get_query_string({self.lookup_kwarg: lookup}, [self.lookup_kwarg2]),
                 'display': title,
             }
-        if isinstance(self.field, models.NullBooleanField):
+        if self.field.null:
             yield {
                 'selected': self.lookup_val2 == 'True',
                 'query_string': changelist.get_query_string({self.lookup_kwarg2: 'True'}, [self.lookup_kwarg]),

+ 2 - 2
django/contrib/admin/utils.py

@@ -394,8 +394,8 @@ def display_for_field(value, field, empty_value_display):
 
     if getattr(field, 'flatchoices', None):
         return dict(field.flatchoices).get(value, empty_value_display)
-    # NullBooleanField needs special-case null-handling, so it comes
-    # before the general null test.
+    # BooleanField needs special-case null-handling, so it comes before the
+    # general null test.
     elif isinstance(field, (models.BooleanField, models.NullBooleanField)):
         return _boolean_icon(value)
     elif value is None:

+ 1 - 0
django/db/backends/oracle/utils.py

@@ -41,6 +41,7 @@ class BulkInsertMapper:
     types = {
         'BigIntegerField': NUMBER,
         'BinaryField': BLOB,
+        'BooleanField': NUMBER,
         'DateField': DATE,
         'DateTimeField': TIMESTAMP,
         'DecimalField': NUMBER,

+ 10 - 34
django/db/models/fields/__init__.py

@@ -988,41 +988,16 @@ class BooleanField(Field):
     empty_strings_allowed = False
     default_error_messages = {
         'invalid': _("'%(value)s' value must be either True or False."),
+        'invalid_nullable': _("'%(value)s' value must be either True, False, or None."),
     }
     description = _("Boolean (Either True or False)")
 
-    def __init__(self, *args, **kwargs):
-        kwargs['blank'] = True
-        super().__init__(*args, **kwargs)
-
-    def check(self, **kwargs):
-        return [
-            *super().check(**kwargs),
-            *self._check_null(**kwargs),
-        ]
-
-    def _check_null(self, **kwargs):
-        if getattr(self, 'null', False):
-            return [
-                checks.Error(
-                    'BooleanFields do not accept null values.',
-                    hint='Use a NullBooleanField instead.',
-                    obj=self,
-                    id='fields.E110',
-                )
-            ]
-        else:
-            return []
-
-    def deconstruct(self):
-        name, path, args, kwargs = super().deconstruct()
-        del kwargs['blank']
-        return name, path, args, kwargs
-
     def get_internal_type(self):
         return "BooleanField"
 
     def to_python(self, value):
+        if self.null and value in self.empty_values:
+            return None
         if value in (True, False):
             # if value is 1 or 0 than it's equal to True or False, but we want
             # to return a true bool for semantic reasons.
@@ -1032,7 +1007,7 @@ class BooleanField(Field):
         if value in ('f', 'False', '0'):
             return False
         raise exceptions.ValidationError(
-            self.error_messages['invalid'],
+            self.error_messages['invalid_nullable' if self.null else 'invalid'],
             code='invalid',
             params={'value': value},
         )
@@ -1044,15 +1019,16 @@ class BooleanField(Field):
         return self.to_python(value)
 
     def formfield(self, **kwargs):
-        # Unlike most fields, BooleanField figures out include_blank from
-        # self.null instead of self.blank.
         if self.choices:
             include_blank = not (self.has_default() or 'initial' in kwargs)
             defaults = {'choices': self.get_choices(include_blank=include_blank)}
         else:
-            defaults = {'form_class': forms.BooleanField}
-        defaults.update(kwargs)
-        return super().formfield(**defaults)
+            form_class = forms.NullBooleanField if self.null else forms.BooleanField
+            # In HTML checkboxes, 'required' means "must be checked" which is
+            # different from the choices case ("must select some value").
+            # required=False allows unchecked checkboxes.
+            defaults = {'form_class': form_class, 'required': False}
+        return super().formfield(**{**defaults, **kwargs})
 
 
 class CharField(Field):

+ 2 - 1
docs/ref/checks.txt

@@ -138,7 +138,8 @@ Model fields
 * **fields.E007**: Primary keys must not have ``null=True``.
 * **fields.E008**: All ``validators`` must be callable.
 * **fields.E100**: ``AutoField``\s must set primary_key=True.
-* **fields.E110**: ``BooleanField``\s do not accept null values.
+* **fields.E110**: ``BooleanField``\s do not accept null values. *This check
+  appeared before support for null values was added in Django 2.1.*
 * **fields.E120**: ``CharField``\s must define a ``max_length`` attribute.
 * **fields.E121**: ``max_length`` must be a positive integer.
 * **fields.W122**: ``max_length`` is ignored when used with ``IntegerField``.

+ 2 - 3
docs/ref/contrib/admin/index.txt

@@ -606,9 +606,8 @@ subclass::
       and add that method's name to ``list_display``. (See below for more
       on custom methods in ``list_display``.)
 
-    * If the field is a ``BooleanField`` or ``NullBooleanField``, Django
-      will display a pretty "on" or "off" icon instead of ``True`` or
-      ``False``.
+    * If the field is a ``BooleanField``, Django will display a pretty "on" or
+      "off" icon instead of ``True`` or ``False``.
 
     * If the string given is a method of the model, ``ModelAdmin`` or a
       callable, Django will HTML-escape the output by default. To escape

+ 10 - 11
docs/ref/models/fields.txt

@@ -61,9 +61,6 @@ set ``blank=True`` if you wish to permit empty values in forms, as the
     When using the Oracle database backend, the value ``NULL`` will be stored to
     denote the empty string regardless of this attribute.
 
-If you want to accept :attr:`~Field.null` values with :class:`BooleanField`,
-use :class:`NullBooleanField` instead.
-
 ``blank``
 ---------
 
@@ -442,15 +439,18 @@ case it can't be included in a :class:`~django.forms.ModelForm`.
 
 A true/false field.
 
-The default form widget for this field is a
-:class:`~django.forms.CheckboxInput`.
-
-If you need to accept :attr:`~Field.null` values then use
-:class:`NullBooleanField` instead.
+The default form widget for this field is :class:`~django.forms.CheckboxInput`,
+or :class:`~django.forms.NullBooleanSelect` if :attr:`null=True <Field.null>`.
 
 The default value of ``BooleanField`` is ``None`` when :attr:`Field.default`
 isn't defined.
 
+.. versionchanged:: 2.1
+
+    In older versions, this field doesn't permit ``null=True``, so you have to
+    use :class:`NullBooleanField` instead. Using the latter is now discouraged
+    as it's likely to be deprecated in a future version of Django.
+
 ``CharField``
 -------------
 
@@ -1008,9 +1008,8 @@ values are stored as null.
 
 .. class:: NullBooleanField(**options)
 
-Like a :class:`BooleanField`, but allows ``NULL`` as one of the options. Use
-this instead of a :class:`BooleanField` with ``null=True``. The default form
-widget for this field is a :class:`~django.forms.NullBooleanSelect`.
+Like :class:`BooleanField` with ``null=True``. Use that instead of this field
+as it's likely to be deprecated in a future version of Django.
 
 ``PositiveIntegerField``
 ------------------------

+ 4 - 0
docs/releases/2.1.txt

@@ -223,6 +223,10 @@ Models
 * :meth:`.QuerySet.order_by` and :meth:`distinct(*fields) <.QuerySet.distinct>`
   now support using field transforms.
 
+* :class:`~django.db.models.BooleanField` can now be ``null=True``. This is
+  encouraged instead of :class:`~django.db.models.NullBooleanField`, which will
+  likely be deprecated in the future.
+
 Requests and Responses
 ~~~~~~~~~~~~~~~~~~~~~~
 

+ 3 - 1
docs/topics/forms/modelforms.txt

@@ -67,7 +67,9 @@ Model field                         Form field
                                     ``True`` on the model field, otherwise not
                                     represented in the form.
 
-:class:`BooleanField`               :class:`~django.forms.BooleanField`
+:class:`BooleanField`               :class:`~django.forms.BooleanField`, or
+                                    :class:`~django.forms.NullBooleanField` if
+                                    ``null=True``.
 
 :class:`CharField`                  :class:`~django.forms.CharField` with
                                     ``max_length`` set to the model field's

+ 2 - 1
tests/admin_filters/models.py

@@ -28,7 +28,8 @@ class Book(models.Model):
         verbose_name='Employee',
         blank=True, null=True,
     )
-    is_best_seller = models.NullBooleanField(default=0)
+    is_best_seller = models.BooleanField(default=0, null=True)
+    is_best_seller2 = models.NullBooleanField(default=0)
     date_registered = models.DateField(null=True)
     # This field name is intentionally 2 characters long (#16080).
     no = models.IntegerField(verbose_name='number', blank=True, null=True)

+ 56 - 0
tests/admin_filters/tests.py

@@ -142,6 +142,10 @@ class BookAdmin(ModelAdmin):
     ordering = ('-id',)
 
 
+class BookAdmin2(ModelAdmin):
+    list_filter = ('year', 'author', 'contributors', 'is_best_seller2', 'date_registered', 'no')
+
+
 class BookAdminWithTupleBooleanFilter(BookAdmin):
     list_filter = (
         'year',
@@ -267,18 +271,22 @@ class ListFiltersTests(TestCase):
         self.djangonaut_book = Book.objects.create(
             title='Djangonaut: an art of living', year=2009,
             author=self.alfred, is_best_seller=True, date_registered=self.today,
+            is_best_seller2=True,
         )
         self.bio_book = Book.objects.create(
             title='Django: a biography', year=1999, author=self.alfred,
             is_best_seller=False, no=207,
+            is_best_seller2=False,
         )
         self.django_book = Book.objects.create(
             title='The Django Book', year=None, author=self.bob,
             is_best_seller=None, date_registered=self.today, no=103,
+            is_best_seller2=None,
         )
         self.guitar_book = Book.objects.create(
             title='Guitar for dummies', year=2002, is_best_seller=True,
             date_registered=self.one_week_ago,
+            is_best_seller2=True,
         )
         self.guitar_book.contributors.set([self.bob, self.lisa])
 
@@ -738,6 +746,54 @@ class ListFiltersTests(TestCase):
         self.assertIs(choice['selected'], True)
         self.assertEqual(choice['query_string'], '?is_best_seller__isnull=True')
 
+    def test_booleanfieldlistfilter_nullbooleanfield(self):
+        modeladmin = BookAdmin2(Book, site)
+
+        request = self.request_factory.get('/')
+        changelist = modeladmin.get_changelist_instance(request)
+
+        request = self.request_factory.get('/', {'is_best_seller2__exact': 0})
+        changelist = modeladmin.get_changelist_instance(request)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_queryset(request)
+        self.assertEqual(list(queryset), [self.bio_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][3]
+        self.assertEqual(filterspec.title, 'is best seller2')
+        choice = select_by(filterspec.choices(changelist), "display", "No")
+        self.assertIs(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?is_best_seller2__exact=0')
+
+        request = self.request_factory.get('/', {'is_best_seller2__exact': 1})
+        changelist = modeladmin.get_changelist_instance(request)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_queryset(request)
+        self.assertEqual(list(queryset), [self.guitar_book, self.djangonaut_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][3]
+        self.assertEqual(filterspec.title, 'is best seller2')
+        choice = select_by(filterspec.choices(changelist), "display", "Yes")
+        self.assertIs(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?is_best_seller2__exact=1')
+
+        request = self.request_factory.get('/', {'is_best_seller2__isnull': 'True'})
+        changelist = modeladmin.get_changelist_instance(request)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_queryset(request)
+        self.assertEqual(list(queryset), [self.django_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][3]
+        self.assertEqual(filterspec.title, 'is best seller2')
+        choice = select_by(filterspec.choices(changelist), "display", "Unknown")
+        self.assertIs(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?is_best_seller2__isnull=True')
+
     def test_fieldlistfilter_underscorelookup_tuple(self):
         """
         Ensure ('fieldpath', ClassName ) lookups pass lookup_allowed checks

+ 4 - 0
tests/admin_utils/tests.py

@@ -166,6 +166,10 @@ class UtilsTests(SimpleTestCase):
         expected = '<img src="%sadmin/img/icon-unknown.svg" alt="None">' % settings.STATIC_URL
         self.assertHTMLEqual(display_value, expected)
 
+        display_value = display_for_field(None, models.BooleanField(null=True), self.empty_value)
+        expected = '<img src="%sadmin/img/icon-unknown.svg" alt="None" />' % settings.STATIC_URL
+        self.assertHTMLEqual(display_value, expected)
+
         display_value = display_for_field(None, models.DecimalField(), self.empty_value)
         self.assertEqual(display_value, self.empty_value)
 

+ 2 - 2
tests/admin_views/models.py

@@ -456,7 +456,7 @@ class Post(models.Model):
         default=datetime.date.today,
         help_text="Some help text for the date (with unicode ŠĐĆŽćžšđ)"
     )
-    public = models.NullBooleanField()
+    public = models.BooleanField(null=True, blank=True)
 
     def awesomeness_level(self):
         return "Very awesome."
@@ -692,7 +692,7 @@ class OtherStory(models.Model):
 class ComplexSortedPerson(models.Model):
     name = models.CharField(max_length=100)
     age = models.PositiveIntegerField()
-    is_employee = models.NullBooleanField()
+    is_employee = models.BooleanField(null=True)
 
 
 class PluggableSearchPerson(models.Model):

+ 3 - 1
tests/annotations/tests.py

@@ -521,13 +521,15 @@ class NonAggregateAnnotationTestCase(TestCase):
         books = Book.objects.annotate(
             is_book=Value(True, output_field=BooleanField()),
             is_pony=Value(False, output_field=BooleanField()),
-            is_none=Value(None, output_field=NullBooleanField()),
+            is_none=Value(None, output_field=BooleanField(null=True)),
+            is_none_old=Value(None, output_field=NullBooleanField()),
         )
         self.assertGreater(len(books), 0)
         for book in books:
             self.assertIs(book.is_book, True)
             self.assertIs(book.is_pony, False)
             self.assertIsNone(book.is_none)
+            self.assertIsNone(book.is_none_old)
 
     def test_annotation_in_f_grouped_by_annotation(self):
         qs = (

+ 1 - 1
tests/backends/oracle/tests.py

@@ -50,7 +50,7 @@ class Tests(unittest.TestCase):
 
     def test_boolean_constraints(self):
         """Boolean fields have check constraints on their values."""
-        for field in (BooleanField(), NullBooleanField()):
+        for field in (BooleanField(), NullBooleanField(), BooleanField(null=True)):
             with self.subTest(field=field):
                 field.set_attributes_from_name('is_nice')
                 self.assertIn('"IS_NICE" IN (0,1)', field.db_check(connection))

+ 2 - 1
tests/bulk_create/models.py

@@ -74,7 +74,8 @@ class NullableFields(models.Model):
     duration_field = models.DurationField(null=True, default=datetime.timedelta(1))
     float_field = models.FloatField(null=True, default=3.2)
     integer_field = models.IntegerField(null=True, default=2)
-    null_boolean_field = models.NullBooleanField(null=True, default=False)
+    null_boolean_field = models.BooleanField(null=True, default=False)
+    null_boolean_field_old = models.NullBooleanField(null=True, default=False)
     positive_integer_field = models.PositiveIntegerField(null=True, default=3)
     positive_small_integer_field = models.PositiveSmallIntegerField(null=True, default=4)
     small_integer_field = models.SmallIntegerField(null=True, default=5)

+ 2 - 1
tests/datatypes/models.py

@@ -9,7 +9,8 @@ from django.db import models
 class Donut(models.Model):
     name = models.CharField(max_length=100)
     is_frosted = models.BooleanField(default=False)
-    has_sprinkles = models.NullBooleanField()
+    has_sprinkles = models.BooleanField(null=True)
+    has_sprinkles_old = models.NullBooleanField()
     baked_date = models.DateField(null=True)
     baked_time = models.TimeField(null=True)
     consumed_at = models.DateTimeField(null=True)

+ 4 - 0
tests/datatypes/tests.py

@@ -12,14 +12,18 @@ class DataTypesTestCase(TestCase):
         d = Donut(name='Apple Fritter')
         self.assertFalse(d.is_frosted)
         self.assertIsNone(d.has_sprinkles)
+        self.assertIsNone(d.has_sprinkles_old)
         d.has_sprinkles = True
+        d.has_sprinkles_old = True
         self.assertTrue(d.has_sprinkles)
+        self.assertTrue(d.has_sprinkles_old)
 
         d.save()
 
         d2 = Donut.objects.get(name='Apple Fritter')
         self.assertFalse(d2.is_frosted)
         self.assertTrue(d2.has_sprinkles)
+        self.assertTrue(d2.has_sprinkles_old)
 
     def test_date_type(self):
         d = Donut(name='Apple Fritter')

+ 2 - 1
tests/expressions_case/models.py

@@ -25,7 +25,8 @@ class CaseTestModel(models.Model):
     if Image:
         image = models.ImageField(null=True)
     generic_ip_address = models.GenericIPAddressField(null=True)
-    null_boolean = models.NullBooleanField()
+    null_boolean = models.BooleanField(null=True)
+    null_boolean_old = models.NullBooleanField()
     positive_integer = models.PositiveIntegerField(null=True)
     positive_small_integer = models.PositiveSmallIntegerField(null=True)
     slug = models.SlugField(default='')

+ 13 - 0
tests/expressions_case/tests.py

@@ -820,6 +820,19 @@ class CaseExpressionTests(TestCase):
             transform=attrgetter('integer', 'null_boolean')
         )
 
+    def test_update_null_boolean_old(self):
+        CaseTestModel.objects.update(
+            null_boolean_old=Case(
+                When(integer=1, then=True),
+                When(integer=2, then=False),
+            ),
+        )
+        self.assertQuerysetEqual(
+            CaseTestModel.objects.all().order_by('pk'),
+            [(1, True), (2, False), (3, None), (2, False), (3, None), (3, None), (4, None)],
+            transform=attrgetter('integer', 'null_boolean_old')
+        )
+
     def test_update_positive_integer(self):
         CaseTestModel.objects.update(
             positive_integer=Case(

+ 1 - 1
tests/generic_relations_regress/models.py

@@ -169,7 +169,7 @@ class HasLinkThing(HasLinks):
 
 
 class A(models.Model):
-    flag = models.NullBooleanField()
+    flag = models.BooleanField(null=True)
     content_type = models.ForeignKey(ContentType, models.CASCADE)
     object_id = models.PositiveIntegerField()
     content_object = GenericForeignKey('content_type', 'object_id')

+ 1 - 1
tests/inspectdb/models.py

@@ -44,7 +44,7 @@ class ColumnTypes(models.Model):
     id = models.AutoField(primary_key=True)
     big_int_field = models.BigIntegerField()
     bool_field = models.BooleanField(default=False)
-    null_bool_field = models.NullBooleanField()
+    null_bool_field = models.BooleanField(null=True)
     char_field = models.CharField(max_length=10)
     null_char_field = models.CharField(max_length=10, blank=True, null=True)
     date_field = models.DateField()

+ 0 - 18
tests/invalid_models_tests/test_ordinary_fields.py

@@ -38,24 +38,6 @@ class AutoFieldTests(SimpleTestCase):
         ])
 
 
-@isolate_apps('invalid_models_tests')
-class BooleanFieldTests(SimpleTestCase):
-
-    def test_nullable_boolean_field(self):
-        class Model(models.Model):
-            field = models.BooleanField(null=True)
-
-        field = Model._meta.get_field('field')
-        self.assertEqual(field.check(), [
-            Error(
-                'BooleanFields do not accept null values.',
-                hint='Use a NullBooleanField instead.',
-                obj=field,
-                id='fields.E110',
-            ),
-        ])
-
-
 @isolate_apps('invalid_models_tests')
 class CharFieldTests(TestCase):
 

+ 1 - 1
tests/managers_regress/models.py

@@ -119,7 +119,7 @@ class Child7(Parent):
 # RelatedManagers
 class RelatedModel(models.Model):
     test_gfk = GenericRelation('RelationModel', content_type_field='gfk_ctype', object_id_field='gfk_id')
-    exact = models.NullBooleanField()
+    exact = models.BooleanField(null=True)
 
     def __str__(self):
         return str(self.pk)

+ 2 - 1
tests/model_fields/models.py

@@ -101,7 +101,8 @@ class Post(models.Model):
 
 
 class NullBooleanModel(models.Model):
-    nbfield = models.NullBooleanField()
+    nbfield = models.BooleanField(null=True, blank=True)
+    nbfield_old = models.NullBooleanField()
 
 
 class BooleanModel(models.Model):

+ 18 - 5
tests/model_fields/test_booleanfield.py

@@ -24,12 +24,18 @@ class BooleanFieldTests(TestCase):
         self._test_get_prep_value(models.BooleanField())
 
     def test_nullbooleanfield_get_prep_value(self):
+        self._test_get_prep_value(models.BooleanField(null=True))
+
+    def test_nullbooleanfield_old_get_prep_value(self):
         self._test_get_prep_value(models.NullBooleanField())
 
     def test_booleanfield_to_python(self):
         self._test_to_python(models.BooleanField())
 
     def test_nullbooleanfield_to_python(self):
+        self._test_to_python(models.BooleanField(null=True))
+
+    def test_nullbooleanfield_old_to_python(self):
         self._test_to_python(models.NullBooleanField())
 
     def test_booleanfield_choices_blank(self):
@@ -42,6 +48,8 @@ class BooleanFieldTests(TestCase):
         self.assertEqual(f.formfield().choices, choices)
 
     def test_nullbooleanfield_formfield(self):
+        f = models.BooleanField(null=True)
+        self.assertIsInstance(f.formfield(), forms.NullBooleanField)
         f = models.NullBooleanField()
         self.assertIsInstance(f.formfield(), forms.NullBooleanField)
 
@@ -54,13 +62,15 @@ class BooleanFieldTests(TestCase):
         b2.refresh_from_db()
         self.assertIs(b2.bfield, False)
 
-        b3 = NullBooleanModel.objects.create(nbfield=True)
+        b3 = NullBooleanModel.objects.create(nbfield=True, nbfield_old=True)
         b3.refresh_from_db()
         self.assertIs(b3.nbfield, True)
+        self.assertIs(b3.nbfield_old, True)
 
-        b4 = NullBooleanModel.objects.create(nbfield=False)
+        b4 = NullBooleanModel.objects.create(nbfield=False, nbfield_old=False)
         b4.refresh_from_db()
         self.assertIs(b4.nbfield, False)
+        self.assertIs(b4.nbfield_old, False)
 
         # When an extra clause exists, the boolean conversions are applied with
         # an offset (#13293).
@@ -73,8 +83,8 @@ class BooleanFieldTests(TestCase):
         """
         bmt = BooleanModel.objects.create(bfield=True)
         bmf = BooleanModel.objects.create(bfield=False)
-        nbmt = NullBooleanModel.objects.create(nbfield=True)
-        nbmf = NullBooleanModel.objects.create(nbfield=False)
+        nbmt = NullBooleanModel.objects.create(nbfield=True, nbfield_old=True)
+        nbmf = NullBooleanModel.objects.create(nbfield=False, nbfield_old=False)
         m1 = FksToBooleans.objects.create(bf=bmt, nbf=nbmt)
         m2 = FksToBooleans.objects.create(bf=bmf, nbf=nbmf)
 
@@ -88,8 +98,10 @@ class BooleanFieldTests(TestCase):
         mc = FksToBooleans.objects.select_related().get(pk=m2.id)
         self.assertIs(mb.bf.bfield, True)
         self.assertIs(mb.nbf.nbfield, True)
+        self.assertIs(mb.nbf.nbfield_old, True)
         self.assertIs(mc.bf.bfield, False)
         self.assertIs(mc.nbf.nbfield, False)
+        self.assertIs(mc.nbf.nbfield_old, False)
 
     def test_null_default(self):
         """
@@ -105,6 +117,7 @@ class BooleanFieldTests(TestCase):
 
         nb = NullBooleanModel()
         self.assertIsNone(nb.nbfield)
+        self.assertIsNone(nb.nbfield_old)
         nb.save()  # no error
 
 
@@ -120,5 +133,5 @@ class ValidationTest(SimpleTestCase):
         NullBooleanField shouldn't throw a validation error when given a value
         of None.
         """
-        nullboolean = NullBooleanModel(nbfield=None)
+        nullboolean = NullBooleanModel(nbfield=None, nbfield_old=None)
         nullboolean.full_clean()

+ 1 - 1
tests/postgres_tests/migrations/0002_create_test_models.py

@@ -168,7 +168,7 @@ class Migration(migrations.Migration):
             name='AggregateTestModel',
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
-                ('boolean_field', models.NullBooleanField()),
+                ('boolean_field', models.BooleanField(null=True)),
                 ('char_field', models.CharField(max_length=30, blank=True)),
                 ('integer_field', models.IntegerField(null=True)),
             ]

+ 1 - 1
tests/postgres_tests/models.py

@@ -156,7 +156,7 @@ class AggregateTestModel(models.Model):
     """
     char_field = models.CharField(max_length=30, blank=True)
     integer_field = models.IntegerField(null=True)
-    boolean_field = models.NullBooleanField()
+    boolean_field = models.BooleanField(null=True)
 
 
 class StatTestModel(models.Model):

+ 1 - 1
tests/queries/models.py

@@ -676,7 +676,7 @@ class Student(models.Model):
 
 class Classroom(models.Model):
     name = models.CharField(max_length=20)
-    has_blackboard = models.NullBooleanField()
+    has_blackboard = models.BooleanField(null=True)
     school = models.ForeignKey(School, models.CASCADE)
     students = models.ManyToManyField(Student, related_name='classroom')
 

+ 1 - 5
tests/serializers/models/data.py

@@ -18,7 +18,7 @@ class BinaryData(models.Model):
 
 
 class BooleanData(models.Model):
-    data = models.BooleanField(default=False)
+    data = models.BooleanField(default=False, null=True)
 
 
 class CharData(models.Model):
@@ -226,10 +226,6 @@ class IntegerPKData(models.Model):
 class GenericIPAddressPKData(models.Model):
     data = models.GenericIPAddressField(primary_key=True)
 
-# This is just a Boolean field with null=True, and we can't test a PK value of NULL.
-# class NullBooleanPKData(models.Model):
-#     data = models.NullBooleanField(primary_key=True)
-
 
 class PositiveIntegerPKData(models.Model):
     data = models.PositiveIntegerField(primary_key=True)

+ 1 - 2
tests/serializers/test_data.py

@@ -200,6 +200,7 @@ test_data = [
     (data_obj, 2, BinaryData, None),
     (data_obj, 5, BooleanData, True),
     (data_obj, 6, BooleanData, False),
+    (data_obj, 7, BooleanData, None),
     (data_obj, 10, CharData, "Test Char Data"),
     (data_obj, 11, CharData, ""),
     (data_obj, 12, CharData, "None"),
@@ -334,8 +335,6 @@ The end."""),
     (pk_obj, 682, IntegerPKData, 0),
     # (XX, ImagePKData
     (pk_obj, 695, GenericIPAddressPKData, "fe80:1424:2223:6cff:fe8a:2e8a:2151:abcd"),
-    # (pk_obj, 700, NullBooleanPKData, True),
-    # (pk_obj, 701, NullBooleanPKData, False),
     (pk_obj, 720, PositiveIntegerPKData, 123456789),
     (pk_obj, 730, PositiveSmallIntegerPKData, 12),
     (pk_obj, 740, SlugPKData, "this-is-a-slug"),

+ 4 - 0
tests/validation/test_error_messages.py

@@ -23,6 +23,10 @@ class ValidationMessagesTest(TestCase):
         f = models.BooleanField()
         self._test_validation_messages(f, 'fõo', ["'fõo' value must be either True or False."])
 
+    def test_nullable_boolean_field_raises_error_message(self):
+        f = models.BooleanField(null=True)
+        self._test_validation_messages(f, 'fõo', ["'fõo' value must be either True, False, or None."])
+
     def test_float_field_raises_error_message(self):
         f = models.FloatField()
         self._test_validation_messages(f, 'fõo', ["'fõo' value must be a float."])