]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
apply Grouping on left side of JSONB subscript in compiler
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 7 Jan 2026 01:03:10 +0000 (20:03 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 7 Jan 2026 03:05:50 +0000 (22:05 -0500)
Fixed regression in PostgreSQL dialect where JSONB subscription syntax
would generate incorrect SQL for :func:`.cast` expressions returning JSONB,
causing syntax errors. The dialect now properly wraps cast expressions in
parentheses when using the ``[]`` subscription syntax, generating
``(CAST(...))[index]`` instead of ``CAST(...)[index]`` to comply with
PostgreSQL syntax requirements. This extends the fix from :ticket:`12778`
which addressed the same issue for function calls.

This reverts how we did the fix for #12778 in Function.self_group()
and instead moves to a direct Grouping() applied in the PG compiler
based on isinstance of the left side.

in retrospect, when we first did #10927, we **definitely** made
the completely wrong choice in how to do this, the original idea
to detect when we were in an UPDATE and use [] only then was
by **far** what we should have done, given the fact that PG indexes
are based on exact syntax matches.  but since we've made everyone
switch to [] format for their indexes now we can't keep going
back and forth.   even though PG would like [] to be the defacto
syntax it simply is not.    We should potentially pursue a dialect/
create_engine option to switch the use of [] back to -> for
all cases except UPDATE.

Fixes: #13067
Change-Id: I2e0d0f45ebb820d2a8f214550f1d1a500bae223b
(cherry picked from commit 217b3fd053857d396a65349a170da1342ae030d1)

doc/build/changelog/unreleased_20/13067.rst [new file with mode: 0644]
doc/build/tutorial/data_select.rst
lib/sqlalchemy/dialects/postgresql/base.py
lib/sqlalchemy/sql/functions.py
test/dialect/postgresql/test_compiler.py
test/dialect/postgresql/test_query.py

diff --git a/doc/build/changelog/unreleased_20/13067.rst b/doc/build/changelog/unreleased_20/13067.rst
new file mode 100644 (file)
index 0000000..12bedfb
--- /dev/null
@@ -0,0 +1,11 @@
+.. change::
+    :tags: bug, postgresql
+    :tickets: 13067
+
+    Fixed regression in PostgreSQL dialect where JSONB subscription syntax
+    would generate incorrect SQL for :func:`.cast` expressions returning JSONB,
+    causing syntax errors. The dialect now properly wraps cast expressions in
+    parentheses when using the ``[]`` subscription syntax, generating
+    ``(CAST(...))[index]`` instead of ``CAST(...)[index]`` to comply with
+    PostgreSQL syntax requirements. This extends the fix from :ticket:`12778`
+    which addressed the same issue for function calls.
index d880b4a4ae7615f107a0f7a4f8003ab5fe030719..38faddb61a1c96aaf87ad80ee0f17ec91f8674f3 100644 (file)
@@ -1476,7 +1476,7 @@ elements::
 
     >>> stmt = select(function_expr["def"])
     >>> print(stmt)
-    {printsql}SELECT (json_object(:json_object_1))[:json_object_2] AS anon_1
+    {printsql}SELECT json_object(:json_object_1)[:json_object_2] AS anon_1
 
 Built-in Functions Have Pre-Configured Return Types
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
index 37307171ff5a0ed98a0f54f1a9d455841bddc1c7..b52aa6c9d0c7b822c50b95f37e0e850e84572926 100644 (file)
@@ -1672,6 +1672,7 @@ from ...sql import coercions
 from ...sql import compiler
 from ...sql import elements
 from ...sql import expression
+from ...sql import functions
 from ...sql import roles
 from ...sql import sqltypes
 from ...sql import util as sql_util
@@ -1946,10 +1947,14 @@ class PGCompiler(compiler.SQLCompiler):
             and isinstance(binary.left.type, _json.JSONB)
             and self.dialect._supports_jsonb_subscripting
         ):
+            left = binary.left
+            if isinstance(left, (functions.FunctionElement, elements.Cast)):
+                left = elements.Grouping(left)
+
             # for pg14+JSONB use subscript notation: col['key'] instead
             # of col -> 'key'
             return "%s[%s]" % (
-                self.process(binary.left, **kw),
+                self.process(left, **kw),
                 self.process(binary.right, **kw),
             )
         else:
