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.HasKey)
JSONField.register_lookup(lookups.HasKeys) JSONField.register_lookup(lookups.HasKeys)
JSONField.register_lookup(lookups.HasAnyKeys) JSONField.register_lookup(lookups.HasAnyKeys)
JSONField.register_lookup(lookups.JSONExact)
class KeyTransform(Transform): class KeyTransform(Transform):

View File

@ -1,4 +1,5 @@
from django.db.models import Lookup, Transform from django.db.models import Lookup, Transform
from django.db.models.lookups import Exact
from .search import SearchVector, SearchVectorExact, SearchVectorField from .search import SearchVector, SearchVectorExact, SearchVectorField
@ -64,3 +65,12 @@ class SearchLookup(SearchVectorExact):
class TrigramSimilar(PostgresSimpleLookup): class TrigramSimilar(PostgresSimpleLookup):
lookup_name = 'trigram_similar' lookup_name = 'trigram_similar'
operator = '%%' 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: class Lookup:
lookup_name = None lookup_name = None
prepare_rhs = True prepare_rhs = True
can_use_none_as_rhs = False
def __init__(self, lhs, rhs): def __init__(self, lhs, rhs):
self.lhs, self.rhs = lhs, rhs self.lhs, self.rhs = lhs, rhs

View File

@ -1083,8 +1083,8 @@ class Query:
lookup = lookup_class(lhs, rhs) lookup = lookup_class(lhs, rhs)
# Interpret '__exact=None' as the sql 'is NULL'; otherwise, reject all # Interpret '__exact=None' as the sql 'is NULL'; otherwise, reject all
# uses of None as a query value. # uses of None as a query value unless the lookup supports it.
if lookup.rhs is None: if lookup.rhs is None and not lookup.can_use_none_as_rhs:
if lookup_name not in ('exact', 'iexact'): if lookup_name not in ('exact', 'iexact'):
raise ValueError("Cannot use None as a query value") raise ValueError("Cannot use None as a query value")
return lhs.get_lookup('isnull')(lhs, True) return lhs.get_lookup('isnull')(lhs, True)
@ -1215,7 +1215,7 @@ class Query:
clause.add(condition, AND) clause.add(condition, AND)
require_outer = lookup_type == 'isnull' and condition.rhs is True and not current_negated 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 require_outer = True
if (lookup_type != 'isnull' and ( if (lookup_type != 'isnull' and (
self.is_nullable(targets[0]) or 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') >>> Dog.objects.filter(data__breed='collie')
<QuerySet [<Dog: Meg>]> <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 If only one key or index is used, the SQL operator ``->`` is used. If multiple
operators are used then the ``#>`` operator is used. 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:: .. warning::
Since any string could be a key in a JSON object, any lookup other than 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 * 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. 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: .. _deprecated-features-2.1:
Features deprecated in 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 import models
from django.db.models.lookups import IsNull
class Alarm(models.Model): class Alarm(models.Model):
@ -55,6 +56,12 @@ class NulledTransform(models.Transform):
template = 'NULL' template = 'NULL'
@NulledTextField.register_lookup
class IsNullWithNoneAsRHS(IsNull):
lookup_name = 'isnull_none_rhs'
can_use_none_as_rhs = True
class Season(models.Model): class Season(models.Model):
year = models.PositiveSmallIntegerField() year = models.PositiveSmallIntegerField()
gt = models.IntegerField(null=True, blank=True) 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.db.models.functions import Substr
from django.test import TestCase, skipUnlessDBFeature 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): class LookupTests(TestCase):
@ -895,3 +897,11 @@ class LookupTests(TestCase):
with self.subTest(lookup=lookup): with self.subTest(lookup=lookup):
authors = Author.objects.filter(**{'name__%s' % lookup: Substr('alias', 1, 3)}) authors = Author.objects.filter(**{'name__%s' % lookup: Substr('alias', 1, 3)})
self.assertCountEqual(authors, result) 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 import checks, exceptions, serializers
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.forms import CharField, Form, widgets from django.forms import CharField, Form, widgets
from django.test.utils import isolate_apps from django.test.utils import isolate_apps
from django.utils.html import escape from django.utils.html import escape
@ -177,6 +178,20 @@ class TestQuerying(PostgreSQLTestCase):
[self.objs[7], self.objs[8]] [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): def test_contains(self):
self.assertSequenceEqual( self.assertSequenceEqual(
JSONModel.objects.filter(field__contains={'a': 'b'}), JSONModel.objects.filter(field__contains={'a': 'b'}),