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/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()