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.
Files changed (30) hide show
  1. {sqlakit-0.13.0 → sqlakit-0.14.0}/PKG-INFO +1 -1
  2. {sqlakit-0.13.0 → sqlakit-0.14.0}/pyproject.toml +1 -1
  3. {sqlakit-0.13.0 → sqlakit-0.14.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/__init__.py +2 -0
  5. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_base.py +81 -25
  6. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_db.py +25 -7
  7. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/_db.py +25 -7
  8. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/exceptions.py +12 -0
  9. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/pytest_plugin.py +140 -18
  10. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/testing.py +2 -1
  11. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/types.py +30 -1
  12. {sqlakit-0.13.0 → sqlakit-0.14.0}/LICENSE +0 -0
  13. {sqlakit-0.13.0 → sqlakit-0.14.0}/README.md +0 -0
  14. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_cli.py +0 -0
  15. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_debugserver.py +0 -0
  16. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_discovery.py +0 -0
  17. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_model.py +0 -0
  18. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_query.py +0 -0
  19. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_recording.py +0 -0
  20. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_registry.py +0 -0
  21. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_routing.py +0 -0
  22. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/_sql.py +0 -0
  23. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/__init__.py +0 -0
  24. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/_registry.py +0 -0
  25. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/orm.py +0 -0
  26. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/asyncio/sql.py +0 -0
  27. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/debugserver.html +0 -0
  28. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/orm.py +0 -0
  29. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/py.typed +0 -0
  30. {sqlakit-0.13.0 → sqlakit-0.14.0}/sqlakit/sql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.13.0"
3
+ version = "0.14.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.13.0"
3
+ version = "0.14.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- session_savepoint: Whether this block's session needs a savepoint of its
186
- own. Two savepoint owners on one connection release each other's out
187
- of order, so only one may have it.
188
- scope: The block's own scope, whose session the savepoint is for.
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.session_savepoint and outer.scope is self._scope.get(None):
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
- ) -> Iterator[_Scope[ConnectionT, SessionT]]:
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
- scope = _Scope[ConnectionT, SessionT](
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
- token = self._scope.set(scope)
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
- session_savepoint: bool = False,
643
- ) -> Iterator[_Outer[ConnectionT] | None]:
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
- session_savepoint=session_savepoint,
693
+ owner=owner,
656
694
  )
657
695
  if connection is not None
658
696
  else None
659
697
  )
660
- token = self._outer.set(outer)
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, alias: str) -> _Using:
932
- """Return the database under that alias, standing in for the default one.
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
- transaction = connection.begin_nested() if savepoint else None
488
- # The block's savepoint isolates it; a session opened inside
489
- # must not add a second one on the same connection.
490
- session_savepoint = outer.session_savepoint and not savepoint
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
- session_savepoint = savepoint
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
- session_savepoint=session_savepoint,
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
- transaction = await connection.begin_nested() if savepoint else None
528
- # The block's savepoint isolates it; a session opened inside
529
- # must not add a second one on the same connection.
530
- session_savepoint = outer.session_savepoint and not savepoint
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
- session_savepoint = savepoint
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
- session_savepoint=session_savepoint,
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
- stack.enter_context(sqlakit_db.unbound())
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
- stack.enter_context(sqlakit_db.unbound())
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
- with db.recording(_named(node), stacks=True, skip_queries_from=skip) as recording:
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
- using = None if marker is None else marker.kwargs.get("using")
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, and one database opens
436
- # itself. `transactions` is the one a registry has.
437
- return [
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