]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add explicit USING support to DELETE
authorcjc0013 <cjc0013@users.noreply.github.com>
Thu, 11 Jun 2026 21:23:04 +0000 (17:23 -0400)
committerMichael Bayer <mike_mp@zzzcomputing.com>
Mon, 22 Jun 2026 19:25:23 +0000 (19:25 +0000)
Added :meth:`_sql.Delete.using`, allowing explicit FROM expressions such as
joins to be rendered in backend-specific multiple-table DELETE forms
including MySQL/MariaDB ``DELETE .. USING``.  Pull request courtesy
cjc0013.

Fixes: #8130
Closes: #13334
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/13334
Pull-request-sha: df5644e96158a2424c85773d335bab6cc0c8c038

Change-Id: I9cadfc7ebb1c8b4f6a7433de3fbddbad205f545d

doc/build/changelog/migration_21.rst
doc/build/changelog/unreleased_21/8130.rst [new file with mode: 0644]
doc/build/tutorial/data_update.rst
lib/sqlalchemy/dialects/mysql/base.py
lib/sqlalchemy/sql/dml.py
test/sql/test_compare.py
test/sql/test_delete.py

index 29e3304036a52226d059c43442bb34398c81fb37..154d370bf38ee3e9d436149517f6e58bf542c973 100644 (file)
@@ -1470,6 +1470,57 @@ SQLite) all handle ``visit_double()`` by rendering either ``DOUBLE`` or
 :ticket:`10300`
 
 
+.. _change_8130:
+
+Explicit USING support for DELETE (MySQL, PostgreSQL)
+------------------------------------------------------
+
+The :meth:`_sql.Delete.using` method has been added, allowing explicit
+``USING`` expressions to be specified in DELETE statements.  This is
+useful for backend-specific multiple-table DELETE forms where the secondary
+FROM clause needs to be stated explicitly, such as joined DELETE on
+MySQL/MariaDB and PostgreSQL.
+
+Previously, multi-table DELETE was supported by inferring extra FROM entries
+from the WHERE clause, which works for simple cases.  The new
+:meth:`_sql.Delete.using` method allows more complex expressions such as
+explicit joins to be stated::
+
+    from sqlalchemy import delete, table, column
+
+    user_table = table("users", column("id"), column("name"))
+    address_table = table("addresses", column("id"), column("user_id"), column("email"))
+
+    stmt = (
+        delete(user_table)
+        .using(
+            user_table.outerjoin(
+                address_table,
+                user_table.c.id == address_table.c.user_id,
+            )
+        )
+        .where(address_table.c.email == "patrick@aol.com")
+    )
+
+On MySQL/MariaDB, the above renders as:
+
+.. sourcecode:: sql
+
+    DELETE FROM users USING users LEFT OUTER JOIN addresses
+    ON users.id = addresses.user_id
+    WHERE addresses.email = %s
+
+On PostgreSQL, a similar form is rendered using the PostgreSQL-specific
+``DELETE .. USING`` syntax.
+
+.. seealso::
+
+    :ref:`tutorial_multi_table_deletes` - updated tutorial section for
+    multi-table deletes
+
+:ticket:`8130`
+
+
 PostgreSQL
 ==========
 
diff --git a/doc/build/changelog/unreleased_21/8130.rst b/doc/build/changelog/unreleased_21/8130.rst
new file mode 100644 (file)
index 0000000..1e866ee
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: usecase, sql
+    :tickets: 8130
+
+    Added :meth:`_sql.Delete.using`, allowing explicit FROM expressions such as
+    joins to be rendered in backend-specific multiple-table DELETE forms
+    including MySQL/MariaDB ``DELETE .. USING``.  Pull request courtesy
+    cjc0013.
+
+    .. seealso::
+
+        :ref:`change_8130`
index 29f9a216a78266a3101c3d2e34855c5b3a1b4bb1..e4a53c7949dbe2e244d8b3cea9530a8d689ca1c6 100644 (file)
@@ -261,6 +261,28 @@ syntaxes, such as ``DELETE FROM..USING`` on MySQL::
   {printsql}DELETE FROM user_account USING user_account, address
   WHERE user_account.id = address.user_id AND address.email_address = %s
 
