1+ """Template system for duckdb SQL statements, based on Python's string.templatelib."""
2+
13from __future__ import annotations
24
35import dataclasses
810if TYPE_CHECKING :
911 from collections .abc import Iterator
1012
11- from typing_extensions import TypeIs
12-
1313__all__ = [
1414 "CompiledSql" ,
1515 "IntoInterpolation" ,
@@ -32,40 +32,17 @@ class CompiledSql:
3232class SupportsDuckdbTemplate (Protocol ):
3333 """Something that can be converted into a Template by implementing the __duckdb_template__ method."""
3434
35- def __duckdb_template__ (self , / , ** future_kwargs ) -> str | IntoInterpolation | Iterable [str | IntoInterpolation ]:
36- """Convert self into an IntoTemplate, by returning either a string, an IntoInterpolation, or an iterable of these.
37-
38- The future_kwargs are for future extensibility, in case duckdb wants
39- to pass additional information in the future.
40- To be future-proof, implementations should accept any additional kwargs,
41- and ignore them at this point.
42-
43- Examples:
44- A simple implementation might just return a string, eg
45-
46- >>> class MyRelation:
47- ... def __duckdb_template__(self, **kwargs):
48- ... return "SELECT * FROM my_table"
49- >>> t = template(MyRelation())
50- >>> t.compile()
51- CompiledSql(sql='SELECT * FROM my_table', params={})
52-
53- A more complex implementation might return an iterable of strings and Params.
54- A Param is a dict with a "value" key, and optionally a "name" key.
55-
56- For example:
57- >>> class Record:
58- ... def __init__(self, table: str, id: int):
59- ... self.table = table
60- ... self.id = id
61- ...
62- ... def __duckdb_template__(self, **kwargs):
63- ... id = self.id
64- ... return t"SELECT * FROM {self.table!s} WHERE id = {id}"
65- >>> t = template(Record("users", 123))
66- >>> t.compile()
67- CompiledSql(sql='SELECT * FROM users WHERE id = $p0_id', params={'p0_id': 123})
68- """
35+ def __duckdb_template__ (
36+ self , / , ** future_kwargs
37+ ) -> (
38+ str
39+ | IntoInterpolation
40+ | Param
41+ | SupportsDuckdbTemplate
42+ | object
43+ | Iterable [str | IntoInterpolation | Param | SupportsDuckdbTemplate | object ]
44+ ):
45+ """Convert self into something that template() understands."""
6946
7047
7148@dataclasses .dataclass (frozen = True , slots = True )
@@ -77,6 +54,7 @@ class Param:
7754 exact : bool = False
7855
7956 def __post_init__ (self ) -> None :
57+ """Ensure passed args were valid."""
8058 if self .exact :
8159 if self .name is None :
8260 msg = "Param with exact=True must have a name."
@@ -90,16 +68,17 @@ def param(value: object, name: str | None = None, *, exact: bool = False) -> Par
9068 return Param (value = value , name = name , exact = exact )
9169
9270
93- def template (
94- thing : str | Param | IntoInterpolation | SupportsDuckdbTemplate | Iterable [str | IntoInterpolation | Param ], /
95- ) -> SqlTemplate :
96- """Convert something to a SqlTemplate.
71+ def template (* part : str | IntoInterpolation | Param | SupportsDuckdbTemplate | object ) -> SqlTemplate :
72+ """Convert a sequence of things into a SqlTemplate.
9773
98- The rules are:
99- - If the thing has a `.__duckdb_template__()` method, call it and convert the
100- resuling strings and IntoParams into a SqlTemplate.
74+ We go through the parts and convert it into a sequence of str and Interpolations,
75+ which we then hand off to SqlTemplate.
76+ - If the thing has a `.__duckdb_template__()` method, call it,
77+ and call template() recursively on the result.
10178 - If the thing is a `str`, treat it as raw SQL and return a SqlTemplate with that string.
102- - Otherwise, treat the thing as a param.
79+ - If it's an Interpolation, leave it as is, treating it as an interpolation.
80+ - It it's a Param, wrap it in an Interpolation.
81+ - Otherwise, treat the thing as a param, and then wrap the Param in an Interpolation.
10382
10483 Examples:
10584 A very simple example is just passing a string, which will be treated as raw SQL:
@@ -155,33 +134,47 @@ def template(
155134 >>> t.compile()
156135 CompiledSql(sql='SELECT * FROM users WHERE id = $p0_id', params={'p0_id': 123})
157136 """ # noqa: E501
158- if isinstance (thing , SupportsDuckdbTemplate ):
159- raw = thing .__duckdb_template__ ()
160- return template (raw )
161- if isinstance (thing , str ):
162- return SqlTemplate (thing )
163- if isinstance (thing , Param ):
164- return SqlTemplate (ParamInterpolation (thing ))
165- if isinstance (thing , IntoInterpolation ):
166- return SqlTemplate (thing )
167- if _is_iterable (thing ):
168- return SqlTemplate (* thing )
169- return SqlTemplate (ParamInterpolation (param (value = thing )))
170-
171-
172- def _is_iterable (thing : object ) -> TypeIs [Iterable ]:
173- return isinstance (thing , Iterable ) and not isinstance (thing , (str , bytes ))
137+ expanded = _expand_part (part )
138+ return SqlTemplate (* expanded )
139+
140+
141+ def _expand_part (part : object ) -> Iterable [str | IntoInterpolation ]:
142+ if isinstance (part , SupportsDuckdbTemplate ):
143+ raw = part .__duckdb_template__ ()
144+ if isinstance (raw , str ): # noqa: SIM114
145+ yield raw
146+ elif isinstance (raw , IntoInterpolation ):
147+ yield raw
148+ elif isinstance (raw , Param ):
149+ yield ParamInterpolation (raw )
150+ elif isinstance (raw , Iterable ):
151+ yield from _expand_part (raw )
152+ else :
153+ p = param (value = raw )
154+ yield ParamInterpolation (p )
155+ elif isinstance (part , str ): # noqa: SIM114
156+ yield part
157+ elif isinstance (part , IntoInterpolation ):
158+ yield part
159+ elif isinstance (part , Param ):
160+ yield ParamInterpolation (part )
161+ else :
162+ p = param (value = part )
163+ yield ParamInterpolation (p )
174164
175165
176166class ParamInterpolation :
177167 """A simple wrapper that implements the IntoInterpolation protocol for a given IntoParam."""
178168
179- def __init__ (self , param : Param ):
169+ def __init__ (self , param : Param ): # noqa: ANN204
180170 self .value = param
181171 self .expression = param .name
182172 self .conversion = None
183173 self .format_spec = ""
184174
175+ def __repr__ (self ) -> str :
176+ return repr (self .value )
177+
185178
186179def _resolve (parts : Iterable [str | IntoInterpolation ]) -> ResolvedSqlTemplate :
187180 """Resolve a stream of strings and Interpolations, recursively resolving inner interpolations."""
@@ -211,19 +204,25 @@ def _resolve_interpolation(interp: IntoInterpolation) -> Iterable[str | Param]:
211204 # or if the user just wants to write raw SQL and doesn't care about safety
212205 # Note that this is potentially unsafe if the value comes from an untrusted source,
213206 # since it could lead to SQL injection vulnerabilities, so it should be used with caution.
214- formatted = format (value , interp .format_spec )
215-
207+ #
208+ # Follow Python's f-string semantics: apply conversion first, then format_spec.
209+ # e.g. {x!r:.10} means format(repr(x), ".10")
216210 if interp .conversion == "s" :
217- return ( str (formatted ), )
211+ converted = str (value )
218212 elif interp .conversion == "r" :
219- return ( repr (formatted ), )
213+ converted = repr (value )
220214 elif interp .conversion == "a" :
221- return (ascii (formatted ),)
215+ converted = ascii (value )
216+ else :
217+ converted = None
218+
219+ if converted is not None :
220+ return (format (converted , interp .format_spec ),)
222221
223222 if isinstance (value , str ):
224223 # do NOT pass to template, since that would treat it as a raw SQL.
225224 return (param (value , name = interp .expression ),)
226- templ = template (value ) # ty:ignore[invalid-argument-type]
225+ templ = template (value )
227226 # If the resolved inner is just a single Interpolation, then just return
228227 # the original value so that we preserve the expression name.
229228 # For example, if we have
@@ -270,20 +269,9 @@ class SqlTemplate:
270269
271270 def __init__ (
272271 self ,
273- * parts : str | IntoInterpolation | Param ,
272+ * parts : str | IntoInterpolation ,
274273 ) -> None :
275- self .strings , interps_and_params = parse_parts (parts )
276- interps = []
277- for part in interps_and_params :
278- if isinstance (part , IntoInterpolation ):
279- interps .append (part )
280- elif isinstance (part , Param ):
281- # it's a Param, so we need to wrap it in a ParamInterpolation
282- interps .append (ParamInterpolation (part ))
283- else :
284- msg = f"Unexpected part type: { type (part )} . Expected str, IntoInterpolation, or Param."
285- raise TypeError (msg )
286- self .interpolations = interps
274+ self .strings , self .interpolations = parse_parts (parts )
287275
288276 def __iter__ (self ) -> Iterator [str | IntoInterpolation ]:
289277 """Iterate over the strings and interpolations in order."""
@@ -362,7 +350,10 @@ def parse_parts(parts: Iterable[str | T]) -> tuple[tuple[str, ...], tuple[T, ...
362350 strings .append ("" )
363351 others .append (part )
364352 last_thing = "other"
365- if last_thing == "other" :
353+ if last_thing is None :
354+ # Empty input — return a single empty string to maintain the invariant
355+ strings .append ("" )
356+ elif last_thing == "other" :
366357 # If the last part was an other, we need to end with an empty string
367358 strings .append ("" )
368359 assert len (strings ) == len (others ) + 1
0 commit comments