]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Allow delete where clause to refer multiple tables.
authorinytar <pietpiet@fastmail.net>
Mon, 30 Oct 2017 16:01:49 +0000 (12:01 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 5 Dec 2017 19:26:28 +0000 (14:26 -0500)
Implemented "DELETE..FROM" syntax for Postgresql, MySQL, MS SQL Server
(as well as within the unsupported Sybase dialect) in a manner similar
to how "UPDATE..FROM" works.  A DELETE statement that refers to more than
one table will switch into "multi-table" mode and render the appropriate
"USING" or multi-table "FROM" clause as understood by the database.
Pull request courtesy Pieter Mulder.

For SQL syntaxes see:

Postgresql: https://www.postgresql.org/docs/current/static/sql-delete.html
MySQL: https://dev.mysql.com/doc/refman/5.7/en/delete.html#multiple-table_syntax
MSSQL: https://docs.microsoft.com/en-us/sql/t-sql/statements/delete-transact-sql
Sybase: http://infocenter.sybase.com/help/index.jsp?topic=/com.sybase.infocenter.dc00801.1510/html/iqrefso/X315721.htm

Co-authored by: Mike Bayer <mike_mp@zzzcomputing.com>
Change-Id: I6dfd57b49e44a095d076dc493cd2360bb5d920d3
Pull-request: https://github.com/zzzeek/sqlalchemy/pull/392
Fixes: #959
20 files changed:
doc/build/changelog/migration_08.rst
doc/build/changelog/migration_12.rst
doc/build/changelog/unreleased_12/959.rst [new file with mode: 0644]
doc/build/core/tutorial.rst
lib/sqlalchemy/dialects/mssql/base.py
lib/sqlalchemy/dialects/mysql/base.py
lib/sqlalchemy/dialects/postgresql/base.py
lib/sqlalchemy/dialects/sybase/base.py
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/sql/dml.py
lib/sqlalchemy/testing/requirements.py
test/dialect/mssql/test_compiler.py
test/dialect/mysql/test_compiler.py
test/dialect/postgresql/test_compiler.py
test/dialect/test_sybase.py
test/orm/test_update_delete.py
test/requirements.py
test/sql/test_compiler.py
test/sql/test_delete.py
test/sql/test_update.py

index 64ef46c263e85c321c39c66ed3a460575996074f..6db942b39b0eb30d0043a5768f997766be70aa31 100644 (file)
@@ -526,6 +526,8 @@ the :class:`.Table` to which ``User`` is mapped.
 
 :ticket:`2245`
 
+.. _change_orm_2365:
+
 Query.update() supports UPDATE..FROM
 ------------------------------------
 
index e78c44fd79a01a3f6629ca3c7aed9b7f93a42c83..9c0218c3a0962fdfcfe69491f076365a92d1030f 100644 (file)
@@ -245,6 +245,31 @@ if not specified, the attribute defaults to ``None``::
 
 :ticket:`3058`
 
+.. _change_orm_959:
+
+ORM Support of multiple-table deletes
+-------------------------------------
+
+The ORM :meth:`.Query.delete` method supports multiple-table criteria
+for DELETE, as introduced in :ref:`change_959`.   The feature works
+in the same manner as multiple-table criteria for UPDATE, first
+introduced in 0.8 and described at :ref:`change_orm_2365`.
+
+Below, we emit a DELETE against ``SomeEntity``, adding
+a FROM clause (or equivalent, depending on backend)
+against ``SomeOtherEntity``::
+
+    query(SomeEntity).\
+        filter(SomeEntity.id==SomeOtherEntity.id).\
+        filter(SomeOtherEntity.foo=='bar').\
+        delete()
+
+.. seealso::
+
+    :ref:`change_959`
+
+:ticket:`959`
+
 .. _change_3229:
 
 Support for bulk updates of hybrids, composites
@@ -776,6 +801,37 @@ Current backend support includes MySQL, Postgresql, and Oracle.
 
 :ticket:`1546`
 
+.. _change_959:
+
+Multiple-table criteria support for DELETE
+------------------------------------------
+
+The :class:`.Delete` construct now supports multiple-table criteria,
+implemented for those backends which support it, currently these are
+Postgresql, MySQL and Microsoft SQL Server (support is also added to the
+currently non-working Sybase dialect).   The feature works in the same
+was as that of multiple-table criteria for UPDATE, first introduced in
+the 0.7 and 0.8 series.
+
+Given a statement as::
+
+    stmt = users.delete().\
+            where(users.c.id == addresses.c.id).\
+            where(addresses.c.email_address.startswith('ed%'))
+    conn.execute(stmt)
+
+The resulting SQL from the above statement on a Postgresql backend
+would render as::
+
+    DELETE FROM users USING addresses
+    WHERE users.id = addresses.id
+    AND (addresses.email_address LIKE %(email_address_1)s || '%%')
+
+.. seealso::
+
+    :ref:`multi_table_deletes`
+
+:ticket:`959`
 
 .. _change_2694:
 
diff --git a/doc/build/changelog/unreleased_12/959.rst b/doc/build/changelog/unreleased_12/959.rst
new file mode 100644 (file)
index 0000000..bcb9cbc
--- /dev/null
@@ -0,0 +1,14 @@
+.. change::
+    :tags: enhancement, sql
+    :tickets: 959
+
+    Implemented "DELETE..FROM" syntax for Postgresql, MySQL, MS SQL Server
+    (as well as within the unsupported Sybase dialect) in a manner similar
+    to how "UPDATE..FROM" works.  A DELETE statement that refers to more than
+    one table will switch into "multi-table" mode and render the appropriate
+    "USING" or multi-table "FROM" clause as understood by the database.
+    Pull request courtesy Pieter Mulder.
+
+    .. seealso::
+
+        :ref:`change_959`
\ No newline at end of file
index b8a362daaf1feb06a0bf6f8dd1315c8564a0ddc0..1069ae5c78f406db47ab0302a7c60c319ef9e91c 100644 (file)
@@ -2055,10 +2055,11 @@ The tables are referenced explicitly in the SET clause::
             users.name=%s WHERE users.id = addresses.id
             AND addresses.email_address LIKE concat(%s, '%')
 
-SQLAlchemy doesn't do anything special when these constructs are used on
-a non-supporting database.  The ``UPDATE FROM`` syntax generates by default
-when multiple tables are present, and the statement will be rejected
-by the database if this syntax is not supported.
+When the construct is used on a non-supporting database, the compiler
+will raise ``NotImplementedError``.   For convenience, when a statement
+is printed as a string without specification of a dialect, the "string SQL"
+compiler will be invoked which provides a non-working SQL representation of the
+construct.
 
 .. _updates_order_parameters:
 
@@ -2129,6 +2130,37 @@ Finally, a delete.  This is accomplished easily enough using the
     COMMIT
     {stop}<sqlalchemy.engine.result.ResultProxy object at 0x...>
 
+.. _multi_table_deletes:
+
+Multiple Table Deletes
+----------------------
+
+.. versionadded:: 1.2
+
+The PostgreSQL, Microsoft SQL Server, and MySQL backends all support DELETE
+statements that refer to multiple tables within the WHERE criteria.   For PG
+and MySQL, this is the "DELETE USING" syntax, and for SQL Server, it's a
+"DELETE FROM" that refers to more than one table.  The SQLAlchemy
+:func:`.delete` construct supports both of these modes
+implicitly, by specifying multiple tables in the WHERE clause::
+
+    stmt = users.delete().\
+            where(users.c.id == addresses.c.id).\
+            where(addresses.c.email_address.startswith('ed%'))
+    conn.execute(stmt)
+
+On a Postgresql backend, the resulting SQL from the above statement would render as::
+
+    DELETE FROM users USING addresses
+    WHERE users.id = addresses.id
+    AND (addresses.email_address LIKE %(email_address_1)s || '%%')
+
+When the construct is used on a non-supporting database, the compiler
+will raise ``NotImplementedError``.   For convenience, when a statement
+is printed as a string without specification of a dialect, the "string SQL"
+compiler will be invoked which provides a non-working SQL representation of the
+construct.
+
 Matched Row Counts
 ------------------
 
index 0c3688c9b9ec2062e2425a71ee25bda1b0870ba8..9f4e7a9c4151e934b720a00f59f774cd3a406b52 100644 (file)
@@ -1510,6 +1510,28 @@ class MSSQLCompiler(compiler.SQLCompiler):
                                  fromhints=from_hints, **kw)
             for t in [from_table] + extra_froms)
 
