From ac5b31114d18f0e35435838430089cd203ae97f4 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Sat, 19 Sep 2026 10:26:02 -0700 Subject: [PATCH] Reparse unnamed statements before cursor binding With `statement_cache_size=0`, type introspection can replace the unnamed statement created by `prepare()`. The statement is marked unprepared, but awaiting `PreparedStatement.cursor()` binds it without reparsing, so the server receives arguments for the introspection query instead. Make `bind()` honor the unprepared flag, as `bind_execute()` already does to fix this. Fixes #1335. Closes #1345. --- asyncpg/protocol/coreproto.pyx | 4 ++++ asyncpg/protocol/protocol.pyx | 3 +++ tests/test_cursor.py | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/asyncpg/protocol/coreproto.pyx b/asyncpg/protocol/coreproto.pyx index da96c412..eae753df 100644 --- a/asyncpg/protocol/coreproto.pyx +++ b/asyncpg/protocol/coreproto.pyx @@ -313,6 +313,10 @@ cdef class CoreProtocol: # ErrorResponse self._parse_msg_error_response(True) + elif mtype == b'1': + # ParseComplete, in case `_bind()` is reparsing + self.buffer.discard_message() + elif mtype == b'2': # BindComplete self.buffer.discard_message() diff --git a/asyncpg/protocol/protocol.pyx b/asyncpg/protocol/protocol.pyx index acce4e9f..91735c87 100644 --- a/asyncpg/protocol/protocol.pyx +++ b/asyncpg/protocol/protocol.pyx @@ -280,6 +280,9 @@ cdef class BaseProtocol(CoreProtocol): waiter = self._new_waiter(timeout) try: + if not state.prepared: + self._send_parse_message(state.name, state.query) + self._bind( portal_name, state.name, diff --git a/tests/test_cursor.py b/tests/test_cursor.py index ad446bc3..ebeaef93 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -159,3 +159,22 @@ async def test_cursor_04(self): st = await self.con.cursor('SELECT generate_series(0, 100)') await st.forward(42) self.assertEqual(await st.fetchrow(), (42,)) + + @tb.with_connection_options(statement_cache_size=0) + async def test_cursor_05_unnamed_statement_reparsed(self): + await self.con.execute( + "CREATE TYPE cursor_05_t AS ENUM ('foo', 'bar')" + ) + try: + async with self.con.transaction(): + # Enum introspection replaces the unnamed statement on the + # server, so opening the cursor must re-parse it first. + st = await self.con.prepare(''' + SELECT $1::int, $2::int, 'foo'::cursor_05_t + ''') + self.assertEqual(st.get_name(), '') + + cur = await st.cursor(1, 2) + self.assertEqual(await cur.fetch(1), [(1, 2, 'foo')]) + finally: + await self.con.execute('DROP TYPE cursor_05_t')