From 1db48b5832b28ac0d0c20bcf666817db6f270762 Mon Sep 17 00:00:00 2001 From: Solaris-star <820622658@qq.com> Date: Tue, 21 Jul 2026 13:11:24 +0800 Subject: [PATCH] fix: re-Parse unprepared statements in protocol.bind for cursors When statement_cache_size=0, prepare(name=None) uses the unnamed statement "". Type introspection also uses "", which clobbers the user statement and marks it unprepared. bind_execute already re-Parses when state.prepared is False, but cursor.bind did not. Opening a server-side cursor after prepare then failed with ProtocolViolationError about mismatched bind parameters. Re-Parse before Bind in protocol.bind, and accept ParseComplete in _process__bind, matching bind_execute. Fixes #1335 --- asyncpg/protocol/coreproto.pyx | 4 ++++ asyncpg/protocol/protocol.pyx | 6 ++++++ tests/test_prepare.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 42 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..94a2077b 100644 --- a/asyncpg/protocol/protocol.pyx +++ b/asyncpg/protocol/protocol.pyx @@ -280,6 +280,12 @@ cdef class BaseProtocol(CoreProtocol): waiter = self._new_waiter(timeout) try: + # Same as bind_execute: anonymous statements may have been + # clobbered by type introspection (statement_cache_size=0) and + # marked unprepared. Re-Parse before Bind (#1335). + if not state.prepared: + self._send_parse_message(state.name, state.query) + self._bind( portal_name, state.name, diff --git a/tests/test_prepare.py b/tests/test_prepare.py index 661021bd..c56ed2ed 100644 --- a/tests/test_prepare.py +++ b/tests/test_prepare.py @@ -430,6 +430,38 @@ async def test_prepare_statement_invalid(self): finally: await self.con.execute('DROP TABLE tab1') + @tb.with_connection_options(statement_cache_size=0) + async def test_prepare_statement_cache_0_cursor_with_enum(self): + # Regression: prepare(name=None) uses the unnamed statement when the + # cache is disabled. Type introspection also uses "", which clobbers + # the user statement. Cursor.bind must re-Parse (like bind_execute) + # or the next bind fails with ProtocolViolationError (#1335). + await self.con.execute( + """ + DROP TYPE IF EXISTS asyncpg_test_enum CASCADE; + CREATE TYPE asyncpg_test_enum AS ENUM ('a', 'b'); + CREATE TEMP TABLE asyncpg_test_enum_tab ( + id int, + val asyncpg_test_enum + ); + INSERT INTO asyncpg_test_enum_tab VALUES (1, 'a'), (2, 'b'); + """ + ) + try: + ps = await self.con.prepare( + 'SELECT id, val FROM asyncpg_test_enum_tab WHERE id = $1' + ) + self.assertEqual(ps.get_name(), '') + async with self.con.transaction(): + cur = await ps.cursor(1) + row = await cur.fetchrow() + self.assertEqual(tuple(row), (1, 'a')) + finally: + await self.con.execute( + 'DROP TABLE IF EXISTS asyncpg_test_enum_tab; ' + 'DROP TYPE IF EXISTS asyncpg_test_enum CASCADE;' + ) + @tb.with_connection_options(statement_cache_size=0) async def test_prepare_23_no_stmt_cache_seq(self): self.assertEqual(self.con._stmt_cache.get_max_size(), 0)