+    def delete_table_clause(self, delete_stmt, from_table,
+                            extra_froms):
+        """If we have extra froms make sure we render any alias as hint."""
+        ashint = False
+        if extra_froms:
+            ashint = True
+        return from_table._compiler_dispatch(
+            self, asfrom=True, iscrud=True, ashint=ashint
+        )
+
+    def delete_extra_from_clause(self, delete_stmt, from_table,
+                                 extra_froms, from_hints, **kw):
+        """Render the DELETE .. FROM clause specific to MSSQL.
+
+        Yes, it has the FROM keyword twice.
+
+        """
+        return "FROM " + ', '.join(
+            t._compiler_dispatch(self, asfrom=True,
+                                 fromhints=from_hints, **kw)
+            for t in [from_table] + extra_froms)
+
 
 class MSSQLStrictCompiler(MSSQLCompiler):
 
index bee62b76f94fefb1f1dfa390cc1c9d10f70c977b..09f8a2a0e2469c2dcab178c48e75a08d0587623d 100644 (file)
@@ -1102,6 +1102,24 @@ class MySQLCompiler(compiler.SQLCompiler):
                            extra_froms, from_hints, **kw):
         return None
 
+    def delete_table_clause(self, delete_stmt, from_table,
+                            extra_froms):
+        """If we have extra froms make sure we render any alias as hint."""
+        ashint = False
+        if extra_froms:
+            ashint = True
+        return from_table._compiler_dispatch(
+            self, asfrom=True, iscrud=True, ashint=ashint
+        )
+
+    def delete_extra_from_clause(self, delete_stmt, from_table,
+                           extra_froms, from_hints, **kw):
+        """Render the DELETE .. USING clause specific to MySQL."""
+        return "USING " + ', '.join(
+            t._compiler_dispatch(self, asfrom=True,
+                                 fromhints=from_hints, **kw)
+            for t in [from_table] + extra_froms)
+
 
 class MySQLDDLCompiler(compiler.DDLCompiler):
     def get_column_specification(self, column, **kw):
