1
0
mirror of https://github.com/django/django.git synced 2025-07-01 08:19:19 +00:00

Fixed #36085 -- Added JSONField support for negative array indexing on SQLite.

This commit is contained in:
savanto 2025-05-13 13:42:58 +02:00 committed by Sarah Boyce
parent a8716f3c4c
commit 8620a3b0c7
8 changed files with 73 additions and 1 deletions

View File

@ -347,6 +347,8 @@ class BaseDatabaseFeatures:
json_key_contains_list_matching_requires_list = False json_key_contains_list_matching_requires_list = False
# Does the backend support JSONObject() database function? # Does the backend support JSONObject() database function?
has_json_object_function = True has_json_object_function = True
# Does the backend support negative JSON array indexing?
supports_json_negative_indexing = True
# Does the backend support column collations? # Does the backend support column collations?
supports_collation_on_charfield = True supports_collation_on_charfield = True

View File

@ -793,6 +793,12 @@ class BaseDatabaseOperations:
# Hook for backends (e.g. NoSQL) to customize formatting. # Hook for backends (e.g. NoSQL) to customize formatting.
return sqlparse.format(sql, reindent=True, keyword_case="upper") 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): def compile_json_path(self, key_transforms, include_root=True):
""" """
Hook for backends to customize all aspects of JSON path construction. Hook for backends to customize all aspects of JSON path construction.
@ -805,5 +811,13 @@ class BaseDatabaseOperations:
path.append(".") path.append(".")
path.append(json.dumps(key_transform)) path.append(json.dumps(key_transform))
else: 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) return "".join(path)

View File

@ -58,6 +58,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_stored_generated_columns = True supports_stored_generated_columns = True
supports_virtual_generated_columns = True supports_virtual_generated_columns = True
supports_json_negative_indexing = False
@cached_property @cached_property
def minimum_database_version(self): def minimum_database_version(self):
if self.connection.mysql_is_mariadb: if self.connection.mysql_is_mariadb:

View File

@ -73,6 +73,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
requires_compound_order_by_subquery = True requires_compound_order_by_subquery = True
allows_multiple_constraints_on_same_fields = False allows_multiple_constraints_on_same_fields = False
supports_json_field_contains = False supports_json_field_contains = False
supports_json_negative_indexing = False
supports_collation_on_textfield = False supports_collation_on_textfield = False
test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
django_test_expected_failures = { django_test_expected_failures = {

View File

@ -441,3 +441,6 @@ class DatabaseOperations(BaseDatabaseOperations):
def force_group_by(self): def force_group_by(self):
return ["GROUP BY TRUE"] if Database.sqlite_version_info < (3, 39) else [] 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)

View File

@ -208,6 +208,9 @@ Models
* :meth:`.QuerySet.raw` now supports models with a * :meth:`.QuerySet.raw` now supports models with a
:class:`~django.db.models.CompositePrimaryKey`. :class:`~django.db.models.CompositePrimaryKey`.
* :class:`~django.db.models.JSONField` now supports
:ref:`negative array indexing <key-index-and-path-transforms>` on SQLite.
Pagination Pagination
~~~~~~~~~~ ~~~~~~~~~~

View File

@ -1092,6 +1092,8 @@ Unless you are sure you wish to work with SQL ``NULL`` values, consider setting
.. fieldlookup:: jsonfield.key .. fieldlookup:: jsonfield.key
.. _key-index-and-path-transforms:
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") >>> Dog.objects.filter(data__owner__other_pets__0__name="Fishy")
<QuerySet [<Dog: Rufus>]> <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 If the key you wish to query by clashes with the name of another lookup, use
the :lookup:`contains <jsonfield.contains>` lookup instead. the :lookup:`contains <jsonfield.contains>` lookup instead.

View File

@ -785,6 +785,21 @@ class TestQuerying(TestCase):
[self.objs[5]], [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): def test_shallow_obj_lookup(self):
self.assertCountEqual( self.assertCountEqual(
NullableJSONModel.objects.filter(value__a="b"), NullableJSONModel.objects.filter(value__a="b"),
@ -817,12 +832,26 @@ class TestQuerying(TestCase):
[self.objs[5]], [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): def test_deep_lookup_mixed(self):
self.assertSequenceEqual( self.assertSequenceEqual(
NullableJSONModel.objects.filter(value__d__1__f="g"), NullableJSONModel.objects.filter(value__d__1__f="g"),
[self.objs[4]], [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): def test_deep_lookup_transform(self):
self.assertCountEqual( self.assertCountEqual(
NullableJSONModel.objects.filter(value__c__gt=2), NullableJSONModel.objects.filter(value__c__gt=2),