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/__init__.py +99 -0
- sqlakit/_base.py +995 -0
- sqlakit/_db.py +540 -0
- sqlakit/_discovery.py +79 -0
- sqlakit/_model.py +275 -0
- sqlakit/_query.py +1156 -0
- sqlakit/_recording.py +385 -0
- sqlakit/_registry.py +69 -0
- sqlakit/_routing.py +43 -0
- sqlakit/_sql.py +408 -0
- sqlakit/asyncio/__init__.py +4 -0
- sqlakit/asyncio/_db.py +552 -0
- sqlakit/asyncio/_registry.py +57 -0
- sqlakit/asyncio/orm.py +662 -0
- sqlakit/asyncio/sql.py +222 -0
- sqlakit/exceptions.py +423 -0
- sqlakit/orm.py +645 -0
- sqlakit/py.typed +0 -0
- sqlakit/sql.py +211 -0
- sqlakit/testing.py +91 -0
- sqlakit/types.py +104 -0
- sqlakit-0.1.0.dist-info/METADATA +408 -0
- sqlakit-0.1.0.dist-info/RECORD +25 -0
- sqlakit-0.1.0.dist-info/WHEEL +4 -0
- sqlakit-0.1.0.dist-info/licenses/LICENSE +21 -0
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_)
|