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/_recording.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import traceback
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
|
|
11
|
+
from .exceptions import DEFAULT_ALIAS
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from typing import TextIO
|
|
16
|
+
|
|
17
|
+
import sqlparse
|
|
18
|
+
|
|
19
|
+
from .types import QueryStats
|
|
20
|
+
else:
|
|
21
|
+
try:
|
|
22
|
+
import sqlparse
|
|
23
|
+
except ImportError: # pragma: no cover - the extra is installed in CI
|
|
24
|
+
sqlparse = None
|
|
25
|
+
|
|
26
|
+
__all__ = ["Recording", "Statement"]
|
|
27
|
+
|
|
28
|
+
_LIBRARIES = (
|
|
29
|
+
str(Path(__file__).parent),
|
|
30
|
+
str(Path(sa.__file__ or "").parent),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_TRUNCATE = 120
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Statement:
|
|
38
|
+
"""One statement the database was asked to run."""
|
|
39
|
+
|
|
40
|
+
sql: str
|
|
41
|
+
parameters: Any
|
|
42
|
+
duration: float
|
|
43
|
+
"""How long it took, in seconds."""
|
|
44
|
+
|
|
45
|
+
database: str = DEFAULT_ALIAS
|
|
46
|
+
"""Which database ran it, for a recording that covers more than one."""
|
|
47
|
+
|
|
48
|
+
stack: tuple[str, ...] = ()
|
|
49
|
+
"""Where it came from, when the recording was asked for stacks."""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def milliseconds(self) -> float:
|
|
53
|
+
"""How long it took, in the unit a log line wants."""
|
|
54
|
+
return self.duration * 1000
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def pretty(self) -> str:
|
|
58
|
+
"""The SQL laid out over several lines, for reading rather than scanning.
|
|
59
|
+
|
|
60
|
+
Laying it out needs `sqlakit[debug]`; without it this is the statement as it
|
|
61
|
+
ran, on one line.
|
|
62
|
+
"""
|
|
63
|
+
return _formatted(self.sql)
|
|
64
|
+
|
|
65
|
+
def __rich__(self) -> Any: # noqa: ANN401
|
|
66
|
+
"""Hand `rich` the SQL to colour, if the application prints with it.
|
|
67
|
+
|
|
68
|
+
Nothing here imports `rich`; this runs only when `rich` is the one
|
|
69
|
+
doing the printing.
|
|
70
|
+
"""
|
|
71
|
+
from rich.syntax import Syntax # noqa: PLC0415
|
|
72
|
+
|
|
73
|
+
return Syntax(self.pretty, "sql", background_color="default", word_wrap=True)
|
|
74
|
+
|
|
75
|
+
def __str__(self) -> str:
|
|
76
|
+
return " ".join(self.sql.split())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Recording:
|
|
81
|
+
"""The statements of a block, and what they add up to.
|
|
82
|
+
|
|
83
|
+
What `Database.recording()` hands back:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
with db.recording() as record:
|
|
87
|
+
build_report()
|
|
88
|
+
|
|
89
|
+
record.count, record.milliseconds, record.duplicates, record.slowest
|
|
90
|
+
```
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
label: str | None = None
|
|
94
|
+
statements: list[Statement] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def count(self) -> int:
|
|
98
|
+
"""How many statements ran."""
|
|
99
|
+
return len(self.statements)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def duration(self) -> float:
|
|
103
|
+
"""How long they took together, in seconds."""
|
|
104
|
+
return sum(statement.duration for statement in self.statements)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def milliseconds(self) -> float:
|
|
108
|
+
"""How long they took together, in the unit a log line wants."""
|
|
109
|
+
return self.duration * 1000
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def slowest(self) -> Statement | None:
|
|
113
|
+
"""The one that took longest, if anything ran at all."""
|
|
114
|
+
if not self.statements:
|
|
115
|
+
return None
|
|
116
|
+
return max(self.statements, key=lambda one: one.duration)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def databases(self) -> tuple[str, ...]:
|
|
120
|
+
"""The databases that ran anything, in the order they first did."""
|
|
121
|
+
return tuple(dict.fromkeys(one.database for one in self.statements))
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def duplicates(self) -> Mapping[str, list[Statement]]:
|
|
125
|
+
"""The statements that ran more than once, by the SQL they ran.
|
|
126
|
+
|
|
127
|
+
Parameters are not part of the SQL, so the N+1 that fetches one row a
|
|
128
|
+
hundred times is one entry with a hundred statements in it.
|
|
129
|
+
"""
|
|
130
|
+
grouped: dict[str, list[Statement]] = {}
|
|
131
|
+
for statement in self.statements:
|
|
132
|
+
grouped.setdefault(f"{statement.database}: {statement}", []).append(
|
|
133
|
+
statement
|
|
134
|
+
)
|
|
135
|
+
return {sql: group for sql, group in grouped.items() if len(group) > 1}
|
|
136
|
+
|
|
137
|
+
def log(
|
|
138
|
+
self,
|
|
139
|
+
logger: logging.Logger,
|
|
140
|
+
*,
|
|
141
|
+
level: int | None = None,
|
|
142
|
+
busy: int = 20,
|
|
143
|
+
slow: float = 500.0,
|
|
144
|
+
repeated: int = 5,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Write a summary, at a level the numbers choose unless you name one.
|
|
147
|
+
|
|
148
|
+
Left to itself it says INFO for a block that did little, WARNING once
|
|
149
|
+
anything repeats or a statement passes 100ms, and ERROR past ``busy``
|
|
150
|
+
statements, ``slow`` milliseconds or ``repeated`` repeated statements:
|
|
151
|
+
the shape of a log you can watch rather than read.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
logger: Where the summary goes.
|
|
155
|
+
level: A level of your own, which turns the thresholds off.
|
|
156
|
+
busy: Statements past which the block logs at ERROR.
|
|
157
|
+
slow: Milliseconds past which the block logs at ERROR.
|
|
158
|
+
repeated: Repeated statements past which the block logs at ERROR.
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
if level is None:
|
|
162
|
+
level = self._level(busy=busy, slow=slow, repeated=repeated)
|
|
163
|
+
logger.log(level, self.summary(), extra=dict(self.stats()))
|
|
164
|
+
|
|
165
|
+
def echo(self, *, file: TextIO | None = None) -> None:
|
|
166
|
+
"""Print the summary and the statements, for a block with no logger.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
with db.recording(echo=True):
|
|
170
|
+
build_report()
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Coloured and laid out where `rich` and `sqlakit[debug]` are installed, plain
|
|
174
|
+
where they are not. A service wants `log` instead.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
from rich.console import Console # noqa: PLC0415
|
|
178
|
+
except ImportError:
|
|
179
|
+
print(self.summary(), file=file)
|
|
180
|
+
print(self.pretty, file=file)
|
|
181
|
+
else:
|
|
182
|
+
console = Console(file=file)
|
|
183
|
+
console.print(self.summary(), markup=False, highlight=False)
|
|
184
|
+
console.print(self)
|
|
185
|
+
|
|
186
|
+
def summary(self) -> str:
|
|
187
|
+
"""Return the one line a log gets."""
|
|
188
|
+
head = f"{self.count} queries in {self.milliseconds:.1f}ms"
|
|
189
|
+
if self.label:
|
|
190
|
+
head = f"{self.label}: {head}"
|
|
191
|
+
notes = []
|
|
192
|
+
if self.duplicates:
|
|
193
|
+
repeated = sum(len(group) for group in self.duplicates.values())
|
|
194
|
+
notes.append(f"{repeated} repeated")
|
|
195
|
+
slowest = self.slowest
|
|
196
|
+
if slowest is not None and slowest.milliseconds >= 100: # noqa: PLR2004
|
|
197
|
+
notes.append(f"slowest {slowest.milliseconds:.1f}ms")
|
|
198
|
+
return f"{head} ({', '.join(notes)})" if notes else head
|
|
199
|
+
|
|
200
|
+
def stats(self) -> QueryStats:
|
|
201
|
+
"""Return what this adds up to, as the fields a structured log takes."""
|
|
202
|
+
slowest = self.slowest
|
|
203
|
+
return {
|
|
204
|
+
"queries": self.count,
|
|
205
|
+
"milliseconds": round(self.milliseconds, 2),
|
|
206
|
+
"slowest_milliseconds": round(slowest.milliseconds, 2) if slowest else 0.0,
|
|
207
|
+
"duplicated": sum(len(group) for group in self.duplicates.values()),
|
|
208
|
+
"databases": self.databases,
|
|
209
|
+
"label": self.label,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def __str__(self) -> str:
|
|
213
|
+
"""Return the statements, numbered, with the repeated ones marked."""
|
|
214
|
+
if not self.statements:
|
|
215
|
+
return "no queries"
|
|
216
|
+
numbered = list(enumerate(self.statements, 1))
|
|
217
|
+
repeats = self._repeats()
|
|
218
|
+
several = len(self.databases) > 1
|
|
219
|
+
lines = []
|
|
220
|
+
for index, statement in numbered:
|
|
221
|
+
sql = str(statement)
|
|
222
|
+
if len(sql) > _TRUNCATE:
|
|
223
|
+
sql = f"{sql[:_TRUNCATE]}…"
|
|
224
|
+
where = f"{statement.database} " if several else ""
|
|
225
|
+
lines.append(f" {index:>2} {statement.milliseconds:5.1f}ms {where}{sql}")
|
|
226
|
+
others = repeats.get(id(statement))
|
|
227
|
+
if others:
|
|
228
|
+
where = ", ".join(str(number) for number in others)
|
|
229
|
+
lines.append(
|
|
230
|
+
f" {' ' * 8}↑ same as {where} ({len(others) + 1} times in all)"
|
|
231
|
+
)
|
|
232
|
+
return "\n".join(lines)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def pretty(self) -> str:
|
|
236
|
+
"""The statements, numbered, each laid out over several lines.
|
|
237
|
+
|
|
238
|
+
What `print()` shows when the one-line listing has run out of room.
|
|
239
|
+
"""
|
|
240
|
+
if not self.statements:
|
|
241
|
+
return "no queries"
|
|
242
|
+
repeats = self._repeats()
|
|
243
|
+
several = len(self.databases) > 1
|
|
244
|
+
lines = []
|
|
245
|
+
for index, statement in enumerate(self.statements, 1):
|
|
246
|
+
where = f" {statement.database}" if several else ""
|
|
247
|
+
lines.append(
|
|
248
|
+
f" {index:>2} {statement.milliseconds:5.1f}ms{where}"
|
|
249
|
+
f"{_repeated(repeats.get(id(statement)))}"
|
|
250
|
+
)
|
|
251
|
+
lines.extend(f" {line}" for line in statement.pretty.splitlines())
|
|
252
|
+
return "\n".join(lines)
|
|
253
|
+
|
|
254
|
+
def __rich__(self) -> Any: # noqa: ANN401
|
|
255
|
+
"""Hand `rich` the same listing as `pretty`, for it to colour."""
|
|
256
|
+
from rich.console import Group # noqa: PLC0415
|
|
257
|
+
from rich.padding import Padding # noqa: PLC0415
|
|
258
|
+
from rich.text import Text # noqa: PLC0415
|
|
259
|
+
|
|
260
|
+
if not self.statements:
|
|
261
|
+
return Text("no queries")
|
|
262
|
+
repeats = self._repeats()
|
|
263
|
+
several = len(self.databases) > 1
|
|
264
|
+
parts: list[Any] = []
|
|
265
|
+
for index, statement in enumerate(self.statements, 1):
|
|
266
|
+
where = f" {statement.database}" if several else ""
|
|
267
|
+
said = _repeated(repeats.get(id(statement)))
|
|
268
|
+
parts.append(
|
|
269
|
+
Text(
|
|
270
|
+
f" {index:>2} {statement.milliseconds:5.1f}ms{where}{said}",
|
|
271
|
+
"yellow" if said else "dim",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
parts.append(Padding(statement.__rich__(), (0, 0, 0, 6)))
|
|
275
|
+
return Group(*parts)
|
|
276
|
+
|
|
277
|
+
def _repeats(self) -> dict[int, list[int]]:
|
|
278
|
+
"""Return, for each repeated statement, where else the same SQL ran."""
|
|
279
|
+
numbered = list(enumerate(self.statements, 1))
|
|
280
|
+
return {
|
|
281
|
+
id(statement): [
|
|
282
|
+
index
|
|
283
|
+
for index, other in numbered
|
|
284
|
+
if other is not statement and other in group
|
|
285
|
+
]
|
|
286
|
+
for group in self.duplicates.values()
|
|
287
|
+
for statement in group
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def _level(self, *, busy: int, slow: float, repeated: int) -> int:
|
|
291
|
+
slowest = self.slowest
|
|
292
|
+
milliseconds = slowest.milliseconds if slowest else 0.0
|
|
293
|
+
duplicated = sum(len(group) for group in self.duplicates.values())
|
|
294
|
+
if self.count > busy or milliseconds >= slow or duplicated > repeated:
|
|
295
|
+
return logging.ERROR
|
|
296
|
+
if self.count > busy // 4 or milliseconds >= 100 or duplicated: # noqa: PLR2004
|
|
297
|
+
return logging.WARNING
|
|
298
|
+
return logging.INFO
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _repeated(others: list[int] | None) -> str:
|
|
302
|
+
"""Return what to say about a statement that ran more than once."""
|
|
303
|
+
if not others:
|
|
304
|
+
return ""
|
|
305
|
+
where = ", ".join(str(number) for number in others)
|
|
306
|
+
return f" ↑ same as {where} ({len(others) + 1} times in all)"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def require_expectation(
|
|
310
|
+
count: int | None,
|
|
311
|
+
at_most: int | None,
|
|
312
|
+
duplicates: bool, # noqa: FBT001 (it mirrors the caller's keyword)
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Refuse a block that asserts nothing, before it runs rather than after.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
count: The statements the block is expected to run.
|
|
318
|
+
at_most: A ceiling on them.
|
|
319
|
+
duplicates: Whether a statement may run more than once.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
TypeError: if none of the three was asked for.
|
|
323
|
+
|
|
324
|
+
"""
|
|
325
|
+
if count is None and at_most is None and duplicates:
|
|
326
|
+
message = "assert_queries needs something to assert"
|
|
327
|
+
raise TypeError(message)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def check(
|
|
331
|
+
recording: Recording,
|
|
332
|
+
*,
|
|
333
|
+
count: int | None,
|
|
334
|
+
at_most: int | None,
|
|
335
|
+
duplicates: bool,
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Fail unless the recording is what the block said it would be.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
recording: What the block ran.
|
|
341
|
+
count: The statements it was expected to run.
|
|
342
|
+
at_most: A ceiling on them.
|
|
343
|
+
duplicates: Whether a statement may run more than once.
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
AssertionError: with the reason and the statements behind it.
|
|
347
|
+
|
|
348
|
+
"""
|
|
349
|
+
if count is not None and recording.count != count:
|
|
350
|
+
_fail(recording, f"{recording.count} queries, expected {count}")
|
|
351
|
+
if at_most is not None and recording.count > at_most:
|
|
352
|
+
_fail(recording, f"{recording.count} queries, expected at most {at_most}")
|
|
353
|
+
if not duplicates and recording.duplicates:
|
|
354
|
+
repeated = sum(len(group) for group in recording.duplicates.values())
|
|
355
|
+
_fail(recording, f"{repeated} of the queries repeat another")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _fail(recording: Recording, reason: str) -> None:
|
|
359
|
+
"""Raise with the reason, and the statements that led to it under it."""
|
|
360
|
+
message = f"{reason}\n\n{recording}\n"
|
|
361
|
+
raise AssertionError(message)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _formatted(sql: str) -> str:
|
|
365
|
+
"""Return the SQL laid out, or as it is when nothing can lay it out."""
|
|
366
|
+
if sqlparse is None: # pragma: no cover - the extra is installed in CI
|
|
367
|
+
return " ".join(sql.split())
|
|
368
|
+
return sqlparse.format(sql, reindent=True, keyword_case="upper").strip()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def caller_stack(skip: Sequence[str] = ()) -> tuple[str, ...]:
|
|
372
|
+
"""Return the frames of your own code that led to a statement.
|
|
373
|
+
|
|
374
|
+
Ours and SQLAlchemy's are left out by directory rather than by name: a
|
|
375
|
+
project of yours may well live in a path that has our name in it.
|
|
376
|
+
"""
|
|
377
|
+
skipped = (*_LIBRARIES, *skip)
|
|
378
|
+
frames = []
|
|
379
|
+
for frame in reversed(traceback.extract_stack()[:-1]):
|
|
380
|
+
if frame.filename.startswith(skipped):
|
|
381
|
+
continue
|
|
382
|
+
frames.append(f"{frame.filename}:{frame.lineno} in {frame.name}")
|
|
383
|
+
if len(frames) == 3: # noqa: PLR2004
|
|
384
|
+
break
|
|
385
|
+
return tuple(frames)
|
sqlakit/_registry.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import ExitStack, contextmanager
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from ._base import _DatabaseRegistryMixin
|
|
7
|
+
from ._db import Database
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
__all__ = ["Databases", "db"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Databases(_DatabaseRegistryMixin[Database], Database):
|
|
16
|
+
"""The databases an application talks to, and the default one among them.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from sqlakit import db
|
|
20
|
+
|
|
21
|
+
db.configure(DB_URL)
|
|
22
|
+
|
|
23
|
+
db.session # the default database
|
|
24
|
+
db["replica"].session # another, if one was configured
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use it when one database is enough and passing a handle around is not worth
|
|
28
|
+
it. `Database(url)` is the alternative.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_database_class = Database
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def transactions(
|
|
35
|
+
self,
|
|
36
|
+
**arguments: Any, # noqa: ANN401
|
|
37
|
+
) -> Iterator[None]:
|
|
38
|
+
"""Open a transaction on every database, not the default one alone.
|
|
39
|
+
|
|
40
|
+
Where a single database needs `transaction(rollback=True)`, a test harness
|
|
41
|
+
with several needs this:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
with db.transactions(rollback=True):
|
|
45
|
+
yield
|
|
46
|
+
```
|
|
47
|
+
"""
|
|
48
|
+
with ExitStack() as stack:
|
|
49
|
+
for alias in self.aliases:
|
|
50
|
+
stack.enter_context(self[alias].transaction(**arguments))
|
|
51
|
+
yield
|
|
52
|
+
|
|
53
|
+
def dispose(self, *, close: bool = True) -> None:
|
|
54
|
+
"""Dispose of every configured database, not just the default one."""
|
|
55
|
+
if self.is_configured:
|
|
56
|
+
super().dispose(close=close)
|
|
57
|
+
for db in self._aliased.values():
|
|
58
|
+
db.dispose(close=close)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
db = Databases()
|
|
62
|
+
"""The importable registry: one [`Databases`][sqlakit.Databases] for the process.
|
|
63
|
+
|
|
64
|
+
`db.configure(url)` fills it, and every module then reaches the same connections
|
|
65
|
+
by importing it. Reconfiguring is allowed until something connects, after which
|
|
66
|
+
it raises
|
|
67
|
+
[`DatabaseAlreadyConfiguredError`][sqlakit.DatabaseAlreadyConfiguredError]
|
|
68
|
+
and `dispose()` has to come first.
|
|
69
|
+
"""
|
sqlakit/_routing.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
__all__ = ["Router", "as_router"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class Router(Protocol):
|
|
13
|
+
"""A policy that says which database a model lives on.
|
|
14
|
+
|
|
15
|
+
Inherit it for the state a policy needs, or hand `route()` a plain function.
|
|
16
|
+
Both are asked the same question:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
db.route(Sharding(SHARDS))
|
|
20
|
+
db.route(lambda model: SHARDS.get(model))
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The contract, either way:
|
|
24
|
+
|
|
25
|
+
- It takes the model class, and nothing else. Not an instance, not the
|
|
26
|
+
statement, not whether it reads or writes: placement is a property of the
|
|
27
|
+
model.
|
|
28
|
+
- It returns the alias the model lives on, or None to leave the question to
|
|
29
|
+
the next router, and then to the model's own ``__db__``.
|
|
30
|
+
- The alias has to be one the registry was configured with.
|
|
31
|
+
- It is asked every time a model resolves its database, so keep it cheap and
|
|
32
|
+
keep the answer the same for the same model.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def db_for(self, model: type[Any], /) -> str | None:
|
|
36
|
+
"""Return the alias this model lives on, or None to say nothing."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def as_router(router: Router | Callable[[type[Any]], str | None]) -> Any: # noqa: ANN401
|
|
41
|
+
"""Return what to ask, whichever of the two shapes came in."""
|
|
42
|
+
question = getattr(router, "db_for", None)
|
|
43
|
+
return router if question is None else question
|