Skip to content

Commit 05188fa

Browse files
committed
adjust implementation and tests
1 parent 1c16375 commit 05188fa

3 files changed

Lines changed: 1249 additions & 81 deletions

File tree

template.py

Lines changed: 72 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Template system for duckdb SQL statements, based on Python's string.templatelib."""
2+
13
from __future__ import annotations
24

35
import dataclasses
@@ -8,8 +10,6 @@
810
if 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:
3232
class 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

176166
class 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

186179
def _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

Comments
 (0)