Skip to content

Commit 495606f

Browse files
committed
overhaul and improve the API
1 parent 50aa534 commit 495606f

2 files changed

Lines changed: 199 additions & 135 deletions

File tree

template.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, runtime_checkable
4+
5+
if TYPE_CHECKING:
6+
from collections.abc import Iterable, Iterator, Sequence
7+
8+
9+
class Param(TypedDict):
10+
"""Represents a parameter to be passed to duckdb, with a name and a value."""
11+
12+
value: object
13+
name: str
14+
15+
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."""
18+
19+
sql: str
20+
params: list[Param]
21+
22+
23+
@runtime_checkable
24+
class SupportsDuckdbTemplate(Protocol):
25+
def __duckdb_template__(
26+
self, /, **future_kwargs
27+
) -> str | IntoInterpolation | Iterable[str | IntoInterpolation]: ...
28+
29+
30+
def resolve_to_template(thing: object, /, **ignored_kwargs) -> SqlTemplate:
31+
if isinstance(thing, SupportsDuckdbTemplate):
32+
raw = thing.__duckdb_template__(**ignored_kwargs)
33+
return SqlTemplate.from_part_or_parts(raw)
34+
if isinstance(thing, IntoTemplate):
35+
return resolve_into_template(thing)
36+
if isinstance(thing, IntoInterpolation):
37+
return resolve_interpolation(thing)
38+
return SqlTemplate(OurInterpolation(thing))
39+
40+
41+
def compile(thing: object) -> CompiledSql:
42+
resolved_template = resolve_to_template(thing)
43+
return compile_sql_template(resolved_template)
44+
45+
46+
def resolve_into_template(template: IntoTemplate) -> SqlTemplate:
47+
"""Resolve a Template, recursively resolving interpolations and flattening nested templates."""
48+
parts: list[str | IntoInterpolation] = []
49+
for part in template:
50+
if isinstance(part, str):
51+
parts.append(part)
52+
else:
53+
inner_parts = resolve_interpolation(part)
54+
parts.extend(inner_parts)
55+
return SqlTemplate(*parts)
56+
57+
58+
def resolve_interpolation(interp: IntoInterpolation) -> SqlTemplate:
59+
# if conversion specified (!s, !r, !a), treat as raw sql, eg
60+
# t"SELECT {"mycol"!s} FROM foo" should be "SELECT mycol FROM foo
61+
if interp.conversion == "s":
62+
return SqlTemplate(str(interp.value))
63+
elif interp.conversion == "r":
64+
return SqlTemplate(repr(interp.value))
65+
elif interp.conversion == "a":
66+
return SqlTemplate(ascii(interp.value))
67+
68+
templ = resolve_to_template(interp.value)
69+
# If the resolved inner is just a single Interpolation, then just return
70+
# the original value so that we preserve the expression name.
71+
# For example, if we have
72+
# ```python
73+
# age = 37
74+
# people = t"SELECT * FROM people WHERE age = {age}"
75+
# resolve_template(people)
76+
# ```
77+
# should resolve to:
78+
# "SELECT * FROM people WHERE age = $age", with a param $age=37,
79+
# eg with a friendly param name, rather than
80+
# "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)
83+
else:
84+
# We got something nested, eg
85+
# age = 37
86+
# people = t"SELECT * FROM people WHERE age = {age}"
87+
# names = t"SELECT name FROM ({foo})"
88+
# names should resolve to:
89+
# "SELECT name FROM (SELECT * FROM people WHERE age = $age)", with a param $age=37
90+
return templ
91+
92+
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
109+
110+
111+
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):
139+
yield s
140+
yield i
141+
yield self.strings[-1]
142+
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):
153+
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
154+
raise NotImplementedError(msg)
155+
156+
157+
def compile_sql_template(template: SqlTemplate) -> CompiledSql:
158+
"""Compile a resolved SqlTemplate into a final SQL string with named parameter placeholders, and a list of Params."""
159+
sql_parts: list[str] = []
160+
params: list[Param] = []
161+
for part in template:
162+
if isinstance(part, str):
163+
sql_parts.append(part)
164+
else:
165+
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)
169+
sql_parts.append(f"${param_name}")
170+
params.append({"name": param_name, "value": part.value})
171+
return {
172+
"sql": "".join(sql_parts),
173+
"params": params,
174+
}
175+
176+
177+
# from string.templatelib import Interpolation, Template
178+
@runtime_checkable
179+
class IntoInterpolation(Protocol):
180+
"""Something that can be converted into a string.templatelib.Interpolation."""
181+
182+
value: object
183+
conversion: Literal["s", "r", "a"] | None
184+
expression: str | None
185+
186+
187+
@runtime_checkable
188+
class IntoTemplate(Protocol):
189+
strings: Sequence[str]
190+
interpolations: Sequence[IntoInterpolation]
191+
192+
def __iter__(self) -> Iterator[str | IntoInterpolation]: ...
193+
194+
195+
def assert_param_name_legal(name: str) -> None:
196+
"""Eg `$param_1` is legal, but `$1param`, `$param-1`, `$param 1`, and `$p ; DROP TABLE users` are not."""
197+
# not implemented yet
198+
# Not exactly sure what part of the stack this should get called in,
199+
# or perhaps we shouldn't even check, just pass it to duckdb and let it error if it's illegal

tstrings.py

Lines changed: 0 additions & 135 deletions
This file was deleted.

0 commit comments

Comments
 (0)