From: Daniele Varrazzo Date: Wed, 26 Nov 2025 19:21:58 +0000 (+0100) Subject: feat: add sql.as_string(), sql.as_bytes() functions X-Git-Tag: 3.3.0~6^2~2 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=dfd43da862e543a25845516a533fd76a46d9f18d;p=thirdparty%2Fpsycopg.git feat: add sql.as_string(), sql.as_bytes() functions The functions are exported by the psycopg.sql module but are primarily useful for template strings support, to allow building SQL queries dynamically using t-strings. --- diff --git a/psycopg/psycopg/_tstrings.py b/psycopg/psycopg/_tstrings.py index c9d1da5f6..e3056b115 100644 --- a/psycopg/psycopg/_tstrings.py +++ b/psycopg/psycopg/_tstrings.py @@ -6,15 +6,14 @@ Template strings support in queries. from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any +from . import abc from . import errors as e from . import sql from ._enums import PyFormat from ._compat import Interpolation, Template - -if TYPE_CHECKING: - from .abc import Transformer +from ._transformer import Transformer # Formats supported by template strings FMT_AUTO = PyFormat.AUTO.value @@ -26,7 +25,7 @@ FMT_SQL = "q" class TemplateProcessor: - def __init__(self, template: Template, *, tx: Transformer, server_params: bool): + def __init__(self, template: Template, *, tx: abc.Transformer, server_params: bool): self.template = template self._tx = tx self._server_params = server_params @@ -141,3 +140,17 @@ class TemplateProcessor: raise e.ProgrammingError( f"{type(item.value).__qualname__} not supported in string templates" ) + + +def as_string(t: Template, context: abc.AdaptContext | None = None) -> str: + tx = Transformer(context) + tp = TemplateProcessor(t, tx=tx, server_params=False) + tp.process() + return tp.query.decode(tx.encoding) + + +def as_bytes(t: Template, context: abc.AdaptContext | None = None) -> bytes: + tx = Transformer(context) + tp = TemplateProcessor(t, tx=tx, server_params=False) + tp.process() + return tp.query diff --git a/psycopg/psycopg/sql.py b/psycopg/psycopg/sql.py index 7a9def37f..227c2d6cc 100644 --- a/psycopg/psycopg/sql.py +++ b/psycopg/psycopg/sql.py @@ -503,3 +503,25 @@ class Placeholder(Composable): # Literals NULL = SQL("NULL") DEFAULT = SQL("DEFAULT") + + +def as_string(obj: Composable | Template, context: AdaptContext | None = None) -> str: + if isinstance(obj, Composable): + return obj.as_string(context=context) + elif isinstance(obj, Template): + from ._tstrings import as_string + + return as_string(obj, context) + else: + raise TypeError(f"{type(obj).__name__} objects not supported by as_string") + + +def as_bytes(obj: Composable | Template, context: AdaptContext | None = None) -> bytes: + if isinstance(obj, Composable): + return obj.as_bytes(context=context) + elif isinstance(obj, Template): + from ._tstrings import as_bytes + + return as_bytes(obj, context) + else: + raise TypeError(f"{type(obj).__name__} objects not supported by as_bytes") diff --git a/tests/test_sql.py b/tests/test_sql.py index b6c10bd84..3431d7a07 100644 --- a/tests/test_sql.py +++ b/tests/test_sql.py @@ -15,6 +15,7 @@ from psycopg.types.string import StrDumper from .utils import eur from .fix_crdb import crdb_encoding, crdb_scs_off +from .test_adapt import make_dumper @pytest.mark.parametrize( @@ -643,6 +644,38 @@ class TestPlaceholder: assert ph.as_bytes() == f"%(foo){format.value}".encode() +def test_as_string(): + query = sql.as_string(sql.SQL("select {}").format("foo")) + assert query == no_e("select 'foo'") + + +def test_as_string_context(conn): + conn.adapters.register_dumper(str, make_dumper("1")) + query = sql.as_string(sql.SQL("select {}").format("foo"), context=conn) + assert query == no_e("select 'foo1'") + + +def test_as_string_error(): + with pytest.raises(TypeError): + sql.as_string("query") # type: ignore[arg-type] + + +def test_as_bytes(): + query = sql.as_bytes(sql.SQL("select {}").format("foo")) + assert query == no_e(b"select 'foo'") + + +def test_as_bytes_context(conn): + conn.adapters.register_dumper(str, make_dumper("1")) + query = sql.as_bytes(sql.SQL("select {}").format("foo"), context=conn) + assert query == no_e(b"select 'foo1'") + + +def test_as_bytes_error(): + with pytest.raises(TypeError): + sql.as_bytes("query") # type: ignore[arg-type] + + class TestValues: def test_null(self, conn): assert isinstance(sql.NULL, sql.SQL) diff --git a/tests/test_tstring.py b/tests/test_tstring.py index 18573ca57..4a6db299a 100644 --- a/tests/test_tstring.py +++ b/tests/test_tstring.py @@ -7,6 +7,8 @@ from psycopg import sql from psycopg.pq import Format from .acompat import alist +from .test_sql import no_e +from .test_adapt import make_dumper vstr = "hello" vint = 16 @@ -238,3 +240,25 @@ async def test_server_cursor(aconn): assert cur.description[1].name == vstr assert b"$2" in cur._query.query assert b"$3" not in cur._query.query + + +def test_as_string(): + query = sql.as_string(t"select {vstr}") + assert query == no_e("select 'hello'") + + +async def test_as_string_context(aconn): + aconn.adapters.register_dumper(str, make_dumper("1")) + query = sql.as_string(t"select {vstr}", context=aconn) + assert query == no_e("select 'hello1'") + + +def test_as_bytes(): + query = sql.as_bytes(t"select {vstr}") + assert query == no_e(b"select 'hello'") + + +async def test_as_bytes_context(aconn): + aconn.adapters.register_dumper(str, make_dumper("1")) + query = sql.as_bytes(t"select {vstr}", context=aconn) + assert query == no_e(b"select 'hello1'")