]> git.ipfire.org Git - thirdparty/psycopg.git/commitdiff
feat: allow to change loaders in results already returned 1218/head
authorDaniele Varrazzo <daniele.varrazzo@gmail.com>
Sun, 23 Nov 2025 23:49:05 +0000 (00:49 +0100)
committerDaniele Varrazzo <daniele.varrazzo@gmail.com>
Mon, 24 Nov 2025 15:30:28 +0000 (16:30 +0100)
Close #884

docs/advanced/adapt.rst
docs/api/connections.rst
docs/api/cursors.rst
docs/news.rst
psycopg/psycopg/_adapters_map.py
psycopg/psycopg/_cursor_base.py
psycopg/psycopg/abc.py
tests/test_cursor_common.py
tests/test_cursor_common_async.py

index 5787298d9b7083b55e3246bf25f8cabe1f10641c..2b1e1e70e83a4a9ca5adfa1b71ab79f26f2d7938 100644 (file)
@@ -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:
 
index 951a6202d6376a58a90e2fbdcf1592438aa2690b..30eb18888896ce2b5fee972bdb033ea2af448664 100644 (file)
@@ -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::
index 546a4fcd097c2c7e98f0e412c3394bd8338fbb73..1458dd458ec63bb2448141ba89a571c35e848746 100644 (file)
@@ -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::
index 44b0052f53913fb9f9a9798a7dd311cbdafd2695..8136ca051ce1391c35d707c13ebc5d0a86a4b574 100644 (file)
@@ -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
 
index 6c72f31e63501eccc6577b3455e07052deb635f8..da19cbacc897044def442ca23dc41e6235b8cdb8 100644 (file)
@@ -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]:
         """
index 31bb751220d93979c9881dab8d4312d1d71c1c39..31eb6981c891c407f365fdf646c1de3546ee7c67 100644 (file)
@@ -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(
index 5fdd1791c15f69dfcbb24a3043506e14247e3371..a73932f5a65a4936ff5c4caf29c3d36c7a4b2884 100644 (file)
@@ -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
index c59650933f6cab646d6168dfd64f418fb5053ab9..9b597661599e287774a362dc416e0df7dd15a9fd 100644 (file)
@@ -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",)]
index c565ab712474b3da07f931fa151896970def5fa8..76974bad72d197218b0a11da01eb5704d95b684f 100644 (file)
@@ -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",)]