summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArtur Beltsov <artur1998g@gmail.com>2020-11-08 12:52:34 +0500
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2020-12-02 09:30:50 +0100
commit48b4bae983a1f9a73624fe62ef5bd1130b0dba39 (patch)
treefb4759d8fcaadd48f45c08cf28f080f52690a356
parentadb40d217ec57ade46b1394cfbf3c513dc669445 (diff)
Fixed #32179 -- Added JSONObject database function.
-rw-r--r--django/db/backends/base/features.py2
-rw-r--r--django/db/backends/oracle/features.py5
-rw-r--r--django/db/backends/sqlite3/features.py1
-rw-r--r--django/db/models/functions/__init__.py6
-rw-r--r--django/db/models/functions/comparison.py42
-rw-r--r--docs/ref/models/database-functions.txt23
-rw-r--r--docs/releases/3.2.txt2
-rw-r--r--tests/db_functions/comparison/test_json_object.py82
8 files changed, 161 insertions, 2 deletions
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()