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,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
|
|
7
|
+
from ._sql import (
|
|
8
|
+
BaseSQLQuery,
|
|
9
|
+
Templates,
|
|
10
|
+
require_pydantic,
|
|
11
|
+
templates_of,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
|
|
17
|
+
from sqlalchemy.engine import Result, ScalarResult
|
|
18
|
+
from sqlalchemy.sql import Executable
|
|
19
|
+
|
|
20
|
+
from ._db import Database
|
|
21
|
+
|
|
22
|
+
__all__ = ["SQL", "SQLQuery", "SQLRows", "Templates"]
|
|
23
|
+
|
|
24
|
+
RowT = TypeVar("RowT")
|
|
25
|
+
OtherT = TypeVar("OtherT")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SQL:
|
|
29
|
+
"""The SQL templates of one database, ready to run.
|
|
30
|
+
|
|
31
|
+
Reached as `db.sql`, and where the templates are is the database's own
|
|
32
|
+
`templates=`:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
db = Database(DB_URL, templates="app/sql")
|
|
36
|
+
|
|
37
|
+
db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
38
|
+
db.sql.from_string("SELECT count(*) FROM users").scalars().one()
|
|
39
|
+
```
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, db: Database) -> None:
|
|
43
|
+
self.db = db
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"{type(self).__name__}({self.db!r})"
|
|
47
|
+
|
|
48
|
+
def __call__(self, template: str, **context: Any) -> SQLQuery: # noqa: ANN401
|
|
49
|
+
"""Read the rows of a template. Short for `from_file`.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
db.sql("users/active.sql", team="red").all()
|
|
53
|
+
```
|
|
54
|
+
"""
|
|
55
|
+
return self.from_file(template, **context)
|
|
56
|
+
|
|
57
|
+
def from_file(self, template: str, **context: Any) -> SQLQuery: # noqa: ANN401
|
|
58
|
+
"""Read the rows of a template kept under the database's ``templates=``.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
db.sql.from_file("users/active.sql", team="red").all()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The keyword arguments are the template's context.
|
|
65
|
+
"""
|
|
66
|
+
return SQLQuery(self.db, template, context)
|
|
67
|
+
|
|
68
|
+
def from_string(self, source: str, **context: Any) -> SQLQuery: # noqa: ANN401
|
|
69
|
+
"""Read the rows of SQL written out here rather than kept in a file.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
db.sql.from_string("SELECT id FROM users WHERE team = {{ team }}", team="red")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Values are named in `{{ }}` and passed by keyword, as in a template. A
|
|
76
|
+
`:name` or a `?` binds nothing here, and rendering says so rather than
|
|
77
|
+
reaching the driver. It needs no ``templates=``.
|
|
78
|
+
"""
|
|
79
|
+
return SQLQuery(self.db, source, context, inline=True)
|
|
80
|
+
|
|
81
|
+
def from_statement(self, statement: Executable) -> SQLQuery:
|
|
82
|
+
"""Read the rows of a statement built with SQLAlchemy.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
db.sql.from_statement(sa.text("SELECT ...").bindparams(id=1)).all()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Nothing is rendered: the statement is the one that runs, parameters
|
|
89
|
+
and all. What this adds is the reading, `typed` and `chunks` included.
|
|
90
|
+
"""
|
|
91
|
+
return SQLQuery(self.db, statement, {})
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def templates(self) -> Templates:
|
|
95
|
+
"""Where this database looks for its templates."""
|
|
96
|
+
return templates_of(self.db)
|
|
97
|
+
|
|
98
|
+
def check(self) -> None:
|
|
99
|
+
"""Compile every `.sql` template, so a broken one fails where deploys do.
|
|
100
|
+
|
|
101
|
+
Call it at startup, next to the rest of the wiring: a template is read
|
|
102
|
+
when something asks for it, and that is a poor time to find a typo.
|
|
103
|
+
"""
|
|
104
|
+
self.templates.check()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SQLRows(BaseSQLQuery[RowT, "Database"]):
|
|
108
|
+
"""The rows of a SQL template, on the connection of the block it runs in.
|
|
109
|
+
|
|
110
|
+
What the rows are is settled: reading them is all that is left.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def all(self) -> Sequence[RowT]:
|
|
114
|
+
"""Return every row."""
|
|
115
|
+
return cast("Sequence[RowT]", self._shaped(self._rows().all()))
|
|
116
|
+
|
|
117
|
+
def first(self) -> RowT | None:
|
|
118
|
+
"""Return the first row, or None."""
|
|
119
|
+
return cast("RowT | None", self._shaped_one(self._rows().first()))
|
|
120
|
+
|
|
121
|
+
def one(self) -> RowT:
|
|
122
|
+
"""Return the single row.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
NoResultFound: if there is none.
|
|
126
|
+
MultipleResultsFound: if there is more than one.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
return cast("RowT", self._shaped_one(self._rows().one()))
|
|
130
|
+
|
|
131
|
+
def one_or_none(self) -> RowT | None:
|
|
132
|
+
"""Return the single row, or None.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
MultipleResultsFound: if there is more than one.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
return cast("RowT | None", self._shaped_one(self._rows().one_or_none()))
|
|
139
|
+
|
|
140
|
+
def chunks(self, size: int) -> Iterator[Sequence[RowT]]:
|
|
141
|
+
"""Read every row, ``size`` of them at a time.
|
|
142
|
+
|
|
143
|
+
One statement, fetched in batches, for a job that walks a table too large to
|
|
144
|
+
hold. The rows come off a cursor the database holds open, so the whole walk
|
|
145
|
+
is one transaction.
|
|
146
|
+
"""
|
|
147
|
+
for batch in self._rows(size=size).partitions(size):
|
|
148
|
+
yield cast("Sequence[RowT]", self._shaped(batch))
|
|
149
|
+
|
|
150
|
+
def execute(self) -> int:
|
|
151
|
+
"""Run it for what it writes, and return how many rows it touched.
|
|
152
|
+
|
|
153
|
+
For a template that inserts, updates or deletes. Inside a transaction the
|
|
154
|
+
write is part of it and the block decides. In a block with no transaction
|
|
155
|
+
the call commits for itself, as ORM writes do.
|
|
156
|
+
"""
|
|
157
|
+
connection = self._connection()
|
|
158
|
+
result = connection.execute(self.statement)
|
|
159
|
+
if not self.db.in_transaction():
|
|
160
|
+
connection.commit()
|
|
161
|
+
return result.rowcount
|
|
162
|
+
|
|
163
|
+
def _rows(self, *, size: int | None = None) -> Result[Any] | ScalarResult[Any]:
|
|
164
|
+
result = self._connection().execute(self._executable(size=size))
|
|
165
|
+
return result.scalars() if self.scalar else result
|
|
166
|
+
|
|
167
|
+
def _connection(self) -> sa.Connection:
|
|
168
|
+
"""Return this block's connection, with any pending ORM writes on it."""
|
|
169
|
+
if self.db.in_session():
|
|
170
|
+
self.db.session.flush()
|
|
171
|
+
return self.db.connection
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SQLQuery(SQLRows[sa.Row[Any]]):
|
|
175
|
+
"""The rows a SQL template returns, as `db.sql(...)` hands them over.
|
|
176
|
+
|
|
177
|
+
`typed` and `scalars` say what one row is; both return rows that read the
|
|
178
|
+
same way and carry no further say, so each is asked once.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def typed(self, type_: type[OtherT], /) -> SQLRows[OtherT]:
|
|
182
|
+
"""Read the rows as this type, one row at a time.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The type is what one row becomes, and the terminal decides the container.
|
|
189
|
+
Anything pydantic can validate works: a model, a dataclass, a
|
|
190
|
+
`TypedDict`. The type also says how much of the row it takes: one built
|
|
191
|
+
from columns is given the whole row, and anything else is given the
|
|
192
|
+
first column, so `SELECT count(*)` with `typed(int)` reads as an `int`.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
MissingDependencyError: if pydantic is not installed.
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
require_pydantic()
|
|
199
|
+
return cast("SQLRows[OtherT]", self._as(SQLRows, type_=type_))
|
|
200
|
+
|
|
201
|
+
def scalars(self) -> SQLRows[Any]:
|
|
202
|
+
"""Read the first column of each row instead of whole rows.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
db.sql.from_string("SELECT count(*) FROM users").scalars().one()
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`typed()` does the same when the type is worth naming; this is for when it is
|
|
209
|
+
not, and needs no pydantic.
|
|
210
|
+
"""
|
|
211
|
+
return self._as(SQLRows, scalar=True)
|
sqlakit/testing.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Helpers a test needs of a database, beyond a rollback and a schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import ExitStack, contextmanager
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from . import db as sync_db
|
|
9
|
+
from ._recording import Recording, check, require_expectation
|
|
10
|
+
from .asyncio import db as async_db
|
|
11
|
+
from .exceptions import UnknownDatabaseError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
__all__ = ["assert_queries"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def assert_queries(
|
|
21
|
+
count: int | None = None,
|
|
22
|
+
*,
|
|
23
|
+
at_most: int | None = None,
|
|
24
|
+
duplicates: bool = True,
|
|
25
|
+
using: str | Any = None, # noqa: ANN401
|
|
26
|
+
) -> Iterator[Recording]:
|
|
27
|
+
"""Assert what the block asks of the database.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
with assert_queries(2):
|
|
31
|
+
User.query.order_by("name").page(limit=10)
|
|
32
|
+
|
|
33
|
+
with assert_queries(at_most=5):
|
|
34
|
+
render(dashboard)
|
|
35
|
+
|
|
36
|
+
with assert_queries(duplicates=False):
|
|
37
|
+
render(users) # the N+1 test, without a number
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The three checks stand alone or together: a count, a ceiling, and whether a
|
|
41
|
+
statement may run twice. What fails prints the statements, numbered and
|
|
42
|
+
timed, with the repeated ones pointing at each other.
|
|
43
|
+
|
|
44
|
+
``using`` is the database to watch, as an alias or as the database itself.
|
|
45
|
+
Left out, it watches the importable registries, `sqlakit.db` and
|
|
46
|
+
`sqlakit.asyncio.db`, and every database each of them has. Awaited work is
|
|
47
|
+
watched the same way, so the block stays `with`.
|
|
48
|
+
|
|
49
|
+
Transaction control is not counted: `BEGIN` and `COMMIT` reach a cursor on
|
|
50
|
+
some drivers and not others.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
TypeError: if there is nothing to assert, or nothing to watch.
|
|
54
|
+
UnknownDatabaseError: if no configured registry has that alias.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
require_expectation(count, at_most, duplicates)
|
|
58
|
+
|
|
59
|
+
with ExitStack() as stack:
|
|
60
|
+
recording = Recording()
|
|
61
|
+
for db in _watched(using):
|
|
62
|
+
stack.enter_context(db.recording(into=recording))
|
|
63
|
+
yield recording
|
|
64
|
+
|
|
65
|
+
check(recording, count=count, at_most=at_most, duplicates=duplicates)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _watched(using: str | Any) -> list[Any]: # noqa: ANN401
|
|
69
|
+
"""Return the databases to record: the one named, or every configured one."""
|
|
70
|
+
if using is None:
|
|
71
|
+
return _configured()
|
|
72
|
+
if not isinstance(using, str):
|
|
73
|
+
return [using]
|
|
74
|
+
registries = _configured()
|
|
75
|
+
watched = [registry[using] for registry in registries if using in registry]
|
|
76
|
+
if not watched:
|
|
77
|
+
known = {alias for registry in registries for alias in registry.aliases}
|
|
78
|
+
raise UnknownDatabaseError(using, tuple(sorted(known)))
|
|
79
|
+
return watched
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _configured() -> list[Any]:
|
|
83
|
+
"""Return the importable registries an application has configured."""
|
|
84
|
+
registries = [db for db in (sync_db, async_db) if db.is_configured]
|
|
85
|
+
if not registries:
|
|
86
|
+
message = (
|
|
87
|
+
"assert_queries has no database to watch. Configure `sqlakit.db`, "
|
|
88
|
+
"or name one with `assert_queries(..., using=db)`."
|
|
89
|
+
)
|
|
90
|
+
raise TypeError(message)
|
|
91
|
+
return registries
|
sqlakit/types.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
from sqlalchemy.engine import Connection, Engine
|
|
10
|
+
from sqlalchemy.orm import Query, Session
|
|
11
|
+
from sqlalchemy.pool import Pool
|
|
12
|
+
|
|
13
|
+
__all__ = ["DatabaseConfig", "EngineArgs", "SessionArgs", "UrlParts"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EngineArgs(TypedDict, total=False):
|
|
17
|
+
"""Keyword arguments accepted by [`sqlalchemy.create_engine`](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine)."""
|
|
18
|
+
|
|
19
|
+
connect_args: dict[str, Any]
|
|
20
|
+
echo: bool | Literal["debug"]
|
|
21
|
+
echo_pool: bool | Literal["debug"]
|
|
22
|
+
enable_from_linting: bool
|
|
23
|
+
execution_options: dict[str, Any]
|
|
24
|
+
hide_parameters: bool
|
|
25
|
+
insertmanyvalues_page_size: int
|
|
26
|
+
isolation_level: str
|
|
27
|
+
json_deserializer: Callable[[str], Any]
|
|
28
|
+
json_serializer: Callable[[Any], str]
|
|
29
|
+
label_length: int | None
|
|
30
|
+
logging_name: str
|
|
31
|
+
max_identifier_length: int | None
|
|
32
|
+
max_overflow: int
|
|
33
|
+
module: Any
|
|
34
|
+
paramstyle: Literal["qmark", "numeric", "named", "format", "pyformat"]
|
|
35
|
+
plugins: list[str]
|
|
36
|
+
pool: Pool
|
|
37
|
+
pool_logging_name: str
|
|
38
|
+
pool_pre_ping: bool
|
|
39
|
+
pool_recycle: int
|
|
40
|
+
pool_reset_on_return: Literal["rollback", "commit"] | None
|
|
41
|
+
pool_size: int
|
|
42
|
+
pool_timeout: float
|
|
43
|
+
pool_use_lifo: bool
|
|
44
|
+
poolclass: type[Pool]
|
|
45
|
+
query_cache_size: int
|
|
46
|
+
skip_autocommit_rollback: bool
|
|
47
|
+
use_insertmanyvalues: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionArgs(TypedDict, total=False):
|
|
51
|
+
"""Keyword arguments accepted by `sqlalchemy.orm.sessionmaker`.
|
|
52
|
+
|
|
53
|
+
No ``bind``: sessions bind to the connection of the surrounding block.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
autobegin: bool
|
|
57
|
+
autoflush: bool
|
|
58
|
+
binds: dict[Any, Engine | Connection]
|
|
59
|
+
class_: type[Session]
|
|
60
|
+
enable_baked_queries: bool
|
|
61
|
+
expire_on_commit: bool
|
|
62
|
+
info: dict[Any, Any]
|
|
63
|
+
join_transaction_mode: Literal[
|
|
64
|
+
"conditional_savepoint",
|
|
65
|
+
"rollback_only",
|
|
66
|
+
"control_fully",
|
|
67
|
+
"create_savepoint",
|
|
68
|
+
]
|
|
69
|
+
query_cls: type[Query[Any]]
|
|
70
|
+
twophase: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UrlParts(TypedDict, total=False):
|
|
74
|
+
"""A database URL spelled out, as `sqlalchemy.URL.create` takes it."""
|
|
75
|
+
|
|
76
|
+
drivername: str
|
|
77
|
+
username: str | None
|
|
78
|
+
password: str | None
|
|
79
|
+
host: str | None
|
|
80
|
+
port: int | None
|
|
81
|
+
database: str | None
|
|
82
|
+
query: Mapping[str, Sequence[str] | str]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class QueryStats(TypedDict):
|
|
86
|
+
"""The numbers a recording adds up to, for a log read by machine."""
|
|
87
|
+
|
|
88
|
+
queries: int
|
|
89
|
+
milliseconds: float
|
|
90
|
+
slowest_milliseconds: float
|
|
91
|
+
duplicated: int
|
|
92
|
+
databases: tuple[str, ...]
|
|
93
|
+
label: str | None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DatabaseConfig(UrlParts, total=False):
|
|
97
|
+
"""One database in a configuration keyed by alias.
|
|
98
|
+
|
|
99
|
+
Give it a ``url`` or the parts to build one from, never both.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
url: str | sa.URL
|
|
103
|
+
engine_args: EngineArgs
|
|
104
|
+
session_args: SessionArgs
|