From: cjc0013 Date: Thu, 11 Jun 2026 21:23:04 +0000 (-0400) Subject: Add explicit USING support to DELETE X-Git-Tag: rel_2_1_0b3~10^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=74ef020b969a9ae0e124762d4531c85b1834f60e;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add explicit USING support to DELETE 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 --- diff --git a/doc/build/changelog/migration_21.rst b/doc/build/changelog/migration_21.rst index 29e3304036..154d370bf3 100644 --- a/doc/build/changelog/migration_21.rst +++ b/doc/build/changelog/migration_21.rst @@ -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 index 0000000000..1e866ee5ee --- /dev/null +++ b/doc/build/changelog/unreleased_21/8130.rst @@ -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` diff --git a/doc/build/tutorial/data_update.rst b/doc/build/tutorial/data_update.rst index 29f9a216a7..e4a53c7949 100644 --- a/doc/build/tutorial/data_update.rst +++ b/doc/build/tutorial/data_update.rst @@ -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 diff --git a/lib/sqlalchemy/dialects/mysql/base.py b/lib/sqlalchemy/dialects/mysql/base.py index 17978e574e..b9a411e453 100644 --- a/lib/sqlalchemy/dialects/mysql/base.py +++ b/lib/sqlalchemy/dialects/mysql/base.py @@ -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( diff --git a/lib/sqlalchemy/sql/dml.py b/lib/sqlalchemy/sql/dml.py index d44a5d5e14..f68f2b2a86 100644 --- a/lib/sqlalchemy/sql/dml.py +++ b/lib/sqlalchemy/sql/dml.py @@ -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 `` 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: diff --git a/test/sql/test_compare.py b/test/sql/test_compare.py index cb38540e45..193e2d1ea5 100644 --- a/test/sql/test_compare.py +++ b/test/sql/test_compare.py @@ -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( diff --git a/test/sql/test_delete.py b/test/sql/test_delete.py index 83f92dcdd0..83840a73d9 100644 --- a/test/sql/test_delete.py +++ b/test/sql/test_delete.py @@ -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