index 32de7861a0cb12c68f65300d85a97f7973ac6b29..043efd6df5ea49c4ddebff9b755d8b320093c507 100644 (file)
@@ -1647,6 +1647,23 @@ class PGCompiler(compiler.SQLCompiler):
 
         return 'ON CONFLICT %s DO UPDATE SET %s' % (target_text, action_text)
 
+    def update_from_clause(self, update_stmt,
+                           from_table, extra_froms,
+                           from_hints,
+                           **kw):
+        return "FROM " + ', '.join(
+            t._compiler_dispatch(self, asfrom=True,
+                                 fromhints=from_hints, **kw)
+            for t in extra_froms)
+
+    def delete_extra_from_clause(self, delete_stmt, from_table,
+                           extra_froms, from_hints, **kw):
+        """Render the DELETE .. USING clause specific to PostgresSQL."""
+        return "USING " + ', '.join(
+            t._compiler_dispatch(self, asfrom=True,
+                                 fromhints=from_hints, **kw)
+            for t in extra_froms)
+
 
 class PGDDLCompiler(compiler.DDLCompiler):
 
index 5d2f0f70c84e18057cecc5c22187f77b75a30766..0d7d03e782fceda2616147dba4382f0441b5fafd 100644 (file)
@@ -369,6 +369,24 @@ class SybaseSQLCompiler(compiler.SQLCompiler):
         else:
             return ""
 
+    def delete_table_clause(self, delete_stmt, from_table,
+                            extra_froms):
+        """If we have extra froms make sure we render any alias as hint."""
+        ashint = False
+        if extra_froms:
+            ashint = True
+        return from_table._compiler_dispatch(
+            self, asfrom=True, iscrud=True, ashint=ashint
+        )
+
+    def delete_extra_from_clause(self, delete_stmt, from_table,
+                                 extra_froms, from_hints, **kw):
+        """Render the DELETE .. FROM clause specific to Sybase."""
+        return "FROM " + ', '.join(
+            t._compiler_dispatch(self, asfrom=True,
+                                 fromhints=from_hints, **kw)
+            for t in [from_table] + extra_froms)
+
 
 class SybaseDDLCompiler(compiler.DDLCompiler):
     def get_column_specification(self, column, **kwargs):
