Skip to content

Commit 438658e

Browse files
committed
tweak API example more
1 parent 45b66f8 commit 438658e

1 file changed

Lines changed: 184 additions & 80 deletions

File tree

template.py

Lines changed: 184 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, runtime_checkable
3+
from typing import TYPE_CHECKING, Literal, NoReturn, Protocol, TypedDict, runtime_checkable
44

55
if TYPE_CHECKING:
66
from collections.abc import Iterable, Iterator, Sequence
77

8+
from typing_extensions import NotRequired, TypeIs
9+
10+
__all__ = [
11+
"CompiledSql",
12+
"IntoInterpolation",
13+
"IntoParam",
14+
"IntoTemplate",
15+
"Param",
16+
"SupportsDuckdbTemplate",
17+
"compile",
18+
"template",
19+
]
20+
21+
22+
class CompiledSql(TypedDict):
23+
"""Represents a compiled SQL statement, with the final SQL string and a list of Params to be passed to duckdb."""
24+
25+
sql: str
26+
params: list[Param]
27+
828

929
class Param(TypedDict):
1030
"""Represents a parameter to be passed to duckdb, with a name and a value."""
@@ -13,39 +33,112 @@ class Param(TypedDict):
1333
name: str
1434

1535

16-
class CompiledSql(TypedDict):
17-
"""Represents a compiled SQL statement, with the final SQL string and a list of Params to be passed to duckdb."""
36+
class IntoParam(TypedDict):
37+
"""A Param with a name that is None, which can be used as input to the template engine, which will assign it a name based on its position and optionally an expression."""
1838

19-
sql: str
20-
params: list[Param]
39+
value: object
40+
name: NotRequired[str | None]
2141

2242

23-
@runtime_checkable
24-
class SupportsDuckdbTemplate(Protocol):
25-
def __duckdb_template__(
26-
self, /, **future_kwargs
27-
) -> str | IntoInterpolation | Iterable[str | IntoInterpolation]: ...
43+
def is_into_param(thing: object) -> TypeIs[IntoParam]:
44+
try:
45+
value = thing["value"] # ty:ignore[not-subscriptable]
46+
except (TypeError, KeyError):
47+
return False
48+
try:
49+
name = thing["name"] # ty:ignore[not-subscriptable]
50+
except KeyError:
51+
name = None
52+
53+
return isinstance(value, object) and isinstance(name, (str, type(None)))
2854

2955

