]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Implement missing `#-`, `@?` and `@@` Postgres' JSONB operators.
authorGuilherme Martins Crocetti <24530683+gmcrocetti@users.noreply.github.com>
Wed, 4 Jan 2023 15:27:26 +0000 (10:27 -0500)
committerFederico Caselli <cfederico87@gmail.com>
Thu, 5 Jan 2023 20:19:07 +0000 (21:19 +0100)
Fixes #7147.
Closes: #9038
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/9038
Pull-request-sha: 8647aaf2d9f48c55c152673828deb8ed54966a11

Change-Id: Id2f611ed8080a2837c70d2ea4b41abc46d2bb026

doc/build/changelog/unreleased_20/7147.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/postgresql/json.py
test/dialect/postgresql/test_types.py

diff --git a/doc/build/changelog/unreleased_20/7147.rst b/doc/build/changelog/unreleased_20/7147.rst
new file mode 100644 (file)
index 0000000..629c232
--- /dev/null
@@ -0,0 +1,11 @@
+.. change::
+    :tags: json, postgresql
+    :tickets: 7147
+
+    Implemented missing ``JSONB`` operations:
+
+    * ``@@`` using :meth:`_postgresql.JSONB.Comparator.path_match`
+    * ``@?`` using :meth:`_postgresql.JSONB.Comparator.path_exists`
+    * ``#-`` using :meth:`_postgresql.JSONB.Comparator.delete_path`
+
+    Pull request curtesy of Guilherme Martins Crocetti.
index b136ff4d5cfa32f53cde338d1a80450fbf00918a..9c29360062454bbb1bc6673695b5a804d5ae4865 100644 (file)
@@ -7,7 +7,10 @@
 # mypy: ignore-errors
 
 
+from .array import ARRAY
+from .array import array as _pg_array
 from ... import types as sqltypes
+from ...sql import cast
 from ...sql import operators
 
 
@@ -65,6 +68,27 @@ CONTAINED_BY = operators.custom_op(
     eager_grouping=True,
 )
 
+DELETE_PATH = operators.custom_op(
+    "#-",
+    precedence=idx_precedence,
+    natural_self_precedent=True,
+    eager_grouping=True,
+)
+
+PATH_EXISTS = operators.custom_op(
+    "@?",
+    precedence=idx_precedence,
+    natural_self_precedent=True,
+    eager_grouping=True,
+)
+
+PATH_MATCH = operators.custom_op(
+    "@@",
+    precedence=idx_precedence,
+    natural_self_precedent=True,
+    eager_grouping=True,
+)
+
 
 class JSONPathType(sqltypes.JSON.JSONPathType):
     def _processor(self, dialect, super_proc):
@@ -279,7 +303,10 @@ class JSONB(JSON):
     It also adds additional operators specific to JSONB, including
     :meth:`.JSONB.Comparator.has_key`, :meth:`.JSONB.Comparator.has_all`,
     :meth:`.JSONB.Comparator.has_any`, :meth:`.JSONB.Comparator.contains`,
-    and :meth:`.JSONB.Comparator.contained_by`.
+    :meth:`.JSONB.Comparator.contained_by`,
+    :meth:`.JSONB.Comparator.delete_path`,
+    :meth:`.JSONB.Comparator.path_exists` and
+    :meth:`.JSONB.Comparator.path_match`.
 
     Like the :class:`_types.JSON` type, the :class:`_postgresql.JSONB`
     type does not detect
@@ -340,4 +367,40 @@ class JSONB(JSON):
                 CONTAINED_BY, other, result_type=sqltypes.Boolean
             )
 
+        def delete_path(self, array):
+            """JSONB expression. Deletes field or array element specified in
+            the argument array.
+
+            The input may be a list of strings that will be coerced to an
+            ``ARRAY`` or an instance of :meth:`_postgres.array`.
+
+            .. versionadded:: 2.0
+            """
+            if not isinstance(array, _pg_array):
+                array = _pg_array(array)
+            right_side = cast(array, ARRAY(sqltypes.TEXT))
+            return self.operate(DELETE_PATH, right_side, result_type=JSONB)
+
+        def path_exists(self, other):
+            """Boolean expression. Test for presence of item given by the
+            argument JSONPath expression.
+
+            .. versionadded:: 2.0
+            """
+            return self.operate(
+                PATH_EXISTS, other, result_type=sqltypes.Boolean
+            )
+
+        def path_match(self, other):
+            """Boolean expression. Test if JSONPath predicate given by the
+            argument JSONPath expression matches.
+
+            Only the first item of the result is taken into account.
+
+            .. versionadded:: 2.0
+            """
+            return self.operate(
+                PATH_MATCH, other, result_type=sqltypes.Boolean
+            )
+
     comparator_factory = Comparator
index e9d5e561f80df28eb0d78eaef8b622e6ad4a2512..22993ae7bb5770dcb126bc7e74a7c00a6c060d59 100644 (file)
@@ -5683,6 +5683,24 @@ class JSONBTest(JSONTest):
             lambda self: self.jsoncol.contained_by({"foo": "1", "bar": None}),
             "test_table.test_column <@ %(test_column_1)s",
         ),
+        (
+            lambda self: self.jsoncol.delete_path(["a", "b"]),
+            "test_table.test_column #- CAST(ARRAY[%(param_1)s, "
+            "%(param_2)s] AS TEXT[])",
+        ),
+        (
+            lambda self: self.jsoncol.delete_path(array(["a", "b"])),
+            "test_table.test_column #- CAST(ARRAY[%(param_1)s, "
+            "%(param_2)s] AS TEXT[])",
+        ),
+        (
+            lambda self: self.jsoncol.path_exists("$.k1"),
+            "test_table.test_column @? %(test_column_1)s",
+        ),
+        (
+            lambda self: self.jsoncol.path_match("$.k1[0] > 2"),
+            "test_table.test_column @@ %(test_column_1)s",
+        ),
     )
     def test_where(self, whereclause_fn, expected):
         super().test_where(whereclause_fn, expected)
@@ -5712,6 +5730,19 @@ class JSONBRoundTripTest(JSONRoundTripTest):
         go("$.k1.k2", 0)
         go("$.k1.r6v1", 1)
 
+    @testing.combinations(
+        ["k1", "r6v1", "subr", 1],
+        array(["k1", "r6v1", "subr", 1]),
+        argnames="path",
+    )
+    def test_delete_path(self, connection, path):
+        self._fixture_data(connection)
+        q = select(self.data_table.c.data.delete_path(path)).where(
+            self.data_table.c.name == "r6"
+        )
+        res = connection.scalar(q)
+        eq_(res, {"k1": {"r6v1": {"subr": [1, 3]}}})
+
 
 class JSONBSuiteTest(suite.JSONTest):
     __requires__ = ("postgresql_jsonb",)