sqlakit 0.16.0__tar.gz → 0.18.0__tar.gz

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.
Files changed (30) hide show
  1. {sqlakit-0.16.0 → sqlakit-0.18.0}/PKG-INFO +1 -1
  2. {sqlakit-0.16.0 → sqlakit-0.18.0}/pyproject.toml +1 -1
  3. {sqlakit-0.16.0 → sqlakit-0.18.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_model.py +41 -0
  5. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/asyncio/orm.py +45 -5
  6. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/orm.py +45 -5
  7. {sqlakit-0.16.0 → sqlakit-0.18.0}/LICENSE +0 -0
  8. {sqlakit-0.16.0 → sqlakit-0.18.0}/README.md +0 -0
  9. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/__init__.py +0 -0
  10. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_cli.py +0 -0
  12. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_db.py +0 -0
  13. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_debugserver.py +0 -0
  14. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_discovery.py +0 -0
  15. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_query.py +0 -0
  16. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_recording.py +0 -0
  17. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_registry.py +0 -0
  18. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_routing.py +0 -0
  19. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/_sql.py +0 -0
  20. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/asyncio/__init__.py +0 -0
  21. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/asyncio/_db.py +0 -0
  22. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/asyncio/_registry.py +0 -0
  23. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/asyncio/sql.py +0 -0
  24. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/debugserver.html +0 -0
  25. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/exceptions.py +0 -0
  26. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/py.typed +0 -0
  27. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/pytest_plugin.py +0 -0
  28. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/sql.py +0 -0
  29. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/testing.py +0 -0
  30. {sqlakit-0.16.0 → sqlakit-0.18.0}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.16.0"
3
+ version = "0.18.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.16.0"
3
+ version = "0.18.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  # Imported here rather than under TYPE_CHECKING: SQLAlchemy resolves the
4
4
  # annotation of `deleted_at` in this module, and needs both names at runtime.
5
+ from collections.abc import Iterable # noqa: TC003
5
6
  from datetime import datetime # noqa: TC003
6
7
  from typing import (
7
8
  TYPE_CHECKING,
@@ -274,6 +275,46 @@ def db_for(model: type[Any]) -> BaseDatabase[Any, Any]:
274
275
  return placement
275
276
 
276
277
 
278
+ def names_of(
279
+ attributes: tuple[Any, ...], listed: Iterable[str] | None
280
+ ) -> list[str] | None:
281
+ """Return the attributes to reload, as the session names them.
282
+
283
+ A rename and an editor follow the attribute a model declares, and neither
284
+ follows a string, so a caller may name either.
285
+ """
286
+ named = [
287
+ attribute if isinstance(attribute, str) else attribute.key
288
+ for attribute in attributes
289
+ ]
290
+ named += list(listed or ())
291
+ return named or None
292
+
293
+
294
+ def every_attribute(model: type[Any]) -> list[str]:
295
+ """Return every attribute a model declares, relationships included."""
296
+ return list(sa.inspect(model).attrs.keys())
297
+
298
+
299
+ def related_to(instance: Any) -> list[Any]: # noqa: ANN401 - a model of either API
300
+ """Return the instances the loaded relationships of this one hold.
301
+
302
+ A relationship read again hands back the instances the session already
303
+ holds, with the values they were loaded with, so a caller that wants the
304
+ rows as they are now expires them.
305
+ """
306
+ state = sa.inspect(instance)
307
+ found = []
308
+ for name in state.mapper.relationships.keys(): # noqa: SIM118
309
+ if name in state.unloaded:
310
+ continue
311
+ value = state.dict.get(name)
312
+ if value is None:
313
+ continue
314
+ found.extend(value if isinstance(value, (list, set, tuple)) else [value])
315
+ return found
316
+
317
+
277
318
  def resolve_alias(model: type[Any], alias: str) -> BaseDatabase[Any, Any]:
278
319
  """Return the database a model knows under that alias.
279
320
 
@@ -21,6 +21,9 @@ from sqlakit._model import (
21
21
  BaseModel,
22
22
  BaseSoftDeletes,
23
23
  DatabaseDescriptor,
24
+ every_attribute,
25
+ names_of,
26
+ related_to,
24
27
  soft_delete_column,
25
28
  tables_for,
26
29
  )
@@ -43,6 +46,7 @@ if TYPE_CHECKING:
43
46
  from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
44
47
 
45
48
  from sqlalchemy.engine import CursorResult, Result, ScalarResult
49
+ from sqlalchemy.orm import InstrumentedAttribute
46
50
  from sqlalchemy.sql import Executable
47
51
  from sqlalchemy.sql._typing import (
48
52
  _ColumnExpressionArgument,
@@ -541,12 +545,28 @@ class QueryDescriptor(Generic[QueryT]):
541
545
 
542
546
  query = QueryDescriptor(AppQuery)
543
547
  ```
548
+
549
+ Given `Query` itself, as `ModelMixin` does, it reads as a query of the model
550
+ it is read from: `User.query` is a `Query[User]`, so `User.query.get(1)` is
551
+ a `User | None`.
544
552
  """
545
553
 
546
554
  def __init__(self, query_class: type[QueryT]) -> None:
547
555
  self.query_class = query_class
548
556
 
549
- def __get__(self, instance: object | None, owner: type[Any]) -> QueryT:
557
+ # `ClassVar` cannot hold a type variable, so the base declares the query
558
+ # every model has and a read narrows it to the model it is read from.
559
+ @overload
560
+ def __get__(
561
+ self: QueryDescriptor[Query[Any]],
562
+ instance: object | None,
563
+ owner: type[ModelT],
564
+ ) -> Query[ModelT]: ...
565
+
566
+ @overload
567
+ def __get__(self, instance: object | None, owner: type[Any]) -> QueryT: ...
568
+
569
+ def __get__(self, instance: object | None, owner: type[Any]) -> Query[Any]:
550
570
  return self.query_class(owner, owner.db)
551
571
 
552
572
 
@@ -633,25 +653,45 @@ class ModelMixin(BaseModel[Database]):
633
653
 
634
654
  async def refresh(
635
655
  self,
636
- *,
656
+ *attributes: str | InstrumentedAttribute[Any],
637
657
  attribute_names: Iterable[str] | None = None,
658
+ with_relationships: bool = False,
638
659
  with_for_update: ForUpdateParameter = None,
639
660
  ) -> None:
640
661
  """Read this instance back from the database.
641
662
 
663
+ ```python
664
+ await user.refresh() # every column, and the relationships already loaded
665
+ await user.refresh(User.team) # and one that was not, though it raises on load
666
+ await user.refresh(with_relationships=True) # every relationship the model has
667
+ ```
668
+
642
669
  Args:
643
- attribute_names: The attributes to reload, rather than all of them.
644
- A relationship named here is loaded again too.
670
+ attributes: The attributes to read again, rather than all of them, as
671
+ the model declares them or by name. A relationship named here is
672
+ loaded, which is how a `lazy="raise"` one is read after a refresh.
673
+ attribute_names: The same, for names a caller holds as a list.
674
+ with_relationships: Read every relationship the model declares, loaded
675
+ or not, which costs a statement each. For a test that compares the
676
+ whole instance and would otherwise name them one by one.
645
677
  with_for_update: Lock the row while it is read, as
646
678
  ``Session.refresh`` takes it: `True` for a plain ``FOR UPDATE``,
647
679
  or a mapping such as ``{"read": True}``.
648
680
 
649
681
  """
682
+ names = names_of(attributes, attribute_names)
683
+ if with_relationships:
684
+ names = list(dict.fromkeys([*(names or ()), *every_attribute(type(self))]))
650
685
  await self.db.session.refresh(
651
686
  self,
652
- attribute_names=attribute_names,
687
+ attribute_names=names,
653
688
  with_for_update=with_for_update,
654
689
  )
690
+ if with_relationships:
691
+ # The relationships hand back the instances the session holds, and
692
+ # those carry the values they were loaded with.
693
+ for related in related_to(self):
694
+ self.db.session.expire(related)
655
695
 
656
696
  async def _persist(self) -> None:
657
697
  db = self.db
@@ -22,6 +22,9 @@ from ._model import (
22
22
  BaseModel,
23
23
  BaseSoftDeletes,
24
24
  DatabaseDescriptor,
25
+ every_attribute,
26
+ names_of,
27
+ related_to,
25
28
  soft_delete_column,
26
29
  tables_for,
27
30
  )
@@ -42,6 +45,7 @@ if TYPE_CHECKING:
42
45
  from collections.abc import Iterable, Iterator, Mapping, Sequence
43
46
 
44
47
  from sqlalchemy.engine import CursorResult, Result, ScalarResult
48
+ from sqlalchemy.orm import InstrumentedAttribute
45
49
  from sqlalchemy.sql import Executable
46
50
  from sqlalchemy.sql._typing import (
47
51
  _ColumnExpressionArgument,
@@ -529,12 +533,28 @@ class QueryDescriptor(Generic[QueryT]):
529
533
 
530
534
  query = QueryDescriptor(AppQuery)
531
535
  ```
536
+
537
+ Given `Query` itself, as `ModelMixin` does, it reads as a query of the model
538
+ it is read from: `User.query` is a `Query[User]`, so `User.query.get(1)` is
539
+ a `User | None`.
532
540
  """
533
541
 
534
542
  def __init__(self, query_class: type[QueryT]) -> None:
535
543
  self.query_class = query_class
536
544
 
537
- def __get__(self, instance: object | None, owner: type[Any]) -> QueryT:
545
+ # `ClassVar` cannot hold a type variable, so the base declares the query
546
+ # every model has and a read narrows it to the model it is read from.
547
+ @overload
548
+ def __get__(
549
+ self: QueryDescriptor[Query[Any]],
550
+ instance: object | None,
551
+ owner: type[ModelT],
552
+ ) -> Query[ModelT]: ...
553
+
554
+ @overload
555
+ def __get__(self, instance: object | None, owner: type[Any]) -> QueryT: ...
556
+
557
+ def __get__(self, instance: object | None, owner: type[Any]) -> Query[Any]:
538
558
  return self.query_class(owner, owner.db)
539
559
 
540
560
 
@@ -620,25 +640,45 @@ class ModelMixin(BaseModel[Database]):
620
640
 
621
641
  def refresh(
622
642
  self,
623
- *,
643
+ *attributes: str | InstrumentedAttribute[Any],
624
644
  attribute_names: Iterable[str] | None = None,
645
+ with_relationships: bool = False,
625
646
  with_for_update: ForUpdateParameter = None,
626
647
  ) -> None:
627
648
  """Read this instance back from the database.
628
649
 
650
+ ```python
651
+ user.refresh() # every column, and the relationships already loaded
652
+ user.refresh(User.team) # and one that was not, though it raises on load
653
+ user.refresh(with_relationships=True) # every relationship the model has
654
+ ```
655
+
629
656
  Args:
630
- attribute_names: The attributes to reload, rather than all of them.
631
- A relationship named here is loaded again too.
657
+ attributes: The attributes to read again, rather than all of them, as
658
+ the model declares them or by name. A relationship named here is
659
+ loaded, which is how a `lazy="raise"` one is read after a refresh.
660
+ attribute_names: The same, for names a caller holds as a list.
661
+ with_relationships: Read every relationship the model declares, loaded
662
+ or not, which costs a statement each. For a test that compares the
663
+ whole instance and would otherwise name them one by one.
632
664
  with_for_update: Lock the row while it is read, as
633
665
  ``Session.refresh`` takes it: `True` for a plain ``FOR UPDATE``,
634
666
  or a mapping such as ``{"read": True}``.
635
667
 
636
668
  """
669
+ names = names_of(attributes, attribute_names)
670
+ if with_relationships:
671
+ names = list(dict.fromkeys([*(names or ()), *every_attribute(type(self))]))
637
672
  self.db.session.refresh(
638
673
  self,
639
- attribute_names=attribute_names,
674
+ attribute_names=names,
640
675
  with_for_update=with_for_update,
641
676
  )
677
+ if with_relationships:
678
+ # The relationships hand back the instances the session holds, and
679
+ # those carry the values they were loaded with.
680
+ for related in related_to(self):
681
+ self.db.session.expire(related)
642
682
 
643
683
  def _persist(self) -> None:
644
684
  db = self.db
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes