From 48b4bae983a1f9a73624fe62ef5bd1130b0dba39 Mon Sep 17 00:00:00 2001 From: Artur Beltsov Date: Sun, 8 Nov 2020 12:52:34 +0500 Subject: [PATCH] Fixed #32179 -- Added JSONObject database function. --- django/db/backends/base/features.py | 2 + django/db/backends/oracle/features.py | 5 ++ django/db/backends/sqlite3/features.py | 1 + django/db/models/functions/__init__.py | 6 +- django/db/models/functions/comparison.py | 42 ++++++++++ docs/ref/models/database-functions.txt | 23 ++++++ docs/releases/3.2.txt | 2 + .../comparison/test_json_object.py | 82 +++++++++++++++++++ 8 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/db_functions/comparison/test_json_object.py diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index 03c97e6691..125d52a21f 100644 --- a/django/db/backends/base/features.py +++ b/django/db/backends/base/features.py @@ -306,6 +306,8 @@ class BaseDatabaseFeatures: # Does value__d__contains={'f': 'g'} (without a list around the dict) match # {'d': [{'f': 'g'}]}? json_key_contains_list_matching_requires_list = False + # Does the backend support JSONObject() database function? + has_json_object_function = True # Does the backend support column collations? supports_collation_on_charfield = True diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index 63d825f68a..e66c5da09f 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -94,3 +94,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): return False raise return True + + @cached_property + def has_json_object_function(self): + # Oracle < 18 supports JSON_OBJECT() but it's not fully functional. + return self.connection.oracle_version >= (18,) diff --git a/django/db/backends/sqlite3/features.py b/django/db/backends/sqlite3/features.py index 5ab3a00914..880d2e50dd 100644 --- a/django/db/backends/sqlite3/features.py +++ b/django/db/backends/sqlite3/features.py @@ -79,3 +79,4 @@ class DatabaseFeatures(BaseDatabaseFeatures): return True can_introspect_json_field = property(operator.attrgetter('supports_json_field')) + has_json_object_function = property(operator.attrgetter('supports_json_field')) diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index 980ce2675a..d687af135d 100644 --- a/django/db/models/functions/__init__.py +++ b/django/db/models/functions/__init__.py @@ -1,4 +1,6 @@ -from .comparison import Cast, Coalesce, Collate, Greatest, Least, NullIf +from .comparison import ( + Cast, Coalesce, Collate, Greatest, JSONObject, Least, NullIf, +) from .datetime import ( Extract, ExtractDay, ExtractHour, ExtractIsoWeekDay, ExtractIsoYear, ExtractMinute, ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek, @@ -22,7 +24,7 @@ from .window import ( __all__ = [ # comparison and conversion - 'Cast', 'Coalesce', 'Collate', 'Greatest', 'Least', 'NullIf', + 'Cast', 'Coalesce', 'Collate', 'Greatest', 'JSONObject', 'Least', 'NullIf', # datetime 'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth', 'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractIsoWeekDay', diff --git a/django/db/models/functions/comparison.py b/django/db/models/functions/comparison.py index c1b7754610..8a1c34430b 100644 --- a/django/db/models/functions/comparison.py +++ b/django/db/models/functions/comparison.py @@ -1,5 +1,7 @@ """Database functions that do comparisons or type conversions.""" +from django.db import NotSupportedError from django.db.models.expressions import Func, Value +from django.db.models.fields.json import JSONField from django.utils.regex_helper import _lazy_re_compile @@ -112,6 +114,46 @@ class Greatest(Func): return super().as_sqlite(compiler, connection, function='MAX', **extra_context) +class JSONObject(Func): + function = 'JSON_OBJECT' + output_field = JSONField() + + def __init__(self, **fields): + expressions = [] + for key, value in fields.items(): + expressions.extend((Value(key), value)) + super().__init__(*expressions) + + def as_sql(self, compiler, connection, **extra_context): + if not connection.features.has_json_object_function: + raise NotSupportedError( + 'JSONObject() is not supported on this database backend.' + ) + return super().as_sql(compiler, connection, **extra_context) + + def as_postgresql(self, compiler, connection, **extra_context): + return self.as_sql( + compiler, + connection, + function='JSONB_BUILD_OBJECT', + **extra_context, + ) + + def as_oracle(self, compiler, connection, **extra_context): + class ArgJoiner: + def join(self, args): + args = [' VALUE '.join(arg) for arg in zip(args[::2], args[1::2])] + return ', '.join(args) + + return self.as_sql( + compiler, + connection, + arg_joiner=ArgJoiner(), + template='%(function)s(%(expressions)s RETURNING CLOB)', + **extra_context, + ) + + class Least(Func): """ Return the minimum expression. diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 5d13f47879..948ce4e3cc 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -148,6 +148,29 @@ and ``comment.modified``. The PostgreSQL behavior can be emulated using ``Coalesce`` if you know a sensible minimum value to provide as a default. +``JSONObject`` +-------------- + +.. class:: JSONObject(**fields) + +.. versionadded:: 3.2 + +Takes a list of key-value pairs and returns a JSON object containing those +pairs. + +Usage example:: + + >>> from django.db.models import F + >>> from django.db.models.functions import JSONObject, Lower + >>> Author.objects.create(name='Margaret Smith', alias='msmith', age=25) + >>> author = Author.objects.annotate(json_object=JSONObject( + ... name=Lower('name'), + ... alias='alias', + ... age=F('age') * 2, + ... )).get() + >>> author.json_object + {'name': 'margaret smith', 'alias': 'msmith', 'age': 50} + ``Least`` --------- diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 089e0c08c4..7d374da0a7 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -367,6 +367,8 @@ Models block exits without errors. A nested atomic block marked as durable will raise a ``RuntimeError``. +* Added the :class:`~django.db.models.functions.JSONObject` database function. + Pagination ~~~~~~~~~~ diff --git a/tests/db_functions/comparison/test_json_object.py b/tests/db_functions/comparison/test_json_object.py new file mode 100644 index 0000000000..2c7c1beae9 --- /dev/null +++ b/tests/db_functions/comparison/test_json_object.py @@ -0,0 +1,82 @@ +from django.db import NotSupportedError +from django.db.models import F, Value +from django.db.models.functions import JSONObject, Lower +from django.test import TestCase +from django.test.testcases import skipIfDBFeature, skipUnlessDBFeature +from django.utils import timezone + +from ..models import Article, Author + + +@skipUnlessDBFeature('has_json_object_function') +class JSONObjectTests(TestCase): + @classmethod + def setUpTestData(cls): + Author.objects.create(name='Ivan Ivanov', alias='iivanov') + + def test_empty(self): + obj = Author.objects.annotate(json_object=JSONObject()).first() + self.assertEqual(obj.json_object, {}) + + def test_basic(self): + obj = Author.objects.annotate(json_object=JSONObject(name='name')).first() + self.assertEqual(obj.json_object, {'name': 'Ivan Ivanov'}) + + def test_expressions(self): + obj = Author.objects.annotate(json_object=JSONObject( + name=Lower('name'), + alias='alias', + goes_by='goes_by', + salary=Value(30000.15), + age=F('age') * 2, + )).first() + self.assertEqual(obj.json_object, { + 'name': 'ivan ivanov', + 'alias': 'iivanov', + 'goes_by': None, + 'salary': 30000.15, + 'age': 60, + }) + + def test_nested_json_object(self): + obj = Author.objects.annotate(json_object=JSONObject( + name='name', + nested_json_object=JSONObject( + alias='alias', + age='age', + ), + )).first() + self.assertEqual(obj.json_object, { + 'name': 'Ivan Ivanov', + 'nested_json_object': { + 'alias': 'iivanov', + 'age': 30, + }, + }) + + def test_nested_empty_json_object(self): + obj = Author.objects.annotate(json_object=JSONObject( + name='name', + nested_json_object=JSONObject(), + )).first() + self.assertEqual(obj.json_object, { + 'name': 'Ivan Ivanov', + 'nested_json_object': {}, + }) + + def test_textfield(self): + Article.objects.create( + title='The Title', + text='x' * 4000, + written=timezone.now(), + ) + obj = Article.objects.annotate(json_object=JSONObject(text=F('text'))).first() + self.assertEqual(obj.json_object, {'text': 'x' * 4000}) + + +@skipIfDBFeature('has_json_object_function') +class JSONObjectNotSupportedTests(TestCase): + def test_not_supported(self): + msg = 'JSONObject() is not supported on this database backend.' + with self.assertRaisesMessage(NotSupportedError, msg): + Author.objects.annotate(json_object=JSONObject()).get()