sqlakit 0.16.0__tar.gz → 0.17.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.17.0}/PKG-INFO +1 -1
  2. {sqlakit-0.16.0 → sqlakit-0.17.0}/pyproject.toml +1 -1
  3. {sqlakit-0.16.0 → sqlakit-0.17.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_model.py +41 -0
  5. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/asyncio/orm.py +28 -4
  6. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/orm.py +28 -4
  7. {sqlakit-0.16.0 → sqlakit-0.17.0}/LICENSE +0 -0
  8. {sqlakit-0.16.0 → sqlakit-0.17.0}/README.md +0 -0
  9. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/__init__.py +0 -0
  10. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_cli.py +0 -0
  12. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_db.py +0 -0
  13. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_debugserver.py +0 -0
  14. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_discovery.py +0 -0
  15. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_query.py +0 -0
  16. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_recording.py +0 -0
  17. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_registry.py +0 -0
  18. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_routing.py +0 -0
  19. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/_sql.py +0 -0
  20. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/asyncio/__init__.py +0 -0
  21. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/asyncio/_db.py +0 -0
  22. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/asyncio/_registry.py +0 -0
  23. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/asyncio/sql.py +0 -0
  24. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/debugserver.html +0 -0
  25. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/exceptions.py +0 -0
  26. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/py.typed +0 -0
  27. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/pytest_plugin.py +0 -0
  28. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/sql.py +0 -0
  29. {sqlakit-0.16.0 → sqlakit-0.17.0}/sqlakit/testing.py +0 -0
  30. {sqlakit-0.16.0 → sqlakit-0.17.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.17.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.17.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.17.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,
@@ -633,25 +637,45 @@ class ModelMixin(BaseModel[Database]):
633
637
 
634
638
  async def refresh(
635
639
  self,
636
- *,
640
+ *attributes: str | InstrumentedAttribute[Any],
637
641
  attribute_names: Iterable[str] | None = None,
642
+ with_relationships: bool = False,
638
643
  with_for_update: ForUpdateParameter = None,
639
644
  ) -> None:
640
645
  """Read this instance back from the database.
641
646
 
647
+ ```python
648
+ await user.refresh() # every column, and the relationships already loaded
649
+ await user.refresh(User.team) # and one that was not, though it raises on load
650
+ await user.refresh(with_relationships=True) # every relationship the model has
651
+ ```
652
+
642
653
  Args:
643
- attribute_names: The attributes to reload, rather than all of them.
644
- A relationship named here is loaded again too.
654
+ attributes: The attributes to read again, rather than all of them, as
655
+ the model declares them or by name. A relationship named here is
656
+ loaded, which is how a `lazy="raise"` one is read after a refresh.
657
+ attribute_names: The same, for names a caller holds as a list.
658
+ with_relationships: Read every relationship the model declares, loaded
659
+ or not, which costs a statement each. For a test that compares the
660
+ whole instance and would otherwise name them one by one.
645
661
  with_for_update: Lock the row while it is read, as
646
662
  ``Session.refresh`` takes it: `True` for a plain ``FOR UPDATE``,
647
663
  or a mapping such as ``{"read": True}``.
648
664
 
649
665
  """
666
+ names = names_of(attributes, attribute_names)
667
+ if with_relationships:
668
+ names = list(dict.fromkeys([*(names or ()), *every_attribute(type(self))]))
650
669
  await self.db.session.refresh(
651
670
  self,
652
- attribute_names=attribute_names,
671
+ attribute_names=names,
653
672
  with_for_update=with_for_update,
654
673
  )
674
+ if with_relationships:
675
+ # The relationships hand back the instances the session holds, and
676
+ # those carry the values they were loaded with.
677
+ for related in related_to(self):
678
+ self.db.session.expire(related)
655
679
 
656
680
  async def _persist(self) -> None:
657
681
  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,
@@ -620,25 +624,45 @@ class ModelMixin(BaseModel[Database]):
620
624
 
621
625
  def refresh(
622
626
  self,
623
- *,
627
+ *attributes: str | InstrumentedAttribute[Any],
624
628
  attribute_names: Iterable[str] | None = None,
629
+ with_relationships: bool = False,
625
630
  with_for_update: ForUpdateParameter = None,
626
631
  ) -> None:
627
632
  """Read this instance back from the database.
628
633
 
634
+ ```python
635
+ user.refresh() # every column, and the relationships already loaded
636
+ user.refresh(User.team) # and one that was not, though it raises on load
637
+ user.refresh(with_relationships=True) # every relationship the model has
638
+ ```
639
+
629
640
  Args:
630
- attribute_names: The attributes to reload, rather than all of them.
631
- A relationship named here is loaded again too.
641
+ attributes: The attributes to read again, rather than all of them, as
642
+ the model declares them or by name. A relationship named here is
643
+ loaded, which is how a `lazy="raise"` one is read after a refresh.
644
+ attribute_names: The same, for names a caller holds as a list.
645
+ with_relationships: Read every relationship the model declares, loaded
646
+ or not, which costs a statement each. For a test that compares the
647
+ whole instance and would otherwise name them one by one.
632
648
  with_for_update: Lock the row while it is read, as
633
649
  ``Session.refresh`` takes it: `True` for a plain ``FOR UPDATE``,
634
650
  or a mapping such as ``{"read": True}``.
635
651
 
636
652
  """
653
+ names = names_of(attributes, attribute_names)
654
+ if with_relationships:
655
+ names = list(dict.fromkeys([*(names or ()), *every_attribute(type(self))]))
637
656
  self.db.session.refresh(
638
657
  self,
639
- attribute_names=attribute_names,
658
+ attribute_names=names,
640
659
  with_for_update=with_for_update,
641
660
  )
661
+ if with_relationships:
662
+ # The relationships hand back the instances the session holds, and
663
+ # those carry the values they were loaded with.
664
+ for related in related_to(self):
665
+ self.db.session.expire(related)
642
666
 
643
667
  def _persist(self) -> None:
644
668
  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