From: MasoNord Date: Mon, 3 Aug 2026 21:33:46 +0000 (-0400) Subject: Add support for multiple on-conflict clauses in inserts on SQLite X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=c3efa7b70c10f79cbbd0acdc743b5b94f6996d60;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add support for multiple on-conflict clauses in inserts on SQLite 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 --- diff --git a/doc/build/changelog/unreleased_21/13113.rst b/doc/build/changelog/unreleased_21/13113.rst new file mode 100644 index 0000000000..d8119ee99d --- /dev/null +++ b/doc/build/changelog/unreleased_21/13113.rst @@ -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. diff --git a/lib/sqlalchemy/dialects/sqlite/base.py b/lib/sqlalchemy/dialects/sqlite/base.py index 4c78375696..fa3f23da73 100644 --- a/lib/sqlalchemy/dialects/sqlite/base.py +++ b/lib/sqlalchemy/dialects/sqlite/base.py @@ -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 diff --git a/lib/sqlalchemy/dialects/sqlite/dml.py b/lib/sqlalchemy/dialects/sqlite/dml.py index f877f0a12a..0a759378af 100644 --- a/lib/sqlalchemy/dialects/sqlite/dml.py +++ b/lib/sqlalchemy/dialects/sqlite/dml.py @@ -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" ) diff --git a/test/dialect/sqlite/test_on_conflict.py b/test/dialect/sqlite/test_on_conflict.py index 782cd0148a..40ba13fa7f 100644 --- a/test/dialect/sqlite/test_on_conflict.py +++ b/test/dialect/sqlite/test_on_conflict.py @@ -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()