Fixed #25718 -- Made a JSONField lookup value of None match keys that have a null value.
This commit is contained in:
parent
4fe5d84666
commit
c979c0a2b8
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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'}),
|
||||||
|
|
Loading…
Reference in New Issue