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