From: Daniele Varrazzo Date: Mon, 18 Dec 2023 01:43:18 +0000 (+0100) Subject: feat: add RawServerCursor class X-Git-Tag: 3.2.0~8^2 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=refs%2Fpull%2F839%2Fhead;p=thirdparty%2Fpsycopg.git feat: add RawServerCursor class --- diff --git a/docs/advanced/cursors.rst b/docs/advanced/cursors.rst index 40e4a4222..3b48454fa 100644 --- a/docs/advanced/cursors.rst +++ b/docs/advanced/cursors.rst @@ -42,6 +42,7 @@ Class Binding Storage Placeholders See also `ClientCursor` cient-side client-side ``%s``, ``%(name)s`` :ref:`client-side-binding-cursors` `ServerCursor` server-side server-side ``%s``, ``%(name)s`` :ref:`server-side-cursors` `RawCursor` server-side client-side ``$1`` :ref:`raw-query-cursors` +`RawServerCursor` server-side server-side ``$1`` :ref:`raw-query-cursors` ================= =========== =========== ==================== ================================== If not specified by a `~Connection.cursor_factory`, `~Connection.cursor()` @@ -292,6 +293,10 @@ One important note is that raw query cursors only accept positional arguments in the form of a list or tuple. This means you cannot use named arguments (i.e., dictionaries). +`!RawCursor` behaves like `Cursor`, in returning the complete result from the +server to the client. The `RawServerCursor` and `AsyncRawServerCursor` +implement :ref:`server-side-cursors` with raw PostgreSQL placeholders. + There are two ways to use raw query cursors: 1. Using the cursor factory: diff --git a/docs/api/cursors.rst b/docs/api/cursors.rst index 1d0ae445d..64af11e4f 100644 --- a/docs/api/cursors.rst +++ b/docs/api/cursors.rst @@ -323,20 +323,6 @@ The `!Cursor` class is text or binary. -The `!RawCursor` class ----------------------- - -.. seealso:: See :ref:`raw-query-cursors` for details. - -.. autoclass:: RawCursor - - This `Cursor` subclass has the same interface of the parent class but - supports placeholders in PostgreSQL format (``$1``, ``$2``...) rather than - in Python format (``%s``). Only positional parameters are supported. - - .. versionadded:: 3.2 - - The `!ClientCursor` class ------------------------- @@ -467,6 +453,30 @@ The `!ServerCursor` class .. _MOVE: https://www.postgresql.org/docs/current/sql-fetch.html +The `!RawCursor` and `!RawServerCursor` class +--------------------------------------------- + +.. seealso:: See :ref:`raw-query-cursors` for details. + +.. autoclass:: RawCursor + + This `Cursor` subclass has the same interface of the parent class but + supports placeholders in PostgreSQL format (``$1``, ``$2``...) rather than + in Python format (``%s``). Only positional parameters are supported. + + .. versionadded:: 3.2 + + +.. autoclass:: RawServerCursor + + This `ServerCursor` subclass has the same interface of the parent class but + supports placeholders in PostgreSQL format (``$1``, ``$2``...) rather than + in Python format (``%s``). Only positional parameters are supported. + + .. versionadded:: 3.2 + + + Async cursor classes -------------------- @@ -534,14 +544,6 @@ semantic with an `!async` interface. The main interface is described in -.. autoclass:: AsyncRawCursor - - This class is the `!async` equivalent of `RawCursor`. The differences - w.r.t. the sync counterpart are the same described in `AsyncCursor`. - - .. versionadded:: 3.2 - - .. autoclass:: AsyncClientCursor This class is the `!async` equivalent of `ClientCursor`. The differences @@ -582,3 +584,19 @@ semantic with an `!async` interface. The main interface is described in ... .. automethod:: scroll + + +.. autoclass:: AsyncRawCursor + + This class is the `!async` equivalent of `RawCursor`. The differences + w.r.t. the sync counterpart are the same described in `AsyncCursor`. + + .. versionadded:: 3.2 + + +.. autoclass:: AsyncRawServerCursor + + This class is the `!async` equivalent of `RawServerCursor`. The differences + w.r.t. the sync counterpart are the same described in `AsyncServerCursor`. + + .. versionadded:: 3.2 diff --git a/docs/news.rst b/docs/news.rst index 87649946d..c7f81f975 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -19,7 +19,7 @@ Psycopg 3.2 (unreleased) (:ticket:`340`). - Allow dumpers to return `!None`, to be converted to NULL (:ticket:`#377`). - Add :ref:`raw-query-cursors` to execute queries using placeholders in - PostgreSQL format (`$1`, `$2`...) (:ticket:`#560`). + PostgreSQL format (`$1`, `$2`...) (:tickets:`#560, #839`). - Add `psycopg.capabilities` object to :ref:`inspect the libpq capabilities ` (:ticket:`#772`). - Add `~rows.scalar_row` to return scalar values from a query (:ticket:`#723`). diff --git a/psycopg/psycopg/__init__.py b/psycopg/psycopg/__init__.py index 6d8661aab..71e70a65a 100644 --- a/psycopg/psycopg/__init__.py +++ b/psycopg/psycopg/__init__.py @@ -25,6 +25,7 @@ from ._capabilities import Capabilities, capabilities from .server_cursor import AsyncServerCursor, ServerCursor from .client_cursor import AsyncClientCursor, ClientCursor from .raw_cursor import AsyncRawCursor, RawCursor +from .raw_cursor import AsyncRawServerCursor, RawServerCursor from ._connection_base import BaseConnection, Notify from ._connection_info import ConnectionInfo from .connection_async import AsyncConnection @@ -68,6 +69,7 @@ __all__ = [ "AsyncCursor", "AsyncPipeline", "AsyncRawCursor", + "AsyncRawServerCursor", "AsyncServerCursor", "AsyncTransaction", "BaseConnection", @@ -83,6 +85,7 @@ __all__ = [ "Notify", "Pipeline", "RawCursor", + "RawServerCursor", "Rollback", "ServerCursor", "Transaction", diff --git a/psycopg/psycopg/raw_cursor.py b/psycopg/psycopg/raw_cursor.py index 03c1903c0..41486a2c2 100644 --- a/psycopg/psycopg/raw_cursor.py +++ b/psycopg/psycopg/raw_cursor.py @@ -13,6 +13,7 @@ from .rows import Row from ._enums import PyFormat from .cursor import Cursor from .cursor_async import AsyncCursor +from .server_cursor import ServerCursor, AsyncServerCursor from ._queries import PostgresQuery from ._cursor_base import BaseCursor @@ -60,3 +61,13 @@ class RawCursor(RawCursorMixin["Connection[Any]", Row], Cursor[Row]): class AsyncRawCursor(RawCursorMixin["AsyncConnection[Any]", Row], AsyncCursor[Row]): __module__ = "psycopg" + + +class RawServerCursor(RawCursorMixin["Connection[Any]", Row], ServerCursor[Row]): + __module__ = "psycopg" + + +class AsyncRawServerCursor( + RawCursorMixin["AsyncConnection[Any]", Row], AsyncServerCursor[Row] +): + __module__ = "psycopg" diff --git a/tests/_test_cursor.py b/tests/_test_cursor.py index 95671d2bb..f2f643e51 100644 --- a/tests/_test_cursor.py +++ b/tests/_test_cursor.py @@ -31,7 +31,9 @@ def execmany(svcconn, _execmany): def ph(cur: Any, query: str) -> str: """Change placeholders in a query from %s to $n if testing a raw cursor""" - if not isinstance(cur, (psycopg.RawCursor, psycopg.AsyncRawCursor)): + from psycopg.raw_cursor import RawCursorMixin + + if not isinstance(cur, RawCursorMixin): return query if "%(" in query: diff --git a/tests/test_cursor_server.py b/tests/test_cursor_server.py index e237d261e..bf990a030 100644 --- a/tests/test_cursor_server.py +++ b/tests/test_cursor_server.py @@ -2,13 +2,26 @@ # from the original file 'test_cursor_server_async.py' # DO NOT CHANGE! Change the original file instead. import pytest +from packaging.version import parse as ver import psycopg from psycopg import pq, rows, errors as e +from ._test_cursor import ph pytestmark = pytest.mark.crdb_skip("server-side cursor") +cursor_classes = [psycopg.ServerCursor] +# Allow to import (not necessarily to run) the module with psycopg 3.1. +if ver(psycopg.__version__) >= ver("3.2.0.dev0"): + cursor_classes.append(psycopg.RawServerCursor) + + +@pytest.fixture(params=cursor_classes) +def conn(conn, request, anyio_backend): + conn.server_cursor_factory = request.param + return conn + def test_init_row_factory(conn): with psycopg.ServerCursor(conn, "foo") as cur: @@ -48,7 +61,7 @@ def test_funny_name(conn): def test_repr(conn): cur = conn.cursor("my-name") - assert "psycopg.%s" % psycopg.ServerCursor.__name__ in str(cur) + assert "psycopg.%s" % conn.server_cursor_factory.__name__ in str(cur) assert "my-name" in repr(cur) cur.close() @@ -83,7 +96,7 @@ def test_format(conn): def test_query_params(conn): with conn.cursor("foo") as cur: assert cur._query is None - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert cur._query is not None assert b"declare" in cur._query.query.lower() assert b"(1, $1)" in cur._query.query.lower() @@ -250,7 +263,7 @@ def test_context(conn, recwarn): def test_close_no_clobber(conn): with pytest.raises(e.DivisionByZero): with conn.cursor("foo") as cur: - cur.execute("select 1 / %s", (0,)) + cur.execute(ph(cur, "select 1 / %s"), (0,)) cur.fetchall() @@ -265,10 +278,12 @@ def test_warn_close(conn, recwarn, gc_collect): def test_execute_reuse(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as foo", (3,)) + query = ph(cur, "select generate_series(1, %s) as foo") + cur.execute(query, (3,)) assert cur.fetchone() == (1,) - cur.execute("select %s::text as bar, %s::text as baz", ("hello", "world")) + query = ph(cur, "select %s::text as bar, %s::text as baz") + cur.execute(query, ("hello", "world")) assert cur.fetchone() == ("hello", "world") assert cur.description[0].name == "bar" assert cur.description[0].type_code == cur.adapters.types["text"].oid @@ -288,13 +303,13 @@ def test_execute_error(conn, stmt): def test_executemany(conn): cur = conn.cursor("foo") with pytest.raises(e.NotSupportedError): - cur.executemany("select %s", [(1,), (2,)]) + cur.executemany(ph(cur, "select %s"), [(1,), (2,)]) cur.close() def test_fetchone(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (2,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (2,)) assert cur.fetchone() == (1,) assert cur.fetchone() == (2,) assert cur.fetchone() is None @@ -302,7 +317,7 @@ def test_fetchone(conn): def test_fetchmany(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (5,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (5,)) assert cur.fetchmany(3) == [(1,), (2,), (3,)] assert cur.fetchone() == (4,) assert cur.fetchmany(3) == [(5,)] @@ -311,12 +326,12 @@ def test_fetchmany(conn): def test_fetchall(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert cur.fetchall() == [(1,), (2,), (3,)] assert cur.fetchall() == [] with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert cur.fetchone() == (1,) assert cur.fetchall() == [(2,), (3,)] assert cur.fetchall() == [] @@ -324,13 +339,14 @@ def test_fetchall(conn): def test_nextset(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert not cur.nextset() def test_no_result(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar where false", (3,)) + query = ph(cur, "select generate_series(1, %s) as bar where false") + cur.execute(query, (3,)) assert len(cur.description) == 1 assert cur.fetchall() == [] @@ -400,12 +416,12 @@ def test_rownumber(conn): def test_iter(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) recs = list(cur) assert recs == [(1,), (2,), (3,)] with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert cur.fetchone() == (1,) recs = list(cur) assert recs == [(2,), (3,)] @@ -413,7 +429,7 @@ def test_iter(conn): def test_iter_rownumber(conn): with conn.cursor("foo") as cur: - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) for row in cur: assert cur.rownumber == row[0] @@ -422,7 +438,7 @@ def test_itersize(conn, commands): with conn.cursor("foo") as cur: assert cur.itersize == 100 cur.itersize = 2 - cur.execute("select generate_series(1, %s) as bar", (3,)) + cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) commands.popall() # flush begin and other noise list(cur) diff --git a/tests/test_cursor_server_async.py b/tests/test_cursor_server_async.py index 16cb3f2da..348dc2f66 100644 --- a/tests/test_cursor_server_async.py +++ b/tests/test_cursor_server_async.py @@ -1,13 +1,26 @@ import pytest +from packaging.version import parse as ver import psycopg from psycopg import pq, rows, errors as e from .acompat import alist +from ._test_cursor import ph pytestmark = pytest.mark.crdb_skip("server-side cursor") +cursor_classes = [psycopg.AsyncServerCursor] +# Allow to import (not necessarily to run) the module with psycopg 3.1. +if ver(psycopg.__version__) >= ver("3.2.0.dev0"): + cursor_classes.append(psycopg.AsyncRawServerCursor) + + +@pytest.fixture(params=cursor_classes) +async def aconn(aconn, request, anyio_backend): + aconn.server_cursor_factory = request.param + return aconn + async def test_init_row_factory(aconn): async with psycopg.AsyncServerCursor(aconn, "foo") as cur: @@ -51,7 +64,7 @@ async def test_funny_name(aconn): async def test_repr(aconn): cur = aconn.cursor("my-name") - assert "psycopg.%s" % psycopg.AsyncServerCursor.__name__ in str(cur) + assert "psycopg.%s" % aconn.server_cursor_factory.__name__ in str(cur) assert "my-name" in repr(cur) await cur.close() @@ -86,7 +99,7 @@ async def test_format(aconn): async def test_query_params(aconn): async with aconn.cursor("foo") as cur: assert cur._query is None - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert cur._query is not None assert b"declare" in cur._query.query.lower() assert b"(1, $1)" in cur._query.query.lower() @@ -257,7 +270,7 @@ async def test_context(aconn, recwarn): async def test_close_no_clobber(aconn): with pytest.raises(e.DivisionByZero): async with aconn.cursor("foo") as cur: - await cur.execute("select 1 / %s", (0,)) + await cur.execute(ph(cur, "select 1 / %s"), (0,)) await cur.fetchall() @@ -272,10 +285,12 @@ async def test_warn_close(aconn, recwarn, gc_collect): async def test_execute_reuse(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as foo", (3,)) + query = ph(cur, "select generate_series(1, %s) as foo") + await cur.execute(query, (3,)) assert await cur.fetchone() == (1,) - await cur.execute("select %s::text as bar, %s::text as baz", ("hello", "world")) + query = ph(cur, "select %s::text as bar, %s::text as baz") + await cur.execute(query, ("hello", "world")) assert await cur.fetchone() == ("hello", "world") assert cur.description[0].name == "bar" assert cur.description[0].type_code == cur.adapters.types["text"].oid @@ -295,13 +310,13 @@ async def test_execute_error(aconn, stmt): async def test_executemany(aconn): cur = aconn.cursor("foo") with pytest.raises(e.NotSupportedError): - await cur.executemany("select %s", [(1,), (2,)]) + await cur.executemany(ph(cur, "select %s"), [(1,), (2,)]) await cur.close() async def test_fetchone(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (2,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (2,)) assert await cur.fetchone() == (1,) assert await cur.fetchone() == (2,) assert await cur.fetchone() is None @@ -309,7 +324,7 @@ async def test_fetchone(aconn): async def test_fetchmany(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (5,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (5,)) assert await cur.fetchmany(3) == [(1,), (2,), (3,)] assert await cur.fetchone() == (4,) assert await cur.fetchmany(3) == [(5,)] @@ -318,12 +333,12 @@ async def test_fetchmany(aconn): async def test_fetchall(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert await cur.fetchall() == [(1,), (2,), (3,)] assert await cur.fetchall() == [] async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert await cur.fetchone() == (1,) assert await cur.fetchall() == [(2,), (3,)] assert await cur.fetchall() == [] @@ -331,13 +346,14 @@ async def test_fetchall(aconn): async def test_nextset(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert not cur.nextset() async def test_no_result(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar where false", (3,)) + query = ph(cur, "select generate_series(1, %s) as bar where false") + await cur.execute(query, (3,)) assert len(cur.description) == 1 assert (await cur.fetchall()) == [] @@ -407,12 +423,12 @@ async def test_rownumber(aconn): async def test_iter(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) recs = await alist(cur) assert recs == [(1,), (2,), (3,)] async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) assert await cur.fetchone() == (1,) recs = await alist(cur) assert recs == [(2,), (3,)] @@ -420,7 +436,7 @@ async def test_iter(aconn): async def test_iter_rownumber(aconn): async with aconn.cursor("foo") as cur: - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) async for row in cur: assert cur.rownumber == row[0] @@ -429,7 +445,7 @@ async def test_itersize(aconn, acommands): async with aconn.cursor("foo") as cur: assert cur.itersize == 100 cur.itersize = 2 - await cur.execute("select generate_series(1, %s) as bar", (3,)) + await cur.execute(ph(cur, "select generate_series(1, %s) as bar"), (3,)) acommands.popall() # flush begin and other noise await alist(cur) diff --git a/tools/async_to_sync.py b/tools/async_to_sync.py index aef571c7d..563214444 100755 --- a/tools/async_to_sync.py +++ b/tools/async_to_sync.py @@ -290,6 +290,7 @@ class RenameAsyncToSync(ast.NodeTransformer): # type: ignore "AsyncPipeline": "Pipeline", "AsyncQueuedLibpqWriter": "QueuedLibpqWriter", "AsyncRawCursor": "RawCursor", + "AsyncRawServerCursor": "RawServerCursor", "AsyncRowFactory": "RowFactory", "AsyncScheduler": "Scheduler", "AsyncServerCursor": "ServerCursor",