index b0f0807d61c14ef84d421c7f7fd4ce12f69124c0..9ed75ca06f4bfd60c00aaa78898b71d3c4797111 100644 (file)
@@ -2146,10 +2146,9 @@ class SQLCompiler(Compiled):
         MySQL and MSSQL override this.
 
         """
-        return "FROM " + ', '.join(
-            t._compiler_dispatch(self, asfrom=True,
-                                 fromhints=from_hints, **kw)
-            for t in extra_froms)
+        raise NotImplementedError(
+            "This backend does not support multiple-table "
+            "criteria within UPDATE")
 
     def visit_update(self, update_stmt, asfrom=False, **kw):
         toplevel = not self.stack
@@ -2232,6 +2231,25 @@ class SQLCompiler(Compiled):
     def _key_getters_for_crud_column(self):
         return crud._key_getters_for_crud_column(self, self.statement)
 
+    def delete_extra_from_clause(self, update_stmt,
+                                 from_table, extra_froms,
+                                 from_hints, **kw):
+        """Provide a hook to override the generation of an
+        DELETE..FROM clause.
+
+        This can be used to implement DELETE..USING for example.
+
+        MySQL and MSSQL override this.
+
+        """
+        raise NotImplementedError(
+            "This backend does not support multiple-table "
+            "criteria within DELETE")
+
+    def delete_table_clause(self, delete_stmt, from_table,
+                            extra_froms):
+        return from_table._compiler_dispatch(self, asfrom=True, iscrud=True)
+
     def visit_delete(self, delete_stmt, asfrom=False, **kw):
         toplevel = not self.stack
 
@@ -2241,6 +2259,8 @@ class SQLCompiler(Compiled):
 
         crud._setup_crud_params(self, delete_stmt, crud.ISDELETE, **kw)
 
+        extra_froms = delete_stmt._extra_froms
+
         text = "DELETE "
 
         if delete_stmt._prefixes:
@@ -2248,12 +2268,14 @@ class SQLCompiler(Compiled):
                                             delete_stmt._prefixes, **kw)
 
         text += "FROM "
-        table_text = delete_stmt.table._compiler_dispatch(
-            self, asfrom=True, iscrud=True)
+        table_text = self.delete_table_clause(delete_stmt, delete_stmt.table,
+                                              extra_froms)
 
         if delete_stmt._hints:
             dialect_hints, table_text = self._setup_crud_hints(
                 delete_stmt, table_text)
+        else:
+            dialect_hints = None
 
         text += table_text
 
@@ -2262,6 +2284,15 @@ class SQLCompiler(Compiled):
                 text += " " + self.returning_clause(
                     delete_stmt, delete_stmt._returning)
 
+        if extra_froms:
+            extra_from_text = self.delete_extra_from_clause(
+                delete_stmt,
+                delete_stmt.table,
+                extra_froms,
+                dialect_hints, **kw)
+            if extra_from_text:
+                text += " " + extra_from_text
+
         if delete_stmt._whereclause is not None:
             t = delete_stmt._whereclause._compiler_dispatch(self, **kw)
             if t:
@@ -2324,6 +2355,24 @@ class StrSQLCompiler(SQLCompiler):
 
         return 'RETURNING ' + ', '.join(columns)
 
+    def update_from_clause(self, update_stmt,
+                           from_table, extra_froms,
+                           from_hints,
+                           **kw):
+        return "FROM " + ', '.join(
+            t._compiler_dispatch(self, asfrom=True,
+                                 fromhints=from_hints, **kw)
+            for t in extra_froms)
+
+    def delete_extra_from_clause(self, update_stmt,
+                           from_table, extra_froms,
+                           from_hints,
+                           **kw):
+        return ', ' + ', '.join(
+            t._compiler_dispatch(self, asfrom=True,
+                                 fromhints=from_hints, **kw)
+            for t in extra_froms)
+
 
 class DDLCompiler(Compiled):
 
index 958e9bfb16074138a5785e78d8d29e215f5439dd..6c8c550b4615c786d12610c59064338d018735d0 100644 (file)
@@ -656,7 +656,7 @@ class Update(ValuesBase):
                     )
 
          .. versionchanged:: 0.7.4
-             The WHERE clause can refer to multiple tables.
+             The WHERE clause of UPDATE can refer to multiple tables.
 
         :param values:
           Optional dictionary which specifies the ``SET`` conditions of the
@@ -768,8 +768,6 @@ class Update(ValuesBase):
 
     @property
     def _extra_froms(self):
-        # TODO: this could be made memoized
-        # if the memoization is reset on each generative call.
         froms = []
         seen = {self.table}
 
@@ -811,6 +809,23 @@ class Delete(UpdateBase):
           condition of the ``DELETE`` statement. Note that the
           :meth:`~Delete.where()` generative method may be used instead.
 
+         The WHERE clause can refer to multiple tables.
+         For databases which support this, a ``DELETE..USING`` or similar
+         clause will be generated.  The statement
+         will fail on databases that don't have support for multi-table
+         delete statements.  A SQL-standard method of referring to
+         additional tables in the WHERE clause is to use a correlated
+         subquery::
+
+            users.delete().where(
+                    users.c.name==select([addresses.c.email_address]).\
+                                where(addresses.c.user_id==users.c.id).\
+                                as_scalar()
+                    )
+
+         .. versionchanged:: 1.2.0
+             The WHERE clause of DELETE can refer to multiple tables.
+
         .. seealso::
 
             :ref:`deletes` - SQL Expression Tutorial
@@ -846,6 +861,19 @@ class Delete(UpdateBase):
         else:
             self._whereclause = _literal_as_text(whereclause)
 
+    @property
+    def _extra_froms(self):
+        froms = []
+        seen = {self.table}
+
+        if self._whereclause is not None:
+            for item in _from_objects(self._whereclause):
+                if not seen.intersection(item._cloned_set):
+                    froms.append(item)
+                seen.update(item._cloned_set)
+
+        return froms
+
     def _copy_internals(self, clone=_clone, **kw):
         # TODO: coverage
         self._whereclause = clone(self._whereclause, **kw)
index a66b091aa7685750b1033b0d9b3722019ae49909..0b89a4506c7085238e56c4cfeca0aa84077b4219 100644 (file)
@@ -692,6 +692,11 @@ class SuiteRequirements(Requirements):
         """Target must support UPDATE..FROM syntax"""
         return exclusions.closed()
 
+    @property
+    def delete_from(self):
+        """Target must support DELETE FROM..FROM or DELETE..USING syntax"""
+        return exclusions.closed()
+
     @property
     def update_where_target_in_subquery(self):
         """Target must support UPDATE where the same table is present in a
