From e8f6087b7256840fcfa3c9b2d837800b3ff7807f Mon Sep 17 00:00:00 2001 From: Artur Date: Wed, 11 Nov 2026 20:47:35 -0500 Subject: [PATCH 1/6] gh-161837: Fix SystemError in sqlite3 when a collation fails during fetch A collation callback cannot report an error to SQLite, so the Python exception stays set. A later error in the same step can make sqlite3_step() fail. Then pysqlite_cursor_iternext() calls set_error_from_db() with the exception still set. This causes a SystemError. In debug builds, an assertion fails. _pysqlite_query_execute() already prints or clears the pending exception before it raises the DB-API exception. Move that code into the new helper clear_callback_error() and use it in both functions. --- Lib/test/test_sqlite3/test_hooks.py | 45 ++++++++++++++++++- ...-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst | 5 +++ Modules/_sqlite/cursor.c | 26 +++++++---- 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst diff --git a/Lib/test/test_sqlite3/test_hooks.py b/Lib/test/test_sqlite3/test_hooks.py index a9373df4d70417d..1760d631f451acc 100644 --- a/Lib/test/test_sqlite3/test_hooks.py +++ b/Lib/test/test_sqlite3/test_hooks.py @@ -24,7 +24,7 @@ import sqlite3 as sqlite import unittest -from test.support import import_helper +from test.support import captured_stderr, import_helper from test.support.os_helper import TESTFN, unlink from .util import memory_database, cx_limit, with_tracebacks @@ -106,6 +106,49 @@ def mycoll(x, y): self.assertEqual(result, [('c',), ('b',), ('a',)], msg="the expected order was not returned") + def test_collation_error_then_step_failure_while_fetching(self): + # gh-161837: A collation can set an exception. Then a later error can + # make the same sqlite3_step() fail. The fetch must raise + # OperationalError for that failure, not SystemError. + def mycoll(x, y): + if x == "boom": + raise KeyError("collation raised") + return (x > y) - (x < y) + self.con.create_collation("mycoll", mycoll) + self.con.execute("create table t(x, y)") + # abs() of the smallest 64-bit integer causes "integer overflow". + self.con.executemany("insert into t values (?, ?)", + [(1, "a"), (-2**63, "boom")]) + sql = """ + select case when y = 'q' collate mycoll then abs(x) else 0 end + from t order by rowid + """ + + def fetch_with_fetchone(): + cur = self.con.execute(sql) + self.assertEqual(cur.fetchone(), (0,)) + cur.fetchone() + + def fetch_by_iteration(): + list(self.con.execute(sql)) + + for fetch in fetch_with_fetchone, fetch_by_iteration: + for tracebacks in False, True: + with self.subTest(fetch=fetch.__name__, tracebacks=tracebacks): + sqlite.enable_callback_tracebacks(tracebacks) + try: + with captured_stderr() as err: + with self.assertRaisesRegex(sqlite.OperationalError, + "integer overflow"): + fetch() + finally: + sqlite.enable_callback_tracebacks(False) + if tracebacks: + self.assertIn("KeyError: 'collation raised'", + err.getvalue()) + else: + self.assertEqual(err.getvalue(), "") + def test_collation_register_twice(self): """ Register two different collation functions under the same name. diff --git a/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst new file mode 100644 index 000000000000000..5edc2df4223134b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst @@ -0,0 +1,5 @@ +Fix a :exc:`SystemError` in :mod:`sqlite3`. The error occurs when a +collation callback raises an exception and the same step then fails during a +fetch. In debug builds, an assertion fails. Now the module raises +:exc:`~sqlite3.OperationalError` for the step failure, as +:meth:`~sqlite3.Cursor.execute` does. diff --git a/Modules/_sqlite/cursor.c b/Modules/_sqlite/cursor.c index ad8c434c1b2fb8f..c7a7cdecc950d92 100644 --- a/Modules/_sqlite/cursor.c +++ b/Modules/_sqlite/cursor.c @@ -553,6 +553,22 @@ stmt_step(sqlite3_stmt *statement) return rc; } +/* A user-defined callback (for example, a collation) can set an exception + * during a failed sqlite3_step(). Report that exception, then clear it. + * The caller can then raise the DB-API exception for the failure. */ +static void +clear_callback_error(pysqlite_state *state) +{ + if (PyErr_Occurred()) { + if (state->enable_callback_tracebacks) { + PyErr_Print(); + } + else { + PyErr_Clear(); + } + } +} + static int bind_param(pysqlite_state *state, pysqlite_Statement *self, int pos, PyObject *parameter) @@ -911,14 +927,7 @@ _pysqlite_query_execute(pysqlite_Cursor* self, int multiple, PyObject* operation rc = stmt_step(self->statement->st); if (rc != SQLITE_DONE && rc != SQLITE_ROW) { - if (PyErr_Occurred()) { - /* there was an error that occurred in a user-defined callback */ - if (state->enable_callback_tracebacks) { - PyErr_Print(); - } else { - PyErr_Clear(); - } - } + clear_callback_error(state); set_error_from_db(state, self->connection->db); goto error; } @@ -1177,6 +1186,7 @@ pysqlite_cursor_iternext(PyObject *op) } } else if (rc != SQLITE_ROW) { + clear_callback_error(self->connection->state); rc = set_error_from_db(self->connection->state, self->connection->db); int reset_rc = stmt_reset(self->statement); Py_CLEAR(self->statement); From 9f015dd77ece7baff4dee660c34bbaf16f2bb104 Mon Sep 17 00:00:00 2001 From: Artur Date: Wed, 11 Nov 2026 21:24:30 -0500 Subject: [PATCH 2/6] Disable colors in the new test The test compares the printed traceback with plain text. When colors are enabled, the traceback contains escape codes and the comparison fails. Use force_not_colorized to make the output plain. --- Lib/test/test_sqlite3/test_hooks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_sqlite3/test_hooks.py b/Lib/test/test_sqlite3/test_hooks.py index 1760d631f451acc..1cf84a42eb430dd 100644 --- a/Lib/test/test_sqlite3/test_hooks.py +++ b/Lib/test/test_sqlite3/test_hooks.py @@ -24,7 +24,7 @@ import sqlite3 as sqlite import unittest -from test.support import captured_stderr, import_helper +from test.support import captured_stderr, force_not_colorized, import_helper from test.support.os_helper import TESTFN, unlink from .util import memory_database, cx_limit, with_tracebacks @@ -106,6 +106,7 @@ def mycoll(x, y): self.assertEqual(result, [('c',), ('b',), ('a',)], msg="the expected order was not returned") + @force_not_colorized def test_collation_error_then_step_failure_while_fetching(self): # gh-161837: A collation can set an exception. Then a later error can # make the same sqlite3_step() fail. The fetch must raise From 673367925ada1fb545ef36e63f6b63094d24f165 Mon Sep 17 00:00:00 2001 From: Artur Date: Wed, 11 Nov 2026 21:40:59 -0500 Subject: [PATCH 3/6] Fix the other paths where a collation exception stays set A collation exception also stays set when the step does not fail. Then pysqlite_cursor_iternext() and executescript() return a result with an exception set. Now the collation exception propagates on these paths. executescript() stops after the statement that caused the exception. It also reports the pending exception when a step fails. Report the pending exception with PyErr_FormatUnraisable() instead of PyErr_Print(). The documentation of enable_callback_tracebacks() says this, and the other callbacks do this. Move the tests into the new class CollationErrorTests and use with_tracebacks(). --- Lib/test/test_sqlite3/test_hooks.py | 123 +++++++++++------- ...-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst | 12 +- Modules/_sqlite/cursor.c | 27 +++- 3 files changed, 105 insertions(+), 57 deletions(-) diff --git a/Lib/test/test_sqlite3/test_hooks.py b/Lib/test/test_sqlite3/test_hooks.py index 1cf84a42eb430dd..45af115e058f9a2 100644 --- a/Lib/test/test_sqlite3/test_hooks.py +++ b/Lib/test/test_sqlite3/test_hooks.py @@ -24,7 +24,7 @@ import sqlite3 as sqlite import unittest -from test.support import captured_stderr, force_not_colorized, import_helper +from test.support import import_helper from test.support.os_helper import TESTFN, unlink from .util import memory_database, cx_limit, with_tracebacks @@ -106,50 +106,6 @@ def mycoll(x, y): self.assertEqual(result, [('c',), ('b',), ('a',)], msg="the expected order was not returned") - @force_not_colorized - def test_collation_error_then_step_failure_while_fetching(self): - # gh-161837: A collation can set an exception. Then a later error can - # make the same sqlite3_step() fail. The fetch must raise - # OperationalError for that failure, not SystemError. - def mycoll(x, y): - if x == "boom": - raise KeyError("collation raised") - return (x > y) - (x < y) - self.con.create_collation("mycoll", mycoll) - self.con.execute("create table t(x, y)") - # abs() of the smallest 64-bit integer causes "integer overflow". - self.con.executemany("insert into t values (?, ?)", - [(1, "a"), (-2**63, "boom")]) - sql = """ - select case when y = 'q' collate mycoll then abs(x) else 0 end - from t order by rowid - """ - - def fetch_with_fetchone(): - cur = self.con.execute(sql) - self.assertEqual(cur.fetchone(), (0,)) - cur.fetchone() - - def fetch_by_iteration(): - list(self.con.execute(sql)) - - for fetch in fetch_with_fetchone, fetch_by_iteration: - for tracebacks in False, True: - with self.subTest(fetch=fetch.__name__, tracebacks=tracebacks): - sqlite.enable_callback_tracebacks(tracebacks) - try: - with captured_stderr() as err: - with self.assertRaisesRegex(sqlite.OperationalError, - "integer overflow"): - fetch() - finally: - sqlite.enable_callback_tracebacks(False) - if tracebacks: - self.assertIn("KeyError: 'collation raised'", - err.getvalue()) - else: - self.assertEqual(err.getvalue(), "") - def test_collation_register_twice(self): """ Register two different collation functions under the same name. @@ -192,6 +148,83 @@ def test_deregister_collation(self): self.assertEqual(str(cm.exception), 'no such collation sequence: mycoll') +class CollationErrorTests(MemoryDatabaseMixin, unittest.TestCase): + # gh-161837: A collation callback cannot report an error to SQLite, so + # the exception stays set while SQLite continues the step. + + # When the collation raises, the comparison is true, so abs() of the + # smallest 64-bit integer causes "integer overflow". + step_fails = """ + select case when y = 'q' collate mycoll then abs(x) else 0 end + from t order by rowid + """ + step_succeeds = "select x, y = 'q' collate mycoll from t order by rowid" + # The cursor reads one row ahead, so the first fetch steps to the + # second row, where the collation raises. + + def setUp(self): + super().setUp() + def mycoll(x, y): + if x == "boom": + raise KeyError("collation raised") + return (x > y) - (x < y) + self.con.create_collation("mycoll", mycoll) + self.con.execute("create table t(x, y)") + self.con.executemany("insert into t values (?, ?)", + [(1, "a"), (-2**63, "boom")]) + + @with_tracebacks(KeyError, regex="collation raised", + msg_regex="sqlite3 callback") + def test_step_failure_in_execute(self): + with self.assertRaisesRegex(sqlite.OperationalError, + "integer overflow"): + self.con.execute(self.step_fails + " desc") + + @with_tracebacks(KeyError, regex="collation raised", + msg_regex="sqlite3 callback") + def test_step_failure_in_fetchone(self): + cur = self.con.execute(self.step_fails) + with self.assertRaisesRegex(sqlite.OperationalError, + "integer overflow"): + cur.fetchone() + self.assertIsNone(cur.fetchone()) + + @with_tracebacks(KeyError, regex="collation raised", + msg_regex="sqlite3 callback") + def test_step_failure_in_iteration(self): + with self.assertRaisesRegex(sqlite.OperationalError, + "integer overflow"): + list(self.con.execute(self.step_fails)) + + @with_tracebacks(KeyError, regex="collation raised", + msg_regex="sqlite3 callback") + def test_step_failure_in_executescript(self): + with self.assertRaisesRegex(sqlite.OperationalError, + "integer overflow"): + self.con.executescript(self.step_fails) + + def test_error_in_fetchone(self): + cur = self.con.execute(self.step_succeeds) + with self.assertRaisesRegex(KeyError, "collation raised"): + cur.fetchone() + self.assertIsNone(cur.fetchone()) + self.assertEqual(cur.execute("select 1").fetchone(), (1,)) + + def test_error_in_iteration(self): + rows = [] + with self.assertRaisesRegex(KeyError, "collation raised"): + for row in self.con.execute(self.step_succeeds): + rows.append(row) + self.assertEqual(rows, []) + + def test_error_in_executescript(self): + with self.assertRaisesRegex(KeyError, "collation raised"): + self.con.executescript(self.step_succeeds + "; create table u(x)") + # The script stops after the statement that caused the error. + with self.assertRaisesRegex(sqlite.OperationalError, "no such table"): + self.con.execute("select * from u") + + class AuthorizerTests(MemoryDatabaseMixin, unittest.TestCase): def assert_not_authorized(self, func, /, *args, **kwargs): diff --git a/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst index 5edc2df4223134b..bf8fbd9b6afaa7f 100644 --- a/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst +++ b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst @@ -1,5 +1,7 @@ -Fix a :exc:`SystemError` in :mod:`sqlite3`. The error occurs when a -collation callback raises an exception and the same step then fails during a -fetch. In debug builds, an assertion fails. Now the module raises -:exc:`~sqlite3.OperationalError` for the step failure, as -:meth:`~sqlite3.Cursor.execute` does. +Fix a :exc:`SystemError` in :mod:`sqlite3` when a collation callback raises +an exception. This error occurs when you fetch rows from a cursor and when you +call :meth:`~sqlite3.Cursor.executescript`. Now the exception from the +collation propagates. If the step also fails, the module raises +:exc:`~sqlite3.OperationalError`, as :meth:`~sqlite3.Cursor.execute` does. +When callback tracebacks are enabled, the module now reports the collation +exception with :func:`sys.unraisablehook`, as the documentation says. diff --git a/Modules/_sqlite/cursor.c b/Modules/_sqlite/cursor.c index c7a7cdecc950d92..d527421b6a01fbb 100644 --- a/Modules/_sqlite/cursor.c +++ b/Modules/_sqlite/cursor.c @@ -553,15 +553,16 @@ stmt_step(sqlite3_stmt *statement) return rc; } -/* A user-defined callback (for example, a collation) can set an exception - * during a failed sqlite3_step(). Report that exception, then clear it. - * The caller can then raise the DB-API exception for the failure. */ +/* A collation callback cannot report an error to SQLite, so it keeps the + * Python exception set while SQLite continues the step. If the step then + * fails, call this function to report and clear that exception. The caller + * can then raise the DB-API exception for the failure. */ static void -clear_callback_error(pysqlite_state *state) +report_pending_callback_error(pysqlite_state *state) { if (PyErr_Occurred()) { if (state->enable_callback_tracebacks) { - PyErr_Print(); + PyErr_FormatUnraisable("Exception ignored on sqlite3 callback"); } else { PyErr_Clear(); @@ -927,7 +928,7 @@ _pysqlite_query_execute(pysqlite_Cursor* self, int multiple, PyObject* operation rc = stmt_step(self->statement->st); if (rc != SQLITE_DONE && rc != SQLITE_ROW) { - clear_callback_error(state); + report_pending_callback_error(state); set_error_from_db(state, self->connection->db); goto error; } @@ -1137,6 +1138,10 @@ pysqlite_cursor_executescript_impl(pysqlite_Cursor *self, if (rc != SQLITE_OK) { goto error; } + if (PyErr_Occurred()) { + /* A collation callback raised, but the step did not fail. */ + return NULL; + } if (*tail == (char)0) { break; @@ -1148,6 +1153,7 @@ pysqlite_cursor_executescript_impl(pysqlite_Cursor *self, return Py_NewRef((PyObject *)self); error: + report_pending_callback_error(self->connection->state); set_error_from_db(self->connection->state, db); return NULL; } @@ -1175,6 +1181,13 @@ pysqlite_cursor_iternext(PyObject *op) return NULL; } int rc = stmt_step(stmt); + if ((rc == SQLITE_ROW || rc == SQLITE_DONE) && PyErr_Occurred()) { + /* A collation callback raised, but the step did not fail. */ + (void)stmt_reset(self->statement); + Py_CLEAR(self->statement); + Py_DECREF(row); + return NULL; + } if (rc == SQLITE_DONE) { if (self->statement->is_dml) { self->rowcount = (long)sqlite3_changes(self->connection->db); @@ -1186,7 +1199,7 @@ pysqlite_cursor_iternext(PyObject *op) } } else if (rc != SQLITE_ROW) { - clear_callback_error(self->connection->state); + report_pending_callback_error(self->connection->state); rc = set_error_from_db(self->connection->state, self->connection->db); int reset_rc = stmt_reset(self->statement); Py_CLEAR(self->statement); From 59b1a3fdef05f4001a471b5c7fd0d16ef1c8caca Mon Sep 17 00:00:00 2001 From: Artur Date: Wed, 11 Nov 2026 22:02:15 -0500 Subject: [PATCH 4/6] Propagate the collation exception on all paths The failed collation returns a wrong result to SQLite. Thus, the collation exception is the cause of any error later in the step. After each step, check PyErr_Occurred() first and propagate the exception. This also prevents execute() and executemany() from calling converters, adapters and parameter iterators while the exception is set. Other callbacks can run in the same step after the collation raised. Do not call Python code from them while the exception is set. Remove the code that printed or cleared the exception. --- Lib/test/test_sqlite3/test_hooks.py | 180 ++++++++++++------ ...-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst | 13 +- Modules/_sqlite/connection.c | 39 ++++ Modules/_sqlite/cursor.c | 40 ++-- 4 files changed, 184 insertions(+), 88 deletions(-) diff --git a/Lib/test/test_sqlite3/test_hooks.py b/Lib/test/test_sqlite3/test_hooks.py index 45af115e058f9a2..199f640bd3e0535 100644 --- a/Lib/test/test_sqlite3/test_hooks.py +++ b/Lib/test/test_sqlite3/test_hooks.py @@ -150,22 +150,24 @@ def test_deregister_collation(self): class CollationErrorTests(MemoryDatabaseMixin, unittest.TestCase): # gh-161837: A collation callback cannot report an error to SQLite, so - # the exception stays set while SQLite continues the step. + # the exception stays set while SQLite continues the step. The module + # must propagate that exception, and it must not call other Python code + # while the exception is set. - # When the collation raises, the comparison is true, so abs() of the - # smallest 64-bit integer causes "integer overflow". + # When the collation raises, it returns 0 ("equal"). Thus the comparison + # is true, and abs() of the smallest 64-bit integer makes the step fail. step_fails = """ select case when y = 'q' collate mycoll then abs(x) else 0 end from t order by rowid """ step_succeeds = "select x, y = 'q' collate mycoll from t order by rowid" - # The cursor reads one row ahead, so the first fetch steps to the - # second row, where the collation raises. def setUp(self): super().setUp() + self.events = [] def mycoll(x, y): if x == "boom": + self.events.append("collation raised") raise KeyError("collation raised") return (x > y) - (x < y) self.con.create_collation("mycoll", mycoll) @@ -173,56 +175,124 @@ def mycoll(x, y): self.con.executemany("insert into t values (?, ?)", [(1, "a"), (-2**63, "boom")]) - @with_tracebacks(KeyError, regex="collation raised", - msg_regex="sqlite3 callback") - def test_step_failure_in_execute(self): - with self.assertRaisesRegex(sqlite.OperationalError, - "integer overflow"): - self.con.execute(self.step_fails + " desc") - - @with_tracebacks(KeyError, regex="collation raised", - msg_regex="sqlite3 callback") - def test_step_failure_in_fetchone(self): - cur = self.con.execute(self.step_fails) - with self.assertRaisesRegex(sqlite.OperationalError, - "integer overflow"): - cur.fetchone() - self.assertIsNone(cur.fetchone()) - - @with_tracebacks(KeyError, regex="collation raised", - msg_regex="sqlite3 callback") - def test_step_failure_in_iteration(self): - with self.assertRaisesRegex(sqlite.OperationalError, - "integer overflow"): - list(self.con.execute(self.step_fails)) - - @with_tracebacks(KeyError, regex="collation raised", - msg_regex="sqlite3 callback") - def test_step_failure_in_executescript(self): - with self.assertRaisesRegex(sqlite.OperationalError, - "integer overflow"): - self.con.executescript(self.step_fails) - - def test_error_in_fetchone(self): - cur = self.con.execute(self.step_succeeds) - with self.assertRaisesRegex(KeyError, "collation raised"): - cur.fetchone() - self.assertIsNone(cur.fetchone()) - self.assertEqual(cur.execute("select 1").fetchone(), (1,)) - - def test_error_in_iteration(self): - rows = [] - with self.assertRaisesRegex(KeyError, "collation raised"): - for row in self.con.execute(self.step_succeeds): - rows.append(row) - self.assertEqual(rows, []) - - def test_error_in_executescript(self): - with self.assertRaisesRegex(KeyError, "collation raised"): - self.con.executescript(self.step_succeeds + "; create table u(x)") - # The script stops after the statement that caused the error. - with self.assertRaisesRegex(sqlite.OperationalError, "no such table"): - self.con.execute("select * from u") + def assertCollationRaises(self): + return self.assertRaisesRegex(KeyError, "collation raised") + + def test_execute(self): + for sql in self.step_fails, self.step_succeeds: + with self.subTest(sql=sql), self.assertCollationRaises(): + self.con.execute(sql + " desc") + + def test_fetchone(self): + # The cursor reads one row ahead, so the first fetch steps to the + # row that makes the collation raise. + for sql in self.step_fails, self.step_succeeds: + with self.subTest(sql=sql): + cur = self.con.execute(sql) + with self.assertCollationRaises(): + cur.fetchone() + self.assertIsNone(cur.fetchone()) + self.assertEqual(cur.execute("select 1").fetchone(), (1,)) + + def test_iteration(self): + for sql in self.step_fails, self.step_succeeds: + with self.subTest(sql=sql): + rows = [] + with self.assertCollationRaises(): + for row in self.con.execute(sql): + rows.append(row) + self.assertEqual(rows, []) + + def test_executemany(self): + def params(): + yield ("boom",) + self.fail("executemany() continued after the exception") + with self.assertCollationRaises(): + self.con.executemany( + "insert into t(y) values (? = 'q' collate mycoll)", params()) + + def test_executescript(self): + for sql in self.step_fails, self.step_succeeds: + with self.subTest(sql=sql): + with self.assertCollationRaises(): + self.con.executescript(sql + "; create table u(x)") + # The script stops after the statement with the exception. + with self.assertRaisesRegex(sqlite.OperationalError, + "no such table"): + self.con.execute("select * from u") + + def test_converter_not_used(self): + with memory_database(detect_types=sqlite.PARSE_COLNAMES) as con: + con.create_collation("mycoll", lambda x, y: 1 / 0) + with self.assertRaises(ZeroDivisionError): + con.execute("select 'a' = 'b' collate mycoll as \"x [conv]\"") + + def test_function_not_called(self): + calls = [] + self.con.create_function("f", 1, calls.append) + with self.assertCollationRaises(): + self.con.execute("select f(y = 'q' collate mycoll) from t " + "order by rowid desc") + self.assertEqual(calls, []) + + def test_aggregate_not_called(self): + calls = [] + class Agg: + def step(self, value): + calls.append(value) + def finalize(self): + return len(calls) + self.con.create_aggregate("agg", 1, Agg) + with self.assertCollationRaises(): + self.con.execute("select agg(y = 'q' collate mycoll) from t " + "where rowid = 2") + self.assertEqual(calls, []) + + def test_window_function_not_called(self): + calls = [] + class Window: + def step(self, value): + calls.append("step") + def inverse(self, value): + calls.append("inverse") + def value(self): + calls.append("value") + return 0 + def finalize(self): + return 0 + self.con.create_window_function("win", 1, Window) + # SQLite evaluates the filter for all rows before it calls the + # window function. The filter is false for the first row, so the + # first call after the exception is value(). + with self.assertCollationRaises(): + self.con.execute(""" + select win(x) filter (where y = 'q' collate mycoll) + over (order by rowid) from t + """).fetchall() + self.assertEqual(calls, []) + + def test_progress_handler_not_called(self): + def progress(): + self.events.append("progress") + return 0 + self.con.set_progress_handler(progress, 1) + with self.assertCollationRaises(): + self.con.execute(self.step_succeeds + " desc") + self.con.set_progress_handler(None, 1) + self.assertEqual(self.events[-1], "collation raised") + self.assertIn("progress", self.events) + + def test_trace_callback_not_called(self): + self.con.execute("create table u(x)") + self.con.execute("create trigger tr after insert on u " + "begin select 1; end") + traced = [] + self.con.set_trace_callback(traced.append) + with self.assertCollationRaises(): + self.con.execute("insert into u select y = 'q' collate mycoll " + "from t order by rowid desc") + self.con.set_trace_callback(None) + self.assertNotIn("-- TRIGGER tr", traced) class AuthorizerTests(MemoryDatabaseMixin, unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst index bf8fbd9b6afaa7f..6b545c18478eb18 100644 --- a/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst +++ b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst @@ -1,7 +1,6 @@ -Fix a :exc:`SystemError` in :mod:`sqlite3` when a collation callback raises -an exception. This error occurs when you fetch rows from a cursor and when you -call :meth:`~sqlite3.Cursor.executescript`. Now the exception from the -collation propagates. If the step also fails, the module raises -:exc:`~sqlite3.OperationalError`, as :meth:`~sqlite3.Cursor.execute` does. -When callback tracebacks are enabled, the module now reports the collation -exception with :func:`sys.unraisablehook`, as the documentation says. +Fix a :exc:`SystemError` and crashes in :mod:`sqlite3` when a collation +callback raises an exception. Now the exception from the collation always +propagates. Before, :meth:`~sqlite3.Cursor.execute` raised +:exc:`~sqlite3.OperationalError` instead if the same step also failed. While +the exception is set, the module no longer calls other callbacks, such as +user-defined functions. diff --git a/Modules/_sqlite/connection.c b/Modules/_sqlite/connection.c index f8bd3a8df35b668..842bd43785c3e0d 100644 --- a/Modules/_sqlite/connection.c +++ b/Modules/_sqlite/connection.c @@ -960,10 +960,32 @@ set_sqlite_error(sqlite3_context *context, const char *msg) print_or_clear_traceback(ctx); } +/* If a collation callback raises, the exception stays set until + * sqlite3_step() returns (see collation_callback()). Other callbacks in the + * same step must not call Python code while the exception is set. Return 1 + * if an exception is set. If context is not NULL, also make the SQL function + * fail, so that SQLite stops the step. */ +static int +exception_is_pending(sqlite3_context *context) +{ + if (!PyErr_Occurred()) { + return 0; + } + if (context != NULL) { + sqlite3_result_error(context, + "an earlier callback raised an exception", -1); + } + return 1; +} + static void func_callback(sqlite3_context *context, int argc, sqlite3_value **argv) { PyGILState_STATE threadstate = PyGILState_Ensure(); + if (exception_is_pending(context)) { + PyGILState_Release(threadstate); + return; + } PyObject* args; PyObject* py_retval = NULL; @@ -994,6 +1016,10 @@ static void step_callback(sqlite3_context *context, int argc, sqlite3_value **params) { PyGILState_STATE threadstate = PyGILState_Ensure(); + if (exception_is_pending(context)) { + PyGILState_Release(threadstate); + return; + } PyObject* args; PyObject* function_result = NULL; @@ -1306,6 +1332,10 @@ static void value_callback(sqlite3_context *context) { PyGILState_STATE gilstate = PyGILState_Ensure(); + if (exception_is_pending(context)) { + PyGILState_Release(gilstate); + return; + } callback_context *ctx = (callback_context *)sqlite3_user_data(context); incref_callback_context(ctx); @@ -1491,6 +1521,11 @@ static int progress_callback(void *ctx_vp) { PyGILState_STATE gilstate = PyGILState_Ensure(); + if (exception_is_pending(NULL)) { + /* Stop the step. */ + PyGILState_Release(gilstate); + return 1; + } int rc; PyObject *ret; @@ -1530,6 +1565,10 @@ trace_callback(unsigned int type, void *ctx_vp, void *stmt, void *sql) } PyGILState_STATE gilstate = PyGILState_Ensure(); + if (exception_is_pending(NULL)) { + PyGILState_Release(gilstate); + return 0; + } callback_context *ctx = (callback_context *)ctx_vp; incref_callback_context(ctx); diff --git a/Modules/_sqlite/cursor.c b/Modules/_sqlite/cursor.c index d527421b6a01fbb..c11e11ed58ad6bb 100644 --- a/Modules/_sqlite/cursor.c +++ b/Modules/_sqlite/cursor.c @@ -541,6 +541,10 @@ get_statement_from_cache(pysqlite_Cursor *self, PyObject *operation) return PyObject_Vectorcall(cache, args + 1, nargsf, NULL); } +/* A collation callback cannot report an error to SQLite. If it raises, the + * exception stays set until sqlite3_step() returns, and SQLite continues + * the step with the wrong result of the comparison. Thus, after a step, check + * PyErr_Occurred() first, and propagate that exception. */ static inline int stmt_step(sqlite3_stmt *statement) { @@ -553,23 +557,6 @@ stmt_step(sqlite3_stmt *statement) return rc; } -/* A collation callback cannot report an error to SQLite, so it keeps the - * Python exception set while SQLite continues the step. If the step then - * fails, call this function to report and clear that exception. The caller - * can then raise the DB-API exception for the failure. */ -static void -report_pending_callback_error(pysqlite_state *state) -{ - if (PyErr_Occurred()) { - if (state->enable_callback_tracebacks) { - PyErr_FormatUnraisable("Exception ignored on sqlite3 callback"); - } - else { - PyErr_Clear(); - } - } -} - static int bind_param(pysqlite_state *state, pysqlite_Statement *self, int pos, PyObject *parameter) @@ -927,8 +914,11 @@ _pysqlite_query_execute(pysqlite_Cursor* self, int multiple, PyObject* operation } rc = stmt_step(self->statement->st); + if (PyErr_Occurred()) { + /* A collation callback raised. */ + goto error; + } if (rc != SQLITE_DONE && rc != SQLITE_ROW) { - report_pending_callback_error(state); set_error_from_db(state, self->connection->db); goto error; } @@ -1135,13 +1125,13 @@ pysqlite_cursor_executescript_impl(pysqlite_Cursor *self, } Py_END_ALLOW_THREADS - if (rc != SQLITE_OK) { - goto error; - } if (PyErr_Occurred()) { - /* A collation callback raised, but the step did not fail. */ + /* A collation callback raised. */ return NULL; } + if (rc != SQLITE_OK) { + goto error; + } if (*tail == (char)0) { break; @@ -1153,7 +1143,6 @@ pysqlite_cursor_executescript_impl(pysqlite_Cursor *self, return Py_NewRef((PyObject *)self); error: - report_pending_callback_error(self->connection->state); set_error_from_db(self->connection->state, db); return NULL; } @@ -1181,8 +1170,8 @@ pysqlite_cursor_iternext(PyObject *op) return NULL; } int rc = stmt_step(stmt); - if ((rc == SQLITE_ROW || rc == SQLITE_DONE) && PyErr_Occurred()) { - /* A collation callback raised, but the step did not fail. */ + if (PyErr_Occurred()) { + /* A collation callback raised. */ (void)stmt_reset(self->statement); Py_CLEAR(self->statement); Py_DECREF(row); @@ -1199,7 +1188,6 @@ pysqlite_cursor_iternext(PyObject *op) } } else if (rc != SQLITE_ROW) { - report_pending_callback_error(self->connection->state); rc = set_error_from_db(self->connection->state, self->connection->db); int reset_rc = stmt_reset(self->statement); Py_CLEAR(self->statement); From 2392f68d04d59ba428d1daf1aa71f63331188cbb Mon Sep 17 00:00:00 2001 From: Artur Date: Wed, 11 Nov 2026 22:14:26 -0500 Subject: [PATCH 5/6] Guard all callbacks and keep the exception in finalize The authorizer can run during a step, because table-valued pragma functions prepare statements. inverse() can run after the collation raised, for example when SQLite adds a row to an index that uses the collation. Do not call Python code from these callbacks while an exception is set. final_callback() calls finalize() with the earlier exception set aside. If finalize() raised, the earlier exception was lost. Now the module sets it again, so that it propagates. Make the comments generic, because a callback other than a collation can also leave an exception set. Make the tests check the order of the calls, and add tests for the authorizer, inverse() and finalize(). --- Lib/test/test_sqlite3/test_hooks.py | 124 ++++++++++++++---- ...-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst | 7 +- Modules/_sqlite/connection.c | 25 ++-- Modules/_sqlite/cursor.c | 14 +- 4 files changed, 123 insertions(+), 47 deletions(-) diff --git a/Lib/test/test_sqlite3/test_hooks.py b/Lib/test/test_sqlite3/test_hooks.py index 199f640bd3e0535..7809afe257d59cf 100644 --- a/Lib/test/test_sqlite3/test_hooks.py +++ b/Lib/test/test_sqlite3/test_hooks.py @@ -166,7 +166,7 @@ def setUp(self): super().setUp() self.events = [] def mycoll(x, y): - if x == "boom": + if "boom" in (x, y): self.events.append("collation raised") raise KeyError("collation raised") return (x > y) - (x < y) @@ -178,6 +178,9 @@ def mycoll(x, y): def assertCollationRaises(self): return self.assertRaisesRegex(KeyError, "collation raised") + def events_after_exception(self): + return self.events[self.events.index("collation raised") + 1:] + def test_execute(self): for sql in self.step_fails, self.step_succeeds: with self.subTest(sql=sql), self.assertCollationRaises(): @@ -221,55 +224,118 @@ def test_executescript(self): "no such table"): self.con.execute("select * from u") - def test_converter_not_used(self): + def test_converter_not_called(self): + calls = [] + sqlite.register_converter("COLLTEST", calls.append) + self.addCleanup(sqlite.converters.pop, "COLLTEST", None) with memory_database(detect_types=sqlite.PARSE_COLNAMES) as con: con.create_collation("mycoll", lambda x, y: 1 / 0) with self.assertRaises(ZeroDivisionError): - con.execute("select 'a' = 'b' collate mycoll as \"x [conv]\"") + con.execute(""" + select 'a' = 'b' collate mycoll as "x [colltest]" + """) + self.assertEqual(calls, []) def test_function_not_called(self): - calls = [] - self.con.create_function("f", 1, calls.append) + def f(value): + self.events.append("function") + self.con.create_function("f", 1, f) with self.assertCollationRaises(): self.con.execute("select f(y = 'q' collate mycoll) from t " "order by rowid desc") - self.assertEqual(calls, []) + self.assertEqual(self.events, ["collation raised"]) - def test_aggregate_not_called(self): - calls = [] + def test_aggregate_step_not_called(self): + events = self.events class Agg: def step(self, value): - calls.append(value) + events.append("step") def finalize(self): - return len(calls) + events.append("finalize") self.con.create_aggregate("agg", 1, Agg) with self.assertCollationRaises(): - self.con.execute("select agg(y = 'q' collate mycoll) from t " - "where rowid = 2") - self.assertEqual(calls, []) + self.con.execute("select agg(y = 'q' collate mycoll) from t") + # SQLite calls finalize() to release the aggregate. + self.assertEqual(self.events_after_exception(), ["finalize"]) - def test_window_function_not_called(self): - calls = [] + @with_tracebacks(ZeroDivisionError) + def test_aggregate_finalize_error(self): + class Agg: + def step(self, value): + pass + def finalize(self): + 1 / 0 + self.con.create_aggregate("agg", 1, Agg) + with self.assertCollationRaises(): + self.con.execute("select agg(y = 'q' collate mycoll) from t") + + def test_window_function_value_not_called(self): + events = self.events class Window: def step(self, value): - calls.append("step") + events.append("step") def inverse(self, value): - calls.append("inverse") + events.append("inverse") def value(self): - calls.append("value") + events.append("value") return 0 def finalize(self): return 0 self.con.create_window_function("win", 1, Window) - # SQLite evaluates the filter for all rows before it calls the - # window function. The filter is false for the first row, so the - # first call after the exception is value(). + # For this query, SQLite evaluates the filter for all rows before it + # calls the window function. The filter is false for the first row, + # so the first call after the exception is value(). with self.assertCollationRaises(): self.con.execute(""" select win(x) filter (where y = 'q' collate mycoll) over (order by rowid) from t - """).fetchall() - self.assertEqual(calls, []) + """) + self.assertEqual(self.events_after_exception(), []) + + def test_window_function_inverse_not_called(self): + events = self.events + class Window: + def step(self, value): + events.append("step") + def inverse(self, value): + events.append("inverse") + def value(self): + events.append("value") + return 0 + def finalize(self): + return 0 + self.con.create_window_function("win", 1, Window) + self.con.executemany("insert into t values (?, ?)", + [(3, "c"), (4, "d")]) + self.con.execute("create table u(w, y)") + # SQLite uses the collation when it adds a row to the index. For the + # next row, the first call is inverse(). + self.con.execute("create index u_y on u(y collate mycoll)") + with self.assertCollationRaises(): + self.con.execute(""" + insert into u + select win(x) over (order by rowid + rows between 1 preceding and current row), + y + from t order by rowid + """) + self.assertEqual(self.events_after_exception(), []) + + def test_authorizer_not_called(self): + def authorizer(*args): + self.events.append("authorizer") + return sqlite.SQLITE_OK + # SQLite prepares a statement for pragma_table_info() during the + # step, and it calls the authorizer for that statement. + self.con.executemany("insert into t values (?, ?)", [(3, "c")]) + self.con.set_authorizer(authorizer) + with self.assertCollationRaises(): + self.con.execute(""" + select t.x, p.name from t join pragma_table_info('t') p + where t.y = 'q' collate mycoll + """) + self.con.set_authorizer(None) + self.assertEqual(self.events_after_exception(), []) def test_progress_handler_not_called(self): def progress(): @@ -279,20 +345,22 @@ def progress(): with self.assertCollationRaises(): self.con.execute(self.step_succeeds + " desc") self.con.set_progress_handler(None, 1) - self.assertEqual(self.events[-1], "collation raised") self.assertIn("progress", self.events) + self.assertEqual(self.events_after_exception(), []) def test_trace_callback_not_called(self): self.con.execute("create table u(x)") - self.con.execute("create trigger tr after insert on u " + # SQLite calls the trace callback again for each trigger program. + self.con.execute("create trigger u_tr after insert on u " "begin select 1; end") traced = [] + sql = ("insert into u select y = 'q' collate mycoll from t " + "order by rowid desc") self.con.set_trace_callback(traced.append) with self.assertCollationRaises(): - self.con.execute("insert into u select y = 'q' collate mycoll " - "from t order by rowid desc") + self.con.execute(sql) self.con.set_trace_callback(None) - self.assertNotIn("-- TRIGGER tr", traced) + self.assertEqual(traced, [sql]) class AuthorizerTests(MemoryDatabaseMixin, unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst index 6b545c18478eb18..db6177611598fc6 100644 --- a/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst +++ b/Misc/NEWS.d/next/Library/2026-11-11-21-00-00.gh-issue-161837.Kq3sXv.rst @@ -1,6 +1,7 @@ Fix a :exc:`SystemError` and crashes in :mod:`sqlite3` when a collation -callback raises an exception. Now the exception from the collation always +callback raises an exception. Now the exception from the collation propagates. Before, :meth:`~sqlite3.Cursor.execute` raised :exc:`~sqlite3.OperationalError` instead if the same step also failed. While -the exception is set, the module no longer calls other callbacks, such as -user-defined functions. +the exception is set, the module no longer calls user-defined functions, the +authorizer, the progress handler or the trace callback. It calls only the +``finalize()`` method of an aggregate, to release the aggregate. diff --git a/Modules/_sqlite/connection.c b/Modules/_sqlite/connection.c index 842bd43785c3e0d..74c6e6aa88567a7 100644 --- a/Modules/_sqlite/connection.c +++ b/Modules/_sqlite/connection.c @@ -960,11 +960,12 @@ set_sqlite_error(sqlite3_context *context, const char *msg) print_or_clear_traceback(ctx); } -/* If a collation callback raises, the exception stays set until - * sqlite3_step() returns (see collation_callback()). Other callbacks in the - * same step must not call Python code while the exception is set. Return 1 - * if an exception is set. If context is not NULL, also make the SQL function - * fail, so that SQLite stops the step. */ +/* A callback can leave an exception set until sqlite3_step() returns. For + * example, a collation callback cannot report an error to SQLite (see + * collation_callback()). Other callbacks in the same step must not call + * Python code while the exception is set. Return 1 if an exception is set. + * If context is not NULL, also make the SQL function fail, so that SQLite + * stops the step. */ static int exception_is_pending(sqlite3_context *context) { @@ -1110,7 +1111,6 @@ final_callback(sqlite3_context *context) } if (!ok) { int attr_err = PyErr_ExceptionMatches(PyExc_AttributeError); - _PyErr_ChainExceptions1(exc); /* Note: contrary to the step, value, and inverse callbacks, SQLite * does _not_, as of SQLite 3.38.0, propagate errors to sqlite3_step() @@ -1120,9 +1120,8 @@ final_callback(sqlite3_context *context) ? "user-defined aggregate's 'finalize' method not defined" : "user-defined aggregate's 'finalize' method raised error"); } - else { - PyErr_SetRaisedException(exc); - } + // Set the exception of the earlier callback again, so that it propagates. + PyErr_SetRaisedException(exc); error: PyGILState_Release(threadstate); @@ -1283,6 +1282,10 @@ static void inverse_callback(sqlite3_context *context, int argc, sqlite3_value **params) { PyGILState_STATE gilstate = PyGILState_Ensure(); + if (exception_is_pending(context)) { + PyGILState_Release(gilstate); + return; + } callback_context *ctx = (callback_context *)sqlite3_user_data(context); incref_callback_context(ctx); @@ -1484,6 +1487,10 @@ authorizer_callback(void *ctx_vp, int action, const char *arg1, const char *access_attempt_source) { PyGILState_STATE gilstate = PyGILState_Ensure(); + if (exception_is_pending(NULL)) { + PyGILState_Release(gilstate); + return SQLITE_DENY; + } PyObject *ret; int rc = SQLITE_DENY; diff --git a/Modules/_sqlite/cursor.c b/Modules/_sqlite/cursor.c index c11e11ed58ad6bb..23395bc9ca31d39 100644 --- a/Modules/_sqlite/cursor.c +++ b/Modules/_sqlite/cursor.c @@ -541,10 +541,10 @@ get_statement_from_cache(pysqlite_Cursor *self, PyObject *operation) return PyObject_Vectorcall(cache, args + 1, nargsf, NULL); } -/* A collation callback cannot report an error to SQLite. If it raises, the - * exception stays set until sqlite3_step() returns, and SQLite continues - * the step with the wrong result of the comparison. Thus, after a step, check - * PyErr_Occurred() first, and propagate that exception. */ +/* A callback can leave an exception set until sqlite3_step() returns. For + * example, a collation callback cannot report an error to SQLite, so SQLite + * continues the step with a wrong result of the comparison. Thus, after a + * step, check PyErr_Occurred() first, and propagate that exception. */ static inline int stmt_step(sqlite3_stmt *statement) { @@ -915,7 +915,7 @@ _pysqlite_query_execute(pysqlite_Cursor* self, int multiple, PyObject* operation rc = stmt_step(self->statement->st); if (PyErr_Occurred()) { - /* A collation callback raised. */ + /* A callback left an exception set. */ goto error; } if (rc != SQLITE_DONE && rc != SQLITE_ROW) { @@ -1126,7 +1126,7 @@ pysqlite_cursor_executescript_impl(pysqlite_Cursor *self, Py_END_ALLOW_THREADS if (PyErr_Occurred()) { - /* A collation callback raised. */ + /* A callback left an exception set. */ return NULL; } if (rc != SQLITE_OK) { @@ -1171,7 +1171,7 @@ pysqlite_cursor_iternext(PyObject *op) } int rc = stmt_step(stmt); if (PyErr_Occurred()) { - /* A collation callback raised. */ + /* A callback left an exception set. */ (void)stmt_reset(self->statement); Py_CLEAR(self->statement); Py_DECREF(row); From 3b654643757cde2b76746b841a6404bddcaa90f2 Mon Sep 17 00:00:00 2001 From: Artur Date: Wed, 11 Nov 2026 22:27:09 -0500 Subject: [PATCH 6/6] Check for an exception after the statement is prepared With STAT4, the query planner can call a collation when SQLite prepares a statement. Check PyErr_Occurred() after sqlite3_prepare_v2() and propagate the exception. Add a control to the authorizer test, so that it shows that SQLite calls the authorizer during the step. --- Lib/test/test_sqlite3/test_hooks.py | 54 ++++++++++++++++++++++------- Modules/_sqlite/statement.c | 6 ++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Lib/test/test_sqlite3/test_hooks.py b/Lib/test/test_sqlite3/test_hooks.py index 7809afe257d59cf..fa9e1925e17afd7 100644 --- a/Lib/test/test_sqlite3/test_hooks.py +++ b/Lib/test/test_sqlite3/test_hooks.py @@ -165,15 +165,18 @@ class CollationErrorTests(MemoryDatabaseMixin, unittest.TestCase): def setUp(self): super().setUp() self.events = [] + self.setUp_collation() + self.con.execute("create table t(x, y)") + self.con.executemany("insert into t values (?, ?)", + [(1, "a"), (-2**63, "boom")]) + + def setUp_collation(self): def mycoll(x, y): if "boom" in (x, y): self.events.append("collation raised") raise KeyError("collation raised") return (x > y) - (x < y) self.con.create_collation("mycoll", mycoll) - self.con.execute("create table t(x, y)") - self.con.executemany("insert into t values (?, ?)", - [(1, "a"), (-2**63, "boom")]) def assertCollationRaises(self): return self.assertRaisesRegex(KeyError, "collation raised") @@ -284,7 +287,7 @@ def finalize(self): self.con.create_window_function("win", 1, Window) # For this query, SQLite evaluates the filter for all rows before it # calls the window function. The filter is false for the first row, - # so the first call after the exception is value(). + # so the first call after the exception is value(), before step(). with self.assertCollationRaises(): self.con.execute(""" select win(x) filter (where y = 'q' collate mycoll) @@ -322,21 +325,48 @@ def finalize(self): self.assertEqual(self.events_after_exception(), []) def test_authorizer_not_called(self): - def authorizer(*args): - self.events.append("authorizer") + def authorizer(action, *args): + self.events.append(action) return sqlite.SQLITE_OK - # SQLite prepares a statement for pragma_table_info() during the - # step, and it calls the authorizer for that statement. + sql = """ + select t.x, p.name from t join pragma_table_info('t') p + where t.y = 'q' collate mycoll + """ self.con.executemany("insert into t values (?, ?)", [(3, "c")]) self.con.set_authorizer(authorizer) + # Control: if the comparison is true, SQLite prepares a statement for + # pragma_table_info() during the step, and it calls the authorizer. + self.con.create_collation("mycoll", lambda x, y: 0) + self.con.execute(sql).fetchall() + self.assertIn(sqlite.SQLITE_PRAGMA, self.events) + self.events.clear() + self.setUp_collation() with self.assertCollationRaises(): - self.con.execute(""" - select t.x, p.name from t join pragma_table_info('t') p - where t.y = 'q' collate mycoll - """) + self.con.execute(sql) self.con.set_authorizer(None) self.assertEqual(self.events_after_exception(), []) + def test_prepare(self): + # With STAT4, the query planner calls the collation when SQLite + # prepares the statement. + options = [o for (o,) in self.con.execute("pragma compile_options")] + if "ENABLE_STAT4" not in options: + self.skipTest("requires SQLite with SQLITE_ENABLE_STAT4") + fail = False + def statcoll(x, y): + if fail: + raise KeyError("collation raised") + return (x > y) - (x < y) + self.con.create_collation("statcoll", statcoll) + self.con.execute("create table s(v text collate statcoll)") + self.con.execute("create index s_v on s(v)") + self.con.executemany("insert into s values (?)", + [(str(i % 7),) for i in range(200)]) + self.con.execute("analyze") + fail = True + with self.assertCollationRaises(): + self.con.execute("select * from s where v = '3'") + def test_progress_handler_not_called(self): def progress(): self.events.append("progress") diff --git a/Modules/_sqlite/statement.c b/Modules/_sqlite/statement.c index f31c699482f6014..16aaf0b085a1c48 100644 --- a/Modules/_sqlite/statement.c +++ b/Modules/_sqlite/statement.c @@ -68,6 +68,12 @@ pysqlite_statement_create(pysqlite_Connection *connection, PyObject *sql) rc = sqlite3_prepare_v2(db, sql_cstr, (int)size + 1, &stmt, &tail); Py_END_ALLOW_THREADS + if (PyErr_Occurred()) { + /* A callback left an exception set. For example, the query planner + * can call a collation. */ + (void)sqlite3_finalize(stmt); + return NULL; + } if (rc != SQLITE_OK) { set_error_from_db(state, db); return NULL;