sqlakit 0.13.0__tar.gz → 0.14.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.14.0}/PKG-INFO +1 -1
- {sqlakit-0.13.0 → sqlakit-0.14.0}/pyproject.toml +1 -1
- {sqlakit-0.13.0 → sqlakit-0.14.0}/pyproject.toml.orig +1 -1
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/__init__.py +2 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_base.py +81 -25
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_db.py +25 -7
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/_db.py +25 -7
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/exceptions.py +12 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/pytest_plugin.py +140 -18
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/testing.py +2 -1
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/types.py +30 -1
- {sqlakit-0.13.0 → sqlakit-0.14.0}/LICENSE +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/README.md +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_cli.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_debugserver.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_query.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/debugserver.html +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/orm.py +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/sql.py +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:
|
|
@@ -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:
|
|
@@ -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
|
|
|
@@ -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)]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|