]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add support for multiple on-conflict clauses in inserts on SQLite
authorMasoNord <trotf233@gmail.com>
Mon, 3 Aug 2026 21:33:46 +0000 (17:33 -0400)
committerMichael Bayer <mike_mp@zzzcomputing.com>
Thu, 13 Aug 2026 01:01:17 +0000 (01:01 +0000)
Multiple on-conflict clauses support for SQLite

Fixes: #13113
### Description
Follows up to [#13113](https://github.com/sqlalchemy/sqlalchemy/issues/13113)
Add support for multiple on-conflict clauses in insert statements for SQLite database
As [CaselIT](https://github.com/CaselIT) proposed, this required a small change in `apply_to_insert()` function. I've replaced `self.append_replacing_same_type` to `lambda ex: [*ex, self]` and removed `@_on_conflict_exclusive` from `on_conflict_do_update()` and `on_conflict_do_nothing()` methods

This pull request is:
- [ ] A new feature implementation
    - Tests included

**Have a nice day!**

Closes: #13457
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/13457
Pull-request-sha: 356885a839850344cfdc0818b1d6bb64ee61c417

Change-Id: I4e184c4e57018048f97b9f7ff30deded6d9f1a9e

doc/build/changelog/unreleased_21/13113.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/sqlite/base.py
lib/sqlalchemy/dialects/sqlite/dml.py
test/dialect/sqlite/test_on_conflict.py

diff --git a/doc/build/changelog/unreleased_21/13113.rst b/doc/build/changelog/unreleased_21/13113.rst
new file mode 100644 (file)
index 0000000..d8119ee
--- /dev/null
@@ -0,0 +1,17 @@
+.. change::
+    :tags: usecase, sqlite, dml
+    :tickets: 13113
+
+    Added support for multiple ``ON CONFLICT`` clauses within a single
+    statement for the SQLite :func:`_sqlite.insert` construct; the
+    :meth:`_sqlite.Insert.on_conflict_do_update` and
+    :meth:`_sqlite.Insert.on_conflict_do_nothing` methods may now each be
+    invoked more than once against the same construct, where the clauses
+    render in the order in which they were established and are evaluated by
+    SQLite in that order.  As SQLite allows only the last ``ON CONFLICT``
+    clause to omit its conflict target, a
+    :meth:`_sqlite.Insert.on_conflict_do_nothing` call that omits
+    :paramref:`_sqlite.Insert.on_conflict_do_nothing.index_elements` must be
+    the last clause established.  Documentation is added at
+    :ref:`sqlite_on_conflict_multiple`.  Pull request courtesy Diemid
+    Berozkin.
index 4c78375696552ac632e408ce2ec23369598958b5..fa3f23da73c8a2586e04c36619a0a07092529152 100644 (file)
@@ -744,6 +744,54 @@ occurs:
     >>> print(stmt)
     {printsql}INSERT INTO my_table (id, data) VALUES (?, ?) ON CONFLICT DO NOTHING
 
+.. _sqlite_on_conflict_multiple:
+
+Specifying Multiple ON CONFLICT Clauses
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+SQLite accepts more than one ``ON CONFLICT`` clause within a single INSERT
+statement.  The :meth:`_sqlite.Insert.on_conflict_do_update` and
+:meth:`_sqlite.Insert.on_conflict_do_nothing` methods may therefore be
+invoked repeatedly against the same construct, and may be combined with each
+other; each clause renders in the order in which it was established:
+
+.. sourcecode:: pycon+sql
+
+    >>> stmt = insert(my_table).values(id="some_id", data="inserted value")
+    >>> stmt = stmt.on_conflict_do_update(
+    ...     index_elements=["id"], set_=dict(data="updated value")
+    ... ).on_conflict_do_nothing(index_elements=["data"])
+    >>> print(stmt)
+    {printsql}INSERT INTO my_table (id, data) VALUES (?, ?)
+    ON CONFLICT (id) DO UPDATE SET data = ?
+    ON CONFLICT (data) DO NOTHING
+
+SQLite tests the clauses in the order given, and applies at most one of them
+to any particular row, that being the first clause whose conflict target
+matches the constraint that was violated.
+
+Only the last ``ON CONFLICT`` clause of a statement may omit its conflict
+target, in which case it fires for any unique violation not already captured
+by a preceding clause.  A :meth:`_sqlite.Insert.on_conflict_do_nothing` call
+that omits
+:paramref:`_sqlite.Insert.on_conflict_do_nothing.index_elements` must
+therefore be the last clause established, else
+:class:`.InvalidRequestError` is raised:
+
+.. sourcecode:: pycon+sql
+
+    >>> stmt = insert(my_table).values(id="some_id", data="inserted value")
+    >>> stmt = stmt.on_conflict_do_update(
+    ...     index_elements=["id"], set_=dict(data="updated value")
+    ... ).on_conflict_do_nothing()
+    >>> print(stmt)
+    {printsql}INSERT INTO my_table (id, data) VALUES (?, ?)
+    ON CONFLICT (id) DO UPDATE SET data = ?
+    ON CONFLICT DO NOTHING
+
+.. versionadded:: 2.1  Multiple ``ON CONFLICT`` clauses may be established
+   on a single :class:`_sqlite.Insert` construct.
+
 .. _sqlite_type_reflection:
 
 Type Reflection
index f877f0a12a531c6d58932eac2be0493d93f97efb..0a759378af8c0e789ba7bd8e7fe844cb0f24c20e 100644 (file)
@@ -10,18 +10,19 @@ from typing import Any
 from typing import Dict
 from typing import List
 from typing import Optional
+from typing import Sequence
 from typing import Union
 
 from .._typing import _OnConflictIndexElementsT
 from .._typing import _OnConflictIndexWhereT
 from .._typing import _OnConflictSetT
 from .._typing import _OnConflictWhereT
+from ... import exc
 from ... import util
 from ...sql import coercions
 from ...sql import roles
 from ...sql import schema
 from ...sql._typing import _DMLTableArgument
-from ...sql.base import _exclusive_against
 from ...sql.base import ColumnCollection
 from ...sql.base import ReadOnlyColumnCollection
 from ...sql.base import SyntaxExtension
@@ -102,15 +103,6 @@ class Insert(StandardInsert):
         """
         return alias(self.table, name="excluded").columns
 
-    _on_conflict_exclusive = _exclusive_against(
-        "_post_values_clause",
-        msgs={
-            "_post_values_clause": "This Insert construct already has "
-            "an ON CONFLICT clause established"
-        },
-    )
-
-    @_on_conflict_exclusive
     def on_conflict_do_update(
         self,
         index_elements: _OnConflictIndexElementsT = None,
@@ -121,6 +113,17 @@ class Insert(StandardInsert):
         r"""
         Specifies a DO UPDATE SET action for ON CONFLICT clause.
 
+        This method may be invoked more than once against the same
+        :class:`_sqlite.Insert` construct, where each ``ON CONFLICT`` clause
+        renders in the order in which it was established.
+
+        .. versionadded:: 2.1  Multiple ``ON CONFLICT`` clauses may be
+           established on a single :class:`_sqlite.Insert` construct.
+
+        .. seealso::
+
+            :ref:`sqlite_on_conflict_multiple`
+
         :param index_elements:
          A sequence consisting of string column names, :class:`_schema.Column`
          objects, or other column expression objects that will be used
@@ -161,7 +164,6 @@ class Insert(StandardInsert):
             OnConflictDoUpdate(index_elements, index_where, set_, where)
         )
 
-    @_on_conflict_exclusive
     def on_conflict_do_nothing(
         self,
         index_elements: _OnConflictIndexElementsT = None,
@@ -170,6 +172,23 @@ class Insert(StandardInsert):
         """
         Specifies a DO NOTHING action for ON CONFLICT clause.
 
+        This method may be invoked more than once against the same
+        :class:`_sqlite.Insert` construct, and may be combined with
+        :meth:`_sqlite.Insert.on_conflict_do_update`, where each
+        ``ON CONFLICT`` clause renders in the order in which it was
+        established.  As SQLite allows only the last ``ON CONFLICT`` clause
+        of a statement to omit its conflict target, a call that omits
+        :paramref:`_sqlite.Insert.on_conflict_do_nothing.index_elements`
+        must be the last clause established, else
+        :class:`.InvalidRequestError` is raised.
+
+        .. versionadded:: 2.1  Multiple ``ON CONFLICT`` clauses may be
+           established on a single :class:`_sqlite.Insert` construct.
+
+        .. seealso::
+
+            :ref:`sqlite_on_conflict_multiple`
+
         :param index_elements:
          A sequence consisting of string column names, :class:`_schema.Column`
          objects, or other column expression objects that will be used
@@ -220,9 +239,25 @@ class OnConflictClause(SyntaxExtension, ClauseElement):
                 self.inferred_target_whereclause
             ) = None
 
+    def _append_to_existing(
+        self, existing: Sequence[ClauseElement]
+    ) -> Sequence[ClauseElement]:
+        if existing:
+            last = existing[-1]
+            if (
+                isinstance(last, OnConflictClause)
+                and last.inferred_target_elements is None
+            ):
+                raise exc.InvalidRequestError(
+                    "This Insert construct already has an ON CONFLICT "
+                    "clause that omits a conflict target; such a clause "
+                    "must be the last ON CONFLICT clause in the statement"
+                )
+        return [*existing, self]
+
     def apply_to_insert(self, insert_stmt: StandardInsert) -> None:
         insert_stmt.apply_syntax_extension_point(
-            self.append_replacing_same_type, "post_values"
+            self._append_to_existing, "post_values"
         )
 
 
index 782cd0148abd9bc9bb069ebf967f9f90ff9b2f99..40ba13fa7fc047d24a4cea8e375bc18a8dbaa5d8 100644 (file)
@@ -3,6 +3,7 @@
 from sqlalchemy import bindparam
 from sqlalchemy import Column
 from sqlalchemy import exc
+from sqlalchemy import MetaData
 from sqlalchemy import schema
 from sqlalchemy import sql
 from sqlalchemy import Table
@@ -11,6 +12,7 @@ from sqlalchemy import types as sqltypes
 from sqlalchemy import UniqueConstraint
 from sqlalchemy.dialects.sqlite import insert
 from sqlalchemy.testing import assert_raises
+from sqlalchemy.testing import AssertsCompiledSQL
 from sqlalchemy.testing import eq_
 from sqlalchemy.testing import expect_raises
 from sqlalchemy.testing import fixtures
@@ -81,26 +83,6 @@ class OnConflictTest(fixtures.TablesTest):
         with expect_raises(ValueError):
             insert(self.tables.users).on_conflict_do_update()
 
-    def test_on_conflict_do_no_call_twice(self):
-        users = self.tables.users
-
-        for stmt in (
-            insert(users).on_conflict_do_nothing(),
-            insert(users).on_conflict_do_update(
-                index_elements=[users.c.id], set_=dict(name="foo")
-            ),
-        ):
-            for meth in (
-                stmt.on_conflict_do_nothing,
-                stmt.on_conflict_do_update,
-            ):
-                with testing.expect_raises_message(
-                    exc.InvalidRequestError,
-                    "This Insert construct already has an "
-                    "ON CONFLICT clause established",
-                ):
-                    meth()
-
     def test_on_conflict_do_nothing(self, connection):
         users = self.tables.users
 
@@ -772,3 +754,198 @@ class OnConflictTest(fixtures.TablesTest):
             ).fetchall(),
             expected_updated,
         )
+
+    def _multiple_clauses_data(self):
+        """rows which conflict against the ``users_xtra`` rows set up by
+        :meth:`._exotic_targets_fixture`.
+
+        the first row conflicts on both ``id`` and ``login_email``, so that
+        which ON CONFLICT clause fires for it depends on the order in which
+        the clauses were established; the second row conflicts on ``id``
+        only.
+
+        """
+        return [
+            dict(
+                id=1,
+                name="name1",
+                login_email="name1@gmail.com",
+                lets_index_this="not",
+            ),
+            dict(
+                id=2,
+                name="name2",
+                login_email="name3@gmail.com",
+                lets_index_this="not",
+            ),
+        ]
+
+    @testing.variation("email_clause_first", [True, False])
+    def test_on_conflict_do_update_multiple_clauses(
+        self, connection, email_clause_first
+    ):
+        """test #13113, where the first clause with a matching conflict
+        target is the one that fires for a given row.
+
+        """
+        users = self.tables.users_xtra
+
+        self._exotic_targets_fixture(connection)
+
+        stmt = insert(users)
+
+        if email_clause_first:
+            stmt = stmt.on_conflict_do_update(
+                index_elements=["login_email"],
+                set_=dict(login_email="nord1@gmail.com"),
+            ).on_conflict_do_update(
+                index_elements=[users.c.id], set_=dict(name="name3")
+            )
+        else:
+            stmt = stmt.on_conflict_do_update(
+                index_elements=[users.c.id], set_=dict(name="name3")
+            ).on_conflict_do_update(
+                index_elements=["login_email"],
+                set_=dict(login_email="nord1@gmail.com"),
+            )
+
+        connection.execute(stmt, self._multiple_clauses_data())
+
+        eq_(
+            connection.execute(users.select()).fetchall(),
+            (
+                [
+                    (1, "name1", "nord1@gmail.com", "not"),
+                    (2, "name3", "name2@gmail.com", "not"),
+                ]
+                if email_clause_first
+                else [
+                    (1, "name3", "name1@gmail.com", "not"),
+                    (2, "name3", "name2@gmail.com", "not"),
+                ]
+            ),
+        )
+
+    def test_on_conflict_do_update_and_do_nothing(self, connection):
+        """test #13113, mixing DO UPDATE and DO NOTHING clauses."""
+
+        users = self.tables.users_xtra
+
+        self._exotic_targets_fixture(connection)
+
+        stmt = (
+            insert(users)
+            .on_conflict_do_update(
+                index_elements=["login_email"],
+                set_=dict(login_email="nord1@gmail.com"),
+            )
+            .on_conflict_do_nothing(index_elements=[users.c.id])
+        )
+
+        connection.execute(stmt, self._multiple_clauses_data())
+
+        eq_(
+            connection.execute(users.select()).fetchall(),
+            [
+                (1, "name1", "nord1@gmail.com", "not"),
+                (2, "name2", "name2@gmail.com", "not"),
+            ],
+        )
+
+    def test_on_conflict_untargeted_do_nothing_last(self, connection):
+        """test #13113, a DO NOTHING clause with no conflict target is
+        legal as the last clause of the statement.
+
+        """
+        users = self.tables.users_xtra
+
+        self._exotic_targets_fixture(connection)
+
+        stmt = (
+            insert(users)
+            .on_conflict_do_update(
+                index_elements=["login_email"],
+                set_=dict(login_email="nord1@gmail.com"),
+            )
+            .on_conflict_do_nothing()
+        )
+
+        connection.execute(stmt, self._multiple_clauses_data())
+
+        eq_(
+            connection.execute(users.select()).fetchall(),
+            [
+                (1, "name1", "nord1@gmail.com", "not"),
+                (2, "name2", "name2@gmail.com", "not"),
+            ],
+        )
+
+
+class OnConflictCompileTest(fixtures.TestBase, AssertsCompiledSQL):
+    """test #13113, multiple ON CONFLICT clauses on a single statement."""
+
+    __dialect__ = "sqlite"
+
+    @testing.fixture
+    def users(self):
+        return Table(
+            "users",
+            MetaData(),
+            Column("id", Integer, primary_key=True),
+            Column("name", String(50)),
+            Column("login_email", String(50)),
+        )
+
+    def test_multiple_clauses_render_in_order(self, users):
+        stmt = (
+            insert(users)
+            .on_conflict_do_update(
+                index_elements=[users.c.id], set_=dict(name="name1")
+            )
+            .on_conflict_do_nothing(index_elements=[users.c.login_email])
+            .on_conflict_do_update(
+                index_elements=[users.c.name], set_=dict(name="name2")
+            )
+        )
+
+        self.assert_compile(
+            stmt,
+            "INSERT INTO users (id, name, login_email) VALUES (?, ?, ?) "
+            "ON CONFLICT (id) DO UPDATE SET name = ? "
+            "ON CONFLICT (login_email) DO NOTHING "
+            "ON CONFLICT (name) DO UPDATE SET name = ?",
+        )
+
+    def test_untargeted_do_nothing_renders_last(self, users):
+        stmt = (
+            insert(users)
+            .on_conflict_do_update(
+                index_elements=[users.c.id], set_=dict(name="name1")
+            )
+            .on_conflict_do_nothing()
+        )
+
+        self.assert_compile(
+            stmt,
+            "INSERT INTO users (id, name, login_email) VALUES (?, ?, ?) "
+            "ON CONFLICT (id) DO UPDATE SET name = ? "
+            "ON CONFLICT DO NOTHING",
+        )
+
+    def test_untargeted_clause_must_be_last(self, users):
+        stmt = insert(users).on_conflict_do_nothing()
+
+        for add_clause in (
+            lambda: stmt.on_conflict_do_nothing(),
+            lambda: stmt.on_conflict_do_nothing(index_elements=[users.c.id]),
+            lambda: stmt.on_conflict_do_update(
+                index_elements=[users.c.id], set_=dict(name="name2")
+            ),
+        ):
+            with testing.expect_raises_message(
+                exc.InvalidRequestError,
+                "This Insert construct already has an ON CONFLICT clause "
+                "that omits a conflict target; such a clause must be the "
+                "last ON CONFLICT clause in the statement",
+            ):
+                add_clause()