Fixed #25718 -- Made a JSONField lookup value of None match keys that have a null value.

This commit is contained in:
Dmitry Dygalo 2018-02-20 16:47:12 +01:00 committed by Tim Graham
parent 4fe5d84666
commit c979c0a2b8
9 changed files with 70 additions and 5 deletions

View File

@ -88,6 +88,7 @@ JSONField.register_lookup(lookups.ContainedBy)
JSONField.register_lookup(lookups.HasKey)
JSONField.register_lookup(lookups.HasKeys)
JSONField.register_lookup(lookups.HasAnyKeys)
JSONField.register_lookup(lookups.JSONExact)
class KeyTransform(Transform):

View File

@ -1,4 +1,5 @@
from django.db.models import Lookup, Transform
from django.db.models.lookups import Exact
from .search import SearchVector, SearchVectorExact, SearchVectorField
@ -64,3 +65,12 @@ class SearchLookup(SearchVectorExact):
class TrigramSimilar(PostgresSimpleLookup):
lookup_name = 'trigram_similar'
operator = '%%'
class JSONExact(Exact):
can_use_none_as_rhs = True
def process_rhs(self, compiler, connection):
result = super().process_rhs(compiler, connection)
# Treat None lookup values as null.
return ("'null'", []) if result == ('%s', [None]) else result

View File

@ -12,6 +12,7 @@ from django.utils.functional import cached_property
class Lookup:
lookup_name = None
prepare_rhs = True
can_use_none_as_rhs = False
def __init__(self, lhs, rhs):
self.lhs, self.rhs = lhs, rhs

View File

@ -1083,8 +1083,8 @@ class Query:
lookup = lookup_class(lhs, rhs)
# Interpret '__exact=None' as the sql 'is NULL'; otherwise, reject all
# uses of None as a query value.
if lookup.rhs is None:
# uses of None as a query value unless the lookup supports it.
if lookup.rhs is None and not lookup.can_use_none_as_rhs:
if lookup_name not in ('exact', 'iexact'):
raise ValueError("Cannot use None as a query value")
return lhs.get_lookup('isnull')(lhs, True)
@ -1215,7 +1215,7 @@ class Query:
clause.add(condition, AND)
require_outer = lookup_type == 'isnull' and condition.rhs is True and not current_negated
if current_negated and (lookup_type != 'isnull' or condition.rhs is False):
if current_negated and (lookup_type != 'isnull' or condition.rhs is False) and condition.rhs is not None:
require_outer = True
if (lookup_type != 'isnull' and (
self.is_nullable(targets[0]) or

View File

@ -544,7 +544,7 @@ name::
... }],
... },
... })
>>> Dog.objects.create(name='Meg', data={'breed': 'collie'})
>>> Dog.objects.create(name='Meg', data={'breed': 'collie', 'owner': None})
>>> Dog.objects.filter(data__breed='collie')
<QuerySet [<Dog: Meg>]>
@ -566,6 +566,23 @@ the :lookup:`jsonfield.contains` lookup instead.
If only one key or index is used, the SQL operator ``->`` is used. If multiple
operators are used then the ``#>`` operator is used.
To query for ``null`` in JSON data, use ``None`` as a value::
>>> Dog.objects.filter(data__owner=None)
<QuerySet [<Dog: Meg>]>
To query for missing keys, use the ``isnull`` lookup::
>>> Dog.objects.create(name='Shep', data={'breed': 'collie'})
>>> Dog.objects.filter(data__owner__isnull=True)
<QuerySet [<Dog: Shep>]>
.. versionchanged:: 2.1
In older versions, using ``None`` as a lookup value matches objects that
don't have the key rather than objects that have the key with a ``None``
value.
.. warning::
Since any string could be a key in a JSON object, any lookup other than

View File

@ -372,6 +372,10 @@ Miscellaneous
* Since migrations are now loaded from ``.pyc`` files, you might need to delete
them if you're working in a mixed Python 2 and Python 3 environment.
* Using ``None`` as a :class:`~django.contrib.postgres.fields.JSONField` lookup
value now matches objects that have the specified key and a null value rather
than objects that don't have the key.
.. _deprecated-features-2.1:
Features deprecated in 2.1

View File

@ -5,6 +5,7 @@ This demonstrates features of the database API.
"""
from django.db import models
from django.db.models.lookups import IsNull
class Alarm(models.Model):
@ -55,6 +56,12 @@ class NulledTransform(models.Transform):
template = 'NULL'
@NulledTextField.register_lookup
class IsNullWithNoneAsRHS(IsNull):
lookup_name = 'isnull_none_rhs'
can_use_none_as_rhs = True
class Season(models.Model):
year = models.PositiveSmallIntegerField()
gt = models.IntegerField(null=True, blank=True)

View File

@ -8,7 +8,9 @@ from django.db import connection
from django.db.models.functions import Substr
from django.test import TestCase, skipUnlessDBFeature
from .models import Article, Author, Game, Player, Season, Tag
from .models import (
Article, Author, Game, IsNullWithNoneAsRHS, Player, Season, Tag,
)
class LookupTests(TestCase):
@ -895,3 +897,11 @@ class LookupTests(TestCase):
with self.subTest(lookup=lookup):
authors = Author.objects.filter(**{'name__%s' % lookup: Substr('alias', 1, 3)})
self.assertCountEqual(authors, result)
def test_custom_lookup_none_rhs(self):
"""Lookup.can_use_none_as_rhs=True allows None as a lookup value."""
season = Season.objects.create(year=2012, nulled_text_field=None)
query = Season.objects.get_queryset().query
field = query.model._meta.get_field('nulled_text_field')
self.assertIsInstance(query.build_lookup(['isnull_none_rhs'], field, None), IsNullWithNoneAsRHS)
self.assertTrue(Season.objects.filter(pk=season.pk, nulled_text_field__isnull_none_rhs=True))

View File

@ -4,6 +4,7 @@ from decimal import Decimal
from django.core import checks, exceptions, serializers
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.forms import CharField, Form, widgets
from django.test.utils import isolate_apps
from django.utils.html import escape
@ -177,6 +178,20 @@ class TestQuerying(PostgreSQLTestCase):
[self.objs[7], self.objs[8]]
)
def test_none_key(self):
self.assertSequenceEqual(JSONModel.objects.filter(field__j=None), [self.objs[8]])
def test_none_key_exclude(self):
obj = JSONModel.objects.create(field={'j': 1})
self.assertSequenceEqual(JSONModel.objects.exclude(field__j=None), [obj])
def test_isnull_key_or_none(self):
obj = JSONModel.objects.create(field={'a': None})
self.assertSequenceEqual(
JSONModel.objects.filter(Q(field__a__isnull=True) | Q(field__a=None)),
self.objs[:7] + self.objs[9:] + [obj]
)
def test_contains(self):
self.assertSequenceEqual(
JSONModel.objects.filter(field__contains={'a': 'b'}),