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/orm.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import (
|
|
5
|
+
TYPE_CHECKING,
|
|
6
|
+
Any,
|
|
7
|
+
ClassVar,
|
|
8
|
+
Generic,
|
|
9
|
+
Self,
|
|
10
|
+
TypeVar,
|
|
11
|
+
cast,
|
|
12
|
+
overload,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
import sqlalchemy as sa
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
17
|
+
|
|
18
|
+
from sqlakit._base import DEFAULT_ALIAS
|
|
19
|
+
from sqlakit._model import (
|
|
20
|
+
BaseModel,
|
|
21
|
+
BaseSoftDeletes,
|
|
22
|
+
DatabaseDescriptor,
|
|
23
|
+
soft_delete_column,
|
|
24
|
+
tables_for,
|
|
25
|
+
)
|
|
26
|
+
from sqlakit._query import (
|
|
27
|
+
BaseQuery,
|
|
28
|
+
CursorPage,
|
|
29
|
+
Page,
|
|
30
|
+
one_row,
|
|
31
|
+
one_row_or_none,
|
|
32
|
+
orderable,
|
|
33
|
+
ordered,
|
|
34
|
+
)
|
|
35
|
+
from sqlakit.exceptions import InstanceNotFoundError
|
|
36
|
+
|
|
37
|
+
from ._db import Database
|
|
38
|
+
from ._registry import Databases, db
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
|
|
42
|
+
|
|
43
|
+
from sqlalchemy.engine import CursorResult, Result, ScalarResult
|
|
44
|
+
from sqlalchemy.sql import Executable
|
|
45
|
+
from sqlalchemy.sql._typing import (
|
|
46
|
+
_ColumnExpressionArgument,
|
|
47
|
+
_TypedColumnClauseArgument,
|
|
48
|
+
)
|
|
49
|
+
from sqlalchemy.sql.selectable import ForUpdateParameter
|
|
50
|
+
|
|
51
|
+
__all__ = ["Model", "ModelMixin", "Query", "SoftDeletes"]
|
|
52
|
+
|
|
53
|
+
ModelT = TypeVar("ModelT")
|
|
54
|
+
RowT = TypeVar("RowT")
|
|
55
|
+
RowT_co = TypeVar("RowT_co", covariant=True)
|
|
56
|
+
QueryT = TypeVar("QueryT", bound="Query[Any]")
|
|
57
|
+
C0 = TypeVar("C0")
|
|
58
|
+
C1 = TypeVar("C1")
|
|
59
|
+
C2 = TypeVar("C2")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Query(BaseQuery[ModelT]):
|
|
63
|
+
"""A query on the session of the block it runs in.
|
|
64
|
+
|
|
65
|
+
Reached as `Model.query`. Every builder method returns a new query, so one
|
|
66
|
+
can be kept and branched from:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
active = User.query.where(User.is_active)
|
|
70
|
+
await active.count()
|
|
71
|
+
await active.order_by(User.id).all()
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def as_descriptor(cls) -> Self:
|
|
77
|
+
"""Return this query as the ``query`` attribute of a model.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
class UserQuery(Query["User"]):
|
|
81
|
+
def active(self) -> Self:
|
|
82
|
+
return self.where(User.is_active.is_(True))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class User(Model):
|
|
86
|
+
query = UserQuery.as_descriptor()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A type checker reads `User.query` as `UserQuery`, so the methods you added
|
|
90
|
+
are visible on it.
|
|
91
|
+
"""
|
|
92
|
+
return cast("Self", QueryDescriptor(cls))
|
|
93
|
+
|
|
94
|
+
def __init__(self, model: type[ModelT], db: Database) -> None:
|
|
95
|
+
super().__init__(model, db)
|
|
96
|
+
|
|
97
|
+
async def _rows(self, statement: Executable) -> ScalarResult[ModelT]:
|
|
98
|
+
"""Rows as entities, one per identity.
|
|
99
|
+
|
|
100
|
+
A `joinedload` against a collection repeats the parent row once per
|
|
101
|
+
child, and SQLAlchemy asks to be told what to do about it.
|
|
102
|
+
"""
|
|
103
|
+
result = await self.db.session.scalars(statement)
|
|
104
|
+
return result.unique()
|
|
105
|
+
|
|
106
|
+
async def get(self, ident: Any) -> ModelT | None: # noqa: ANN401
|
|
107
|
+
"""Look the row up by primary key, or return None.
|
|
108
|
+
|
|
109
|
+
Goes through the session's identity map, so a row already loaded costs no
|
|
110
|
+
query. This query's loader options and lock carry over, nothing else does,
|
|
111
|
+
and a query narrowed by `where` is refused rather than ignored. Loader
|
|
112
|
+
options read the row again even when the session holds it.
|
|
113
|
+
"""
|
|
114
|
+
options = self._lookup_options()
|
|
115
|
+
statement = self._lookup_statement(ident)
|
|
116
|
+
if statement is not None:
|
|
117
|
+
statement = statement.options(*options)
|
|
118
|
+
if options:
|
|
119
|
+
# Loader options say nothing to a row the session already
|
|
120
|
+
# holds, unless it is told to read that row again.
|
|
121
|
+
statement = statement.execution_options(populate_existing=True)
|
|
122
|
+
result = await self._rows(statement)
|
|
123
|
+
return result.one_or_none()
|
|
124
|
+
return await self.db.session.get(
|
|
125
|
+
self.model,
|
|
126
|
+
ident,
|
|
127
|
+
options=options,
|
|
128
|
+
with_for_update=self._lock(),
|
|
129
|
+
populate_existing=bool(options),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def get_one(self, ident: Any) -> ModelT: # noqa: ANN401
|
|
133
|
+
"""Look the row up by primary key.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
InstanceNotFoundError: if there is no such row.
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
instance = await self.get(ident)
|
|
140
|
+
if instance is None:
|
|
141
|
+
raise InstanceNotFoundError(self.model.__name__)
|
|
142
|
+
return instance
|
|
143
|
+
|
|
144
|
+
async def all(self) -> Sequence[ModelT]:
|
|
145
|
+
"""Return every matching row."""
|
|
146
|
+
result = await self._rows(self._executable())
|
|
147
|
+
return result.all()
|
|
148
|
+
|
|
149
|
+
async def first(self) -> ModelT | None:
|
|
150
|
+
"""Return the first matching row, or None."""
|
|
151
|
+
# A raw statement takes no limit; the first row is read off the result.
|
|
152
|
+
query = self if self._statement is not None else self.limit(1)
|
|
153
|
+
result = await self._rows(query._executable()) # noqa: SLF001
|
|
154
|
+
return result.first()
|
|
155
|
+
|
|
156
|
+
async def latest(self, column: Any) -> ModelT | None: # noqa: ANN401
|
|
157
|
+
"""Return the row with the greatest value in this column, or None.
|
|
158
|
+
|
|
159
|
+
Takes a column, or the name of a field the model offers.
|
|
160
|
+
"""
|
|
161
|
+
return await self.order_by(self._directed(column, descending=True)).first()
|
|
162
|
+
|
|
163
|
+
async def earliest(self, column: Any) -> ModelT | None: # noqa: ANN401
|
|
164
|
+
"""Return the row with the least value in this column, or None.
|
|
165
|
+
|
|
166
|
+
Takes a column, or the name of a field the model offers.
|
|
167
|
+
"""
|
|
168
|
+
return await self.order_by(self._directed(column, descending=False)).first()
|
|
169
|
+
|
|
170
|
+
async def one(self) -> ModelT:
|
|
171
|
+
"""Return the single matching row.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
InstanceNotFoundError: if there is none.
|
|
175
|
+
MultipleInstancesFoundError: if there is more than one.
|
|
176
|
+
|
|
177
|
+
"""
|
|
178
|
+
result = await self._rows(self._executable())
|
|
179
|
+
return one_row(result, self.model.__name__)
|
|
180
|
+
|
|
181
|
+
async def one_or_none(self) -> ModelT | None:
|
|
182
|
+
"""Return the single matching row, or None.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
MultipleInstancesFoundError: if there is more than one.
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
result = await self._rows(self._executable())
|
|
189
|
+
return one_row_or_none(result, self.model.__name__)
|
|
190
|
+
|
|
191
|
+
async def count(self) -> int:
|
|
192
|
+
"""Count the matching rows."""
|
|
193
|
+
return await self.db.session.scalar(self._count_statement()) or 0
|
|
194
|
+
|
|
195
|
+
async def exists(self) -> bool:
|
|
196
|
+
"""Check whether any row matches."""
|
|
197
|
+
return bool(await self.db.session.scalar(self._exists_statement()))
|
|
198
|
+
|
|
199
|
+
async def page(
|
|
200
|
+
self, *, limit: int, offset: int = 0, total: bool = True
|
|
201
|
+
) -> Page[ModelT]:
|
|
202
|
+
"""Read one page of rows, with the total.
|
|
203
|
+
|
|
204
|
+
The model's key is appended to the ordering, so a row that ties with another
|
|
205
|
+
keeps its place between requests.
|
|
206
|
+
|
|
207
|
+
The total costs a second query over the whole match, and the database walks
|
|
208
|
+
the rows the offset skips; past the first few pages
|
|
209
|
+
[`cursor_page`][sqlakit.asyncio.orm.Query.cursor_page] does
|
|
210
|
+
neither. With ``total=False`` there is no counting query: the page reads one
|
|
211
|
+
row more than it shows, which answers `Page.has_next` and leaves
|
|
212
|
+
`Page.total` None.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
UnorderedPageError: if the query names no order.
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
if not total:
|
|
219
|
+
result = await self._rows(
|
|
220
|
+
self._page_statement(limit=limit + 1, offset=offset)
|
|
221
|
+
)
|
|
222
|
+
rows = list(result.all())
|
|
223
|
+
return Page(
|
|
224
|
+
items=rows[:limit],
|
|
225
|
+
total=None,
|
|
226
|
+
limit=limit,
|
|
227
|
+
offset=offset,
|
|
228
|
+
has_next=len(rows) > limit,
|
|
229
|
+
)
|
|
230
|
+
statement = self._page_statement(limit=limit, offset=offset)
|
|
231
|
+
counted = await self.count()
|
|
232
|
+
if counted <= offset:
|
|
233
|
+
return Page(items=[], total=counted, limit=limit, offset=offset)
|
|
234
|
+
result = await self._rows(statement)
|
|
235
|
+
return Page(items=result.all(), total=counted, limit=limit, offset=offset)
|
|
236
|
+
|
|
237
|
+
async def cursor_page(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
limit: int,
|
|
241
|
+
cursor: str | None = None,
|
|
242
|
+
) -> CursorPage[ModelT]:
|
|
243
|
+
"""Read one page of rows, and the cursors that read the ones either side.
|
|
244
|
+
|
|
245
|
+
The page follows the order the query carries, with the model's key appended
|
|
246
|
+
so that rows sharing a value cannot fall on both sides of a boundary. Rows
|
|
247
|
+
inserted in between do not shift it, and a page deep in the table costs the
|
|
248
|
+
same as the first.
|
|
249
|
+
|
|
250
|
+
Either cursor of a page goes back in as ``cursor``, and the page it names
|
|
251
|
+
comes out: the direction rides along in the cursor.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
page = await User.query.order_by("created_at.desc").cursor_page(limit=20)
|
|
255
|
+
older = await User.query.order_by("created_at.desc").cursor_page(
|
|
256
|
+
limit=20, cursor=page.next_cursor
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
InvalidCursorError: if the cursor was not made for this ordering.
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
statement = self._cursor_statement(limit=limit, cursor=cursor)
|
|
265
|
+
result = await self._rows(statement)
|
|
266
|
+
return self._cursor_page(list(result.all()), limit=limit, cursor=cursor)
|
|
267
|
+
|
|
268
|
+
async def chunks(self, size: int) -> AsyncIterator[Sequence[ModelT]]:
|
|
269
|
+
"""Read every matching row, ``size`` of them at a time.
|
|
270
|
+
|
|
271
|
+
One statement, whose rows are fetched in batches rather than all at once,
|
|
272
|
+
for the job that walks a table too large to hold:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
async with db.transaction():
|
|
276
|
+
async for contacts in Contact.query.where(Contact.is_stale).chunks(1000):
|
|
277
|
+
await deliver(contacts)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The rows come off a cursor the database holds open, so the walk is one
|
|
281
|
+
transaction, and committing part-way through ends it. To commit each batch,
|
|
282
|
+
page through the table with `cursor_page` instead.
|
|
283
|
+
|
|
284
|
+
A batch is whole rows, which a `joinedload` of a collection is not: load
|
|
285
|
+
collections with `selectinload` here.
|
|
286
|
+
"""
|
|
287
|
+
result = await self.db.session.stream_scalars(
|
|
288
|
+
self._executable(), execution_options={"yield_per": size}
|
|
289
|
+
)
|
|
290
|
+
async for batch in result.partitions(size):
|
|
291
|
+
yield batch
|
|
292
|
+
|
|
293
|
+
@overload
|
|
294
|
+
def only_columns(
|
|
295
|
+
self, column: _TypedColumnClauseArgument[C0], /
|
|
296
|
+
) -> ColumnQuery[C0]: ...
|
|
297
|
+
|
|
298
|
+
@overload
|
|
299
|
+
def only_columns(
|
|
300
|
+
self,
|
|
301
|
+
column: _TypedColumnClauseArgument[C0],
|
|
302
|
+
other: _TypedColumnClauseArgument[C1],
|
|
303
|
+
/,
|
|
304
|
+
) -> ColumnQuery[tuple[C0, C1]]: ...
|
|
305
|
+
|
|
306
|
+
@overload
|
|
307
|
+
def only_columns(
|
|
308
|
+
self,
|
|
309
|
+
column: _TypedColumnClauseArgument[C0],
|
|
310
|
+
other: _TypedColumnClauseArgument[C1],
|
|
311
|
+
third: _TypedColumnClauseArgument[C2],
|
|
312
|
+
/,
|
|
313
|
+
) -> ColumnQuery[tuple[C0, C1, C2]]: ...
|
|
314
|
+
|
|
315
|
+
@overload
|
|
316
|
+
def only_columns(
|
|
317
|
+
self, *columns: _TypedColumnClauseArgument[Any]
|
|
318
|
+
) -> ColumnQuery[Any]: ...
|
|
319
|
+
|
|
320
|
+
def only_columns(
|
|
321
|
+
self, *columns: _TypedColumnClauseArgument[Any]
|
|
322
|
+
) -> ColumnQuery[Any]:
|
|
323
|
+
"""Read these columns instead of whole rows.
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
names = User.query.where(User.is_active).only_columns(User.name).all()
|
|
327
|
+
```
|
|
328
|
+
"""
|
|
329
|
+
return ColumnQuery(
|
|
330
|
+
self.model,
|
|
331
|
+
self._columns_select(columns),
|
|
332
|
+
self.db,
|
|
333
|
+
scalar=len(columns) == 1,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def create(self, **values: Any) -> ModelT: # noqa: ANN401
|
|
337
|
+
"""Write a new row, and return it as an instance.
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
user = await User.query.create(name="ada", team="red")
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The row goes through the session, so defaults, relationships and the identity
|
|
344
|
+
map behave as they do for a model that saves itself. What it adds is a write
|
|
345
|
+
that needs no model layer: `Query(User, db).create(...)` works on any mapped
|
|
346
|
+
class.
|
|
347
|
+
"""
|
|
348
|
+
instance = self.model(**values)
|
|
349
|
+
self.db.session.add(instance)
|
|
350
|
+
await self._persist()
|
|
351
|
+
return instance
|
|
352
|
+
|
|
353
|
+
async def create_many(self, rows: Sequence[Mapping[str, Any]]) -> int:
|
|
354
|
+
"""Write these rows in one statement, and return how many.
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
await User.query.create_many([{"name": "ada"}, {"name": "grace"}])
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Nothing is instantiated and no ORM event fires, which is the point for a job
|
|
361
|
+
that loads a file.
|
|
362
|
+
"""
|
|
363
|
+
if not rows:
|
|
364
|
+
return 0
|
|
365
|
+
await self.db.session.execute(sa.insert(self.model), list(rows))
|
|
366
|
+
await self._persist()
|
|
367
|
+
return len(rows)
|
|
368
|
+
|
|
369
|
+
async def update(self, values: Mapping[str, Any]) -> int:
|
|
370
|
+
"""Write these values to every matching row, and return how many.
|
|
371
|
+
|
|
372
|
+
One statement, so the session's objects are updated from the database
|
|
373
|
+
rather than in memory. Only the narrowing carries over.
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
BulkQueryError: if the query carries anything a statement drops.
|
|
377
|
+
|
|
378
|
+
"""
|
|
379
|
+
result = await self.db.session.execute(self._update_statement(values))
|
|
380
|
+
await self._persist()
|
|
381
|
+
return cast("CursorResult[Any]", result).rowcount
|
|
382
|
+
|
|
383
|
+
async def delete(self, *, force: bool = False) -> int:
|
|
384
|
+
"""Delete every matching row, and return how many.
|
|
385
|
+
|
|
386
|
+
A model that [soft-deletes](models.md#soft-deletes) is marked rather
|
|
387
|
+
than removed. Pass ``force`` to remove the rows anyway.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
BulkQueryError: if the query carries anything a statement drops.
|
|
391
|
+
|
|
392
|
+
"""
|
|
393
|
+
result = await self.db.session.execute(self._delete_statement(force=force))
|
|
394
|
+
await self._persist()
|
|
395
|
+
return cast("CursorResult[Any]", result).rowcount
|
|
396
|
+
|
|
397
|
+
async def _persist(self) -> None:
|
|
398
|
+
"""Commit what a write left behind, unless a block owns the commit.
|
|
399
|
+
|
|
400
|
+
Inside a transaction the statement is part of it and the block decides.
|
|
401
|
+
Outside one there is nobody to commit, and a write that reported rows
|
|
402
|
+
would be rolled back when the connection is returned.
|
|
403
|
+
"""
|
|
404
|
+
if not self.db.in_transaction():
|
|
405
|
+
await self.db.session.commit()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class ColumnQuery(Generic[RowT]):
|
|
409
|
+
"""Rows of the columns asked for, not of the model.
|
|
410
|
+
|
|
411
|
+
Built by `Query.only_columns`. One column comes back as its own values,
|
|
412
|
+
several come back as tuples.
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
def __init__(
|
|
416
|
+
self,
|
|
417
|
+
model: type[Any],
|
|
418
|
+
select: sa.Select[Any],
|
|
419
|
+
db: Database,
|
|
420
|
+
*,
|
|
421
|
+
scalar: bool,
|
|
422
|
+
) -> None:
|
|
423
|
+
self.model = model
|
|
424
|
+
self._select = select
|
|
425
|
+
self.db = db
|
|
426
|
+
self.scalar = scalar
|
|
427
|
+
|
|
428
|
+
def where(self, *criteria: _ColumnExpressionArgument[bool]) -> Self:
|
|
429
|
+
"""Narrow the rows."""
|
|
430
|
+
return self.with_select(self._select.where(*criteria))
|
|
431
|
+
|
|
432
|
+
def order_by(
|
|
433
|
+
self,
|
|
434
|
+
*criteria: Any, # noqa: ANN401
|
|
435
|
+
ci_fields: Sequence[str] = (),
|
|
436
|
+
) -> Self:
|
|
437
|
+
"""Order the rows, by columns or by the names the model offers."""
|
|
438
|
+
return self.with_select(
|
|
439
|
+
ordered(self._select, orderable(self.model), criteria, ci_fields)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def distinct(self) -> Self:
|
|
443
|
+
"""Drop the duplicate rows."""
|
|
444
|
+
return self.with_select(self._select.distinct())
|
|
445
|
+
|
|
446
|
+
def limit(self, limit: int) -> Self:
|
|
447
|
+
"""Take at most this many rows."""
|
|
448
|
+
return self.with_select(self._select.limit(limit))
|
|
449
|
+
|
|
450
|
+
def offset(self, offset: int) -> Self:
|
|
451
|
+
"""Skip this many rows."""
|
|
452
|
+
return self.with_select(self._select.offset(offset))
|
|
453
|
+
|
|
454
|
+
def with_select(self, select: sa.Select[Any]) -> Self:
|
|
455
|
+
return type(self)(self.model, select, self.db, scalar=self.scalar)
|
|
456
|
+
|
|
457
|
+
async def _rows(self) -> ScalarResult[Any] | Result[Any]:
|
|
458
|
+
if self.scalar:
|
|
459
|
+
return await self.db.session.scalars(self._select)
|
|
460
|
+
return await self.db.session.execute(self._select)
|
|
461
|
+
|
|
462
|
+
async def all(self) -> Sequence[RowT]:
|
|
463
|
+
"""Return every matching row."""
|
|
464
|
+
rows = await self._rows()
|
|
465
|
+
return cast("Sequence[RowT]", rows.all())
|
|
466
|
+
|
|
467
|
+
async def first(self) -> RowT | None:
|
|
468
|
+
"""Return the first matching row, or None."""
|
|
469
|
+
rows = await self._rows()
|
|
470
|
+
return cast("RowT | None", rows.first())
|
|
471
|
+
|
|
472
|
+
async def one(self) -> RowT:
|
|
473
|
+
"""Return the single matching row.
|
|
474
|
+
|
|
475
|
+
Raises:
|
|
476
|
+
InstanceNotFoundError: if there is none.
|
|
477
|
+
MultipleInstancesFoundError: if there is more than one.
|
|
478
|
+
|
|
479
|
+
"""
|
|
480
|
+
rows = await self._rows()
|
|
481
|
+
return cast("RowT", one_row(rows, self.model.__name__))
|
|
482
|
+
|
|
483
|
+
async def one_or_none(self) -> RowT | None:
|
|
484
|
+
"""Return the single matching row, or None.
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
MultipleInstancesFoundError: if there is more than one.
|
|
488
|
+
|
|
489
|
+
"""
|
|
490
|
+
rows = await self._rows()
|
|
491
|
+
return cast("RowT | None", one_row_or_none(rows, self.model.__name__))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class QueryDescriptor(Generic[QueryT]):
|
|
495
|
+
"""Hand out a query bound to the model's database, as `Model.query`.
|
|
496
|
+
|
|
497
|
+
Assign one to give a model, or a whole base, a query of your own:
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
class Base(Model):
|
|
501
|
+
__abstract__ = True
|
|
502
|
+
|
|
503
|
+
query = QueryDescriptor(AppQuery)
|
|
504
|
+
```
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
def __init__(self, query_class: type[QueryT]) -> None:
|
|
508
|
+
self.query_class = query_class
|
|
509
|
+
|
|
510
|
+
def __get__(self, instance: object | None, owner: type[Any]) -> QueryT:
|
|
511
|
+
return self.query_class(owner, owner.db)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class ModelMixin(BaseModel[Database]):
|
|
515
|
+
"""Adds saving, lookup and the current database to a declarative base.
|
|
516
|
+
|
|
517
|
+
Mix it into your own base when it carries settings of its own, such as a
|
|
518
|
+
``type_annotation_map``, a naming convention or ``MappedAsDataclass``:
|
|
519
|
+
|
|
520
|
+
```python
|
|
521
|
+
class Model(ModelMixin, MappedAsDataclass, DeclarativeBase):
|
|
522
|
+
type_annotation_map = {str: sa.Text}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
`Model` is the same thing with a plain declarative base mixed in. The
|
|
526
|
+
database is the one ``__db__`` names, the ``"default"`` alias of the
|
|
527
|
+
importable registry, or one set with
|
|
528
|
+
[`set_db`][sqlakit.asyncio.orm.ModelMixin.set_db].
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
__db__: ClassVar[str | Database] = DEFAULT_ALIAS
|
|
532
|
+
__dbs__: ClassVar[Databases] = db
|
|
533
|
+
|
|
534
|
+
query: ClassVar[QueryDescriptor[Query[Any]]] = QueryDescriptor(Query)
|
|
535
|
+
|
|
536
|
+
db: ClassVar[DatabaseDescriptor[Database]] = DatabaseDescriptor()
|
|
537
|
+
|
|
538
|
+
@classmethod
|
|
539
|
+
@asynccontextmanager
|
|
540
|
+
async def provisioned_tables(cls, alias: str | None = None) -> AsyncIterator[None]:
|
|
541
|
+
"""Create the tables that belong on this model's database, and drop them after.
|
|
542
|
+
|
|
543
|
+
What a test session opens once, around everything that needs a schema:
|
|
544
|
+
|
|
545
|
+
```python
|
|
546
|
+
@pytest.fixture(scope="session")
|
|
547
|
+
async def tables():
|
|
548
|
+
async with Model.provisioned_tables():
|
|
549
|
+
yield
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
With models on more than one database, name the alias, and each database
|
|
553
|
+
gets the tables of the models pointed at it.
|
|
554
|
+
"""
|
|
555
|
+
db = cls.db if alias is None else cls.__dbs__[alias]
|
|
556
|
+
async with db.provisioned_tables(cls.metadata, tables=tables_for(cls, db)):
|
|
557
|
+
yield
|
|
558
|
+
|
|
559
|
+
async def save(self) -> Self:
|
|
560
|
+
"""Write this instance out.
|
|
561
|
+
|
|
562
|
+
Commits when nothing else owns a transaction, and flushes when one
|
|
563
|
+
does, so what was written is there for the queries that follow.
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
DetachedInstanceError: if its session has closed.
|
|
567
|
+
|
|
568
|
+
"""
|
|
569
|
+
self._prepare_save()
|
|
570
|
+
await self._persist()
|
|
571
|
+
return self
|
|
572
|
+
|
|
573
|
+
async def delete(self, *, force: bool = False) -> None:
|
|
574
|
+
"""Delete the row for this instance.
|
|
575
|
+
|
|
576
|
+
A model that [soft-deletes](models.md#soft-deletes) is marked rather
|
|
577
|
+
than removed. Pass ``force`` to remove the row anyway.
|
|
578
|
+
"""
|
|
579
|
+
column = soft_delete_column(type(self))
|
|
580
|
+
if column is None or force:
|
|
581
|
+
await self.db.session.delete(self)
|
|
582
|
+
else:
|
|
583
|
+
setattr(self, column, sa.func.now())
|
|
584
|
+
await self._persist()
|
|
585
|
+
|
|
586
|
+
async def merge(self) -> Self:
|
|
587
|
+
"""Copy this instance into the current session and return the copy.
|
|
588
|
+
|
|
589
|
+
An instance loaded in a block that has since closed needs this before it can
|
|
590
|
+
be saved in another one. The row is read again, so anything changed in
|
|
591
|
+
between is overwritten by what this instance holds.
|
|
592
|
+
"""
|
|
593
|
+
return await self.db.session.merge(self)
|
|
594
|
+
|
|
595
|
+
async def refresh(
|
|
596
|
+
self,
|
|
597
|
+
*,
|
|
598
|
+
attribute_names: Iterable[str] | None = None,
|
|
599
|
+
with_for_update: ForUpdateParameter = None,
|
|
600
|
+
) -> None:
|
|
601
|
+
"""Read this instance back from the database.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
attribute_names: The attributes to reload, rather than all of them.
|
|
605
|
+
A relationship named here is loaded again too.
|
|
606
|
+
with_for_update: Lock the row while it is read, as
|
|
607
|
+
``Session.refresh`` takes it: `True` for a plain ``FOR UPDATE``,
|
|
608
|
+
or a mapping such as ``{"read": True}``.
|
|
609
|
+
|
|
610
|
+
"""
|
|
611
|
+
await self.db.session.refresh(
|
|
612
|
+
self,
|
|
613
|
+
attribute_names=attribute_names,
|
|
614
|
+
with_for_update=with_for_update,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
async def _persist(self) -> None:
|
|
618
|
+
db = self.db
|
|
619
|
+
if db.in_transaction():
|
|
620
|
+
await db.session.flush()
|
|
621
|
+
else:
|
|
622
|
+
await db.session.commit()
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class Model(ModelMixin, DeclarativeBase):
|
|
626
|
+
"""A declarative base whose instances know how to save themselves.
|
|
627
|
+
|
|
628
|
+
```python
|
|
629
|
+
class User(Model):
|
|
630
|
+
__tablename__ = "users"
|
|
631
|
+
|
|
632
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
633
|
+
name: Mapped[str]
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
await User(name="ada").save()
|
|
637
|
+
```
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
__abstract__ = True
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class SoftDeletes(BaseSoftDeletes):
|
|
644
|
+
"""Rows this model marks as deleted instead of removing.
|
|
645
|
+
|
|
646
|
+
```python
|
|
647
|
+
class Note(Model, SoftDeletes):
|
|
648
|
+
__tablename__ = "notes"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
await note.delete() # UPDATE notes SET deleted_at = now()
|
|
652
|
+
await note.restore() # and back
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Reads skip the marked rows, `get()` included. `Note.query.with_deleted()`
|
|
656
|
+
reads them, and `delete(force=True)` removes them for good.
|
|
657
|
+
"""
|
|
658
|
+
|
|
659
|
+
async def restore(self: Any) -> Any: # noqa: ANN401 - a mixin, on any model
|
|
660
|
+
"""Clear the mark, and save the row."""
|
|
661
|
+
setattr(self, self.__soft_delete__, None)
|
|
662
|
+
return await self.save()
|