30-
def resolve_to_template(thing: object, /, **ignored_kwargs) -> SqlTemplate:
56+
@runtime_checkable
57+
class SupportsDuckdbTemplate(Protocol):
58+
"""Something that can be converted into a SqlTemplate by implementing the __duckdb_template__ method."""
59+
60+
def __duckdb_template__(self, /, **future_kwargs) -> str | IntoParam | Iterable[str | IntoParam]:
61+
"""Convert self into a SqlTemplate, by returning either a string, an IntoParam, or an iterable of these.
62+
63+
The future_kwargs are for future extensibility, in case duckdb wants
64+
to pass additional information in the future.
65+
To be future-proof, implementations should accept any additional kwargs,
66+
and ignore them at this point.
67+
68+
Examples:
69+
A simple implementation might just return a string, eg
70+
```python
71+
class MyRelation:
72+
def __duckdb_template__(self, **kwargs):
73+
return "SELECT * FROM my_table"
74+
```
75+
76+
A more complex implementation might return an iterable of strings and IntoParams.
77+
An IntoParam is a dict with a "value" key, and optionally a "name" key.
78+
79+
For example:
80+
```python
81+
class User:
82+
def __init__(self, user_id: int):
83+
self.user_id = user_id
84+
85+
def __duckdb_template__(self, **kwargs):
86+
return [
87+
"SELECT * FROM users WHERE id = ",
88+
{"value": self.user_id, "name": "user_id"},
89+
]
90+
```
91+
92+
This will resolve to the final SQL and params:
93+
```python
94+
{
95+
"sql": "SELECT * FROM users WHERE id = $p0_user_id",
96+
"params": [{"name": "p0_user_id", "value": 123}],
97+
}
98+
```
99+
"""
100+
101+
102+
def param(value: object, name: str | None = None) -> IntoParam:
103+
"""Helper function to create an IntoParam with an optional name."""
104+
if name is not None:
105+
assert_param_name_legal(name)
106+
return IntoParam(value=value, name=name)
107+
108+
109+
def template(thing: object, /, **ignored_kwargs) -> SqlTemplate:
110+
"""Convert something to a SqlTemplate.
111+
112+
The rules are:
113+
- If the thing has a __duckdb_template__ method, call it and convert the
114+
resuling strings and IntoParams into a SqlTemplate.
115+
- If the thing is a string, treat it as raw SQL and return a SqlTemplate with that string.
116+
- If the thing is an IntoTemplate, resolve it into a SqlTemplate by recursively resolving
117+
any inner IntoInterpolations and flattening any nested templates.
118+
- If the thing is an IntoInterpolation, resolve it into a SqlTemplate by recursively resolving
119+
its value, and if it has a conversion specified (!s, !r, !a), treat it as raw SQL.
120+
- Otherwise, treat the thing as a param.
121+
"""
31122
if isinstance(thing, SupportsDuckdbTemplate):
32123
raw = thing.__duckdb_template__(**ignored_kwargs)
33-
return SqlTemplate.from_part_or_parts(raw)
124+
return SqlTemplate(raw)
125+
if isinstance(thing, str):
126+
return SqlTemplate(thing)
34127
if isinstance(thing, IntoTemplate):
35128
return resolve_into_template(thing)
36129
if isinstance(thing, IntoInterpolation):
37130
return resolve_interpolation(thing)
38-
return SqlTemplate(OurInterpolation(thing))
131+
return SqlTemplate(param(value=thing))
39132

40133

41134
def compile(thing: object) -> CompiledSql:
42-
resolved_template = resolve_to_template(thing)
135+
resolved_template = template(thing)
43136
return compile_sql_template(resolved_template)
44137

45138

46139
def resolve_into_template(template: IntoTemplate) -> SqlTemplate:
47140
"""Resolve a Template, recursively resolving interpolations and flattening nested templates."""
48-
parts: list[str | IntoInterpolation] = []
141+
parts: list[str | IntoParam] = []
49142
for part in template:
50143
if isinstance(part, str):
51144
parts.append(part)
@@ -56,16 +149,30 @@ def resolve_into_template(template: IntoTemplate) -> SqlTemplate:
56149

57150

58151
def resolve_interpolation(interp: IntoInterpolation) -> SqlTemplate:
152+
"""Resolve something that can be converted into an Interpolation, recursively resolving any inner templates."""
153+
value = interp.value
59154
# if conversion specified (!s, !r, !a), treat as raw sql, eg
60-
# t"SELECT {"mycol"!s} FROM foo" should be "SELECT mycol FROM foo
155+
# name = "Alice"
156+
# t"SELECT * FROM users where name = '{name!s}'" should be
157+
# "SELECT * FROM users where name = 'Alice'", eg with no param,
158+
# since the user is explicitly asking for the value to be directly interpolated into the SQL string,
159+
# rather than passed as a param.
160+
# This is useful for cases where the value is not something that can be passed as a param,
161+
# eg an identifier like a table or column name, or a SQL expression like "CURRENT_DATE",
162+
# or if the user just wants to write raw SQL and doesn't care about safety
163+
# Note that this is potentially unsafe if the value comes from an untrusted source,
164+
# since it could lead to SQL injection vulnerabilities, so it should be used with caution.
61165
if interp.conversion == "s":
62-
return SqlTemplate(str(interp.value))
166+
return SqlTemplate(str(value))
63167
elif interp.conversion == "r":
64-
return SqlTemplate(repr(interp.value))
168+
return SqlTemplate(repr(value))
65169
elif interp.conversion == "a":
66-
return SqlTemplate(ascii(interp.value))
170+
return SqlTemplate(ascii(value))
67171