+For backends that support a full FROM expression in the ``USING`` clause,
+the :meth:`_sql.Delete.using` method may be used to state that expression
+explicitly, such as a MySQL joined DELETE::
+
+  >>> delete_stmt = (
+  ...     delete(user_table)
+  ...     .using(
+  ...         user_table.outerjoin(
+  ...             address_table,
+  ...             user_table.c.id == address_table.c.user_id,
+  ...         )
+  ...     )
+  ...     .where(address_table.c.email_address == "patrick@aol.com")
+  ... )
+  >>> print(delete_stmt.compile(dialect=mysql.dialect()))
+  {printsql}DELETE FROM user_account USING user_account LEFT OUTER JOIN address
+  ON user_account.id = address.user_id
+  WHERE address.email_address = %s
+
+.. versionadded:: 2.1 Added :meth:`_sql.Delete.using` to support explicit
+   USING for DELETE statements on supported backends
+
 .. _tutorial_update_delete_rowcount:
 
 Getting Affected Row Count from UPDATE, DELETE
index 17978e574e3eb5920081079bd9d1bc29cf24e560..b9a411e453b15b3b6e69614d3475ce5d4ebfe024 100644 (file)
@@ -530,6 +530,11 @@ available.
   .. versionchanged:: 2.1 the :func:`_mysql.limit()` extension supersedes the
      previous use of ``mysql_limit``
 
+* DELETE with an explicit ``USING`` expression such as a JOIN, use
+  :meth:`_sql.Delete.using`.  See :ref:`tutorial_multi_table_deletes`.
+
+  .. versionadded:: 2.1
+
 * optimizer hints, use :meth:`_expression.Select.prefix_with` and
   :meth:`_query.Query.prefix_with`::
 
@@ -1894,9 +1899,18 @@ class MySQLCompiler(
     ) -> str:
         """Render the DELETE .. USING clause specific to MySQL."""
         kw["asfrom"] = True
+        if any(
+            from_table in elem._cloned_set
+            for extra_from in extra_froms
+            for elem in sql_util.surface_selectables_only(extra_from)
+        ):
+            froms = extra_froms
+        else:
+            froms = [from_table] + extra_froms
+
         return "USING " + ", ".join(
             t._compiler_dispatch(self, fromhints=from_hints, **kw)
-            for t in [from_table] + extra_froms
+            for t in froms
         )
 
     def visit_empty_set_expr(
index d44a5d5e14e4bdb9755121ebe58f63fe25ded848..f68f2b2a8666f0e8b9f64d32260b44aef705a8c3 100644 (file)
@@ -83,6 +83,7 @@ if TYPE_CHECKING:
     from ._typing import _DMLColumnArgument
     from ._typing import _DMLColumnKeyMapping
     from ._typing import _DMLTableArgument
+    from ._typing import _FromClauseArgument
     from ._typing import _T0  # noqa
     from ._typing import _T1  # noqa
     from ._typing import _T2  # noqa
@@ -212,7 +213,9 @@ class DMLState(CompileState):
         ]
 
     def _make_extra_froms(
-        self, statement: DMLWhereBase
+        self,
+        statement: DMLWhereBase,
+        explicit_froms: Sequence[FromClause] = (),
     ) -> Tuple[FromClause, List[FromClause]]:
         froms: List[FromClause] = []
 
@@ -220,15 +223,26 @@ class DMLState(CompileState):
         primary_table = all_tables[0]
         seen = {primary_table}
 
+        def _consider_from(
+            from_: FromClause, include_surface_selectables: bool = False
+        ) -> None:
+            if not seen.intersection(from_._cloned_set):
+                froms.append(from_)
+            seen.update(from_._cloned_set)
+            if include_surface_selectables:
+                for elem in sql_util.surface_selectables_only(from_):
+                    seen.update(elem._cloned_set)
+
+        for from_ in explicit_froms:
+            _consider_from(from_, include_surface_selectables=True)
+
         consider = statement._where_criteria
         if self._dict_parameters:
             consider += tuple(self._dict_parameters.values())
 
         for crit in consider:
             for item in _from_objects(crit):
-                if not seen.intersection(item._cloned_set):
-                    froms.append(item)
-                seen.update(item._cloned_set)
+                _consider_from(item)
 
         froms.extend(all_tables[1:])
         return primary_table, froms
@@ -385,7 +399,7 @@ class DeleteDMLState(DMLState):
         self.statement = statement
 
         self.isdelete = True
-        t, ef = self._make_extra_froms(statement)
+        t, ef = self._make_extra_froms(statement, statement._extra_froms)
         self._primary_table = t
         self._extra_froms = ef
         self.is_multitable = ef
