summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDmitry Dygalo <dadygalo@gmail.com>2018-02-20 16:47:12 +0100
committerTim Graham <timograham@gmail.com>2018-04-04 10:53:46 -0400
commitc979c0a2b8abca325a549961fd7a17bdc36bcb1f (patch)
treeb6d221918b9c0989cbb3d6dc08cd7ebee08381bf
parent4fe5d846666d46a5395a5f0ea2845a96b6837a75 (diff)
Fixed #25718 -- Made a JSONField lookup value of None match keys that have a null value.
-rw-r--r--django/contrib/postgres/fields/jsonb.py1
-rw-r--r--django/contrib/postgres/lookups.py10
-rw-r--r--django/db/models/lookups.py1
-rw-r--r--django/db/models/sql/query.py6
-rw-r--r--docs/ref/contrib/postgres/fields.txt19
-rw-r--r--docs/releases/2.1.txt4
-rw-r--r--tests/lookup/models.py7
-rw-r--r--tests/lookup/tests.py12
-rw-r--r--tests/postgres_tests/test_json.py15
9 files changed, 70 insertions, 5 deletions
diff --git a/django/contrib/postgres/fields/jsonb.py b/django/contrib/postgres/fields/jsonb.py
index 3c27607acd..966e8f1141 100644
--- a/django/contrib/postgres/fields/jsonb.py
+++ b/django/contrib/postgres/fields/jsonb.py
@@ -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):
diff --git a/django/contrib/postgres/lookups.py b/django/contrib/postgres/lookups.py
index afef01ef9e..c2b3d2b569 100644
--- a/django/contrib/postgres/lookups.py
+++ b/django/contrib/postgres/lookups.py
@@ -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
diff --git a/django/db/models/lookups.py b/django/db/models/lookups.py
index a4c7459bfe..22dd56ced9 100644
--- a/django/db/models/lookups.py
+++ b/django/db/models/lookups.py
@@ -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
diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py
index d39514a0a5..3756ecbb5d 100644
--- a/django/db/models/sql/query.py
+++ b/django/db/models/sql/query.py
@@ -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
diff --git a/docs/ref/contrib/postgres/fields.txt b/docs/ref/contrib/postgres/fields.txt
index 4c910527c9..1837557665 100644
--- a/docs/ref/contrib/postgres/fields.txt
+++ b/docs/ref/contrib/postgres/fields.txt
@@ -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
diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt
index 46a903ba11..8ad64f250a 100644
--- a/docs/releases/2.1.txt
+++ b/docs/releases/2.1.txt
@@ -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
diff --git a/tests/lookup/models.py b/tests/lookup/models.py
index 3f1f14dfbc..8c8cb67827 100644
--- a/tests/lookup/models.py
+++ b/tests/lookup/models.py
@@ -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)
diff --git a/tests/lookup/tests.py b/tests/lookup/tests.py
index eb6600bc9b..1d2a78c717 100644
--- a/tests/lookup/tests.py
+++ b/tests/lookup/tests.py
@@ -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))
diff --git a/tests/postgres_tests/test_json.py b/tests/postgres_tests/test_json.py
index 305278fc6a..2f0b55a292 100644
--- a/tests/postgres_tests/test_json.py
+++ b/tests/postgres_tests/test_json.py
@@ -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'}),