68-
templ = resolve_to_template(interp.value)
172+
if isinstance(value, str):
173+
# do NOT pass to template, since that would treat it as a raw SQL.
174+
return SqlTemplate(param(value, name=interp.expression))
175+
templ = template(value)
69176
# If the resolved inner is just a single Interpolation, then just return
70177
# the original value so that we preserve the expression name.
71178
# For example, if we have
@@ -78,8 +185,8 @@ def resolve_interpolation(interp: IntoInterpolation) -> SqlTemplate:
78185
# "SELECT * FROM people WHERE age = $age", with a param $age=37,
79186
# eg with a friendly param name, rather than
80187
# "SELECT * FROM people WHERE age = $p0", with a param $p0=37
81-
if len(templ.strings) == 2 and templ.strings[0] == "" and templ.strings[1] == "" and len(templ.interpolations) == 1:
82-
return SqlTemplate(interp)
188+
if len(templ.strings) == 2 and templ.strings[0] == "" and templ.strings[1] == "" and len(templ.params) == 1:
189+
return SqlTemplate(param(value=value, name=interp.expression))
83190
else:
84191
# We got something nested, eg
85192
# age = 37
@@ -90,69 +197,37 @@ def resolve_interpolation(interp: IntoInterpolation) -> SqlTemplate:
90197
return templ
91198

92199

93-
class DuckdDbPyRelation:
94-
def __duckdb_template__(self, /, **future_kwargs) -> str:
95-
# this would just return the existing SQL.
96-
return "SELECT * FROM some_table"
97-
98-
99-
class OurInterpolation:
100-
def __init__(
101-
self,
102-
value: object,
103-
conversion: Literal["s", "r", "a"] | None = None,
104-
expression: str | None = None,
105-
):
106-
self.value = value
107-
self.conversion = conversion
108-
self.expression = expression
200+
# class DuckdDbPyRelation:
201+
# def __duckdb_template__(self, /, **future_kwargs) -> str:
202+
# # this would just return the existing SQL.
203+
# return "SELECT * FROM some_table"
109204

110205

111206
class SqlTemplate:
112-
def __init__(self, *parts: str | IntoInterpolation):
113-
strings, interpolations = [], []
114-
last_thing: Literal["string", "interpolation"] | None = None
115-
for part in parts:
116-
if isinstance(part, str):
117-
if last_thing == "string":
118-
# Merge adjacent string parts for efficiency, since the template engine allows that
119-
strings[-1] += part
120-
strings.append(part)
121-
last_thing = "string"
122-
else:
123-
if last_thing is None or last_thing == "interpolation":
124-
# this is the first part,
125-
# or there were two adjacent interpolations,
126-
# so we need an empty string spacer
127-
strings.append("")
128-
interpolations.append(OurInterpolation(part.value, part.conversion))
129-
last_thing = "interpolation"
130-
if last_thing == "interpolation":
131-
# If the last part was an interpolation, we need to end with an empty string
132-
strings.append("")
133-
assert len(strings) == len(interpolations) + 1
134-
self.strings = strings
135-
self.interpolations = interpolations
136-
137-
def __iter__(self):
138-
for s, i in zip(self.strings, self.interpolations):
207+
"""Very similar to string.templatelib.Template, but instead of Interpolations, we use IntoParams."""
208+
209+
def __init__(self, thing: str | IntoParam | Iterable[str | IntoParam]) -> None:
210+
parts = [thing] if isinstance(thing, str) or is_into_param(thing) else list(thing)
211+
strings, params = parse_strings_and_params(parts)
212+
for param in params:
213+
if name := param.get("name"):
214+
assert_param_name_legal(name)
215+
self.strings = tuple(strings)
216+
self.params = tuple(params)
217+
218+
def __iter__(self) -> Iterator[str | IntoParam]:
219+
for s, i in zip(self.strings, self.params, strict=False):
139220
yield s
140221
yield i
141222
yield self.strings[-1]
142223