index 1f4a4da4ba71dcd25aa441d616e310e1d2de57b1..d62753b9d74c4b423436abcba598d8d9bfcf8e75 100644 (file)
@@ -139,6 +139,23 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "sometable.somecolumn = :somecolumn_1"
         )
 
+    def test_delete_extra_froms(self):
+        t1 = table('t1', column('c1'))
+        t2 = table('t2', column('c1'))
+        q = sql.delete(t1).where(t1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM t1 FROM t1, t2 WHERE t1.c1 = t2.c1"
+        )
+
+    def test_delete_extra_froms_alias(self):
+        a1 = table('t1', column('c1')).alias('a1')
+        t2 = table('t2', column('c1'))
+        q = sql.delete(a1).where(a1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM a1 FROM t1 AS a1, t2 WHERE a1.c1 = t2.c1"
+        )
+        self.assert_compile(sql.delete(a1), "DELETE FROM t1 AS a1")
+
     def test_update_from_hint(self):
         t = table('sometable', column('somecolumn'))
         t2 = table('othertable', column('somecolumn'))
index cebbfc8968a0486709c6b4a838435b33beb20e0f..fdf7995395571069e1316f9e22770695d7b3ecc4 100644 (file)
@@ -232,6 +232,23 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "SELECT mytable.myid, mytable.name, mytable.description "
             "FROM mytable WHERE mytable.myid = %s LOCK IN SHARE MODE")
 
