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/asyncio/sql.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
|
|
7
|
+
from sqlakit._sql import (
|
|
8
|
+
BaseSQLQuery,
|
|
9
|
+
Templates,
|
|
10
|
+
require_pydantic,
|
|
11
|
+
templates_of,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import AsyncIterator, Sequence
|
|
16
|
+
|
|
17
|
+
from sqlalchemy.engine import Result, ScalarResult
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
19
|
+
from sqlalchemy.sql import Executable
|
|
20
|
+
|
|
21
|
+
from ._db import Database
|
|
22
|
+
|
|
23
|
+
__all__ = ["SQL", "SQLQuery", "SQLRows", "Templates"]
|
|
24
|
+
|
|
25
|
+
RowT = TypeVar("RowT")
|
|
26
|
+
OtherT = TypeVar("OtherT")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SQL:
|
|
30
|
+
"""The SQL templates of one database, awaited.
|
|
31
|
+
|
|
32
|
+
Reached as `db.sql`, and where the templates are is the database's own
|
|
33
|
+
`templates=`:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
db = Database(DB_URL, templates="app/sql")
|
|
37
|
+
|
|
38
|
+
await db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
39
|
+
await db.sql.from_string("SELECT count(*) FROM users").scalars().one()
|
|
40
|
+
```
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, db: Database) -> None:
|
|
44
|
+
self.db = db
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return f"{type(self).__name__}({self.db!r})"
|
|
48
|
+
|
|
49
|
+
def __call__(self, template: str, **context: Any) -> SQLQuery: # noqa: ANN401
|
|
50
|
+
"""Read the rows of a template. Short for `from_file`.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
await db.sql("users/active.sql", team="red").all()
|
|
54
|
+
```
|
|
55
|
+
"""
|
|
56
|
+
return self.from_file(template, **context)
|
|
57
|
+
|
|
58
|
+
def from_file(self, template: str, **context: Any) -> SQLQuery: # noqa: ANN401
|
|
59
|
+
"""Read the rows of a template kept under the database's ``templates=``.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
await db.sql.from_file("users/active.sql", team="red").all()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The keyword arguments are the template's context.
|
|
66
|
+
"""
|
|
67
|
+
return SQLQuery(self.db, template, context)
|
|
68
|
+
|
|
69
|
+
def from_string(self, source: str, **context: Any) -> SQLQuery: # noqa: ANN401
|
|
70
|
+
"""Read the rows of SQL written out here rather than kept in a file.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
await db.sql.from_string(
|
|
74
|
+
"SELECT id FROM users WHERE team = {{ team }}", team="red"
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Values are named in `{{ }}` and passed by keyword, as in a template. A
|
|
79
|
+
`:name` or a `?` binds nothing here, and rendering says so rather than
|
|
80
|
+
reaching the driver. It needs no ``templates=``.
|
|
81
|
+
"""
|
|
82
|
+
return SQLQuery(self.db, source, context, inline=True)
|
|
83
|
+
|
|
84
|
+
def from_statement(self, statement: Executable) -> SQLQuery:
|
|
85
|
+
"""Read the rows of a statement built with SQLAlchemy.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
await db.sql.from_statement(sa.text("SELECT ...").bindparams(id=1)).all()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Nothing is rendered: the statement is the one that runs, parameters
|
|
92
|
+
and all. What this adds is the reading, `typed` and `chunks` included.
|
|
93
|
+
"""
|
|
94
|
+
return SQLQuery(self.db, statement, {})
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def templates(self) -> Templates:
|
|
98
|
+
"""Where this database looks for its templates."""
|
|
99
|
+
return templates_of(self.db)
|
|
100
|
+
|
|
101
|
+
def check(self) -> None:
|
|
102
|
+
"""Compile every `.sql` template, so a broken one fails where deploys do.
|
|
103
|
+
|
|
104
|
+
Call it at startup, next to the rest of the wiring: a template is read
|
|
105
|
+
when something asks for it, and that is a poor time to find a typo.
|
|
106
|
+
"""
|
|
107
|
+
self.templates.check()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SQLRows(BaseSQLQuery[RowT, "Database"]):
|
|
111
|
+
"""The rows of a SQL template, on the connection of the block it runs in.
|
|
112
|
+
|
|
113
|
+
What the rows are is settled: reading them is all that is left.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
async def all(self) -> Sequence[RowT]:
|
|
117
|
+
"""Return every row."""
|
|
118
|
+
rows = await self._rows()
|
|
119
|
+
return cast("Sequence[RowT]", self._shaped(rows.all()))
|
|
120
|
+
|
|
121
|
+
async def first(self) -> RowT | None:
|
|
122
|
+
"""Return the first row, or None."""
|
|
123
|
+
rows = await self._rows()
|
|
124
|
+
return cast("RowT | None", self._shaped_one(rows.first()))
|
|
125
|
+
|
|
126
|
+
async def one(self) -> RowT:
|
|
127
|
+
"""Return the single row.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
NoResultFound: if there is none.
|
|
131
|
+
MultipleResultsFound: if there is more than one.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
rows = await self._rows()
|
|
135
|
+
return cast("RowT", self._shaped_one(rows.one()))
|
|
136
|
+
|
|
137
|
+
async def one_or_none(self) -> RowT | None:
|
|
138
|
+
"""Return the single row, or None.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
MultipleResultsFound: if there is more than one.
|
|
142
|
+
|
|
143
|
+
"""
|
|
144
|
+
rows = await self._rows()
|
|
145
|
+
return cast("RowT | None", self._shaped_one(rows.one_or_none()))
|
|
146
|
+
|
|
147
|
+
async def chunks(self, size: int) -> AsyncIterator[Sequence[RowT]]:
|
|
148
|
+
"""Read every row, ``size`` of them at a time.
|
|
149
|
+
|
|
150
|
+
One statement, fetched in batches, for a job that walks a table too large to
|
|
151
|
+
hold. The rows come off a cursor the database holds open, so the whole walk
|
|
152
|
+
is one transaction.
|
|
153
|
+
"""
|
|
154
|
+
connection = await self._connection()
|
|
155
|
+
streamed = await connection.stream(self._executable(size=size))
|
|
156
|
+
rows = streamed.scalars() if self.scalar else streamed
|
|
157
|
+
async for batch in rows.partitions(size):
|
|
158
|
+
yield cast("Sequence[RowT]", self._shaped(batch))
|
|
159
|
+
|
|
160
|
+
async def execute(self) -> int:
|
|
161
|
+
"""Run it for what it writes, and return how many rows it touched.
|
|
162
|
+
|
|
163
|
+
For a template that inserts, updates or deletes. Inside a transaction the
|
|
164
|
+
write is part of it and the block decides. In a block with no transaction
|
|
165
|
+
the call commits for itself, as ORM writes do.
|
|
166
|
+
"""
|
|
167
|
+
connection = await self._connection()
|
|
168
|
+
result = await connection.execute(self.statement)
|
|
169
|
+
if not self.db.in_transaction():
|
|
170
|
+
await connection.commit()
|
|
171
|
+
return result.rowcount
|
|
172
|
+
|
|
173
|
+
async def _rows(self) -> Result[Any] | ScalarResult[Any]:
|
|
174
|
+
connection = await self._connection()
|
|
175
|
+
result = await connection.execute(self.statement)
|
|
176
|
+
return result.scalars() if self.scalar else result
|
|
177
|
+
|
|
178
|
+
async def _connection(self) -> AsyncConnection:
|
|
179
|
+
"""Return this block's connection, with any pending ORM writes on it."""
|
|
180
|
+
if self.db.in_session():
|
|
181
|
+
await self.db.session.flush()
|
|
182
|
+
return self.db.connection
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class SQLQuery(SQLRows[sa.Row[Any]]):
|
|
186
|
+
"""The rows a SQL template returns, as `db.sql(...)` hands them over.
|
|
187
|
+
|
|
188
|
+
`typed` and `scalars` say what one row is; both return rows that read the
|
|
189
|
+
same way and carry no further say, so each is asked once.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def typed(self, type_: type[OtherT], /) -> SQLRows[OtherT]:
|
|
193
|
+
"""Read the rows as this type, one row at a time.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
await db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The type is what one row becomes, and the terminal decides the container.
|
|
200
|
+
Anything pydantic can validate works: a model, a dataclass, a
|
|
201
|
+
`TypedDict`. The type also says how much of the row it takes: one built
|
|
202
|
+
from columns is given the whole row, and anything else is given the
|
|
203
|
+
first column, so `SELECT count(*)` with `typed(int)` reads as an `int`.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
MissingDependencyError: if pydantic is not installed.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
require_pydantic()
|
|
210
|
+
return cast("SQLRows[OtherT]", self._as(SQLRows, type_=type_))
|
|
211
|
+
|
|
212
|
+
def scalars(self) -> SQLRows[Any]:
|
|
213
|
+
"""Read the first column of each row instead of whole rows.
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
await db.sql.from_string("SELECT count(*) FROM users").scalars().one()
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`typed()` does the same when the type is worth naming; this is for when it is
|
|
220
|
+
not, and needs no pydantic.
|
|
221
|
+
"""
|
|
222
|
+
return self._as(SQLRows, scalar=True)
|
sqlakit/exceptions.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.orm import exc as sa_exc
|
|
4
|
+
|
|
5
|
+
DEFAULT_ALIAS = "default"
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AsyncFilterError",
|
|
9
|
+
"BulkQueryError",
|
|
10
|
+
"ConflictingDatabaseUrlError",
|
|
11
|
+
"DatabaseAlreadyConfiguredError",
|
|
12
|
+
"DatabaseNotConfiguredError",
|
|
13
|
+
"DetachedInstanceError",
|
|
14
|
+
"InstanceNotFoundError",
|
|
15
|
+
"InvalidCursorError",
|
|
16
|
+
"InvalidDatabaseConfigError",
|
|
17
|
+
"InvalidOrderFieldError",
|
|
18
|
+
"MissingConnectionError",
|
|
19
|
+
"MissingDatabaseUrlError",
|
|
20
|
+
"MissingDefaultDatabaseError",
|
|
21
|
+
"MissingDependencyError",
|
|
22
|
+
"MissingSessionError",
|
|
23
|
+
"MultipleInstancesFoundError",
|
|
24
|
+
"NullCursorValueError",
|
|
25
|
+
"RawStatementError",
|
|
26
|
+
"RetryNotSupportedError",
|
|
27
|
+
"SQLAKitError",
|
|
28
|
+
"SQLNotConfiguredError",
|
|
29
|
+
"StrayParameterError",
|
|
30
|
+
"TemplateNotFoundError",
|
|
31
|
+
"TransactionRolledBackError",
|
|
32
|
+
"UnknownDatabaseError",
|
|
33
|
+
"UnknownFieldError",
|
|
34
|
+
"UnknownImportPathError",
|
|
35
|
+
"UnorderedPageError",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SQLAKitError(Exception):
|
|
40
|
+
"""Base class for all sqlakit errors."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MissingConnectionError(SQLAKitError, RuntimeError):
|
|
44
|
+
"""Raised when no connection is bound to the current context."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
message: str = (
|
|
49
|
+
"No connection is bound to the current context. "
|
|
50
|
+
"Enter `Database.connect()` or `Database.transaction()` first."
|
|
51
|
+
),
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MissingSessionError(SQLAKitError, RuntimeError):
|
|
57
|
+
"""Raised when no session is bound to the current context."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
message: str = (
|
|
62
|
+
"No session is bound to the current context. "
|
|
63
|
+
"Enter `Database.session_factory()` or `Database.connect()` first."
|
|
64
|
+
),
|
|
65
|
+
) -> None:
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RetryNotSupportedError(SQLAKitError, TypeError):
|
|
70
|
+
"""Raised when a transaction with ``retry_on`` is entered as a block."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
message: str = (
|
|
75
|
+
"A transaction with `retry_on` cannot be used as a context manager: "
|
|
76
|
+
"retrying re-runs the block, which only a decorator can do. "
|
|
77
|
+
"Use `@db.transaction(retry_on=...)` instead."
|
|
78
|
+
),
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DatabaseNotConfiguredError(SQLAKitError, RuntimeError):
|
|
84
|
+
"""Raised when the importable database is used before it has a URL."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
message: str = (
|
|
89
|
+
"The database is not configured. "
|
|
90
|
+
"Call `db.configure(url, ...)` once, at startup, before using it."
|
|
91
|
+
),
|
|
92
|
+
) -> None:
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DatabaseAlreadyConfiguredError(SQLAKitError, RuntimeError):
|
|
97
|
+
"""Raised when reconfiguring a database whose engine is already in use."""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
message: str = (
|
|
102
|
+
"The database is already connected. Dispose of the engine before "
|
|
103
|
+
"configuring it again."
|
|
104
|
+
),
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__(message)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UnknownDatabaseError(SQLAKitError, KeyError):
|
|
110
|
+
"""Raised when asking for a database alias that was never configured."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, alias: str, known: tuple[str, ...] = ()) -> None:
|
|
113
|
+
super().__init__(
|
|
114
|
+
f"No database is configured as {alias!r}. "
|
|
115
|
+
f"Configured: {', '.join(map(repr, known)) or 'none'}."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MissingDefaultDatabaseError(SQLAKitError, ValueError):
|
|
120
|
+
"""Raised when a configuration keyed by alias carries no default."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, aliases: tuple[str, ...] = ()) -> None:
|
|
123
|
+
super().__init__(
|
|
124
|
+
f"A configuration keyed by alias has to carry a {DEFAULT_ALIAS!r}: "
|
|
125
|
+
f"that is the database reached without naming an alias. "
|
|
126
|
+
f"Got: {', '.join(map(repr, aliases)) or 'nothing'}."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class InvalidDatabaseConfigError(SQLAKitError, ValueError):
|
|
131
|
+
"""Raised when a configuration cannot be turned into a database."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class MissingDatabaseUrlError(InvalidDatabaseConfigError):
|
|
135
|
+
"""Raised when a configuration says nowhere to connect."""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
message: str = (
|
|
140
|
+
"A database has to be configured with a `url`, or with at least a "
|
|
141
|
+
"`drivername` to build one from."
|
|
142
|
+
),
|
|
143
|
+
) -> None:
|
|
144
|
+
super().__init__(message)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ConflictingDatabaseUrlError(InvalidDatabaseConfigError):
|
|
148
|
+
"""Raised when a configuration says where to connect twice over."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, parts: tuple[str, ...] = ()) -> None:
|
|
151
|
+
super().__init__(
|
|
152
|
+
"A database is configured with a `url` or with its parts "
|
|
153
|
+
f"({', '.join(parts)}), not with both."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TransactionRolledBackError(SQLAKitError, RuntimeError):
|
|
158
|
+
"""Raised when a block finds its transaction already rolled back."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
message: str = (
|
|
163
|
+
"The transaction was rolled back from inside the block, leaving "
|
|
164
|
+
"nothing to commit. A session that only takes part in a "
|
|
165
|
+
"transaction rolls back the whole of it; for a block that has to "
|
|
166
|
+
"fail on its own, use `transaction(savepoint=True)`."
|
|
167
|
+
),
|
|
168
|
+
) -> None:
|
|
169
|
+
super().__init__(message)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DetachedInstanceError(SQLAKitError, sa_exc.DetachedInstanceError):
|
|
173
|
+
"""Raised when saving an instance whose session is gone.
|
|
174
|
+
|
|
175
|
+
A subclass of SQLAlchemy's error of the same name, so code that catches
|
|
176
|
+
either one catches this.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, model: str) -> None:
|
|
180
|
+
super().__init__(
|
|
181
|
+
f"This {model} belongs to a session that has closed, so saving it "
|
|
182
|
+
f"would silently copy it into another one. Load it again in this "
|
|
183
|
+
f"block, or merge it yourself with `session.merge(...)`."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class InstanceNotFoundError(SQLAKitError, sa_exc.NoResultFound):
|
|
188
|
+
"""Raised when a query that must match one instance matches none.
|
|
189
|
+
|
|
190
|
+
A subclass of SQLAlchemy's `NoResultFound`, so code that catches either one
|
|
191
|
+
catches this. It names the model, which is what an API answering 404 wants:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
except InstanceNotFoundError as error:
|
|
195
|
+
raise HTTPException(404, f"{error.model} not found")
|
|
196
|
+
```
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, model: str) -> None:
|
|
200
|
+
self.model = model
|
|
201
|
+
super().__init__(f"No {model} matches this query.")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class MultipleInstancesFoundError(SQLAKitError, sa_exc.MultipleResultsFound):
|
|
205
|
+
"""Raised when a query that must match one instance matches several.
|
|
206
|
+
|
|
207
|
+
A subclass of SQLAlchemy's `MultipleResultsFound`, so code that catches
|
|
208
|
+
either one catches this. Reaching it means the query is not as narrow as
|
|
209
|
+
the call assumed, or the column it narrows on is not unique.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, model: str) -> None:
|
|
213
|
+
self.model = model
|
|
214
|
+
super().__init__(f"More than one {model} matches this query.")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class InvalidCursorError(SQLAKitError, ValueError):
|
|
218
|
+
"""Raised when a pagination cursor was not made here, or not for this order."""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
message: str = (
|
|
223
|
+
"This cursor does not belong to this query. Pass back the one the "
|
|
224
|
+
"previous page returned, unchanged, and order the query the same way."
|
|
225
|
+
),
|
|
226
|
+
) -> None:
|
|
227
|
+
super().__init__(message)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class MissingDependencyError(SQLAKitError, ImportError):
|
|
231
|
+
"""Raised when something optional is used and its package is not installed."""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self, package: str, needed_by: str, install: str | None = None
|
|
235
|
+
) -> None:
|
|
236
|
+
self.package = package
|
|
237
|
+
super().__init__(
|
|
238
|
+
f"`{package}` is not installed, and {needed_by} cannot work "
|
|
239
|
+
f"without it. Install `{install or package}`."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class SQLNotConfiguredError(SQLAKitError, RuntimeError):
|
|
244
|
+
"""Raised when a database is asked for a template file and has no path."""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
message: str = (
|
|
249
|
+
"This database does not know where its SQL templates are. Pass "
|
|
250
|
+
"`templates=` to `Database(...)` or to `db.configure(...)`, or "
|
|
251
|
+
"write the SQL out with `db.sql.from_string(...)`."
|
|
252
|
+
),
|
|
253
|
+
) -> None:
|
|
254
|
+
super().__init__(message)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class TemplateNotFoundError(SQLAKitError, FileNotFoundError):
|
|
258
|
+
"""Raised when no configured path holds the template asked for."""
|
|
259
|
+
|
|
260
|
+
def __init__(self, template: str, paths: Iterable[object] = ()) -> None:
|
|
261
|
+
self.template = template
|
|
262
|
+
looked = ", ".join(str(path) for path in paths)
|
|
263
|
+
super().__init__(
|
|
264
|
+
f"No SQL template named `{template}`. Looked in: {looked or 'nowhere'}."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class AsyncFilterError(SQLAKitError, TypeError):
|
|
269
|
+
"""Raised when a template is given a filter or global that has to be awaited."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, name: str) -> None:
|
|
272
|
+
super().__init__(
|
|
273
|
+
f"`{name}` is a coroutine function, and templates render "
|
|
274
|
+
f"synchronously: rendering builds SQL and awaits nothing, in the "
|
|
275
|
+
f"async API as well. Await the value first, and pass what it "
|
|
276
|
+
f"returns in the context."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class StrayParameterError(SQLAKitError, ValueError):
|
|
281
|
+
"""Raised when rendered SQL holds a parameter the template never bound."""
|
|
282
|
+
|
|
283
|
+
def __init__(self, names: Iterable[str] = (), template: str | None = None) -> None:
|
|
284
|
+
self.names = tuple(names)
|
|
285
|
+
where = f"`{template}`" if template else "This SQL"
|
|
286
|
+
listed = ", ".join(f"`:{name}`" for name in self.names)
|
|
287
|
+
written = ", ".join(f"`{{{{ {name} }}}}`" for name in self.names)
|
|
288
|
+
super().__init__(
|
|
289
|
+
f"{where} reads as though {listed} were a parameter, and nothing "
|
|
290
|
+
f"binds it. Values come from the template: write {written} and "
|
|
291
|
+
f"pass them in the context. A colon that belongs to the SQL, "
|
|
292
|
+
f"inside a JSON document or a string that starts with one, is "
|
|
293
|
+
f"written `\\:`."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class RawStatementError(SQLAKitError, TypeError):
|
|
298
|
+
"""Raised when building on a query that already carries a statement."""
|
|
299
|
+
|
|
300
|
+
def __init__(self, method: str = "this", advice: str | None = None) -> None:
|
|
301
|
+
super().__init__(
|
|
302
|
+
f"A query built from a statement cannot be narrowed further: "
|
|
303
|
+
f"{advice or f'put `{method}` in the statement itself'}."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class NullCursorValueError(InvalidCursorError):
|
|
308
|
+
"""Raised when a page would have to start at a row that is NULL in the order."""
|
|
309
|
+
|
|
310
|
+
def __init__(self, column: str = "the ordering") -> None:
|
|
311
|
+
super().__init__(
|
|
312
|
+
f"Cannot page past a row whose `{column}` is NULL: comparing against "
|
|
313
|
+
f"NULL matches nothing. Order by a column that is never NULL, or "
|
|
314
|
+
f"add one."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class BulkQueryError(SQLAKitError, TypeError):
|
|
319
|
+
"""Raised when a bulk update or delete is asked to honour what it cannot."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, method: str = "update", dropped: tuple[str, ...] = ()) -> None:
|
|
322
|
+
super().__init__(
|
|
323
|
+
f"A bulk `{method}` writes one statement, which has no room for "
|
|
324
|
+
f"{', '.join(dropped) or 'this'}. Narrow it with `where` alone, or "
|
|
325
|
+
f"read the rows and write them one by one."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class PageItemsMismatchError(SQLAKitError, TypeError):
|
|
330
|
+
"""Raised when a page transform returns the wrong number of items."""
|
|
331
|
+
|
|
332
|
+
def __init__(self, expected: int = 0, got: int = 0) -> None:
|
|
333
|
+
super().__init__(
|
|
334
|
+
f"The page holds {expected} items and the transform returned {got}. "
|
|
335
|
+
f"Totals and cursors belong to the page's rows, so a transform has "
|
|
336
|
+
f"to return one item per row."
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class UnknownOrderFieldError(SQLAKitError, ValueError):
|
|
341
|
+
"""Raised when an ordering names a field the model does not offer."""
|
|
342
|
+
|
|
343
|
+
def __init__(self, field: str, orderable: Iterable[str] = ()) -> None:
|
|
344
|
+
offered = ", ".join(sorted(orderable))
|
|
345
|
+
super().__init__(
|
|
346
|
+
f"`{field}` is not something this model orders by. "
|
|
347
|
+
f"It offers: {offered or 'nothing'}."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class UnknownImportPathError(SQLAKitError, ImportError):
|
|
352
|
+
"""Raised when a dotted path names nothing that can be imported."""
|
|
353
|
+
|
|
354
|
+
def __init__(self, path: str) -> None:
|
|
355
|
+
self.path = path
|
|
356
|
+
super().__init__(
|
|
357
|
+
f"`{path}` does not name anything importable. A path is "
|
|
358
|
+
f"`package.module.name`, or `package.module:name` when the name "
|
|
359
|
+
f"could be read as a module of its own."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class UnknownFieldError(SQLAKitError, AttributeError):
|
|
364
|
+
"""Raised when a model is handed a field it does not have."""
|
|
365
|
+
|
|
366
|
+
def __init__(self, model: str, field: str) -> None:
|
|
367
|
+
self.model = model
|
|
368
|
+
self.field = field
|
|
369
|
+
super().__init__(f"`{model}` has no field `{field}`.")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class InvalidOrderFieldError(SQLAKitError, TypeError):
|
|
373
|
+
"""Raised when ``__orderable__`` names something the model does not have.
|
|
374
|
+
|
|
375
|
+
A mistake in the declaration rather than in the request, which is why it is
|
|
376
|
+
not the error a bad sort string raises: answering a client with 400 for it
|
|
377
|
+
would blame the wrong side.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
def __init__(self, model: str, field: str) -> None:
|
|
381
|
+
self.model = model
|
|
382
|
+
self.field = field
|
|
383
|
+
super().__init__(
|
|
384
|
+
f"`{model}.__orderable__` names `{field}`, which is not a mapped "
|
|
385
|
+
f"column of it. Name a column, or return a mapping from a "
|
|
386
|
+
f"classmethod for fields that are not columns."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class KeyLookupError(SQLAKitError, TypeError):
|
|
391
|
+
"""Raised when a lookup by primary key is asked to honour what it cannot."""
|
|
392
|
+
|
|
393
|
+
def __init__(self, carried: tuple[str, ...] = ()) -> None:
|
|
394
|
+
super().__init__(
|
|
395
|
+
f"`get` looks a row up by its primary key, which has no room for "
|
|
396
|
+
f"{', '.join(carried) or 'this'}. Read the rows the query narrows "
|
|
397
|
+
f"to with `one`, `first` or `all` instead."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class UncomparableOrderingError(SQLAKitError, TypeError):
|
|
402
|
+
"""Raised when a cursor page is ordered by something it cannot compare."""
|
|
403
|
+
|
|
404
|
+
def __init__(self, clause: object) -> None:
|
|
405
|
+
super().__init__(
|
|
406
|
+
f"A cursor cannot page an ordering by `{clause}`. It compares rows "
|
|
407
|
+
f"by the values it reads back from them, so the order has to name "
|
|
408
|
+
f"columns of the model rather than text or an expression."
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class UnorderedPageError(SQLAKitError, TypeError):
|
|
413
|
+
"""Raised when a page is read from a query that names no order."""
|
|
414
|
+
|
|
415
|
+
def __init__(
|
|
416
|
+
self,
|
|
417
|
+
message: str = (
|
|
418
|
+
"Pagination needs an order. Without one the database returns rows "
|
|
419
|
+
"in whatever order it finds them, so pages repeat and skip rows. "
|
|
420
|
+
"Add `.order_by(...)`."
|
|
421
|
+
),
|
|
422
|
+
) -> None:
|
|
423
|
+
super().__init__(message)
|