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