summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsavanto <savanto@users.noreply.github.com>2025-05-13 13:42:58 +0200
committerSarah Boyce <42296566+sarahboyce@users.noreply.github.com>2025-05-14 13:21:18 +0200
commit8620a3b0c79e4e8098b88f1176ed26fad0bf6c5c (patch)
tree4d1a2be9331f5aa702ae0b1551fe38a62786990a
parenta8716f3c4c6d78d38ce86d79c816062346dcc5bf (diff)
Fixed #36085 -- Added JSONField support for negative array indexing on SQLite.
-rw-r--r--django/db/backends/base/features.py2
-rw-r--r--django/db/backends/base/operations.py16
-rw-r--r--django/db/backends/mysql/features.py2
-rw-r--r--django/db/backends/oracle/features.py1
-rw-r--r--django/db/backends/sqlite3/operations.py3
-rw-r--r--docs/releases/6.0.txt3
-rw-r--r--docs/topics/db/queries.txt18
-rw-r--r--tests/model_fields/test_jsonfield.py29
8 files changed, 73 insertions, 1 deletions
diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py
index 4ad248108a..d3b148dc3d 100644
--- a/django/db/backends/base/features.py
+++ b/django/db/backends/base/features.py
@@ -347,6 +347,8 @@ class BaseDatabaseFeatures:
json_key_contains_list_matching_requires_list = False
# Does the backend support JSONObject() database function?
has_json_object_function = True
+ # Does the backend support negative JSON array indexing?
+ supports_json_negative_indexing = True
# Does the backend support column collations?
supports_collation_on_charfield = True
diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py
index ba6650ed69..c426a2b90a 100644
--- a/django/db/backends/base/operations.py
+++ b/django/db/backends/base/operations.py
@@ -793,6 +793,12 @@ class BaseDatabaseOperations:
# Hook for backends (e.g. NoSQL) to customize formatting.
return sqlparse.format(sql, reindent=True, keyword_case="upper")
+ def format_json_path_numeric_index(self, num):
+ """
+ Hook for backends to customize array indexing in JSON paths.
+ """
+ return "[%s]" % num
+
def compile_json_path(self, key_transforms, include_root=True):
"""
Hook for backends to customize all aspects of JSON path construction.
@@ -805,5 +811,13 @@ class BaseDatabaseOperations:
path.append(".")
path.append(json.dumps(key_transform))
else:
- path.append("[%s]" % num)
+ if (
+ num < 0
+ and not self.connection.features.supports_json_negative_indexing
+ ):
+ raise NotSupportedError(
+ "Using negative JSON array indices is not supported on this "
+ "database backend."
+ )
+ path.append(self.format_json_path_numeric_index(num))
return "".join(path)
diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py
index 535a7174b7..d0749eb5e6 100644
--- a/django/db/backends/mysql/features.py
+++ b/django/db/backends/mysql/features.py
@@ -58,6 +58,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_stored_generated_columns = True
supports_virtual_generated_columns = True
+ supports_json_negative_indexing = False
+
@cached_property
def minimum_database_version(self):
if self.connection.mysql_is_mariadb:
diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py
index a21b8490c8..b355e7136c 100644
--- a/django/db/backends/oracle/features.py
+++ b/django/db/backends/oracle/features.py
@@ -73,6 +73,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
requires_compound_order_by_subquery = True
allows_multiple_constraints_on_same_fields = False
supports_json_field_contains = False
+ supports_json_negative_indexing = False
supports_collation_on_textfield = False
test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
django_test_expected_failures = {
diff --git a/django/db/backends/sqlite3/operations.py b/django/db/backends/sqlite3/operations.py
index 340bd28ef8..2a5179755b 100644
--- a/django/db/backends/sqlite3/operations.py
+++ b/django/db/backends/sqlite3/operations.py
@@ -441,3 +441,6 @@ class DatabaseOperations(BaseDatabaseOperations):
def force_group_by(self):
return ["GROUP BY TRUE"] if Database.sqlite_version_info < (3, 39) else []
+
+ def format_json_path_numeric_index(self, num):
+ return "[#%s]" % num if num < 0 else super().format_json_path_numeric_index(num)
diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt
index 611530bb0a..ec2fa37f6f 100644
--- a/docs/releases/6.0.txt
+++ b/docs/releases/6.0.txt
@@ -208,6 +208,9 @@ Models
* :meth:`.QuerySet.raw` now supports models with a
:class:`~django.db.models.CompositePrimaryKey`.
+* :class:`~django.db.models.JSONField` now supports
+ :ref:`negative array indexing <key-index-and-path-transforms>` on SQLite.
+
Pagination
~~~~~~~~~~
diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt
index a6819daf01..eb5b323abb 100644
--- a/docs/topics/db/queries.txt
+++ b/docs/topics/db/queries.txt
@@ -1092,6 +1092,8 @@ Unless you are sure you wish to work with SQL ``NULL`` values, consider setting
.. fieldlookup:: jsonfield.key
+.. _key-index-and-path-transforms:
+
Key, index, and path transforms
-------------------------------
@@ -1134,6 +1136,22 @@ array:
>>> Dog.objects.filter(data__owner__other_pets__0__name="Fishy")
<QuerySet [<Dog: Rufus>]>
+If the key is a negative integer, it cannot be used in a filter keyword
+directly, but you can still use dictionary unpacking to use it in a query:
+
+.. code-block:: pycon
+
+ >>> Dog.objects.filter(**{"data__owner__other_pets__-1__name": "Fishy"})
+ <QuerySet [<Dog: Rufus>]>
+
+.. admonition:: MySQL, MariaDB, and Oracle
+
+ Negative JSON array indices are not supported.
+
+.. versionchanged:: 6.0
+
+ SQLite support for negative JSON array indices was added.
+
If the key you wish to query by clashes with the name of another lookup, use
the :lookup:`contains <jsonfield.contains>` lookup instead.
diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py
index 1788933774..16ab8887a9 100644
--- a/tests/model_fields/test_jsonfield.py
+++ b/tests/model_fields/test_jsonfield.py
@@ -785,6 +785,21 @@ class TestQuerying(TestCase):
[self.objs[5]],
)
+ @skipIfDBFeature("supports_json_negative_indexing")
+ def test_unsupported_negative_lookup(self):
+ msg = (
+ "Using negative JSON array indices is not supported on this database "
+ "backend."
+ )
+ with self.assertRaisesMessage(NotSupportedError, msg):
+ NullableJSONModel.objects.filter(**{"value__-2": 1}).get()
+
+ @skipUnlessDBFeature("supports_json_negative_indexing")
+ def test_shallow_list_negative_lookup(self):
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(**{"value__-2": 1}), [self.objs[5]]
+ )
+
def test_shallow_obj_lookup(self):
self.assertCountEqual(
NullableJSONModel.objects.filter(value__a="b"),
@@ -817,12 +832,26 @@ class TestQuerying(TestCase):
[self.objs[5]],
)
+ @skipUnlessDBFeature("supports_json_negative_indexing")
+ def test_deep_negative_lookup_array(self):
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(**{"value__-1__0": 2}),
+ [self.objs[5]],
+ )
+
def test_deep_lookup_mixed(self):
self.assertSequenceEqual(
NullableJSONModel.objects.filter(value__d__1__f="g"),
[self.objs[4]],
)
+ @skipUnlessDBFeature("supports_json_negative_indexing")
+ def test_deep_negative_lookup_mixed(self):
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(**{"value__d__-1__f": "g"}),
+ [self.objs[4]],
+ )
+
def test_deep_lookup_transform(self):
self.assertCountEqual(
NullableJSONModel.objects.filter(value__c__gt=2),