From c979c0a2b8abca325a549961fd7a17bdc36bcb1f Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Tue, 20 Feb 2018 16:47:12 +0100 Subject: [PATCH] Fixed #25718 -- Made a JSONField lookup value of None match keys that have a null value. --- django/contrib/postgres/fields/jsonb.py | 1 + django/contrib/postgres/lookups.py | 10 ++++++++++ django/db/models/lookups.py | 1 + django/db/models/sql/query.py | 6 +++--- docs/ref/contrib/postgres/fields.txt | 19 ++++++++++++++++++- docs/releases/2.1.txt | 4 ++++ tests/lookup/models.py | 7 +++++++ tests/lookup/tests.py | 12 +++++++++++- tests/postgres_tests/test_json.py | 15 +++++++++++++++ 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') ]> @@ -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) + ]> + +To query for missing keys, use the ``isnull`` lookup:: + + >>> Dog.objects.create(name='Shep', data={'breed': 'collie'}) + >>> Dog.objects.filter(data__owner__isnull=True) + ]> + +.. 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'}),