diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py index d21abca244d..633a26b67ae 100644 --- a/django/contrib/admin/filters.py +++ b/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]), diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index b8df69c1d8d..73bcd8bcbb4 100644 --- a/django/contrib/admin/utils.py +++ b/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: diff --git a/django/db/backends/oracle/utils.py b/django/db/backends/oracle/utils.py index 6c81d5cd7ba..3b26112071c 100644 --- a/django/db/backends/oracle/utils.py +++ b/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, diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index ea31096374c..59cc963b18b 100644 --- a/django/db/models/fields/__init__.py +++ b/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): diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 6310500d0c3..1b9fa183287 100644 --- a/docs/ref/checks.txt +++ b/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``. diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index face1297ace..cc0cf87c8a5 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/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 diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index afd0d6ff0c8..adb12aac63f 100644 --- a/docs/ref/models/fields.txt +++ b/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 `. 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`` ------------------------ diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index 4d8a9a1d422..ec2a3266a12 100644 --- a/docs/releases/2.1.txt +++ b/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 ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index 553271e5981..ec7c7a05879 100644 --- a/docs/topics/forms/modelforms.txt +++ b/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 diff --git a/tests/admin_filters/models.py b/tests/admin_filters/models.py index d9ce5c7cfd5..ae78282d345 100644 --- a/tests/admin_filters/models.py +++ b/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) diff --git a/tests/admin_filters/tests.py b/tests/admin_filters/tests.py index 200c8c8db17..554b1a4e236 100644 --- a/tests/admin_filters/tests.py +++ b/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 diff --git a/tests/admin_utils/tests.py b/tests/admin_utils/tests.py index 4d56313ae1b..d2697ca87e5 100644 --- a/tests/admin_utils/tests.py +++ b/tests/admin_utils/tests.py @@ -166,6 +166,10 @@ class UtilsTests(SimpleTestCase): expected = 'None' % settings.STATIC_URL self.assertHTMLEqual(display_value, expected) + display_value = display_for_field(None, models.BooleanField(null=True), self.empty_value) + expected = '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) diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py index 38565b0a7f1..59228876c59 100644 --- a/tests/admin_views/models.py +++ b/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): diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py index d7759c8552f..f7f84743299 100644 --- a/tests/annotations/tests.py +++ b/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 = ( diff --git a/tests/backends/oracle/tests.py b/tests/backends/oracle/tests.py index 77de9cf2793..333d224b3f6 100644 --- a/tests/backends/oracle/tests.py +++ b/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)) diff --git a/tests/bulk_create/models.py b/tests/bulk_create/models.py index c0ea527a62c..c4329fbfc9d 100644 --- a/tests/bulk_create/models.py +++ b/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) diff --git a/tests/datatypes/models.py b/tests/datatypes/models.py index 570b2ba8e06..6e31a3a4532 100644 --- a/tests/datatypes/models.py +++ b/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) diff --git a/tests/datatypes/tests.py b/tests/datatypes/tests.py index 52f24fe051f..924d7961213 100644 --- a/tests/datatypes/tests.py +++ b/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') diff --git a/tests/expressions_case/models.py b/tests/expressions_case/models.py index 7c1a8d75b79..570b8c1849d 100644 --- a/tests/expressions_case/models.py +++ b/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='') diff --git a/tests/expressions_case/tests.py b/tests/expressions_case/tests.py index a688dc073d9..94731491e06 100644 --- a/tests/expressions_case/tests.py +++ b/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( diff --git a/tests/generic_relations_regress/models.py b/tests/generic_relations_regress/models.py index 5d8716aa0ce..f9cdb1b5494 100644 --- a/tests/generic_relations_regress/models.py +++ b/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') diff --git a/tests/inspectdb/models.py b/tests/inspectdb/models.py index 1a001b653e1..8c660658cd5 100644 --- a/tests/inspectdb/models.py +++ b/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() diff --git a/tests/invalid_models_tests/test_ordinary_fields.py b/tests/invalid_models_tests/test_ordinary_fields.py index a3c3a708080..2411abc9300 100644 --- a/tests/invalid_models_tests/test_ordinary_fields.py +++ b/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): diff --git a/tests/managers_regress/models.py b/tests/managers_regress/models.py index 8d5725c5b70..36cd4e75729 100644 --- a/tests/managers_regress/models.py +++ b/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) diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index 1996011512c..13d4843632f 100644 --- a/tests/model_fields/models.py +++ b/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): diff --git a/tests/model_fields/test_booleanfield.py b/tests/model_fields/test_booleanfield.py index f295f24980a..72c9293d937 100644 --- a/tests/model_fields/test_booleanfield.py +++ b/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() diff --git a/tests/postgres_tests/migrations/0002_create_test_models.py b/tests/postgres_tests/migrations/0002_create_test_models.py index cbc984d4855..57465612b58 100644 --- a/tests/postgres_tests/migrations/0002_create_test_models.py +++ b/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)), ] diff --git a/tests/postgres_tests/models.py b/tests/postgres_tests/models.py index 35e76d06223..d5865818e74 100644 --- a/tests/postgres_tests/models.py +++ b/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): diff --git a/tests/queries/models.py b/tests/queries/models.py index 64f426c4a7e..587d2e683ee 100644 --- a/tests/queries/models.py +++ b/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') diff --git a/tests/serializers/models/data.py b/tests/serializers/models/data.py index b62f85a8684..533ccf68301 100644 --- a/tests/serializers/models/data.py +++ b/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) diff --git a/tests/serializers/test_data.py b/tests/serializers/test_data.py index 467292bff14..9f0e311d623 100644 --- a/tests/serializers/test_data.py +++ b/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"), diff --git a/tests/validation/test_error_messages.py b/tests/validation/test_error_messages.py index d2fc3e49396..0869d0fc10a 100644 --- a/tests/validation/test_error_messages.py +++ b/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."])