Skip to content

Commit 8620a3b

Browse files
savantosarahboyce
authored andcommitted
Fixed #36085 -- Added JSONField support for negative array indexing on SQLite.
1 parent a8716f3 commit 8620a3b

File tree

8 files changed

+73
-1
lines changed

8 files changed

+73
-1
lines changed

django/db/backends/base/features.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ class BaseDatabaseFeatures:
347347
json_key_contains_list_matching_requires_list = False
348348
# Does the backend support JSONObject() database function?
349349
has_json_object_function = True
350+
# Does the backend support negative JSON array indexing?
351+
supports_json_negative_indexing = True
350352

351353
# Does the backend support column collations?
352354
supports_collation_on_charfield = True

django/db/backends/base/operations.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,12 @@ def format_debug_sql(self, sql):
793793
# Hook for backends (e.g. NoSQL) to customize formatting.
794794
return sqlparse.format(sql, reindent=True, keyword_case="upper")
795795

796+
def format_json_path_numeric_index(self, num):
797+
"""
798+
Hook for backends to customize array indexing in JSON paths.
799+
"""
800+
return "[%s]" % num
801+
796802
def compile_json_path(self, key_transforms, include_root=True):
797803
"""
798804
Hook for backends to customize all aspects of JSON path construction.
@@ -805,5 +811,13 @@ def compile_json_path(self, key_transforms, include_root=True):
805811
path.append(".")
806812
path.append(json.dumps(key_transform))
807813
else:
808-
path.append("[%s]" % num)
814+
if (
815+
num < 0
816+
and not self.connection.features.supports_json_negative_indexing
817+
):
818+
raise NotSupportedError(
819+
"Using negative JSON array indices is not supported on this "
820+
"database backend."
821+
)
822+
path.append(self.format_json_path_numeric_index(num))
809823
return "".join(path)

django/db/backends/mysql/features.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
5858
supports_stored_generated_columns = True
5959
supports_virtual_generated_columns = True
6060

61+
supports_json_negative_indexing = False
62+
6163
@cached_property
6264
def minimum_database_version(self):
6365
if self.connection.mysql_is_mariadb:

django/db/backends/oracle/features.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
7373
requires_compound_order_by_subquery = True
7474
allows_multiple_constraints_on_same_fields = False
7575
supports_json_field_contains = False
76+
supports_json_negative_indexing = False
7677
supports_collation_on_textfield = False
7778
test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
7879
django_test_expected_failures = {

django/db/backends/sqlite3/operations.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,3 +441,6 @@ def on_conflict_suffix_sql(self, fields, on_conflict, update_fields, unique_fiel
441441

442442
def force_group_by(self):
443443
return ["GROUP BY TRUE"] if Database.sqlite_version_info < (3, 39) else []
444+
445+
def format_json_path_numeric_index(self, num):
446+
return "[#%s]" % num if num < 0 else super().format_json_path_numeric_index(num)

docs/releases/6.0.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ Models
208208
* :meth:`.QuerySet.raw` now supports models with a
209209
:class:`~django.db.models.CompositePrimaryKey`.
210210

211+
* :class:`~django.db.models.JSONField` now supports
212+
:ref:`negative array indexing <key-index-and-path-transforms>` on SQLite.
213+
211214
Pagination
212215
~~~~~~~~~~
213216

docs/topics/db/queries.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,8 @@ Unless you are sure you wish to work with SQL ``NULL`` values, consider setting
10921092

10931093
.. fieldlookup:: jsonfield.key
10941094

1095+
.. _key-index-and-path-transforms:
1096+
10951097
Key, index, and path transforms
10961098
-------------------------------
10971099

@@ -1134,6 +1136,22 @@ array:
11341136
>>> Dog.objects.filter(data__owner__other_pets__0__name="Fishy")
11351137
<QuerySet [<Dog: Rufus>]>
11361138

1139+
If the key is a negative integer, it cannot be used in a filter keyword
1140+
directly, but you can still use dictionary unpacking to use it in a query:
1141+
1142+
.. code-block:: pycon
1143+
1144+
>>> Dog.objects.filter(**{"data__owner__other_pets__-1__name": "Fishy"})
1145+
<QuerySet [<Dog: Rufus>]>
1146+
1147+
.. admonition:: MySQL, MariaDB, and Oracle
1148+
1149+
Negative JSON array indices are not supported.
1150+
1151+
.. versionchanged:: 6.0
1152+
1153+
SQLite support for negative JSON array indices was added.
1154+
11371155
If the key you wish to query by clashes with the name of another lookup, use
11381156
the :lookup:`contains <jsonfield.contains>` lookup instead.
11391157

tests/model_fields/test_jsonfield.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,21 @@ def test_shallow_list_lookup(self):
785785
[self.objs[5]],
786786
)
787787

788+
@skipIfDBFeature("supports_json_negative_indexing")
789+
def test_unsupported_negative_lookup(self):
790+
msg = (
791+
"Using negative JSON array indices is not supported on this database "
792+
"backend."
793+
)
794+
with self.assertRaisesMessage(NotSupportedError, msg):
795+
NullableJSONModel.objects.filter(**{"value__-2": 1}).get()
796+
797+
@skipUnlessDBFeature("supports_json_negative_indexing")
798+
def test_shallow_list_negative_lookup(self):
799+
self.assertSequenceEqual(
800+
NullableJSONModel.objects.filter(**{"value__-2": 1}), [self.objs[5]]
801+
)
802+
788803
def test_shallow_obj_lookup(self):
789804
self.assertCountEqual(
790805
NullableJSONModel.objects.filter(value__a="b"),
@@ -817,12 +832,26 @@ def test_deep_lookup_array(self):
817832
[self.objs[5]],
818833
)
819834

835+
@skipUnlessDBFeature("supports_json_negative_indexing")
836+
def test_deep_negative_lookup_array(self):
837+
self.assertSequenceEqual(
838+
NullableJSONModel.objects.filter(**{"value__-1__0": 2}),
839+
[self.objs[5]],
840+
)
841+
820842
def test_deep_lookup_mixed(self):
821843
self.assertSequenceEqual(
822844
NullableJSONModel.objects.filter(value__d__1__f="g"),
823845
[self.objs[4]],
824846
)
825847

848+
@skipUnlessDBFeature("supports_json_negative_indexing")
849+
def test_deep_negative_lookup_mixed(self):
850+
self.assertSequenceEqual(
851+
NullableJSONModel.objects.filter(**{"value__d__-1__f": "g"}),
852+
[self.objs[4]],
853+
)
854+
826855
def test_deep_lookup_transform(self):
827856
self.assertCountEqual(
828857
NullableJSONModel.objects.filter(value__c__gt=2),

0 commit comments

Comments
 (0)