sqlakit 0.13.0__tar.gz → 0.15.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.13.0 → sqlakit-0.15.0}/PKG-INFO +1 -1
- {sqlakit-0.13.0 → sqlakit-0.15.0}/pyproject.toml +1 -1
- {sqlakit-0.13.0 → sqlakit-0.15.0}/pyproject.toml.orig +1 -1
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/__init__.py +2 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_base.py +81 -25
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_db.py +25 -7
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_query.py +25 -2
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/asyncio/_db.py +25 -7
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/asyncio/orm.py +24 -4
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/asyncio/sql.py +36 -13
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/exceptions.py +12 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/orm.py +22 -4
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/pytest_plugin.py +140 -18
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/sql.py +36 -13
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/testing.py +2 -1
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/types.py +30 -1
- {sqlakit-0.13.0 → sqlakit-0.15.0}/LICENSE +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/README.md +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_cli.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_debugserver.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/debugserver.html +0 -0
- {sqlakit-0.13.0 → sqlakit-0.15.0}/sqlakit/py.typed +0 -0
|
@@ -52,6 +52,7 @@ from .exceptions import (
|
|
|
52
52
|
UnknownImportPathError,
|
|
53
53
|
UnknownOrderFieldError,
|
|
54
54
|
UnorderedPageError,
|
|
55
|
+
UnregisteredDatabaseError,
|
|
55
56
|
)
|
|
56
57
|
from .types import (
|
|
57
58
|
DatabaseConfig,
|
|
@@ -122,6 +123,7 @@ __all__ = [
|
|
|
122
123
|
"UnknownImportPathError",
|
|
123
124
|
"UnknownOrderFieldError",
|
|
124
125
|
"UnorderedPageError",
|
|
126
|
+
"UnregisteredDatabaseError",
|
|
125
127
|
"UrlParts",
|
|
126
128
|
"ValidationArgs",
|
|
127
129
|
"db",
|
|
@@ -52,6 +52,7 @@ from .exceptions import (
|
|
|
52
52
|
MissingSessionError,
|
|
53
53
|
RetryNotSupportedError,
|
|
54
54
|
UnknownDatabaseError,
|
|
55
|
+
UnregisteredDatabaseError,
|
|
55
56
|
)
|
|
56
57
|
|
|
57
58
|
if TYPE_CHECKING:
|
|
@@ -111,6 +112,7 @@ def _elsewhere(frames: tuple[str, ...], skipped: tuple[str, ...]) -> bool:
|
|
|
111
112
|
|
|
112
113
|
ConnectionT = TypeVar("ConnectionT")
|
|
113
114
|
SessionT = TypeVar("SessionT")
|
|
115
|
+
ValueT = TypeVar("ValueT")
|
|
114
116
|
|
|
115
117
|
|
|
116
118
|
class _Lazy(Generic[ConnectionT]):
|
|
@@ -182,18 +184,45 @@ class _Outer(Generic[ConnectionT]):
|
|
|
182
184
|
connection: What blocks below reuse, with ``join_nested``.
|
|
183
185
|
join_nested: Whether blocks below reuse that connection.
|
|
184
186
|
savepoint: Whether nested blocks run as savepoints.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
of
|
|
188
|
-
|
|
187
|
+
scope: The block's own scope, whose session the connection is bound to.
|
|
188
|
+
owner: The scope whose session owns the savepoints of this connection.
|
|
189
|
+
That session joins with a savepoint of its own, and a block below
|
|
190
|
+
takes its savepoint through it, so the two are released in the
|
|
191
|
+
order they were taken. One owner per connection: two of them
|
|
192
|
+
release each other's savepoints out of order.
|
|
189
193
|
|
|
190
194
|
"""
|
|
191
195
|
|
|
192
196
|
connection: ConnectionT
|
|
193
197
|
join_nested: bool = True
|
|
194
198
|
savepoint: bool = False
|
|
195
|
-
session_savepoint: bool = False
|
|
196
199
|
scope: Any = None
|
|
200
|
+
owner: Any = None
|
|
201
|
+
|
|
202
|
+
def savepoint_owner(self) -> Any: # noqa: ANN401 - a session of either API
|
|
203
|
+
"""Return the session that owns the savepoints here, if one does."""
|
|
204
|
+
return None if self.owner is None else self.owner.session
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _Binding(Generic[ValueT]):
|
|
208
|
+
"""A value bound to a context variable for as long as a block is open.
|
|
209
|
+
|
|
210
|
+
The generator `@contextmanager` builds costs more than the two calls it
|
|
211
|
+
saves, and every block binds two of these.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
__slots__ = ("_token", "_value", "_var")
|
|
215
|
+
|
|
216
|
+
def __init__(self, var: ContextVar[Any], value: ValueT) -> None:
|
|
217
|
+
self._var = var
|
|
218
|
+
self._value = value
|
|
219
|
+
|
|
220
|
+
def __enter__(self) -> ValueT:
|
|
221
|
+
self._token = self._var.set(self._value)
|
|
222
|
+
return self._value
|
|
223
|
+
|
|
224
|
+
def __exit__(self, *_: object) -> None:
|
|
225
|
+
self._var.reset(self._token)
|
|
197
226
|
|
|
198
227
|
|
|
199
228
|
class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
@@ -561,12 +590,25 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
561
590
|
outer = self._outer.get(None)
|
|
562
591
|
if outer is None or outer.connection is not connection:
|
|
563
592
|
return {}
|
|
564
|
-
if outer.
|
|
593
|
+
if outer.owner is not None and outer.owner is self._scope.get(None):
|
|
565
594
|
return {"join_transaction_mode": "create_savepoint"}
|
|
566
595
|
# Spelled out: SQLAlchemy's default would open a savepoint of its own
|
|
567
596
|
# whenever the connection is already inside one.
|
|
568
597
|
return {"join_transaction_mode": "rollback_only"}
|
|
569
598
|
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _owns_savepoints(outer: _Outer[Any] | None, *, savepoint: bool) -> bool:
|
|
601
|
+
"""Whether this block's session owns the savepoints of its connection.
|
|
602
|
+
|
|
603
|
+
A block rolled back at the end gives its session one, so a test or the
|
|
604
|
+
code under it may commit that session without ending the block. A block
|
|
605
|
+
that took a savepoint of its own does not: the owner stays the one
|
|
606
|
+
above, and blocks below take their savepoints through it.
|
|
607
|
+
"""
|
|
608
|
+
if outer is None:
|
|
609
|
+
return savepoint
|
|
610
|
+
return not savepoint and outer.owner is not None
|
|
611
|
+
|
|
570
612
|
def _plan(self, *, savepoint: bool, rollback: bool) -> tuple[_Outer | None, bool]:
|
|
571
613
|
"""Decide what a transaction joins, and whether it is a savepoint.
|
|
572
614
|
|
|
@@ -609,38 +651,34 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
609
651
|
return None
|
|
610
652
|
return scope
|
|
611
653
|
|
|
612
|
-
@contextmanager
|
|
613
654
|
def _bind(
|
|
614
655
|
self,
|
|
615
656
|
connection: ConnectionT | None,
|
|
616
657
|
checkout: _Lazy[ConnectionT] | None = None,
|
|
617
658
|
*,
|
|
618
659
|
autocommit: bool = False,
|
|
619
|
-
) ->
|
|
660
|
+
) -> _Binding[_Scope[ConnectionT, SessionT]]:
|
|
620
661
|
"""Bind a scope holding ``connection`` to the current context.
|
|
621
662
|
|
|
622
663
|
Every block gets a scope, and so a session, of its own. Ending that
|
|
623
664
|
session is left to the caller, which knows whether it takes an
|
|
624
665
|
``await``. A lazy block passes ``checkout`` instead of a connection.
|
|
625
666
|
"""
|
|
626
|
-
|
|
667
|
+
# Unparameterized: subscripting a generic builds an alias and calls
|
|
668
|
+
# through it, and every block binds a scope.
|
|
669
|
+
scope: _Scope[ConnectionT, SessionT] = _Scope(
|
|
627
670
|
connection, checkout=checkout, autocommit=autocommit
|
|
628
671
|
)
|
|
629
|
-
|
|
630
|
-
try:
|
|
631
|
-
yield scope
|
|
632
|
-
finally:
|
|
633
|
-
self._scope.reset(token)
|
|
672
|
+
return _Binding(self._scope, scope)
|
|
634
673
|
|
|
635
|
-
@contextmanager
|
|
636
674
|
def _set_outer(
|
|
637
675
|
self,
|
|
638
676
|
connection: ConnectionT | None,
|
|
639
677
|
*,
|
|
640
678
|
join_nested: bool = True,
|
|
641
679
|
savepoint: bool = False,
|
|
642
|
-
|
|
643
|
-
) ->
|
|
680
|
+
owner: Any = None, # noqa: ANN401 - the scope whose session owns them
|
|
681
|
+
) -> _Binding[_Outer[ConnectionT] | None]:
|
|
644
682
|
"""Make ``connection`` the outer one for this context. See `_Outer`.
|
|
645
683
|
|
|
646
684
|
``None`` leaves this context without an outer transaction at all, as
|
|
@@ -652,16 +690,12 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
652
690
|
connection,
|
|
653
691
|
join_nested=join_nested,
|
|
654
692
|
savepoint=savepoint,
|
|
655
|
-
|
|
693
|
+
owner=owner,
|
|
656
694
|
)
|
|
657
695
|
if connection is not None
|
|
658
696
|
else None
|
|
659
697
|
)
|
|
660
|
-
|
|
661
|
-
try:
|
|
662
|
-
yield outer
|
|
663
|
-
finally:
|
|
664
|
-
self._outer.reset(token)
|
|
698
|
+
return _Binding(self._outer, outer)
|
|
665
699
|
|
|
666
700
|
|
|
667
701
|
DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
|
|
@@ -928,8 +962,11 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
928
962
|
db._name = alias # noqa: SLF001
|
|
929
963
|
return db
|
|
930
964
|
|
|
931
|
-
def using(self,
|
|
932
|
-
"""Return
|
|
965
|
+
def using(self, target: str | DatabaseT) -> _Using:
|
|
966
|
+
"""Return that database, standing in for the default one.
|
|
967
|
+
|
|
968
|
+
Named or handed over, as `recording(using=...)` and a query's `using()`
|
|
969
|
+
take it.
|
|
933
970
|
|
|
934
971
|
The block opens on it, and models that live on the default database resolve
|
|
935
972
|
there for as long as it is open:
|
|
@@ -945,12 +982,31 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
945
982
|
|
|
946
983
|
Raises:
|
|
947
984
|
UnknownDatabaseError: if nothing is configured under that alias.
|
|
985
|
+
UnregisteredDatabaseError: if the database is not one this registry
|
|
986
|
+
holds, since the redirection works by the name it holds it under.
|
|
948
987
|
|
|
949
988
|
"""
|
|
989
|
+
alias = target if isinstance(target, str) else self._alias_of(target)
|
|
950
990
|
if alias not in self:
|
|
951
991
|
raise UnknownDatabaseError(alias, self.aliases)
|
|
952
992
|
return _Using(self[alias], self._using, alias)
|
|
953
993
|
|
|
994
|
+
def _alias_of(self, db: DatabaseT) -> str:
|
|
995
|
+
"""Return the alias this registry holds a database under.
|
|
996
|
+
|
|
997
|
+
Raises:
|
|
998
|
+
UnregisteredDatabaseError: if it holds it under none.
|
|
999
|
+
|
|
1000
|
+
"""
|
|
1001
|
+
# A configured registry is the default database itself, and a
|
|
1002
|
+
# registered one holds it.
|
|
1003
|
+
if db is self or db is self._default:
|
|
1004
|
+
return DEFAULT_ALIAS
|
|
1005
|
+
for alias, held in self._aliased.items():
|
|
1006
|
+
if held is db:
|
|
1007
|
+
return alias
|
|
1008
|
+
raise UnregisteredDatabaseError(self.aliases)
|
|
1009
|
+
|
|
954
1010
|
def route(self, *routers: Router | RouterFunction | str) -> None:
|
|
955
1011
|
"""Say which database a model lives on, for models that do not say it.
|
|
956
1012
|
|
|
@@ -482,12 +482,23 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
482
482
|
owner: Session | None = None
|
|
483
483
|
if outer is not None:
|
|
484
484
|
connection = outer.connection
|
|
485
|
+
holder = outer.savepoint_owner()
|
|
485
486
|
# Without a savepoint the block only takes part in the
|
|
486
487
|
# transaction around it, which commits it.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
488
|
+
if not savepoint:
|
|
489
|
+
transaction = None
|
|
490
|
+
elif holder is not None:
|
|
491
|
+
# The session holding this connection's savepoints takes
|
|
492
|
+
# this one too, so a commit of its own releases them in the
|
|
493
|
+
# order they were taken.
|
|
494
|
+
transaction = holder.begin_nested()
|
|
495
|
+
# It opens the savepoint when it next reaches the
|
|
496
|
+
# connection, which may be after this block has written
|
|
497
|
+
# through a session of its own.
|
|
498
|
+
holder.connection()
|
|
499
|
+
else:
|
|
500
|
+
transaction = connection.begin_nested()
|
|
501
|
+
held_by = outer.owner
|
|
491
502
|
else:
|
|
492
503
|
borrowed = self.db._scope_to_borrow() # noqa: SLF001
|
|
493
504
|
if borrowed is not None:
|
|
@@ -496,20 +507,22 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
496
507
|
else:
|
|
497
508
|
connection = stack.enter_context(self.db.engine.connect())
|
|
498
509
|
owner, transaction = None, connection.begin()
|
|
499
|
-
|
|
510
|
+
held_by = None
|
|
500
511
|
# Unwound in reverse: session, context, transaction, connection.
|
|
501
|
-
stack.push(self._finish(transaction, owner))
|
|
512
|
+
stack.push(self._finish(transaction, owner, connection))
|
|
502
513
|
bound = stack.enter_context(
|
|
503
514
|
self.db._set_outer( # noqa: SLF001
|
|
504
515
|
connection,
|
|
505
516
|
join_nested=self.join_nested,
|
|
506
517
|
savepoint=savepoint,
|
|
507
|
-
|
|
518
|
+
owner=held_by,
|
|
508
519
|
)
|
|
509
520
|
)
|
|
510
521
|
scope = stack.enter_context(self.db._bind(connection)) # noqa: SLF001
|
|
511
522
|
if bound is not None:
|
|
512
523
|
bound.scope = scope
|
|
524
|
+
if self.db._owns_savepoints(outer, savepoint=savepoint): # noqa: SLF001
|
|
525
|
+
bound.owner = scope
|
|
513
526
|
stack.push(self._close_session(scope))
|
|
514
527
|
except BaseException:
|
|
515
528
|
stack.close()
|
|
@@ -552,6 +565,7 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
552
565
|
self,
|
|
553
566
|
transaction: sa.Transaction | None,
|
|
554
567
|
owner: Session | None = None,
|
|
568
|
+
connection: sa.Connection | None = None,
|
|
555
569
|
) -> Callable[..., None]:
|
|
556
570
|
"""Commit or roll back, unless this block only takes part in another."""
|
|
557
571
|
|
|
@@ -571,6 +585,10 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
571
585
|
if transaction is None:
|
|
572
586
|
return
|
|
573
587
|
if not transaction.is_active:
|
|
588
|
+
if connection is not None and connection.in_transaction():
|
|
589
|
+
# The session of an enclosing block ended this savepoint by
|
|
590
|
+
# committing, and the transaction around it holds the work.
|
|
591
|
+
return
|
|
574
592
|
# Rolled back from inside the block. Say so, unless an
|
|
575
593
|
# exception is already on its way out with the reason.
|
|
576
594
|
if exc is None:
|
|
@@ -112,6 +112,21 @@ RowT = TypeVar("RowT")
|
|
|
112
112
|
RowT_co = TypeVar("RowT_co", covariant=True)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def merged(
|
|
116
|
+
mapping: Mapping[str, Any] | None, keywords: Mapping[str, Any]
|
|
117
|
+
) -> Mapping[str, Any]:
|
|
118
|
+
"""Return the values a call was given, however they were passed.
|
|
119
|
+
|
|
120
|
+
A caller handed a mapping passes it as it is, and the keywords beside it
|
|
121
|
+
win, so one value can be replaced without copying the mapping.
|
|
122
|
+
"""
|
|
123
|
+
if not mapping:
|
|
124
|
+
return keywords
|
|
125
|
+
if not keywords:
|
|
126
|
+
return mapping
|
|
127
|
+
return {**mapping, **keywords}
|
|
128
|
+
|
|
129
|
+
|
|
115
130
|
class CaseInsensitive(sa.ColumnElement[Any]):
|
|
116
131
|
"""A column compared without regard to case, however the dialect does it.
|
|
117
132
|
|
|
@@ -466,18 +481,26 @@ class BaseQuery(Generic[ModelT]):
|
|
|
466
481
|
)
|
|
467
482
|
return query
|
|
468
483
|
|
|
469
|
-
def from_sql(
|
|
484
|
+
def from_sql(
|
|
485
|
+
self,
|
|
486
|
+
template: str,
|
|
487
|
+
/,
|
|
488
|
+
context: Mapping[str, Any] | None = None,
|
|
489
|
+
**values: Any, # noqa: ANN401
|
|
490
|
+
) -> Self:
|
|
470
491
|
"""Take the rows of a SQL template, mapped onto the model.
|
|
471
492
|
|
|
472
493
|
```python
|
|
473
494
|
User.query.from_sql("users/active.sql", team="red").all()
|
|
495
|
+
User.query.from_sql("users/active.sql", context=filters).all()
|
|
474
496
|
```
|
|
475
497
|
|
|
476
498
|
Read from the database this query runs on, and rendered for its dialect. As
|
|
477
499
|
with `from_statement`, nothing can be added afterwards and
|
|
478
500
|
``__query_filter__`` is not applied.
|
|
479
501
|
"""
|
|
480
|
-
|
|
502
|
+
rows = self.db.sql.from_file(template, context, **values)
|
|
503
|
+
return self.from_statement(rows.statement)
|
|
481
504
|
|
|
482
505
|
def where(self, *criteria: _ColumnExpressionArgument[bool]) -> Self:
|
|
483
506
|
"""Narrow the rows, as `Select.where` does."""
|
|
@@ -522,12 +522,23 @@ class Transaction(
|
|
|
522
522
|
owner: AsyncSession | None = None
|
|
523
523
|
if outer is not None:
|
|
524
524
|
connection = outer.connection
|
|
525
|
+
holder = outer.savepoint_owner()
|
|
525
526
|
# Without a savepoint the block only takes part in the
|
|
526
527
|
# transaction around it, which commits it.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
528
|
+
if not savepoint:
|
|
529
|
+
transaction = None
|
|
530
|
+
elif holder is not None:
|
|
531
|
+
# The session holding this connection's savepoints takes
|
|
532
|
+
# this one too, so a commit of its own releases them in the
|
|
533
|
+
# order they were taken.
|
|
534
|
+
transaction = await holder.begin_nested()
|
|
535
|
+
# It opens the savepoint when it next reaches the
|
|
536
|
+
# connection, which may be after this block has written
|
|
537
|
+
# through a session of its own.
|
|
538
|
+
await holder.connection()
|
|
539
|
+
else:
|
|
540
|
+
transaction = await connection.begin_nested()
|
|
541
|
+
held_by = outer.owner
|
|
531
542
|
else:
|
|
532
543
|
borrowed = self.db._scope_to_borrow() # noqa: SLF001
|
|
533
544
|
if borrowed is not None:
|
|
@@ -538,20 +549,22 @@ class Transaction(
|
|
|
538
549
|
self.db.engine.connect()
|
|
539
550
|
)
|
|
540
551
|
transaction = await connection.begin()
|
|
541
|
-
|
|
552
|
+
held_by = None
|
|
542
553
|
# Unwound in reverse: session, context, transaction, connection.
|
|
543
|
-
stack.push_async_exit(self._finish(transaction, owner))
|
|
554
|
+
stack.push_async_exit(self._finish(transaction, owner, connection))
|
|
544
555
|
bound = stack.enter_context(
|
|
545
556
|
self.db._set_outer( # noqa: SLF001
|
|
546
557
|
connection,
|
|
547
558
|
join_nested=self.join_nested,
|
|
548
559
|
savepoint=savepoint,
|
|
549
|
-
|
|
560
|
+
owner=held_by,
|
|
550
561
|
)
|
|
551
562
|
)
|
|
552
563
|
scope = stack.enter_context(self.db._bind(connection)) # noqa: SLF001
|
|
553
564
|
if bound is not None:
|
|
554
565
|
bound.scope = scope
|
|
566
|
+
if self.db._owns_savepoints(outer, savepoint=savepoint): # noqa: SLF001
|
|
567
|
+
bound.owner = scope
|
|
555
568
|
stack.push_async_exit(self._close_session(scope))
|
|
556
569
|
except BaseException:
|
|
557
570
|
await stack.aclose()
|
|
@@ -594,6 +607,7 @@ class Transaction(
|
|
|
594
607
|
self,
|
|
595
608
|
transaction: AsyncTransaction | None,
|
|
596
609
|
owner: AsyncSession | None = None,
|
|
610
|
+
connection: AsyncConnection | None = None,
|
|
597
611
|
) -> Callable[..., Coroutine[None, None, None]]:
|
|
598
612
|
"""Commit or roll back, unless this block only takes part in another."""
|
|
599
613
|
|
|
@@ -613,6 +627,10 @@ class Transaction(
|
|
|
613
627
|
if transaction is None:
|
|
614
628
|
return
|
|
615
629
|
if not transaction.is_active:
|
|
630
|
+
if connection is not None and connection.in_transaction():
|
|
631
|
+
# The session of an enclosing block ended this savepoint by
|
|
632
|
+
# committing, and the transaction around it holds the work.
|
|
633
|
+
return
|
|
616
634
|
# Rolled back from inside the block. Say so, unless an
|
|
617
635
|
# exception is already on its way out with the reason.
|
|
618
636
|
if exc is None:
|
|
@@ -28,6 +28,7 @@ from sqlakit._query import (
|
|
|
28
28
|
BaseQuery,
|
|
29
29
|
CursorPage,
|
|
30
30
|
Page,
|
|
31
|
+
merged,
|
|
31
32
|
one_row,
|
|
32
33
|
one_row_or_none,
|
|
33
34
|
orderable,
|
|
@@ -344,19 +345,28 @@ class Query(BaseQuery[ModelT]):
|
|
|
344
345
|
scalar=len(columns) == 1,
|
|
345
346
|
)
|
|
346
347
|
|
|
347
|
-
async def create(
|
|
348
|
+
async def create(
|
|
349
|
+
self,
|
|
350
|
+
values: Mapping[str, Any] | None = None,
|
|
351
|
+
/,
|
|
352
|
+
**fields: Any, # noqa: ANN401
|
|
353
|
+
) -> ModelT:
|
|
348
354
|
"""Write a new row, and return it as an instance.
|
|
349
355
|
|
|
350
356
|
```python
|
|
351
357
|
user = await User.query.create(name="ada", team="red")
|
|
358
|
+
user = await User.query.create(payload.model_dump())
|
|
352
359
|
```
|
|
353
360
|
|
|
361
|
+
The fields are keywords, a mapping, or both, where a keyword replaces
|
|
362
|
+
the value of that name.
|
|
363
|
+
|
|
354
364
|
The row goes through the session, so defaults, relationships and the identity
|
|
355
365
|
map behave as they do for a model that saves itself. What it adds is a write
|
|
356
366
|
that needs no model layer: `Query(User, db).create(...)` works on any mapped
|
|
357
367
|
class.
|
|
358
368
|
"""
|
|
359
|
-
instance = self.model(**values)
|
|
369
|
+
instance = self.model(**merged(values, fields))
|
|
360
370
|
self.db.session.add(instance)
|
|
361
371
|
await self._persist()
|
|
362
372
|
return instance
|
|
@@ -377,9 +387,17 @@ class Query(BaseQuery[ModelT]):
|
|
|
377
387
|
await self._persist()
|
|
378
388
|
return len(rows)
|
|
379
389
|
|
|
380
|
-
async def update(
|
|
390
|
+
async def update(
|
|
391
|
+
self,
|
|
392
|
+
values: Mapping[str, Any] | None = None,
|
|
393
|
+
/,
|
|
394
|
+
**fields: Any, # noqa: ANN401
|
|
395
|
+
) -> int:
|
|
381
396
|
"""Write these values to every matching row, and return how many.
|
|
382
397
|
|
|
398
|
+
A mapping, keywords, or both: `update({"team": "green"})` and
|
|
399
|
+
`update(team="green")` write the same statement.
|
|
400
|
+
|
|
383
401
|
One statement, so the session's objects are updated from the database
|
|
384
402
|
rather than in memory. Only the narrowing carries over.
|
|
385
403
|
|
|
@@ -387,7 +405,9 @@ class Query(BaseQuery[ModelT]):
|
|
|
387
405
|
BulkQueryError: if the query carries anything a statement drops.
|
|
388
406
|
|
|
389
407
|
"""
|
|
390
|
-
result = await self.db.session.execute(
|
|
408
|
+
result = await self.db.session.execute(
|
|
409
|
+
self._update_statement(merged(values, fields))
|
|
410
|
+
)
|
|
391
411
|
await self._persist()
|
|
392
412
|
return cast("CursorResult[Any]", result).rowcount
|
|
393
413
|
|
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
|
5
5
|
import sqlalchemy as sa
|
|
6
6
|
from typing_extensions import Unpack
|
|
7
7
|
|
|
8
|
+
from sqlakit._query import merged
|
|
8
9
|
from sqlakit._sql import (
|
|
9
10
|
BaseSQLQuery,
|
|
10
11
|
Filter,
|
|
@@ -14,7 +15,7 @@ from sqlakit._sql import (
|
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
|
-
from collections.abc import AsyncIterator, Sequence
|
|
18
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
18
19
|
|
|
19
20
|
from sqlalchemy.engine import Result, ScalarResult
|
|
20
21
|
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
@@ -50,27 +51,48 @@ class SQL:
|
|
|
50
51
|
def __repr__(self) -> str:
|
|
51
52
|
return f"{type(self).__name__}({self.db!r})"
|
|
52
53
|
|
|
53
|
-
def __call__(
|
|
54
|
+
def __call__(
|
|
55
|
+
self,
|
|
56
|
+
template: str,
|
|
57
|
+
/,
|
|
58
|
+
context: Mapping[str, Any] | None = None,
|
|
59
|
+
**values: Any, # noqa: ANN401
|
|
60
|
+
) -> SQLQuery:
|
|
54
61
|
"""Read the rows of a template. Short for `from_file`.
|
|
55
62
|
|
|
56
63
|
```python
|
|
57
64
|
await db.sql("users/active.sql", team="red").all()
|
|
58
65
|
```
|
|
59
66
|
"""
|
|
60
|
-
return self.from_file(template, **
|
|
61
|
-
|
|
62
|
-
def from_file(
|
|
67
|
+
return self.from_file(template, context, **values)
|
|
68
|
+
|
|
69
|
+
def from_file(
|
|
70
|
+
self,
|
|
71
|
+
template: str,
|
|
72
|
+
/,
|
|
73
|
+
context: Mapping[str, Any] | None = None,
|
|
74
|
+
**values: Any, # noqa: ANN401
|
|
75
|
+
) -> SQLQuery:
|
|
63
76
|
"""Read the rows of a template kept under the database's ``templates=``.
|
|
64
77
|
|
|
65
78
|
```python
|
|
66
79
|
await db.sql.from_file("users/active.sql", team="red").all()
|
|
80
|
+
await db.sql.from_file("users/active.sql", context=filters).all()
|
|
67
81
|
```
|
|
68
82
|
|
|
69
|
-
The keyword arguments are the template's context
|
|
83
|
+
The keyword arguments are the template's context, and ``context`` takes
|
|
84
|
+
the same values as a mapping, for values a caller was handed rather than
|
|
85
|
+
wrote. A value named `context` lives in that mapping.
|
|
70
86
|
"""
|
|
71
|
-
return SQLQuery(self.db, template, context)
|
|
72
|
-
|
|
73
|
-
def from_string(
|
|
87
|
+
return SQLQuery(self.db, template, merged(context, values))
|
|
88
|
+
|
|
89
|
+
def from_string(
|
|
90
|
+
self,
|
|
91
|
+
source: str,
|
|
92
|
+
/,
|
|
93
|
+
context: Mapping[str, Any] | None = None,
|
|
94
|
+
**values: Any, # noqa: ANN401
|
|
95
|
+
) -> SQLQuery:
|
|
74
96
|
"""Read the rows of SQL written out here rather than kept in a file.
|
|
75
97
|
|
|
76
98
|
```python
|
|
@@ -79,11 +101,12 @@ class SQL:
|
|
|
79
101
|
)
|
|
80
102
|
```
|
|
81
103
|
|
|
82
|
-
Values are named in `{{ }}` and passed by keyword, as
|
|
83
|
-
`:name` or a `?` binds nothing here, and
|
|
84
|
-
reaching the driver. It needs no
|
|
104
|
+
Values are named in `{{ }}` and passed by keyword, or as the ``context``
|
|
105
|
+
mapping, as in a template. A `:name` or a `?` binds nothing here, and
|
|
106
|
+
rendering says so rather than reaching the driver. It needs no
|
|
107
|
+
``templates=``.
|
|
85
108
|
"""
|
|
86
|
-
return SQLQuery(self.db, source, context, inline=True)
|
|
109
|
+
return SQLQuery(self.db, source, merged(context, values), inline=True)
|
|
87
110
|
|
|
88
111
|
def from_statement(self, statement: Executable) -> SQLQuery:
|
|
89
112
|
"""Read the rows of a statement built with SQLAlchemy.
|
|
@@ -38,6 +38,7 @@ __all__ = [
|
|
|
38
38
|
"UnknownFieldError",
|
|
39
39
|
"UnknownImportPathError",
|
|
40
40
|
"UnorderedPageError",
|
|
41
|
+
"UnregisteredDatabaseError",
|
|
41
42
|
]
|
|
42
43
|
|
|
43
44
|
|
|
@@ -158,6 +159,17 @@ class UnknownDatabaseError(SQLAKitError, KeyError):
|
|
|
158
159
|
)
|
|
159
160
|
|
|
160
161
|
|
|
162
|
+
class UnregisteredDatabaseError(SQLAKitError, ValueError):
|
|
163
|
+
"""Raised when a registry is handed a database it does not hold."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, known: tuple[str, ...] = ()) -> None:
|
|
166
|
+
super().__init__(
|
|
167
|
+
"That database is not one this registry holds. Register it with "
|
|
168
|
+
"`register(alias, db)`, or name one of the databases it has: "
|
|
169
|
+
f"{', '.join(map(repr, known)) or 'none'}."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
161
173
|
class MissingRegistryError(SQLAKitError, ValueError):
|
|
162
174
|
"""Raised when a model has nowhere to look a database alias up."""
|
|
163
175
|
|
|
@@ -29,6 +29,7 @@ from ._query import (
|
|
|
29
29
|
BaseQuery,
|
|
30
30
|
CursorPage,
|
|
31
31
|
Page,
|
|
32
|
+
merged,
|
|
32
33
|
one_row,
|
|
33
34
|
one_row_or_none,
|
|
34
35
|
orderable,
|
|
@@ -334,19 +335,28 @@ class Query(BaseQuery[ModelT]):
|
|
|
334
335
|
scalar=len(columns) == 1,
|
|
335
336
|
)
|
|
336
337
|
|
|
337
|
-
def create(
|
|
338
|
+
def create(
|
|
339
|
+
self,
|
|
340
|
+
values: Mapping[str, Any] | None = None,
|
|
341
|
+
/,
|
|
342
|
+
**fields: Any, # noqa: ANN401
|
|
343
|
+
) -> ModelT:
|
|
338
344
|
"""Write a new row, and return it as an instance.
|
|
339
345
|
|
|
340
346
|
```python
|
|
341
347
|
user = User.query.create(name="ada", team="red")
|
|
348
|
+
user = User.query.create(payload.model_dump())
|
|
342
349
|
```
|
|
343
350
|
|
|
351
|
+
The fields are keywords, a mapping, or both, where a keyword replaces
|
|
352
|
+
the value of that name.
|
|
353
|
+
|
|
344
354
|
The row goes through the session, so defaults, relationships and the identity
|
|
345
355
|
map behave as they do for a model that saves itself. What it adds is a write
|
|
346
356
|
that needs no model layer: `Query(User, db).create(...)` works on any mapped
|
|
347
357
|
class.
|
|
348
358
|
"""
|
|
349
|
-
instance = self.model(**values)
|
|
359
|
+
instance = self.model(**merged(values, fields))
|
|
350
360
|
self.db.session.add(instance)
|
|
351
361
|
self._persist()
|
|
352
362
|
return instance
|
|
@@ -367,9 +377,17 @@ class Query(BaseQuery[ModelT]):
|
|
|
367
377
|
self._persist()
|
|
368
378
|
return len(rows)
|
|
369
379
|
|
|
370
|
-
def update(
|
|
380
|
+
def update(
|
|
381
|
+
self,
|
|
382
|
+
values: Mapping[str, Any] | None = None,
|
|
383
|
+
/,
|
|
384
|
+
**fields: Any, # noqa: ANN401
|
|
385
|
+
) -> int:
|
|
371
386
|
"""Write these values to every matching row, and return how many.
|
|
372
387
|
|
|
388
|
+
A mapping, keywords, or both: `update({"team": "green"})` and
|
|
389
|
+
`update(team="green")` write the same statement.
|
|
390
|
+
|
|
373
391
|
One statement, so the session's objects are updated from the database
|
|
374
392
|
rather than in memory. Only the narrowing carries over.
|
|
375
393
|
|
|
@@ -377,7 +395,7 @@ class Query(BaseQuery[ModelT]):
|
|
|
377
395
|
BulkQueryError: if the query carries anything a statement drops.
|
|
378
396
|
|
|
379
397
|
"""
|
|
380
|
-
result = self.db.session.execute(self._update_statement(values))
|
|
398
|
+
result = self.db.session.execute(self._update_statement(merged(values, fields)))
|
|
381
399
|
self._persist()
|
|
382
400
|
return cast("CursorResult[Any]", result).rowcount
|
|
383
401
|
|
|
@@ -42,19 +42,24 @@ import inspect
|
|
|
42
42
|
import pathlib
|
|
43
43
|
import time
|
|
44
44
|
import warnings
|
|
45
|
+
from collections.abc import Mapping
|
|
45
46
|
from contextlib import AsyncExitStack, ExitStack, contextmanager
|
|
46
47
|
from typing import TYPE_CHECKING, Any
|
|
47
48
|
|
|
48
49
|
import pytest
|
|
49
50
|
|
|
50
51
|
from ._debugserver import as_payload, write_report
|
|
52
|
+
from ._recording import Recording, check, require_expectation
|
|
51
53
|
from ._registry import db as importable_db
|
|
54
|
+
from .exceptions import UnknownDatabaseError
|
|
52
55
|
|
|
53
56
|
if TYPE_CHECKING:
|
|
54
|
-
from collections.abc import AsyncIterator, Iterator
|
|
57
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
55
58
|
|
|
56
59
|
import sqlalchemy as sa
|
|
57
60
|
|
|
61
|
+
from .types import AssertQueries
|
|
62
|
+
|
|
58
63
|
MARKER = "db"
|
|
59
64
|
SYNC_FIXTURE = "_sqlakit_transaction"
|
|
60
65
|
ASYNC_FIXTURE = "_sqlakit_async_transaction"
|
|
@@ -183,7 +188,8 @@ def sqlakit_db(sqlakit_base: Any) -> Any: # noqa: ANN401
|
|
|
183
188
|
|
|
184
189
|
The one the models live on: their registry, which knows every alias, or the
|
|
185
190
|
`Database` they were given in person. Override it for a project with no
|
|
186
|
-
model layer
|
|
191
|
+
model layer, and return a dict or a list for a project on several databases
|
|
192
|
+
with no registry between them.
|
|
187
193
|
"""
|
|
188
194
|
if sqlakit_base is None:
|
|
189
195
|
if not importable_db.is_configured:
|
|
@@ -278,6 +284,47 @@ def sqlakit_seed(sqlakit_schema: None) -> None: # noqa: ARG001 - after the sche
|
|
|
278
284
|
return
|
|
279
285
|
|
|
280
286
|
|
|
287
|
+
@pytest.fixture
|
|
288
|
+
def assert_queries(sqlakit_db: Any) -> AssertQueries: # noqa: ANN401
|
|
289
|
+
"""Assert what a block asks of the databases under test.
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
@pytest.mark.db
|
|
293
|
+
def test_the_page_costs_two_queries(assert_queries: AssertQueries):
|
|
294
|
+
with assert_queries(2):
|
|
295
|
+
User.query.order_by("name").page(limit=10)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The same checks as `sqlakit.testing.assert_queries`: a count, an `at_most`
|
|
299
|
+
ceiling, and `duplicates=False` to forbid a statement running twice. It
|
|
300
|
+
watches what `sqlakit_db` returned, so a project whose databases are its
|
|
301
|
+
own needs no registry for `using` to name one:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
with assert_queries(1, using="replica"):
|
|
305
|
+
build_report()
|
|
306
|
+
```
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
@contextmanager
|
|
310
|
+
def asserted(
|
|
311
|
+
count: int | None = None,
|
|
312
|
+
*,
|
|
313
|
+
at_most: int | None = None,
|
|
314
|
+
duplicates: bool = True,
|
|
315
|
+
using: Any = None, # noqa: ANN401
|
|
316
|
+
) -> Iterator[Recording]:
|
|
317
|
+
require_expectation(count, at_most, duplicates)
|
|
318
|
+
recording = Recording()
|
|
319
|
+
with ExitStack() as stack:
|
|
320
|
+
for one in _picked(sqlakit_db, _as_asked(using)):
|
|
321
|
+
stack.enter_context(one.recording(into=recording))
|
|
322
|
+
yield recording
|
|
323
|
+
check(recording, count=count, at_most=at_most, duplicates=duplicates)
|
|
324
|
+
|
|
325
|
+
return asserted
|
|
326
|
+
|
|
327
|
+
|
|
281
328
|
@pytest.fixture
|
|
282
329
|
def _sqlakit_transaction(
|
|
283
330
|
request: pytest.FixtureRequest,
|
|
@@ -294,7 +341,8 @@ def _sqlakit_transaction(
|
|
|
294
341
|
)
|
|
295
342
|
stack.enter_context(block)
|
|
296
343
|
if _hidden(request):
|
|
297
|
-
|
|
344
|
+
for one in _each(sqlakit_db):
|
|
345
|
+
stack.enter_context(one.unbound())
|
|
298
346
|
with _reported(request, sqlakit_db):
|
|
299
347
|
yield
|
|
300
348
|
|
|
@@ -314,7 +362,8 @@ async def _sqlakit_async_transaction(
|
|
|
314
362
|
else:
|
|
315
363
|
stack.enter_context(block)
|
|
316
364
|
if _hidden(request):
|
|
317
|
-
|
|
365
|
+
for one in _each(sqlakit_db):
|
|
366
|
+
stack.enter_context(one.unbound())
|
|
318
367
|
with _reported(request, sqlakit_db):
|
|
319
368
|
yield
|
|
320
369
|
|
|
@@ -331,7 +380,12 @@ def _reported(request: pytest.FixtureRequest, db: Any) -> Iterator[None]: # noq
|
|
|
331
380
|
return
|
|
332
381
|
node = request.node
|
|
333
382
|
skip = request.config.getini("sqlakit_skip_queries_from")
|
|
334
|
-
|
|
383
|
+
recording = Recording(label=_named(node))
|
|
384
|
+
with ExitStack() as stack:
|
|
385
|
+
for one in _each(db):
|
|
386
|
+
stack.enter_context(
|
|
387
|
+
one.recording(into=recording, stacks=True, skip_queries_from=skip)
|
|
388
|
+
)
|
|
335
389
|
yield
|
|
336
390
|
request.config.stash[REPORT].append(
|
|
337
391
|
as_payload(
|
|
@@ -384,6 +438,17 @@ def _schema_blocks(
|
|
|
384
438
|
gets one block per alias. Without it there is one metadata, and one
|
|
385
439
|
database to put it on.
|
|
386
440
|
"""
|
|
441
|
+
databases = _each(db)
|
|
442
|
+
if len(databases) > 1:
|
|
443
|
+
if metadata is None:
|
|
444
|
+
pytest.fail(
|
|
445
|
+
"`sqlakit_db` returned several databases, and the tables of each "
|
|
446
|
+
"are the project's to create. Define `sqlakit_schema` with a "
|
|
447
|
+
"`provisioned_tables()` per database, or `sqlakit_metadata` with "
|
|
448
|
+
"the tables they share.",
|
|
449
|
+
pytrace=False,
|
|
450
|
+
)
|
|
451
|
+
return [one.provisioned_tables(metadata) for one in databases]
|
|
387
452
|
if base is not None:
|
|
388
453
|
# A database of its own is where the models are pinned, and the alias
|
|
389
454
|
# a registry knows it by is not how the base reaches it.
|
|
@@ -403,7 +468,11 @@ def _schema_blocks(
|
|
|
403
468
|
def _asked_for(request: pytest.FixtureRequest) -> tuple[Any, ...]:
|
|
404
469
|
"""Return the databases the marker asks for, by name or in person."""
|
|
405
470
|
marker = request.node.get_closest_marker(MARKER)
|
|
406
|
-
|
|
471
|
+
return _as_asked(None if marker is None else marker.kwargs.get("using"))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _as_asked(using: Any) -> tuple[Any, ...]: # noqa: ANN401
|
|
475
|
+
"""Return what ``using`` names, as the several it may be."""
|
|
407
476
|
if using is None:
|
|
408
477
|
return ()
|
|
409
478
|
if isinstance(using, str) or not isinstance(using, (list, tuple, set, frozenset)):
|
|
@@ -424,6 +493,67 @@ def _hidden(request: pytest.FixtureRequest) -> bool:
|
|
|
424
493
|
return bool(asked)
|
|
425
494
|
|
|
426
495
|
|
|
496
|
+
def _each(db: Any) -> tuple[Any, ...]: # noqa: ANN401
|
|
497
|
+
"""Return the databases a project handed over, as several or as one.
|
|
498
|
+
|
|
499
|
+
`sqlakit_db` returns one database, a registry of them, or several: a list,
|
|
500
|
+
or a dict naming each, for a project with no registry between them.
|
|
501
|
+
"""
|
|
502
|
+
if isinstance(db, Mapping):
|
|
503
|
+
return tuple(db.values())
|
|
504
|
+
if isinstance(db, (list, tuple)):
|
|
505
|
+
return tuple(db)
|
|
506
|
+
return (db,)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _picked(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
|
|
510
|
+
"""Return the databases a marker asks for, out of what a project handed over.
|
|
511
|
+
|
|
512
|
+
A dict names them itself. A list names them by the alias each database
|
|
513
|
+
carries, and a registry by the alias it holds one under.
|
|
514
|
+
|
|
515
|
+
Raises:
|
|
516
|
+
UnknownDatabaseError: if an alias belongs to none of them.
|
|
517
|
+
|
|
518
|
+
"""
|
|
519
|
+
if not using:
|
|
520
|
+
return list(_each(db))
|
|
521
|
+
picked = []
|
|
522
|
+
for asked in using:
|
|
523
|
+
if not isinstance(asked, str):
|
|
524
|
+
picked.append(asked)
|
|
525
|
+
elif isinstance(db, Mapping):
|
|
526
|
+
if asked not in db:
|
|
527
|
+
raise UnknownDatabaseError(asked, tuple(db))
|
|
528
|
+
picked.append(db[asked])
|
|
529
|
+
elif isinstance(db, (list, tuple)):
|
|
530
|
+
picked.append(_of_the_list(db, asked))
|
|
531
|
+
else:
|
|
532
|
+
picked.append(db[asked])
|
|
533
|
+
return picked
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _of_the_list(databases: Sequence[Any], asked: str) -> Any: # noqa: ANN401
|
|
537
|
+
"""Return the database of a list that carries this name.
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
UnknownDatabaseError: if none of them carries it.
|
|
541
|
+
|
|
542
|
+
"""
|
|
543
|
+
found = [one for one in databases if asked in one]
|
|
544
|
+
if not found:
|
|
545
|
+
aliases = tuple(alias for one in databases for alias in one.aliases)
|
|
546
|
+
raise UnknownDatabaseError(asked, aliases)
|
|
547
|
+
if len(found) > 1:
|
|
548
|
+
pytest.fail(
|
|
549
|
+
f"`sqlakit_db` returned {len(found)} databases named `{asked}`. "
|
|
550
|
+
"Name them with `Database(url, alias=...)`, or return a dict "
|
|
551
|
+
"naming each, so a marker can ask for one.",
|
|
552
|
+
pytrace=False,
|
|
553
|
+
)
|
|
554
|
+
return found[0]
|
|
555
|
+
|
|
556
|
+
|
|
427
557
|
def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
|
|
428
558
|
"""Return the blocks that undo what a test writes.
|
|
429
559
|
|
|
@@ -431,15 +561,7 @@ def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
|
|
|
431
561
|
the one they have. Naming one is how a project on several stops paying for
|
|
432
562
|
a connection to each in the tests that read one.
|
|
433
563
|
"""
|
|
434
|
-
if not using:
|
|
435
|
-
# A registry opens every database it holds
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
db.transactions(rollback=True)
|
|
439
|
-
if hasattr(db, "transactions")
|
|
440
|
-
else db.transaction(rollback=True)
|
|
441
|
-
]
|
|
442
|
-
return [
|
|
443
|
-
(db[one] if isinstance(one, str) else one).transaction(rollback=True)
|
|
444
|
-
for one in using
|
|
445
|
-
]
|
|
564
|
+
if not using and len(_each(db)) == 1 and hasattr(db, "transactions"):
|
|
565
|
+
# A registry opens every database it holds in one block.
|
|
566
|
+
return [db.transactions(rollback=True)]
|
|
567
|
+
return [one.transaction(rollback=True) for one in _picked(db, using)]
|
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
|
5
5
|
import sqlalchemy as sa
|
|
6
6
|
from typing_extensions import Unpack
|
|
7
7
|
|
|
8
|
+
from ._query import merged
|
|
8
9
|
from ._sql import (
|
|
9
10
|
BaseSQLQuery,
|
|
10
11
|
Filter,
|
|
@@ -14,7 +15,7 @@ from ._sql import (
|
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
|
-
from collections.abc import Iterator, Sequence
|
|
18
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
18
19
|
|
|
19
20
|
from sqlalchemy.engine import Result, ScalarResult
|
|
20
21
|
from sqlalchemy.sql import Executable
|
|
@@ -48,38 +49,60 @@ class SQL:
|
|
|
48
49
|
def __repr__(self) -> str:
|
|
49
50
|
return f"{type(self).__name__}({self.db!r})"
|
|
50
51
|
|
|
51
|
-
def __call__(
|
|
52
|
+
def __call__(
|
|
53
|
+
self,
|
|
54
|
+
template: str,
|
|
55
|
+
/,
|
|
56
|
+
context: Mapping[str, Any] | None = None,
|
|
57
|
+
**values: Any, # noqa: ANN401
|
|
58
|
+
) -> SQLQuery:
|
|
52
59
|
"""Read the rows of a template. Short for `from_file`.
|
|
53
60
|
|
|
54
61
|
```python
|
|
55
62
|
db.sql("users/active.sql", team="red").all()
|
|
56
63
|
```
|
|
57
64
|
"""
|
|
58
|
-
return self.from_file(template, **
|
|
59
|
-
|
|
60
|
-
def from_file(
|
|
65
|
+
return self.from_file(template, context, **values)
|
|
66
|
+
|
|
67
|
+
def from_file(
|
|
68
|
+
self,
|
|
69
|
+
template: str,
|
|
70
|
+
/,
|
|
71
|
+
context: Mapping[str, Any] | None = None,
|
|
72
|
+
**values: Any, # noqa: ANN401
|
|
73
|
+
) -> SQLQuery:
|
|
61
74
|
"""Read the rows of a template kept under the database's ``templates=``.
|
|
62
75
|
|
|
63
76
|
```python
|
|
64
77
|
db.sql.from_file("users/active.sql", team="red").all()
|
|
78
|
+
db.sql.from_file("users/active.sql", context=filters).all()
|
|
65
79
|
```
|
|
66
80
|
|
|
67
|
-
The keyword arguments are the template's context
|
|
81
|
+
The keyword arguments are the template's context, and ``context`` takes
|
|
82
|
+
the same values as a mapping, for values a caller was handed rather than
|
|
83
|
+
wrote. A value named `context` lives in that mapping.
|
|
68
84
|
"""
|
|
69
|
-
return SQLQuery(self.db, template, context)
|
|
70
|
-
|
|
71
|
-
def from_string(
|
|
85
|
+
return SQLQuery(self.db, template, merged(context, values))
|
|
86
|
+
|
|
87
|
+
def from_string(
|
|
88
|
+
self,
|
|
89
|
+
source: str,
|
|
90
|
+
/,
|
|
91
|
+
context: Mapping[str, Any] | None = None,
|
|
92
|
+
**values: Any, # noqa: ANN401
|
|
93
|
+
) -> SQLQuery:
|
|
72
94
|
"""Read the rows of SQL written out here rather than kept in a file.
|
|
73
95
|
|
|
74
96
|
```python
|
|
75
97
|
db.sql.from_string("SELECT id FROM users WHERE team = {{ team }}", team="red")
|
|
76
98
|
```
|
|
77
99
|
|
|
78
|
-
Values are named in `{{ }}` and passed by keyword, as
|
|
79
|
-
`:name` or a `?` binds nothing here, and
|
|
80
|
-
reaching the driver. It needs no
|
|
100
|
+
Values are named in `{{ }}` and passed by keyword, or as the ``context``
|
|
101
|
+
mapping, as in a template. A `:name` or a `?` binds nothing here, and
|
|
102
|
+
rendering says so rather than reaching the driver. It needs no
|
|
103
|
+
``templates=``.
|
|
81
104
|
"""
|
|
82
|
-
return SQLQuery(self.db, source, context, inline=True)
|
|
105
|
+
return SQLQuery(self.db, source, merged(context, values), inline=True)
|
|
83
106
|
|
|
84
107
|
def from_statement(self, statement: Executable) -> SQLQuery:
|
|
85
108
|
"""Read the rows of a statement built with SQLAlchemy.
|
|
@@ -9,11 +9,12 @@ from . import db as sync_db
|
|
|
9
9
|
from ._recording import Recording, check, require_expectation
|
|
10
10
|
from .asyncio import db as async_db
|
|
11
11
|
from .exceptions import UnknownDatabaseError
|
|
12
|
+
from .types import AssertQueries
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from collections.abc import Iterator
|
|
15
16
|
|
|
16
|
-
__all__ = ["assert_queries"]
|
|
17
|
+
__all__ = ["AssertQueries", "assert_queries"]
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
@contextmanager
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeAlias
|
|
4
4
|
|
|
5
5
|
from typing_extensions import TypedDict
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
8
|
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from contextlib import AbstractContextManager
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
import sqlalchemy as sa
|
|
@@ -13,9 +14,11 @@ if TYPE_CHECKING:
|
|
|
13
14
|
from sqlalchemy.orm import Query, Session
|
|
14
15
|
from sqlalchemy.pool import Pool
|
|
15
16
|
|
|
17
|
+
from ._recording import Recording
|
|
16
18
|
from ._sql import Templates
|
|
17
19
|
|
|
18
20
|
__all__ = [
|
|
21
|
+
"AssertQueries",
|
|
19
22
|
"DatabaseConfig",
|
|
20
23
|
"EngineArgs",
|
|
21
24
|
"SessionArgs",
|
|
@@ -130,6 +133,32 @@ class QueryStats(TypedDict):
|
|
|
130
133
|
label: str | None
|
|
131
134
|
|
|
132
135
|
|
|
136
|
+
class AssertQueries(Protocol):
|
|
137
|
+
"""The `assert_queries` fixture, for a test that annotates what it asks for.
|
|
138
|
+
|
|
139
|
+
Imported from `sqlakit.testing`, where the function of the same name is:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from sqlakit.testing import AssertQueries
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.db
|
|
146
|
+
def test_the_page_costs_two_queries(assert_queries: AssertQueries) -> None:
|
|
147
|
+
with assert_queries(2):
|
|
148
|
+
User.query.order_by("name").page(limit=10)
|
|
149
|
+
```
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __call__(
|
|
153
|
+
self,
|
|
154
|
+
count: int | None = None,
|
|
155
|
+
*,
|
|
156
|
+
at_most: int | None = None,
|
|
157
|
+
duplicates: bool = True,
|
|
158
|
+
using: Any = None, # noqa: ANN401 - an alias, or the database itself
|
|
159
|
+
) -> AbstractContextManager[Recording]: ...
|
|
160
|
+
|
|
161
|
+
|
|
133
162
|
class DatabaseConfig(UrlParts, total=False):
|
|
134
163
|
"""One database in a configuration keyed by alias.
|
|
135
164
|
|
|
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
|