Ver código fonte

Fixed #32179 -- Added JSONObject database function.

Artur Beltsov 4 anos atrás
pai
commit
48b4bae983

+ 2 - 0
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

+ 5 - 0
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,)

+ 1 - 0
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'))

+ 4 - 2
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',

+ 42 - 0
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.

+ 23 - 0
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``
 ---------
 

+ 2 - 0
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
 ~~~~~~~~~~
 

+ 82 - 0
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()