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.
- {sqlakit-0.15.0 → sqlakit-0.17.0}/PKG-INFO +1 -1
- {sqlakit-0.15.0 → sqlakit-0.17.0}/pyproject.toml +1 -1
- {sqlakit-0.15.0 → sqlakit-0.17.0}/pyproject.toml.orig +1 -1
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_model.py +41 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_query.py +82 -3
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/orm.py +28 -4
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/orm.py +32 -4
- {sqlakit-0.15.0 → sqlakit-0.17.0}/LICENSE +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/README.md +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/__init__.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_base.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_cli.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_db.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_debugserver.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/debugserver.html +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/exceptions.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/pytest_plugin.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/sql.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/testing.py +0 -0
- {sqlakit-0.15.0 → sqlakit-0.17.0}/sqlakit/types.py +0 -0
|
@@ -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(
|
|
511
|
-
|
|
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
|
-
|
|
644
|
-
A relationship named here is
|
|
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=
|
|
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
|
-
|
|
627
|
-
A relationship named here is
|
|
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=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|