]> git.ipfire.org Git - thirdparty/psycopg.git/commitdiff
fix: discard prepared statement upon ALTER or DISCARD statements 1307/head
authorDaniele Varrazzo <daniele.varrazzo@gmail.com>
Thu, 21 May 2026 16:27:34 +0000 (09:27 -0700)
committerDaniele Varrazzo <daniele.varrazzo@gmail.com>
Thu, 21 May 2026 18:16:01 +0000 (11:16 -0700)
There is still the case that people may issue a DROP or ALTER into a
prepared statement or something else obfuscating the status message, but
at least now an explicit DISCARD can be used as escape hatch for
whatever client/server state inconsistency.

docs/news.rst
psycopg/psycopg/_preparing.py
tests/test_prepared.py
tests/test_prepared_async.py

index ccc29dace659ffebd53ff4e369e40641abf05c3e..75664bf3bd4e1d56d8cae53932ef834f0df402ee 100644 (file)
@@ -7,6 +7,16 @@
 ``psycopg`` release notes
 =========================
 
+Future releases
+---------------
+
+Psycopg 3.3.5 (unreleased)
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+- Discard prepared statements upon :sql:`ALTER *` or `DISCARD *`
+  (:ticket:`#1307`).
+
+
 Current release
 ---------------
 
index 7dbcd66c1e130d5c8b4e9de835d7e6461943d0b4..863188163260c5b05b57755ba52b643c99b1e73b 100644 (file)
@@ -6,6 +6,7 @@ Support for prepared statements
 
 from __future__ import annotations
 
+import re
 from enum import IntEnum, auto
 from typing import TYPE_CHECKING, Any, TypeAlias
 from collections import OrderedDict, deque
@@ -78,7 +79,12 @@ class PrepareManager:
             # The query is not to be prepared yet
             return Prepare.NO, b""
 
-    def _should_discard(self, prep: Prepare, results: Sequence[PGresult]) -> bool:
+    def _should_discard(
+        self,
+        prep: Prepare,
+        results: Sequence[PGresult],
+        __should_clear: Any = re.compile(rb"^(?:DROP|ALTER|ROLLBACK|DISCARD)\b").match,
+    ) -> bool:
         """Check if we need to discard our entire state: it should happen on
         rollback or on dropping objects, because the same object may get
         recreated and postgres would fail internal lookups.
@@ -87,8 +93,7 @@ class PrepareManager:
             for result in results:
                 if result.status != COMMAND_OK:
                     continue
-                cmdstat = result.command_status
-                if cmdstat and (cmdstat.startswith(b"DROP ") or cmdstat == b"ROLLBACK"):
+                if (cmdstat := result.command_status) and __should_clear(cmdstat):
                     return self.clear()
         return False
 
index 20d09e29170539454689238b85e53da241361fd9..3b16780c68ba51ca7f9f3d2eacf4f251f7ca215d 100644 (file)
@@ -9,6 +9,7 @@ import sys
 import logging
 import datetime as dt
 from decimal import Decimal
+from operator import itemgetter
 
 import pytest
 
@@ -303,6 +304,29 @@ def test_change_type_savepoint(conn):
                     raise ZeroDivisionError()
 
 
+def test_alter_table_clears_state(conn):
+    conn.prepare_threshold = 0
+    conn.execute("DROP TABLE IF EXISTS testalter")
+    conn.execute("CREATE TABLE testalter (id serial primary key, data text)")
+    conn.execute("INSERT INTO testalter (data) values (%s)", ["foo"])
+    cur = conn.execute("SELECT * from testalter ORDER BY id")
+    assert list(map(itemgetter(1), cur.fetchall())) == ["foo"]
+    conn.execute("ALTER TABLE testalter ADD data2 text")
+    conn.execute("INSERT INTO testalter (data) values (%s)", ["bar"])
+    cur = conn.execute("SELECT * from testalter ORDER BY id")
+    assert list(map(itemgetter(1), cur.fetchall())) == ["foo", "bar"]
+
+
+def test_discard_clears_state(conn):
+    conn.set_autocommit(True)
+    conn.prepare_threshold = 0
+    conn.execute("DROP TABLE IF EXISTS testdisc")
+    conn.execute("CREATE TABLE testdisc (id serial primary key, data text)")
+    conn.execute("INSERT INTO testdisc (data) values (%s)", ["foo"])
+    conn.execute("DISCARD ALL")
+    conn.execute("INSERT INTO testdisc (data) values (%s)", ["bar"])
+
+
 def get_prepared_statements(conn):
     cur = conn.cursor(row_factory=namedtuple_row)
     # CRDB has 'PREPARE name AS' in the statement.
index fa73735974a24ede3d0649a60b032a120a8a7f77..e010a963a2a46883313f347b652108a8248b43b6 100644 (file)
@@ -6,6 +6,7 @@ import sys
 import logging
 import datetime as dt
 from decimal import Decimal
+from operator import itemgetter
 
 import pytest
 
@@ -306,6 +307,29 @@ async def test_change_type_savepoint(aconn):
                     raise ZeroDivisionError()
 
 
+async def test_alter_table_clears_state(aconn):
+    aconn.prepare_threshold = 0
+    await aconn.execute("DROP TABLE IF EXISTS testalter")
+    await aconn.execute("CREATE TABLE testalter (id serial primary key, data text)")
+    await aconn.execute("INSERT INTO testalter (data) values (%s)", ["foo"])
+    cur = await aconn.execute("SELECT * from testalter ORDER BY id")
+    assert list(map(itemgetter(1), await cur.fetchall())) == ["foo"]
+    await aconn.execute("ALTER TABLE testalter ADD data2 text")
+    await aconn.execute("INSERT INTO testalter (data) values (%s)", ["bar"])
+    cur = await aconn.execute("SELECT * from testalter ORDER BY id")
+    assert list(map(itemgetter(1), await cur.fetchall())) == ["foo", "bar"]
+
+
+async def test_discard_clears_state(aconn):
+    await aconn.set_autocommit(True)
+    aconn.prepare_threshold = 0
+    await aconn.execute("DROP TABLE IF EXISTS testdisc")
+    await aconn.execute("CREATE TABLE testdisc (id serial primary key, data text)")
+    await aconn.execute("INSERT INTO testdisc (data) values (%s)", ["foo"])
+    await aconn.execute("DISCARD ALL")
+    await aconn.execute("INSERT INTO testdisc (data) values (%s)", ["bar"])
+
+
 async def get_prepared_statements(aconn):
     cur = aconn.cursor(row_factory=namedtuple_row)
     # CRDB has 'PREPARE name AS' in the statement.