@@ -1846,6 +1860,7 @@ class Delete(
     _traverse_internals = (
         [
             ("table", InternalTraversal.dp_clauseelement),
+            ("_extra_froms", InternalTraversal.dp_clauseelement_tuple),
             ("_where_criteria", InternalTraversal.dp_clauseelement_tuple),
             ("_returning", InternalTraversal.dp_clauseelement_tuple),
             ("_hints", InternalTraversal.dp_table_hint_list),
@@ -1861,11 +1876,37 @@ class Delete(
         {"post_criteria": "_post_criteria_clause"}
     )
 
+    _extra_froms: Tuple[FromClause, ...] = ()
+
     def __init__(self, table: _DMLTableArgument):
         self.table = coercions.expect(
             roles.DMLTableRole, table, apply_propagate_attrs=self
         )
 
+    @_generative
+    def using(self, *froms: _FromClauseArgument) -> Self:
+        r"""Add one or more explicit ``USING`` expressions to this DELETE.
+
+        This method may be used for backend-specific multiple-table DELETE
+        forms where the secondary FROM expression needs to be stated
+        explicitly, such as MySQL's ``DELETE FROM table USING <join>`` form.
+
+        .. versionadded:: 2.1
+
+        .. seealso::
+
+            :ref:`tutorial_multi_table_deletes`
+
+        """
+
+        self._extra_froms += tuple(
+            coercions.expect(
+                roles.FromClauseRole, from_, apply_propagate_attrs=self
+            )
+            for from_ in froms
+        )
+        return self
+
     def _apply_syntax_extension_to_self(
         self, extension: SyntaxExtension
     ) -> None:
index cb38540e45d17a3a17b9ba39d49e4e525170d828..193e2d1ea51b5cac2cfc42b7cb4e90500e8848b8 100644 (file)
@@ -770,6 +770,10 @@ class CoreFixtures:
             table_b.delete().with_dialect_options(sqlite_foo="some value"),
             table_b.delete().where(table_b.c.a == 5),
             table_b.delete().where(table_b.c.b == 5),
+            table_b.delete().using(table_a),
+            table_b.delete().using(
+                table_b.join(table_a, table_b.c.a == table_a.c.a)
+            ),
         ),
         lambda: (
             values(
index 83f92dcdd097bafcf1c46ed238c4c1bd20ba364d..83840a73d9d137a823e87ccbe39e240df4a00b3a 100644 (file)
@@ -9,6 +9,7 @@ from sqlalchemy import select
 from sqlalchemy import String
 from sqlalchemy import testing
 from sqlalchemy.dialects import mysql
+from sqlalchemy.dialects import postgresql
 from sqlalchemy.engine import default
 from sqlalchemy.testing import assert_raises_message
 from sqlalchemy.testing import AssertsCompiledSQL
@@ -165,6 +166,63 @@ class DeleteFromCompileTest(
             "WHERE mytable.myid = myothertable.otherid",
         )
 
+    def test_delete_explicit_using(self):
+        table1, table2 = self.tables.mytable, self.tables.myothertable
+
+        stmt = table1.delete().using(table2).where(table1.c.myid == 7)
+        self.assert_compile(
+            stmt,
+            "DELETE FROM mytable , myothertable "
+            "WHERE mytable.myid = :myid_1",
+            checkparams={"myid_1": 7},
+        )
+
+    def test_delete_explicit_using_dedupe_implicit_from(self):
+        table1, table2 = self.tables.mytable, self.tables.myothertable
+
+        stmt = (
+            table1.delete()
+            .using(table2)
+            .where(table1.c.myid == table2.c.otherid)
+        )
+        self.assert_compile(
+            stmt,
+            "DELETE FROM mytable , myothertable "
+            "WHERE mytable.myid = myothertable.otherid",
+        )
+
+    def test_delete_explicit_using_join_mysql(self):
+        table1, table2 = self.tables.mytable, self.tables.myothertable
+
+        stmt = (
+            table1.delete()
+            .using(table1.outerjoin(table2, table1.c.myid == table2.c.otherid))
+            .where(table2.c.othername == None)
+        )
+        self.assert_compile(
+            stmt,
+            "DELETE FROM mytable USING mytable LEFT OUTER JOIN myothertable "
+            "ON mytable.myid = myothertable.otherid "
+            "WHERE myothertable.othername IS NULL",
+            dialect=mysql.dialect(),
+        )
+
+    def test_delete_explicit_using_join_postgresql(self):
+        table1, table2 = self.tables.mytable, self.tables.myothertable
+
+        stmt = (
+            table1.delete()
+            .using(table2.outerjoin(table1, table1.c.myid == table2.c.otherid))
+            .where(table2.c.othername == None)
+        )
+        self.assert_compile(
+            stmt,
+            "DELETE FROM mytable USING myothertable LEFT OUTER JOIN mytable "
+            "ON mytable.myid = myothertable.otherid "
+            "WHERE myothertable.othername IS NULL",
+            dialect=postgresql.dialect(),
+        )
+
     def test_correlation_to_extra(self):
         table1, table2 = self.tables.mytable, self.tables.myothertable