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 ` 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") ]> +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"}) + ]> + +.. 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 ` 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),