From d7d711c68cc070d9b6962f43f97ece097162adcc Mon Sep 17 00:00:00 2001 From: Sage Abdullah Date: Sun, 15 Dec 2024 16:01:45 +0000 Subject: Refs #35718, Refs #32179 -- Moved JSONObject to django.db.models.functions.json. --- django/db/models/functions/__init__.py | 6 +- django/db/models/functions/comparison.py | 62 ------------ django/db/models/functions/json.py | 64 ++++++++++++ docs/ref/models/database-functions.txt | 55 ++++++----- tests/db_functions/comparison/test_json_object.py | 114 ---------------------- tests/db_functions/json/__init__.py | 0 tests/db_functions/json/test_json_object.py | 114 ++++++++++++++++++++++ 7 files changed, 212 insertions(+), 203 deletions(-) create mode 100644 django/db/models/functions/json.py delete mode 100644 tests/db_functions/comparison/test_json_object.py create mode 100644 tests/db_functions/json/__init__.py create mode 100644 tests/db_functions/json/test_json_object.py diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index cd7c801894..7e5e31e0f8 100644 --- a/django/db/models/functions/__init__.py +++ b/django/db/models/functions/__init__.py @@ -1,4 +1,4 @@ -from .comparison import Cast, Coalesce, Collate, Greatest, JSONObject, Least, NullIf +from .comparison import Cast, Coalesce, Collate, Greatest, Least, NullIf from .datetime import ( Extract, ExtractDay, @@ -25,6 +25,7 @@ from .datetime import ( TruncWeek, TruncYear, ) +from .json import JSONObject from .math import ( Abs, ACos, @@ -97,7 +98,6 @@ __all__ = [ "Coalesce", "Collate", "Greatest", - "JSONObject", "Least", "NullIf", # datetime @@ -125,6 +125,8 @@ __all__ = [ "TruncTime", "TruncWeek", "TruncYear", + # json + "JSONObject", # math "Abs", "ACos", diff --git a/django/db/models/functions/comparison.py b/django/db/models/functions/comparison.py index 76ef5d219b..11af0c0750 100644 --- a/django/db/models/functions/comparison.py +++ b/django/db/models/functions/comparison.py @@ -1,9 +1,6 @@ """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 import TextField -from django.db.models.fields.json import JSONField from django.utils.regex_helper import _lazy_re_compile @@ -143,65 +140,6 @@ 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 join(self, args): - pairs = zip(args[::2], args[1::2], strict=True) - # Wrap 'key' in parentheses in case of postgres cast :: syntax. - return ", ".join([f"({key}) VALUE {value}" for key, value in pairs]) - - def as_native(self, compiler, connection, *, returning, **extra_context): - return self.as_sql( - compiler, - connection, - arg_joiner=self, - template=f"%(function)s(%(expressions)s RETURNING {returning})", - **extra_context, - ) - - def as_postgresql(self, compiler, connection, **extra_context): - # Casting keys to text is only required when using JSONB_BUILD_OBJECT - # or when using JSON_OBJECT on PostgreSQL 16+ with server-side bindings. - # This is done in all cases for consistency. - copy = self.copy() - copy.set_source_expressions( - [ - Cast(expression, TextField()) if index % 2 == 0 else expression - for index, expression in enumerate(copy.get_source_expressions()) - ] - ) - - if connection.features.is_postgresql_16: - return copy.as_native( - compiler, connection, returning="JSONB", **extra_context - ) - - return super(JSONObject, copy).as_sql( - compiler, - connection, - function="JSONB_BUILD_OBJECT", - **extra_context, - ) - - def as_oracle(self, compiler, connection, **extra_context): - return self.as_native(compiler, connection, returning="CLOB", **extra_context) - - class Least(Func): """ Return the minimum expression. diff --git a/django/db/models/functions/json.py b/django/db/models/functions/json.py new file mode 100644 index 0000000000..25c3872419 --- /dev/null +++ b/django/db/models/functions/json.py @@ -0,0 +1,64 @@ +from django.db import NotSupportedError +from django.db.models.expressions import Func, Value +from django.db.models.fields import TextField +from django.db.models.fields.json import JSONField +from django.db.models.functions import Cast + + +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 join(self, args): + pairs = zip(args[::2], args[1::2], strict=True) + # Wrap 'key' in parentheses in case of postgres cast :: syntax. + return ", ".join([f"({key}) VALUE {value}" for key, value in pairs]) + + def as_native(self, compiler, connection, *, returning, **extra_context): + return self.as_sql( + compiler, + connection, + arg_joiner=self, + template=f"%(function)s(%(expressions)s RETURNING {returning})", + **extra_context, + ) + + def as_postgresql(self, compiler, connection, **extra_context): + # Casting keys to text is only required when using JSONB_BUILD_OBJECT + # or when using JSON_OBJECT on PostgreSQL 16+ with server-side bindings. + # This is done in all cases for consistency. + copy = self.copy() + copy.set_source_expressions( + [ + Cast(expression, TextField()) if index % 2 == 0 else expression + for index, expression in enumerate(copy.get_source_expressions()) + ] + ) + + if connection.features.is_postgresql_16: + return copy.as_native( + compiler, connection, returning="JSONB", **extra_context + ) + + return super(JSONObject, copy).as_sql( + compiler, + connection, + function="JSONB_BUILD_OBJECT", + **extra_context, + ) + + def as_oracle(self, compiler, connection, **extra_context): + return self.as_native(compiler, connection, returning="CLOB", **extra_context) diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index eb08e160f7..f23585739e 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -163,31 +163,6 @@ 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) - -Takes a list of key-value pairs and returns a JSON object containing those -pairs. - -Usage example: - -.. code-block:: pycon - - >>> 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`` --------- @@ -861,6 +836,36 @@ that deal with time-parts can be used with ``TimeField``: 2014-06-16 00:00:00+10:00 2 2016-01-01 04:00:00+11:00 1 +.. _json-functions: + +JSON Functions +============== + +``JSONObject`` +-------------- + +.. class:: JSONObject(**fields) + +Takes a list of key-value pairs and returns a JSON object containing those +pairs. + +Usage example: + +.. code-block:: pycon + + >>> 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} + .. _math-functions: Math Functions diff --git a/tests/db_functions/comparison/test_json_object.py b/tests/db_functions/comparison/test_json_object.py deleted file mode 100644 index 9a3d48288c..0000000000 --- a/tests/db_functions/comparison/test_json_object.py +++ /dev/null @@ -1,114 +0,0 @@ -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.bulk_create( - [ - Author(name="Ivan Ivanov", alias="iivanov"), - Author(name="Bertha Berthy", alias="bberthy"), - ] - ) - - 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}) - - def test_order_by_key(self): - qs = Author.objects.annotate(attrs=JSONObject(alias=F("alias"))).order_by( - "attrs__alias" - ) - self.assertQuerySetEqual(qs, Author.objects.order_by("alias")) - - def test_order_by_nested_key(self): - qs = Author.objects.annotate( - attrs=JSONObject(nested=JSONObject(alias=F("alias"))) - ).order_by("-attrs__nested__alias") - self.assertQuerySetEqual(qs, Author.objects.order_by("-alias")) - - -@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() diff --git a/tests/db_functions/json/__init__.py b/tests/db_functions/json/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/db_functions/json/test_json_object.py b/tests/db_functions/json/test_json_object.py new file mode 100644 index 0000000000..9a3d48288c --- /dev/null +++ b/tests/db_functions/json/test_json_object.py @@ -0,0 +1,114 @@ +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.bulk_create( + [ + Author(name="Ivan Ivanov", alias="iivanov"), + Author(name="Bertha Berthy", alias="bberthy"), + ] + ) + + 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}) + + def test_order_by_key(self): + qs = Author.objects.annotate(attrs=JSONObject(alias=F("alias"))).order_by( + "attrs__alias" + ) + self.assertQuerySetEqual(qs, Author.objects.order_by("alias")) + + def test_order_by_nested_key(self): + qs = Author.objects.annotate( + attrs=JSONObject(nested=JSONObject(alias=F("alias"))) + ).order_by("-attrs__nested__alias") + self.assertQuerySetEqual(qs, Author.objects.order_by("-alias")) + + +@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() -- cgit v1.3