143-
@classmethod
144-
def from_part_or_parts(
145-
cls, part_or_parts: str | IntoInterpolation | Iterable[str | IntoInterpolation]
146-
) -> SqlTemplate:
147-
if isinstance(part_or_parts, (str, IntoInterpolation)):
148-
return cls(part_or_parts)
149-
else:
150-
return cls(*part_or_parts)
151-
152-
def __str__(self):
224+
def __str__(self) -> NoReturn:
153225
msg = f"{self.__class__.__name__} cannot be directly converted to string. It needs to be processed by the SQL engine to produce the final SQL string." # noqa: E501
154226
raise NotImplementedError(msg)
155227

228+
def compile(self) -> CompiledSql:
229+
return compile_sql_template(self)
230+
156231

157232
def compile_sql_template(template: SqlTemplate) -> CompiledSql:
158233
"""Compile a resolved SqlTemplate into a final SQL string with named parameter placeholders, and a list of Params."""
@@ -163,11 +238,10 @@ def compile_sql_template(template: SqlTemplate) -> CompiledSql:
163238
sql_parts.append(part)
164239
else:
165240
param_name = f"p{len(params)}"
166-
if part.expression is not None:
167-
param_name += f"_{part.expression}"
168-
assert_param_name_legal(param_name)
241+
if passed_name := part.get("name"):
242+
param_name += f"_{passed_name}"
169243
sql_parts.append(f"${param_name}")
170-
params.append({"name": param_name, "value": part.value})
244+
params.append({"name": param_name, "value": part["value"]})
171245
return {
172246
"sql": "".join(sql_parts),
173247
"params": params,
@@ -186,14 +260,44 @@ class IntoInterpolation(Protocol):
186260

187261
@runtime_checkable
188262
class IntoTemplate(Protocol):
263+
"""Something that can be converted into string.templatelib.Template."""
264+
189265
strings: Sequence[str]
190266
interpolations: Sequence[IntoInterpolation]
191267

192-
def __iter__(self) -> Iterator[str | IntoInterpolation]: ...
268+
def __iter__(self) -> Iterator[str | IntoInterpolation]:
269+
"""Iterate over the strings and interpolations in order."""
193270

194271

195272
def assert_param_name_legal(name: str) -> None:
196273
"""Eg `$param_1` is legal, but `$1param`, `$param-1`, `$param 1`, and `$p ; DROP TABLE users` are not."""
197274
# not implemented yet
198275
# Not exactly sure what part of the stack this should get called in,
199276
# or perhaps we shouldn't even check, just pass it to duckdb and let it error if it's illegal
277+
278+
279+
def parse_strings_and_params(
280+
parts: Iterable[str | IntoParam],
281+
) -> tuple[tuple[str, ...], tuple[IntoParam, ...]]:
282+
"""Parse an iterable of strings and params into separate tuples of strings and params, merging adjacent strings and ensuring that the number of strings is one more than the number of params."""
283+
strings, params = [], []
284+
last_thing: Literal["string", "param"] | None = None
285+
for part in parts:
286+
if isinstance(part, str):
287+
if last_thing == "string":
288+
# Merge adjacent string parts for efficiency, since the template engine allows that
289+
strings[-1] += part
290+
strings.append(part)
291+
last_thing = "string"
292+
else:
293+
if last_thing is None or last_thing == "param":
294+
# this is the first part or there were two adjacent params,
295+
# so we need an empty string spacer
296+
strings.append("")
297+
params.append(part)
298+
last_thing = "param"
299+
if last_thing == "param":
300+
# If the last part was a param, we need to end with an empty string
301+
strings.append("")
302+
assert len(strings) == len(params) + 1
303+
return tuple(strings), tuple(params)

0 commit comments

Comments
 (0)