sqlakit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlakit/_sql.py ADDED
@@ -0,0 +1,408 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from contextvars import ContextVar
5
+ from dataclasses import is_dataclass
6
+ from functools import cache, cached_property
7
+ from inspect import iscoroutinefunction
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, get_origin, is_typeddict
10
+
11
+ import sqlalchemy as sa
12
+ from markupsafe import Markup
13
+
14
+ from .exceptions import (
15
+ AsyncFilterError,
16
+ MissingDependencyError,
17
+ SQLNotConfiguredError,
18
+ StrayParameterError,
19
+ TemplateNotFoundError,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable, Sequence
24
+
25
+ import jinja2
26
+ from jinja2sql import Jinja2SQL
27
+ from sqlalchemy.sql import Executable
28
+
29
+ from ._base import BaseDatabase
30
+ else:
31
+ try:
32
+ import jinja2
33
+ from jinja2sql import Jinja2SQL
34
+ except ImportError: # pragma: no cover - the extra is installed in CI
35
+ jinja2 = Jinja2SQL = None
36
+
37
+ if TYPE_CHECKING:
38
+ from pydantic import BaseModel, TypeAdapter
39
+ else:
40
+ try:
41
+ from pydantic import BaseModel, TypeAdapter
42
+ except ImportError: # pragma: no cover - pydantic is installed in CI
43
+ BaseModel = TypeAdapter = None
44
+
45
+ __all__ = ["BaseSQLQuery", "Templates", "require_pydantic", "templates_of"]
46
+
47
+ _preparer: ContextVar[Any] = ContextVar("sqlakit.identifier_preparer")
48
+ """The preparer of the database a template is rendering for."""
49
+
50
+ RowT = TypeVar("RowT")
51
+ OtherT = TypeVar("OtherT")
52
+ SessionT = TypeVar("SessionT")
53
+ DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
54
+ QueryT = TypeVar("QueryT", bound="BaseSQLQuery[Any, Any]")
55
+
56
+ PathLike = str | Path
57
+ """Where templates are looked for: one directory, or several."""
58
+
59
+
60
+ class Templates:
61
+ """Where a database's SQL templates live, and how they are rendered.
62
+
63
+ A path is enough; the object is for the rest:
64
+
65
+ ```python
66
+ db = Database(DB_URL, templates=Templates("app/sql", auto_reload=DEBUG))
67
+ ```
68
+
69
+ ``auto_reload`` reads a template again when its file changes, which a
70
+ development server wants and a production one does not. ``filters`` and
71
+ ``globals`` are handed to the Jinja environment, and are refused if they have
72
+ to be awaited: rendering makes a string, in both APIs.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ path: PathLike | Sequence[PathLike] = (),
78
+ *,
79
+ auto_reload: bool = False,
80
+ filters: Mapping[str, Callable[..., Any]] | None = None,
81
+ globals: Mapping[str, Any] | None = None, # noqa: A002
82
+ ) -> None:
83
+ self.paths = (
84
+ (path,) if isinstance(path, str | Path) else tuple(path) # type: ignore[misc]
85
+ )
86
+ self.auto_reload = auto_reload
87
+ self.filters = dict(filters or {})
88
+ self.globals = dict(globals or {})
89
+ for name, value in (*self.filters.items(), *self.globals.items()):
90
+ if iscoroutinefunction(value):
91
+ raise AsyncFilterError(name)
92
+
93
+ def __repr__(self) -> str:
94
+ paths = ", ".join(str(path) for path in self.paths)
95
+ return f"{type(self).__name__}({paths!r})"
96
+
97
+ @cached_property
98
+ def renderer(self) -> Jinja2SQL:
99
+ """The Jinja environment behind this, built on first use.
100
+
101
+ Raises:
102
+ MissingDependencyError: if the extra is not installed.
103
+
104
+ """
105
+ jinja2sql = _required(Jinja2SQL, "jinja2sql", "SQL templates", "sqlakit[sql]")
106
+ environment = jinja2.Environment(
107
+ loader=jinja2.FileSystemLoader([str(path) for path in self.paths]),
108
+ auto_reload=self.auto_reload,
109
+ autoescape=True,
110
+ )
111
+ environment.globals.update(self.globals)
112
+ # Always named parameters: what comes back is handed to `text()`,
113
+ # which reads `:name` and nothing else. The driver's own style is
114
+ # SQLAlchemy's business, and a template that picked one would be wrong
115
+ # on the next database.
116
+ renderer = jinja2sql(environment, param_style=_placeholder)
117
+ # Ours rather than jinja2sql's: the preparer of the database in hand
118
+ # knows both how it quotes and when it has to, which is the difference
119
+ # between `"name"` and `name` on Oracle.
120
+ renderer.register_filter("identifier", _identifier)
121
+ for name, filter_ in self.filters.items():
122
+ renderer.register_filter(name, filter_)
123
+ return renderer
124
+
125
+ def render(
126
+ self,
127
+ source: str,
128
+ context: Mapping[str, Any],
129
+ *,
130
+ preparer: Any, # noqa: ANN401 - SQLAlchemy's IdentifierPreparer
131
+ inline: bool = False,
132
+ ) -> tuple[str, Mapping[str, Any]]:
133
+ """Return the SQL of a template, and the values to bind to it.
134
+
135
+ Synchronous in both APIs: it reads a compiled template and builds a string.
136
+
137
+ Raises:
138
+ SQLNotConfiguredError: if a file is asked for and no path was given.
139
+ TemplateNotFoundError: if no path holds that template.
140
+
141
+ """
142
+ token = _preparer.set(preparer)
143
+ try:
144
+ if inline:
145
+ sql, params = self.renderer.from_string(source, context=context)
146
+ else:
147
+ if not self.paths:
148
+ raise SQLNotConfiguredError
149
+ try:
150
+ sql, params = self.renderer.from_file(source, context=context)
151
+ except jinja2.TemplateNotFound:
152
+ raise TemplateNotFoundError(source, self.paths) from None
153
+ finally:
154
+ _preparer.reset(token)
155
+ # Always a mapping: the parameters are named, and only a positional
156
+ # style would hand back a sequence.
157
+ return sql, cast("Mapping[str, Any]", params)
158
+
159
+ def check(self) -> None:
160
+ """Compile every `.sql` template, so a broken one fails where deploys do.
161
+
162
+ Raises:
163
+ SQLNotConfiguredError: if there is nowhere to look, which makes checking
164
+ a lie rather than a pass.
165
+ jinja2.TemplateSyntaxError: naming the file and the line.
166
+
167
+ """
168
+ if not self.paths:
169
+ raise SQLNotConfiguredError
170
+ environment = self.renderer.env
171
+ for name in environment.list_templates(extensions=("sql",)):
172
+ environment.get_template(name)
173
+
174
+
175
+ class BaseSQLQuery(Generic[RowT, DatabaseT]):
176
+ """Where the SQL comes from, its context, and what its rows become.
177
+
178
+ Built by `db.sql(...)`. Nothing can be narrowed: what the SQL selects is
179
+ what comes back.
180
+ """
181
+
182
+ def __init__( # noqa: PLR0913 - the shape of a query, not a call site
183
+ self,
184
+ db: DatabaseT,
185
+ source: str | Executable,
186
+ context: Mapping[str, Any],
187
+ *,
188
+ inline: bool = False,
189
+ type_: type[Any] | None = None,
190
+ scalar: bool = False,
191
+ ) -> None:
192
+ self.db = db
193
+ self.source = source
194
+ self.context = context
195
+ self.inline = inline
196
+ self.type = type_
197
+ self.scalar = scalar
198
+
199
+ def __repr__(self) -> str:
200
+ return f"{type(self).__name__}({self.source!r})"
201
+
202
+ @cached_property
203
+ def statement(self) -> Executable:
204
+ """The SQL this runs, rendered and bound.
205
+
206
+ A test asserts on it, and `EXPLAIN` takes it. A statement handed over ready
207
+ is itself.
208
+ """
209
+ if not isinstance(self.source, str):
210
+ return self.source
211
+ dialect = self.db.engine.dialect
212
+ context = {"dialect": dialect.name, **self.context}
213
+ sql, params = templates_of(self.db).render(
214
+ self.source,
215
+ context,
216
+ preparer=dialect.identifier_preparer,
217
+ inline=self.inline,
218
+ )
219
+ return _statement(sql, params, label=None if self.inline else self.source)
220
+
221
+ def __clause_element__(self) -> Executable:
222
+ """Stand in for the statement wherever SQLAlchemy expects one.
223
+
224
+ ```python
225
+ User.query.from_statement(db.sql("users/active.sql", team="red")).all()
226
+ ```
227
+ """
228
+ return self.statement
229
+
230
+ def _as(self, query: type[QueryT], **changes: Any) -> QueryT: # noqa: ANN401
231
+ """Return the same template read another way, as another class.
232
+
233
+ The classes are the API: what a query no longer offers, it no longer
234
+ has, so `typed()` cannot be called on rows that carry a type already.
235
+ """
236
+ arguments = {
237
+ "inline": self.inline,
238
+ "type_": self.type,
239
+ "scalar": self.scalar,
240
+ **changes,
241
+ }
242
+ return query(self.db, self.source, self.context, **arguments)
243
+
244
+ def _shaped(self, rows: Sequence[Any]) -> Sequence[Any]:
245
+ if self.type is None:
246
+ return rows
247
+ adapter = _adapter(self.type)
248
+ return [adapter.validate_python(_as_python(row, self.type)) for row in rows]
249
+
250
+ def _shaped_one(self, row: Any) -> Any: # noqa: ANN401
251
+ if self.type is None or row is None:
252
+ return row
253
+ return _adapter(self.type).validate_python(_as_python(row, self.type))
254
+
255
+ def _executable(self, *, size: int | None = None) -> Executable:
256
+ if size is None:
257
+ return self.statement
258
+ return self.statement.execution_options(yield_per=size)
259
+
260
+
261
+ def _identifier(value: Any) -> Markup: # noqa: ANN401
262
+ """Return a name quoted the way the database in hand quotes one.
263
+
264
+ The preparer decides both the quoting character and whether a name needs
265
+ quoting at all: `name` is left alone on Oracle, where a quoted lowercase
266
+ name is a different, non-existent column.
267
+ """
268
+ parts = (value,) if isinstance(value, str) else value
269
+ preparer = _preparer.get()
270
+ # The preparer escapes what it quotes; nothing here reaches the SQL raw.
271
+ return Markup(".".join(preparer.quote(str(part)) for part in parts)) # noqa: S704
272
+
273
+
274
+ def _placeholder(name: str, index: int) -> str: # noqa: ARG001 - the style's shape
275
+ """Return the placeholder a value renders as.
276
+
277
+ A space follows it so that a cast can: `{{ id }}::uuid` renders `:id__1
278
+ ::uuid`, and `text()` reads the parameter and leaves the cast alone. Without
279
+ the space it reads `:id__` and the statement never runs.
280
+ """
281
+ return f":{name} "
282
+
283
+
284
+ def require_pydantic() -> None:
285
+ """Raise unless pydantic is installed, which `typed()` validates rows with.
286
+
287
+ Raises:
288
+ MissingDependencyError: if it is not.
289
+
290
+ """
291
+ _required(TypeAdapter, "pydantic", "`typed()`")
292
+
293
+
294
+ def templates_of(db: BaseDatabase[Any, Any]) -> Templates:
295
+ """Return the templates of this database, made once and kept on it."""
296
+ templates = db.templates
297
+ if not isinstance(templates, Templates):
298
+ templates = Templates() if templates is None else Templates(templates)
299
+ db.templates = templates
300
+ return templates
301
+
302
+
303
+ def _required(
304
+ module: Any, # noqa: ANN401
305
+ package: str,
306
+ needed_by: str,
307
+ install: str | None = None,
308
+ ) -> Any: # noqa: ANN401
309
+ """Return it, or say what to install.
310
+
311
+ Raises:
312
+ MissingDependencyError: if the import failed.
313
+
314
+ """
315
+ if module is None:
316
+ raise MissingDependencyError(package, needed_by, install)
317
+ return module
318
+
319
+
320
+ def _statement(
321
+ sql: str,
322
+ params: Mapping[str, Any],
323
+ *,
324
+ label: str | None,
325
+ ) -> sa.TextClause:
326
+ """Return the SQL as a statement, with every value bound to it.
327
+
328
+ The template's name goes in as a comment, so a slow query log and `Recording`
329
+ say which file the SQL came from.
330
+
331
+ Raises:
332
+ StrayParameterError: if the SQL holds something SQLAlchemy reads as a
333
+ parameter that nothing binds, such as a colon inside a JSON literal.
334
+
335
+ """
336
+ if label is not None:
337
+ # `*/` in a name would end the comment early and leak into the SQL.
338
+ sql = f"/* {label.replace('*/', '* /')} */\n{sql}"
339
+ clause = sa.text(sql)
340
+ stray = set(clause._bindparams) - set(params) # noqa: SLF001
341
+ if stray:
342
+ raise StrayParameterError(sorted(stray), label)
343
+ return clause.bindparams(*(_bound(name, value) for name, value in params.items()))
344
+
345
+
346
+ def _bound(name: str, value: Any) -> sa.BindParameter[Any]: # noqa: ANN401
347
+ """Return the parameter to bind, as the value it holds asks to be bound.
348
+
349
+ A sequence becomes an expanding parameter, so `IN :ids` is a list rather
350
+ than a syntax error. A `bindparam` of your own carries its type through,
351
+ which is how a value the driver cannot type is spelled out.
352
+ """
353
+ if isinstance(value, sa.BindParameter):
354
+ return sa.bindparam(
355
+ name,
356
+ _expandable(value.value),
357
+ type_=value.type,
358
+ expanding=value.expanding or _expands(value.value),
359
+ )
360
+ return sa.bindparam(name, _expandable(value), expanding=_expands(value))
361
+
362
+
363
+ def _expands(value: Any) -> bool: # noqa: ANN401
364
+ """Whether this is many values rather than one."""
365
+ return isinstance(value, list | tuple | set | frozenset)
366
+
367
+
368
+ def _expandable(value: Any) -> Any: # noqa: ANN401
369
+ """Return it as the list an expanding parameter takes."""
370
+ return list(value) if _expands(value) else value
371
+
372
+
373
+ def _as_python(row: Any, type_: Any) -> Any: # noqa: ANN401
374
+ """Return what pydantic validates: the whole row, or one column of it.
375
+
376
+ The type decides. One that reads a mapping takes the row as a mapping;
377
+ anything else takes the first column's value, so `SELECT count(*)` reads
378
+ as an `int` and a JSON column reads as what it holds.
379
+ """
380
+ mapping = getattr(row, "_mapping", None)
381
+ if mapping is None:
382
+ return row
383
+ if _reads_a_row(type_):
384
+ return dict(mapping)
385
+ return next(iter(mapping.values()), None)
386
+
387
+
388
+ @cache
389
+ def _reads_a_row(type_: Any) -> bool: # noqa: ANN401
390
+ """Whether this type is built from a row's columns rather than one value."""
391
+ origin = get_origin(type_) or type_
392
+ if is_typeddict(type_):
393
+ return True
394
+ if not isinstance(origin, type):
395
+ return False
396
+ if BaseModel is not None and issubclass(origin, BaseModel):
397
+ return True
398
+ if is_dataclass(origin):
399
+ return True
400
+ if issubclass(origin, tuple) and hasattr(origin, "_fields"): # a NamedTuple
401
+ return True
402
+ return issubclass(origin, Mapping)
403
+
404
+
405
+ @cache
406
+ def _adapter(type_: Any) -> TypeAdapter[Any]: # noqa: ANN401
407
+ """Return the adapter for this type, built once for the process."""
408
+ return TypeAdapter(type_)
@@ -0,0 +1,4 @@
1
+ from ._db import Database, RetryingTransaction, Transaction
2
+ from ._registry import Databases, db
3
+
4
+ __all__ = ["Database", "Databases", "RetryingTransaction", "Transaction", "db"]