From: Daniele Varrazzo Date: Sun, 23 Nov 2025 23:49:05 +0000 (+0100) Subject: feat: allow to change loaders in results already returned X-Git-Tag: 3.3.0~11^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=665118299912e7c01a8a91d322b8de52cd366a1b;p=thirdparty%2Fpsycopg.git feat: allow to change loaders in results already returned Close #884 --- diff --git a/docs/advanced/adapt.rst b/docs/advanced/adapt.rst index 5787298d9..2b1e1e70e 100644 --- a/docs/advanced/adapt.rst +++ b/docs/advanced/adapt.rst @@ -62,6 +62,11 @@ returned. affected. For instance, changing the global context will only change newly created connections, not the ones already existing. + .. versionchanged:: 3.3 + + you can call `~AdaptersMap.register_loader()` on `!Cursor.adapters` + after a query has returned a result to change the loaders used. + .. _adapt-life-cycle: diff --git a/docs/api/connections.rst b/docs/api/connections.rst index 951a6202d..30eb18888 100644 --- a/docs/api/connections.rst +++ b/docs/api/connections.rst @@ -68,6 +68,14 @@ The `!Connection` class .. versionchanged:: 3.1 added `!prepare_threshold` and `!cursor_factory` parameters. + .. attribute:: adapters + :type: ~adapt.AdaptersMap + + The adapters configuration used to convert Python parameters and + PostgreSQL results for the queries executed on this cursor. + + It affects all the cursors created by this connection afterwards. + .. automethod:: close .. note:: diff --git a/docs/api/cursors.rst b/docs/api/cursors.rst index 546a4fcd0..1458dd458 100644 --- a/docs/api/cursors.rst +++ b/docs/api/cursors.rst @@ -43,6 +43,17 @@ The `!Cursor` class The connection this cursor is using. + .. attribute:: adapters + :type: ~adapt.AdaptersMap + + The adapters configuration used to convert Python parameters and + PostgreSQL results for the queries executed on this cursor. + + .. versionchanged:: 3.3 + + reconfiguring loaders using `~adapt.AdaptersMap.register_loader()` + affects the results of a query already executed. + .. automethod:: close .. note:: diff --git a/docs/news.rst b/docs/news.rst index 44b0052f5..8136ca051 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -26,6 +26,9 @@ Psycopg 3.3.0 (unreleased) `~Cursor.execute()` with multiple statements (:tickets:`#1080, #1170`). - Add :ref:`transaction-status` to report the status during and after a `~Connection.transaction()` block (:ticket:`#969`). +- Allow to change loaders using `~adapt.AdaptersMap.register_loader()` on + `Cursor.adapters` after a query result has been already returned + (:ticket:`#884`). .. rubric:: New libpq wrapper features diff --git a/psycopg/psycopg/_adapters_map.py b/psycopg/psycopg/_adapters_map.py index 6c72f31e6..da19cbacc 100644 --- a/psycopg/psycopg/_adapters_map.py +++ b/psycopg/psycopg/_adapters_map.py @@ -7,6 +7,7 @@ Mapping from types/oids to Dumpers/Loaders from __future__ import annotations from typing import TYPE_CHECKING, Any, cast +from collections.abc import Callable from . import errors as e from . import pq @@ -68,6 +69,9 @@ class AdaptersMap: # Record if a dumper or loader has an optimised version. _optimised: dict[type, type] = {} + # Callable to be called when register_loader() is called. + _register_loader_callback: Callable[[int, type[Loader]], None] | None = None + def __init__( self, template: AdaptersMap | None = None, types: TypesRegistry | None = None ): @@ -182,6 +186,8 @@ class AdaptersMap: self._own_loaders[fmt] = True self._loaders[fmt][oid] = loader + if self._register_loader_callback: + self._register_loader_callback(oid, loader) def get_dumper(self, cls: type, format: PyFormat) -> type[Dumper]: """ diff --git a/psycopg/psycopg/_cursor_base.py b/psycopg/psycopg/_cursor_base.py index 31bb75122..31eb6981c 100644 --- a/psycopg/psycopg/_cursor_base.py +++ b/psycopg/psycopg/_cursor_base.py @@ -7,13 +7,14 @@ Psycopg BaseCursor object from __future__ import annotations from typing import TYPE_CHECKING, Any, Generic, NoReturn +from weakref import ReferenceType, ref from functools import partial from collections.abc import Iterable, Sequence from . import adapt from . import errors as e from . import pq -from .abc import ConnectionType, Params, PQGen, Query +from .abc import ConnectionType, Loader, Params, PQGen, Query from .rows import Row, RowMaker from ._column import Column from ._compat import Template @@ -67,6 +68,11 @@ class BaseCursor(Generic[ConnectionType, Row]): self._last_query: Query | None = None self._reset() + # Set up a callback to allow changing loaders on already returned result. + self._adapters._register_loader_callback = partial( + self._loaders_changed, ref(self) + ) + def _reset(self, reset_query: bool = True) -> None: self._results: list[PGresult] = [] self.pgresult: PGresult | None = None @@ -533,6 +539,24 @@ class BaseCursor(Generic[ConnectionType, Row]): for res in results: self._rowcount += res.command_tuples or 0 + @classmethod + def _loaders_changed( + cls, wself: ReferenceType[BaseCursor[Any, Any]], oid: int, loader: type[Loader] + ) -> None: + """Callback called when self.adapters.set_loaders is called. + + Allow to change the loaders after the results have been returned already. + """ + if not (self := wself()): + return + + # Replace the transformer with a new one, restore the current state. + self._tx = adapt.Transformer(self) + pos = self._pos + if self._iresult < len(self._results): + self._select_current_result(self._iresult) + self._pos = pos + def _send_prepare(self, name: bytes, query: PostgresQuery) -> None: if self._conn._pipeline: self._conn._pipeline.command_queue.append( diff --git a/psycopg/psycopg/abc.py b/psycopg/psycopg/abc.py index 5fdd1791c..a73932f5a 100644 --- a/psycopg/psycopg/abc.py +++ b/psycopg/psycopg/abc.py @@ -70,7 +70,7 @@ class AdaptContext(Protocol): """ A context describing how types are adapted. - Example of `~AdaptContext` are `~psycopg.Connection`, `~psycopg.Cursor`, + Example of `!AdaptContext` are `~psycopg.Connection`, `~psycopg.Cursor`, `~psycopg.adapt.Transformer`, `~psycopg.adapt.AdaptersMap`. Note that this is a `~typing.Protocol`, so objects implementing diff --git a/tests/test_cursor_common.py b/tests/test_cursor_common.py index c59650933..9b5976615 100644 --- a/tests/test_cursor_common.py +++ b/tests/test_cursor_common.py @@ -22,6 +22,7 @@ from . import _test_cursor from .utils import raiseif from .acompat import gather, spawn from .fix_crdb import crdb_encoding +from .test_adapt import make_loader from ._test_cursor import my_row_factory, ph execmany = _test_cursor.execmany # avoid F811 underneath @@ -974,3 +975,35 @@ def test_results_after_executemany(conn, count, returning): assert ress == [[(j + 1,) for j in range(i)] for i in range(count)] else: assert ress == [] + + +def test_change_loader_results(conn): + cur = conn.cursor() + # With no result + cur.adapters.register_loader("text", make_loader("1")) + + cur.execute( + """ + values ('foo'::text); + values ('bar'::text), ('baz'); + values ('qux'::text); + """ + ) + assert cur.fetchall() == [("foo1",)] + + cur.nextset() + assert cur.fetchone() == ("bar1",) + cur.adapters.register_loader("text", make_loader("2")) + assert cur.fetchone() == ("baz2",) + cur.scroll(-2) + assert cur.fetchall() == [("bar2",), ("baz2",)] + + cur.nextset() + assert cur.fetchall() == [("qux2",)] + + # After the final result + assert not cur.nextset() + cur.adapters.register_loader("text", make_loader("3")) + assert cur.fetchone() is None + cur.set_result(0) + assert cur.fetchall() == [("foo3",)] diff --git a/tests/test_cursor_common_async.py b/tests/test_cursor_common_async.py index c565ab712..76974bad7 100644 --- a/tests/test_cursor_common_async.py +++ b/tests/test_cursor_common_async.py @@ -19,6 +19,7 @@ from . import _test_cursor from .utils import raiseif from .acompat import alist, gather, spawn from .fix_crdb import crdb_encoding +from .test_adapt import make_loader from ._test_cursor import my_row_factory, ph execmany = _test_cursor.execmany # avoid F811 underneath @@ -982,3 +983,35 @@ async def test_results_after_executemany(aconn, count, returning): assert ress == [[(j + 1,) for j in range(i)] for i in range(count)] else: assert ress == [] + + +async def test_change_loader_results(aconn): + cur = aconn.cursor() + # With no result + cur.adapters.register_loader("text", make_loader("1")) + + await cur.execute( + """ + values ('foo'::text); + values ('bar'::text), ('baz'); + values ('qux'::text); + """ + ) + assert (await cur.fetchall()) == [("foo1",)] + + cur.nextset() + assert (await cur.fetchone()) == ("bar1",) + cur.adapters.register_loader("text", make_loader("2")) + assert (await cur.fetchone()) == ("baz2",) + await cur.scroll(-2) + assert (await cur.fetchall()) == [("bar2",), ("baz2",)] + + cur.nextset() + assert (await cur.fetchall()) == [("qux2",)] + + # After the final result + assert not cur.nextset() + cur.adapters.register_loader("text", make_loader("3")) + assert (await cur.fetchone()) is None + await cur.set_result(0) + assert (await cur.fetchall()) == [("foo3",)]