index 31f5015b524298445fedec667407678bd817c68b..15f8045646913af0ffb42bd21a8730f6e1cc71c7 100644 (file)
@@ -745,7 +745,9 @@ class FunctionElement(Executable, ColumnElement[_T], FromClause, Generative):
         # expressions against getitem.  This may need to be made
         # more portable if in the future we support other DBs
         # besides postgresql.
-        if against in (operators.getitem, operators.json_getitem_op):
+        if against is operators.getitem and isinstance(
+            self.type, sqltypes.ARRAY
+        ):
             return Grouping(self)
         else:
             return super().self_group(against=against)
index b158e4730d26b16bc489e397f197400998fb8611..f73fa8e37fa2034595574cf1472448b4775d6a1b 100644 (file)
@@ -2984,6 +2984,31 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "[%(jsonb_array_elements_1)s] AS anon_1 FROM data",
         )
 
+    def test_jsonb_cast_use_parentheses_with_subscripting(self):
+        """test #13067 - JSONB cast expressions parenthesized with [] syntax"""
+
+        # Test that JSONB cast expressions are properly parenthesized with []
+        # syntax. This ensures correct PostgreSQL syntax: (CAST(...))[index]
+        # instead of the invalid: CAST(...)[index]
+
+        stmt = select(cast({"foo": "bar"}, JSONB)["foo"])
+        self.assert_compile(
+            stmt,
+            "SELECT (CAST(%(param_1)s::JSONB AS JSONB))[%(param_2)s::TEXT] "
+            "AS anon_1",
+            dialect="postgresql+psycopg",
+        )
+
+        # Test with nested cast within subscripts
+        data = table("data", column("id", Integer), column("x", JSONB))
+        stmt = select(data.c.x[cast("key", String)])
+        self.assert_compile(
+            stmt,
+            "SELECT data.x[CAST(%(param_1)s::VARCHAR AS VARCHAR)] AS anon_1 "
+            "FROM data",
+            dialect="postgresql+psycopg",
+        )
+
     def test_range_custom_object_hook(self):
         # See issue #8884
         from datetime import date
index 008b8c0b45803f2d65c408b45e85ee6e3dd21c60..f52c949e665a17fda1ceada96fffc412bfe4d329 100644 (file)
@@ -1897,7 +1897,7 @@ class TableValuedRoundTripTest(fixtures.TestBase):
         eq_(connection.execute(stmt).all(), [(1, "foo"), (2, "bar")])
 
 
-class JSONUpdateTest(fixtures.TablesTest):
+class JSONQueryTest(fixtures.TablesTest):
     """round trip tests related to using JSON and JSONB in UPDATE statements
     with PG-specific features
 
@@ -2099,3 +2099,51 @@ class JSONUpdateTest(fixtures.TablesTest):
             row.jb,
             {"tags": ["python", "postgresql", "postgres"], "priority": "high"},
         )
+
+    @testing.combinations(
+        (cast({"foo": "bar"}, JSONB)["foo"], "bar"),
+        (
+            cast({"user": {"name": "Alice", "age": 30}}, JSONB)["user"][
+                "name"
+            ],
+            "Alice",
+        ),
+        (cast({"x": 1, "y": 2}, JSONB)["x"], 1),
+        (
+            func.jsonb_build_object("key", "value", type_=JSONB)["key"],
+            "value",
+        ),
+        (
+            func.jsonb_array_elements(
+                cast([{"name": "Bob"}, {"name": "Carol"}], JSONB), type_=JSONB
+            )["name"],
+            "Bob",
+        ),
+        (
+            cast(func.jsonb_build_object("key1", "val1", type_=JSONB), JSONB)[
+                "key1"
+            ],
+            "val1",
+        ),
+        (
+            func.jsonb_build_array(
+                cast({"item": "first"}, JSONB),
+                cast({"item": "second"}, JSONB),
+                type_=JSONB,
+            )[0]["item"],
+            "first",
+        ),
+        argnames="expr, expected",
+    )
+    def test_jsonb_cast_and_function_with_subscript(
+        self, connection, expr, expected
+    ):
+        """Test JSONB cast/function expressions with newer subscript [] syntax
+        that occurs on pg14+
+
+        these tests cover round trips for #12778 and #13067 (so far)
+
+        """
+        stmt = select(expr)
+        result = connection.scalar(stmt)
+        eq_(result, expected)