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,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