]> git.ipfire.org Git - thirdparty/psycopg.git/commitdiff
feat: add sql.as_string(), sql.as_bytes() functions
authorDaniele Varrazzo <daniele.varrazzo@gmail.com>
Wed, 26 Nov 2025 19:21:58 +0000 (20:21 +0100)
committerDaniele Varrazzo <daniele.varrazzo@gmail.com>
Thu, 27 Nov 2025 02:00:14 +0000 (03:00 +0100)
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.

psycopg/psycopg/_tstrings.py
psycopg/psycopg/sql.py
tests/test_sql.py
tests/test_tstring.py

index c9d1da5f608586d3f084f9c8e0a98169099404b8..e3056b115c8ee80b3ed542a3cb4c7116f80113a9 100644 (file)
@@ -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
index 7a9def37f68c0a79785250b25df6eb742db148f2..227c2d6cc8eb4e60afca5686029243f83a7682ce 100644 (file)
@@ -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")
index b6c10bd845d6707e7e66a695c10c01c86d21b863..3431d7a0774693b81bc77be73133d3ae28ec7d89 100644 (file)
@@ -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)
index 18573ca57b10ba6f9c0fc8a3585b2cc4763032a8..4a6db299a51fcfc10c18224ac16b23adc0f94af1 100644 (file)
@@ -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'")