|
| 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 |
0 commit comments