From 4dc3f7717835b74ccc4cab0e2b2eb2f227995be8 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Tue, 25 Aug 2026 10:56:30 +0100 Subject: [PATCH] fix: stop AsyncDictServerCursor forwarding prepare to the server cursor psycopg's AsyncServerCursor.execute() has never accepted a prepare kwarg (a server-side DECLARE CURSOR can't be a prepared statement) and raises TypeError on any unexpected keyword, even one whose value is None. PR #10030 widened AsyncDictCursor.execute()/_execute() to forward prepare/binary to whatever cursor it holds, fixing a real psycopg_pool.ConnectionPool.check_connection breakage. AsyncDictServerCursor inherits that same execute()/_execute() without overriding it, so every server-cursor query now unconditionally forwards prepare=None straight into AsyncServerCursor.execute() and fails with "TypeError: keyword not supported: prepare". That TypeError isn't a psycopg.Error, so execute_async()'s "except psycopg.Error" doesn't catch it. It propagates into the background QueryThread's generic exception handler, which logs it and builds an internal_server_error response that is discarded (the thread's return value goes nowhere) - so the query silently never runs, invisible to the user, and the async cursor is left with no query having actually executed on it. Give AsyncDictServerCursor its own _execute() that drops prepare before delegating, leaving DictCursor/AsyncDictCursor's forwarding for the pool-checkout case it was written for untouched. Strengthen test_server_cursor.py's existing scenario to assert the poll response's actual status/result instead of only the HTTP status code and the echoed server_cursor flag, and add a fast, DB-less regression test asserting AsyncDictServerCursor._execute never forwards prepare. --- .../sqleditor/tests/test_server_cursor.py | 5 +++ web/pgadmin/utils/driver/psycopg3/cursor.py | 16 ++++++++ .../tests/test_psycopg3_cursor_signature.py | 40 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/web/pgadmin/tools/sqleditor/tests/test_server_cursor.py b/web/pgadmin/tools/sqleditor/tests/test_server_cursor.py index 8144a5350f0..b5ff2ad8d40 100644 --- a/web/pgadmin/tools/sqleditor/tests/test_server_cursor.py +++ b/web/pgadmin/tools/sqleditor/tests/test_server_cursor.py @@ -89,6 +89,11 @@ def runTest(self): self.assertEqual(response.status_code, 200) _resp = json.loads(response.data.decode()) self.assertTrue(_resp['data']['server_cursor']) + # The query must actually have executed under the server cursor, + # not merely echoed the server_cursor flag back. + self.assertEqual(_resp['data']['status'], 'Success') + self.assertEqual(len(_resp['data']['result']), 1) + self.assertEqual(_resp['data']['result'][0][0], 1) self.set_server_cursor(False) diff --git a/web/pgadmin/utils/driver/psycopg3/cursor.py b/web/pgadmin/utils/driver/psycopg3/cursor.py index 9fe2f95a842..ae97597ed43 100644 --- a/web/pgadmin/utils/driver/psycopg3/cursor.py +++ b/web/pgadmin/utils/driver/psycopg3/cursor.py @@ -418,5 +418,21 @@ def __init__(self, *args, name=None, **kwargs): _async_server_cursor.__init__(self, name=name, *args, **kwargs) self.cursor = _async_server_cursor + async def _execute(self, query, params=None, *, + prepare=None, binary=None): + """ + Execute function + + Unlike ``AsyncDictCursor``, this does not forward ``prepare`` to + the underlying cursor: ``psycopg``'s ``AsyncServerCursor.execute`` + never accepts it (a server-side ``DECLARE CURSOR`` can't be a + prepared statement) and raises ``TypeError`` on any unexpected + keyword, even one whose value is ``None``. + """ + if params is not None and len(params) == 0: + params = None + + return await self.cursor.execute(self, query, params, binary=binary) + def get_rowcount(self): return 1 diff --git a/web/pgadmin/utils/tests/test_psycopg3_cursor_signature.py b/web/pgadmin/utils/tests/test_psycopg3_cursor_signature.py index a8a2a83aae8..989626ee572 100644 --- a/web/pgadmin/utils/tests/test_psycopg3_cursor_signature.py +++ b/web/pgadmin/utils/tests/test_psycopg3_cursor_signature.py @@ -24,9 +24,11 @@ here for full ``psycopg.Cursor`` signature parity. """ +import asyncio import inspect -from pgadmin.utils.driver.psycopg3.cursor import AsyncDictCursor, DictCursor +from pgadmin.utils.driver.psycopg3.cursor import AsyncDictCursor, \ + AsyncDictServerCursor, DictCursor from pgadmin.utils.route import BaseTestGenerator @@ -49,3 +51,39 @@ def runTest(self): inspect.Parameter.KEYWORD_ONLY) self.assertEqual(params['binary'].kind, inspect.Parameter.KEYWORD_ONLY) + + +class TestAsyncDictServerCursorDropsPrepare(BaseTestGenerator): + """ + ``AsyncDictServerCursor`` accepts ``prepare`` too (it inherits + ``AsyncDictCursor.execute`` for ``psycopg.AsyncCursor`` substitutability), + but must NOT forward it any further: the underlying + ``psycopg.AsyncServerCursor.execute`` never accepts ``prepare`` (a + server-side ``DECLARE CURSOR`` can't be a prepared statement) and raises + ``TypeError`` on any unexpected keyword, even one whose value is + ``None``. Without this, every server-cursor query fails with + ``TypeError: keyword not supported: prepare``. + """ + + def runTest(self): + captured = {} + + async def fake_execute(_self, query, params, **kwargs): + captured['query'] = query + captured['params'] = params + captured.update(kwargs) + return _self + + fake_underlying_cursor = type( + 'FakeServerCursor', (), {'execute': fake_execute}) + + cur = AsyncDictServerCursor.__new__(AsyncDictServerCursor) + cur.cursor = fake_underlying_cursor + + asyncio.run( + cur._execute('SELECT 1', None, prepare=None, binary=None) + ) + + self.assertNotIn('prepare', captured) + self.assertIn('binary', captured) + self.assertEqual(captured['query'], 'SELECT 1')