+    def test_delete_extra_froms(self):
+        t1 = table('t1', column('c1'))
+        t2 = table('t2', column('c1'))
+        q = sql.delete(t1).where(t1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM t1 USING t1, t2 WHERE t1.c1 = t2.c1"
+        )
+
+    def test_delete_extra_froms_alias(self):
+        a1 = table('t1', column('c1')).alias('a1')
+        t2 = table('t2', column('c1'))
+        q = sql.delete(a1).where(a1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM a1 USING t1 AS a1, t2 WHERE a1.c1 = t2.c1"
+        )
+        self.assert_compile(sql.delete(a1), "DELETE FROM t1 AS a1")
+
 
 class SQLTest(fixtures.TestBase, AssertsCompiledSQL):
 
index 18940ed5f866d903d93605823d88d5b62f499365..db142a6576673cc65d1b2d86270ceebb39a6e7f0 100644 (file)
@@ -1091,6 +1091,22 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "FROM table1 AS foo"
         )
 
+    def test_delete_extra_froms(self):
+        t1 = table('t1', column('c1'))
+        t2 = table('t2', column('c1'))
+        q = delete(t1).where(t1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM t1 USING t2 WHERE t1.c1 = t2.c1"
+        )
+
+    def test_delete_extra_froms_alias(self):
+        a1 = table('t1', column('c1')).alias('a1')
+        t2 = table('t2', column('c1'))
+        q = delete(a1).where(a1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM t1 AS a1 USING t2 WHERE a1.c1 = t2.c1"
+        )
+
 
 class InsertOnConflictTest(fixtures.TestBase, AssertsCompiledSQL):
     __dialect__ = postgresql.dialect()
index fee72a5b71957858632e0eaf13e64dbc1f786510..6027471061f0d7f750e7cbb3dad52f2b7b92976d 100644 (file)
@@ -32,3 +32,20 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "Sybase ASE does not support OFFSET",
             stmt.compile, dialect=self.__dialect__
         )
+
+    def test_delete_extra_froms(self):
+        t1 = sql.table('t1', sql.column('c1'))
+        t2 = sql.table('t2', sql.column('c1'))
+        q = sql.delete(t1).where(t1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM t1 FROM t1, t2 WHERE t1.c1 = t2.c1"
+        )
+
+    def test_delete_extra_froms_alias(self):
+        a1 = sql.table('t1', sql.column('c1')).alias('a1')
+        t2 = sql.table('t2', sql.column('c1'))
+        q = sql.delete(a1).where(a1.c.c1 == t2.c.c1)
+        self.assert_compile(
+            q, "DELETE FROM a1 FROM t1 AS a1, t2 WHERE a1.c1 = t2.c1"
+        )
+        self.assert_compile(sql.delete(a1), "DELETE FROM t1 AS a1")
index e387ad9e6392bc22b5fdc6c29c9cfc827f9c08fc..98fcc4f00beecbb8055e1def6a0a9c739ea1a15d 100644 (file)
@@ -726,6 +726,7 @@ class UpdateDeleteIgnoresLoadersTest(fixtures.MappedTest):
 
 
 class UpdateDeleteFromTest(fixtures.MappedTest):
+    __backend__ = True
 
     @classmethod
     def define_tables(cls, metadata):
@@ -802,6 +803,25 @@ class UpdateDeleteFromTest(fixtures.MappedTest):
                 (5, True), (6, None)])
         )
 
+    @testing.requires.delete_from
+    def test_delete_from_joined_subq_test(self):
+        Document = self.classes.Document
+        s = Session()
+
+        subq = s.query(func.max(Document.title).label('title')).\
+            group_by(Document.user_id).subquery()
+
+        s.query(Document).filter(Document.title == subq.c.title).\
+            delete(synchronize_session=False)
+
+        eq_(
+            set(s.query(Document.id, Document.flag)),
+            set([
+                (2, None),
+                (3, None),
+                (6, None)])
+        )
+
     def test_no_eval_against_multi_table_criteria(self):
         User = self.classes.User
         Document = self.classes.Document
@@ -1016,6 +1036,19 @@ class InheritTest(fixtures.DeclarativeMappedTest):
             set([('e1', 'e1', ), ('e2', 'e5')])
         )
 
