sqlakit 0.15.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.15.0 → sqlakit-0.17.0}/PKG-INFO +1 -1
  2. {sqlakit-0.15.0 → sqlakit-0.17.0}/pyproject.toml +1 -1
  3. {sqlakit-0.15.0 → sqlakit-0.17.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_model.py +41 -0
  5. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_query.py +82 -3
  6. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/orm.py +28 -4
  7. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/orm.py +32 -4
  8. {sqlakit-0.15.0 → sqlakit-0.17.0}/LICENSE +0 -0
  9. {sqlakit-0.15.0 → sqlakit-0.17.0}/README.md +0 -0
  10. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/__init__.py +0 -0
  11. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_base.py +0 -0
  12. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_cli.py +0 -0
  13. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_db.py +0 -0
  14. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_debugserver.py +0 -0
  15. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_discovery.py +0 -0
  16. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_recording.py +0 -0
  17. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_registry.py +0 -0
  18. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_routing.py +0 -0
  19. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_sql.py +0 -0
  20. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/__init__.py +0 -0
  21. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/_db.py +0 -0
  22. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/_registry.py +0 -0
  23. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/sql.py +0 -0
  24. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/debugserver.html +0 -0
  25. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/exceptions.py +0 -0
  26. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/py.typed +0 -0
  27. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/pytest_plugin.py +0 -0
  28. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/sql.py +0 -0
  29. {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/testing.py +0 -0
  30. {sqlakit-0.15.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.15.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.15.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.15.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
 
@@ -24,9 +24,14 @@ from sqlalchemy.ext.compiler import compiles
24
24
  from sqlalchemy.orm import (
25
25
  InstrumentedAttribute,
26
26
  contains_eager,
27
+ defer,
27
28
  joinedload,
29
+ load_only,
28
30
  selectinload,
29
31
  subqueryload,
32
+ undefer,
33
+ undefer_group,
34
+ with_expression,
30
35
  )
31
36
  from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
32
37
  from sqlalchemy.sql import operators
@@ -507,10 +512,23 @@ class BaseQuery(Generic[ModelT]):
507
512
  self._reject_statement("where")
508
513
  return self.with_select(self._select.where(*criteria))
509
514
 
510
- def filter_by(self, **values: Any) -> Self: # noqa: ANN401
511
- """Narrow the rows by equality, as `Select.filter_by` does."""
515
+ def filter_by(
516
+ self,
517
+ values: Mapping[str, Any] | None = None,
518
+ /,
519
+ **fields: Any, # noqa: ANN401
520
+ ) -> Self:
521
+ """Narrow the rows by equality, as `Select.filter_by` does.
522
+
523
+ ```python
524
+ db.query(User).filter_by(team="red")
525
+ db.query(User).filter_by(request.query_params)
526
+ ```
527
+
528
+ The fields are keywords, a mapping, or both, as `create()` takes them.
529
+ """
512
530
  self._reject_statement("filter_by")
513
- return self.with_select(self._select.filter_by(**values))
531
+ return self.with_select(self._select.filter_by(**merged(values, fields)))
514
532
 
515
533
  def join(
516
534
  self,
@@ -659,6 +677,67 @@ class BaseQuery(Generic[ModelT]):
659
677
  """Read a relationship from a join this query already makes."""
660
678
  return self.options(_chain(contains_eager, keys))
661
679
 
680
+ def load_only(self, *columns: Any) -> Self: # noqa: ANN401
681
+ """Load these columns of the row, and defer the rest.
682
+
683
+ ```python
684
+ db.query(User).load_only(User.id, User.name).all()
685
+ ```
686
+
687
+ The rows are still instances: a column left out is read from the
688
+ database when something touches it, one statement per instance, which
689
+ is the cost this trades the narrower row for. `only_columns` is the
690
+ other one: it gives the instances up and reads values.
691
+ """
692
+ return self.options(load_only(*columns))
693
+
694
+ def defer(self, *columns: Any) -> Self: # noqa: ANN401
695
+ """Leave these columns out of the row until something reads them.
696
+
697
+ ```python
698
+ db.query(Post).defer(Post.body).all()
699
+ ```
700
+
701
+ For the wide column of a table read for everything else.
702
+ """
703
+ return self.options(*(defer(column) for column in columns))
704
+
705
+ def undefer(self, *columns: Any) -> Self: # noqa: ANN401
706
+ """Load these columns with the row, though the model defers them.
707
+
708
+ ```python
709
+ db.query(Post).undefer(Post.body).all()
710
+ ```
711
+
712
+ The other side of `mapped_column(deferred=True)`, for the read that
713
+ wants the column after all.
714
+ """
715
+ return self.options(*(undefer(column) for column in columns))
716
+
717
+ def undefer_group(self, name: str) -> Self:
718
+ """Load the columns a model defers under this group name.
719
+
720
+ ```python
721
+ db.query(Post).undefer_group("body").all()
722
+ ```
723
+
724
+ The group is the one `mapped_column(deferred_group="body")` names, for
725
+ the columns a read wants together or not at all.
726
+ """
727
+ return self.options(undefer_group(name))
728
+
729
+ def with_expression(self, key: Any, expression: Any) -> Self: # noqa: ANN401
730
+ """Give a `query_expression()` attribute its value for this read.
731
+
732
+ ```python
733
+ db.query(Post).with_expression(Post.comments, _comment_count()).all()
734
+ ```
735
+
736
+ The attribute holds what this statement selects into it, so a count or
737
+ a window function arrives on the instance rather than beside it.
738
+ """
739
+ return self.options(with_expression(key, expression))
740
+
662
741
  def with_for_update(
663
742
  self,
664
743
  *,
@@ -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,
@@ -327,6 +331,10 @@ class Query(BaseQuery[ModelT]):
327
331
  ```python
328
332
  names = User.query.where(User.is_active).only_columns(User.name).all()
329
333
  ```
334
+
335
+ One column arrives as values and several as tuples, and neither is an
336
+ instance. `load_only` is the other one: the rows stay instances, and
337
+ the columns it leaves out are read when something touches them.
330
338
  """
331
339
  return ColumnQuery(
332
340
  self.model,
@@ -616,25 +624,45 @@ class ModelMixin(BaseModel[Database]):
616
624
 
617
625
  def refresh(
618
626
  self,
619
- *,
627
+ *attributes: str | InstrumentedAttribute[Any],
620
628
  attribute_names: Iterable[str] | None = None,
629
+ with_relationships: bool = False,
621
630
  with_for_update: ForUpdateParameter = None,
622
631
  ) -> None:
623
632
  """Read this instance back from the database.
624
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
+
625
640
  Args:
626
- attribute_names: The attributes to reload, rather than all of them.
627
- 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.
628
648
  with_for_update: Lock the row while it is read, as
629
649
  ``Session.refresh`` takes it: `True` for a plain ``FOR UPDATE``,
630
650
  or a mapping such as ``{"read": True}``.
631
651
 
632
652
  """
653
+ names = names_of(attributes, attribute_names)
654
+ if with_relationships:
655
+ names = list(dict.fromkeys([*(names or ()), *every_attribute(type(self))]))
633
656
  self.db.session.refresh(
634
657
  self,
635
- attribute_names=attribute_names,
658
+ attribute_names=names,
636
659
  with_for_update=with_for_update,
637
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)
638
666
 
639
667
  def _persist(self) -> None:
640
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