+    @testing.requires.delete_from
+    def test_delete_from(self):
+        Engineer = self.classes.Engineer
+        Person = self.classes.Person
+        s = Session(testing.db)
+        s.query(Engineer).filter(Engineer.id == Person.id).\
+            filter(Person.name == 'e2').delete()
+
+        eq_(
+            set(s.query(Person.name, Engineer.engineer_name)),
+            set([('e1', 'e1', )])
+        )
+
     @testing.only_on('mysql', 'Multi table update')
     def test_update_from_multitable(self):
         Engineer = self.classes.Engineer
index 39a78dfa55712521090f022a14619452025e91ab..be85c1c0dc54e7dea7e1dd472ffdf52a450fc124 100644 (file)
@@ -324,6 +324,12 @@ class DefaultRequirements(SuiteRequirements):
         return only_on(['postgresql', 'mssql', 'mysql'],
                        "Backend does not support UPDATE..FROM")
 
+    @property
+    def delete_from(self):
+        """Target must support DELETE FROM..FROM or DELETE..USING syntax"""
+        return only_on(['postgresql', 'mssql', 'mysql', 'sybase'],
+                       "Backend does not support UPDATE..FROM")
+
     @property
     def update_where_target_in_subquery(self):
         """Target must support UPDATE where the same table is present in a
index 053619f46f0631ffd9616f9db78bca9b2700751a..988230ac5c66d8ef3dfcbf7c1216bbc2976088fe 100644 (file)
@@ -2926,7 +2926,7 @@ class ExecutionOptionsTest(fixtures.TestBase):
 
 
 class CRUDTest(fixtures.TestBase, AssertsCompiledSQL):
-    __dialect__ = 'default'
+    __dialect__ = 'default_enhanced'
 
     def test_insert_literal_binds(self):
         stmt = table1.insert().values(myid=3, name='jack')
index 904dcee3f2922ff1d777b965c45ef2dcc3d85470..7d18db9c9c18842cd09eda3a35e32a4f788a607a 100644 (file)
@@ -1,9 +1,11 @@
 #! coding:utf-8
 
-from sqlalchemy import Column, Integer, String, Table, delete, select, and_, \
+from sqlalchemy import Integer, String, ForeignKey, delete, select, and_, \
     or_
 from sqlalchemy.dialects import mysql
-from sqlalchemy.testing import AssertsCompiledSQL, fixtures
+from sqlalchemy import testing
+from sqlalchemy.testing import AssertsCompiledSQL, fixtures, eq_
+from sqlalchemy.testing.schema import Table, Column
 
 
 class _DeleteTestBase(object):
@@ -99,3 +101,142 @@ class DeleteTest(_DeleteTestBase, fixtures.TablesTest, AssertsCompiledSQL):
                             'FROM myothertable '
                             'WHERE myothertable.otherid = mytable.myid'
                             ')')
+
+
+class DeleteFromRoundTripTest(fixtures.TablesTest):
+    __backend__ = True
+
+    @classmethod
+    def define_tables(cls, metadata):
+        Table('mytable', metadata,
+              Column('myid', Integer),
+              Column('name', String(30)),
+              Column('description', String(50)))
+        Table('myothertable', metadata,
+              Column('otherid', Integer),
+              Column('othername', String(30)))
+        Table('users', metadata,
+              Column('id', Integer, primary_key=True,
+                     test_needs_autoincrement=True),
+              Column('name', String(30), nullable=False))
+        Table('addresses', metadata,
+              Column('id', Integer, primary_key=True,
+                     test_needs_autoincrement=True),
+              Column('user_id', None, ForeignKey('users.id')),
+              Column('name', String(30), nullable=False),
+              Column('email_address', String(50), nullable=False))
+        Table('dingalings', metadata,
+              Column('id', Integer, primary_key=True,
+                     test_needs_autoincrement=True),
+              Column('address_id', None, ForeignKey('addresses.id')),
+              Column('data', String(30)))
+        Table('update_w_default', metadata,
+              Column('id', Integer, primary_key=True),
+              Column('x', Integer),
+              Column('ycol', Integer, key='y'),
+              Column('data', String(30), onupdate=lambda: "hi"))
+
+    @classmethod
+    def fixtures(cls):
+        return dict(
+            users=(
+                ('id', 'name'),
+                (7, 'jack'),
+                (8, 'ed'),
+                (9, 'fred'),
+                (10, 'chuck')
+            ),
+            addresses=(
+                ('id', 'user_id', 'name', 'email_address'),
+                (1, 7, 'x', 'jack@bean.com'),
+                (2, 8, 'x', 'ed@wood.com'),
+                (3, 8, 'x', 'ed@bettyboop.com'),
+                (4, 8, 'x', 'ed@lala.com'),
+                (5, 9, 'x', 'fred@fred.com')
+            ),
+            dingalings=(
+                ('id', 'address_id', 'data'),
+                (1, 2, 'ding 1/2'),
+                (2, 5, 'ding 2/5')
+            ),
+        )
+
+    @testing.requires.delete_from
+    def test_exec_two_table(self):
+        users, addresses = self.tables.users, self.tables.addresses
+        dingalings = self.tables.dingalings
+
+        with testing.db.connect() as conn:
+            conn.execute(dingalings.delete())  # fk violation otherwise
+
+            conn.execute(
+                addresses.delete().
+                where(users.c.id == addresses.c.user_id).
+                where(users.c.name == 'ed')
+            )
+
+            expected = [
+                (1, 7, 'x', 'jack@bean.com'),
+                (5, 9, 'x', 'fred@fred.com')
+            ]
+        self._assert_table(addresses, expected)
+
+    @testing.requires.delete_from
+    def test_exec_three_table(self):
+        users = self.tables.users
+        addresses = self.tables.addresses
+        dingalings = self.tables.dingalings
+
+        testing.db.execute(
+            dingalings.delete().
+            where(users.c.id == addresses.c.user_id).
+            where(users.c.name == 'ed').
+            where(addresses.c.id == dingalings.c.address_id))
+
+        expected = [
+            (2, 5, 'ding 2/5')
+        ]
+        self._assert_table(dingalings, expected)
+
+    @testing.requires.delete_from
+    def test_exec_two_table_plus_alias(self):
+        users, addresses = self.tables.users, self.tables.addresses
+        dingalings = self.tables.dingalings
+
+        with testing.db.connect() as conn:
+            conn.execute(dingalings.delete())  # fk violation otherwise
+            a1 = addresses.alias()
+            conn.execute(
+                addresses.delete().
+                where(users.c.id == addresses.c.user_id).
+                where(users.c.name == 'ed').
+                where(a1.c.id == addresses.c.id)
+            )
+
+        expected = [
+            (1, 7, 'x', 'jack@bean.com'),
+            (5, 9, 'x', 'fred@fred.com')
+        ]
+        self._assert_table(addresses, expected)
+
+    @testing.requires.delete_from
+    def test_exec_alias_plus_table(self):
+        users, addresses = self.tables.users, self.tables.addresses
+        dingalings = self.tables.dingalings
+
+        d1 = dingalings.alias()
+
+        testing.db.execute(
+            delete(d1).
+            where(users.c.id == addresses.c.user_id).
+            where(users.c.name == 'ed').
+            where(addresses.c.id == d1.c.address_id))
+
+        expected = [
+            (2, 5, 'ding 2/5')
+        ]
+        self._assert_table(dingalings, expected)
+
+    def _assert_table(self, table, expected):
+        stmt = table.select().order_by(table.c.id)
+        eq_(testing.db.execute(stmt).fetchall(), expected)
index 71ac82ce09b7bc101d7779efc7fcfdf4a7463924..9ebaddffd1d96382317cabf7bd62cb11e13eaf16 100644 (file)
@@ -391,7 +391,7 @@ class UpdateTest(_UpdateFromTestBase, fixtures.TablesTest, AssertsCompiledSQL):
             where(table1.c.name == sel.c.othername).\
             values(name='foo')
 
-        dialect = default.DefaultDialect()
+        dialect = default.StrCompileDialect()
         dialect.positional = True
         self.assert_compile(
             upd,
@@ -419,7 +419,7 @@ class UpdateTest(_UpdateFromTestBase, fixtures.TablesTest, AssertsCompiledSQL):
 
 class UpdateFromCompileTest(_UpdateFromTestBase, fixtures.TablesTest,
                             AssertsCompiledSQL):
-    __dialect__ = 'default'
+    __dialect__ = 'default_enhanced'
 
     run_create_tables = run_inserts = run_deletes = None