sqlakit 0.12.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.12.0 → sqlakit-0.14.0}/PKG-INFO +2 -2
  2. {sqlakit-0.12.0 → sqlakit-0.14.0}/pyproject.toml +4 -4
  3. {sqlakit-0.12.0 → sqlakit-0.14.0}/pyproject.toml.orig +4 -4
  4. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/__init__.py +4 -0
  5. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_base.py +81 -25
  6. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_db.py +25 -7
  7. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_sql.py +63 -15
  8. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/_db.py +25 -7
  9. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/sql.py +19 -4
  10. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/exceptions.py +12 -0
  11. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/pytest_plugin.py +152 -19
  12. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/sql.py +17 -3
  13. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/testing.py +2 -1
  14. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/types.py +47 -1
  15. {sqlakit-0.12.0 → sqlakit-0.14.0}/LICENSE +0 -0
  16. {sqlakit-0.12.0 → sqlakit-0.14.0}/README.md +0 -0
  17. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_cli.py +0 -0
  18. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_debugserver.py +0 -0
  19. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_discovery.py +0 -0
  20. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_model.py +0 -0
  21. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_query.py +0 -0
  22. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_recording.py +0 -0
  23. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_registry.py +0 -0
  24. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_routing.py +0 -0
  25. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/__init__.py +0 -0
  26. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/_registry.py +0 -0
  27. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/orm.py +0 -0
  28. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/debugserver.html +0 -0
  29. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/orm.py +0 -0
  30. {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.12.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
@@ -22,7 +22,7 @@ Requires-Dist: sqlalchemy>=2.0.22
22
22
  Requires-Dist: typing-extensions>=4.13
23
23
  Requires-Dist: sqlalchemy[asyncio]>=2.0.22 ; extra == 'asyncio'
24
24
  Requires-Dist: sqlparse>=0.6.0 ; extra == 'debug'
25
- Requires-Dist: jinja2sql>=0.11.0 ; extra == 'sql'
25
+ Requires-Dist: jinja2sql>=0.12.0 ; extra == 'sql'
26
26
  Requires-Python: >=3.11
27
27
  Project-URL: Repository, https://github.com/antonrh/sqlakit
28
28
  Project-URL: Documentation, https://github.com/antonrh/sqlakit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.12.0"
3
+ version = "0.14.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -47,7 +47,7 @@ Documentation = "https://github.com/antonrh/sqlakit"
47
47
 
48
48
  [project.optional-dependencies]
49
49
  asyncio = ["sqlalchemy[asyncio]>=2.0.22"]
50
- sql = ["jinja2sql>=0.11.0"]
50
+ sql = ["jinja2sql>=0.12.0"]
51
51
  debug = ["sqlparse>=0.6.0"]
52
52
 
53
53
  [build-system]
@@ -183,7 +183,7 @@ skip = "./.venv,./site,./uv.lock,./.git,./debugserver/node_modules,./debugserver
183
183
  [dependency-groups]
184
184
  dev = [
185
185
  "pytest>=9.1.1",
186
- "ty>=0.0.79",
186
+ "ty>=0.0.80",
187
187
  "ruff>=0.16.6",
188
188
  "poethepoet>=0.48.0",
189
189
  "anyio>=4.14.2",
@@ -194,7 +194,7 @@ dev = [
194
194
  "httpx>=0.28.1",
195
195
  "httpx2>=2.10.0",
196
196
  "sqlmodel>=0.0.39",
197
- "jinja2sql>=0.11.0",
197
+ "jinja2sql>=0.12.0",
198
198
  "pydantic>=2.13.4",
199
199
  "sqlparse>=0.6.0",
200
200
  "rich>=15.0.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.12.0"
3
+ version = "0.14.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -41,7 +41,7 @@ asyncio = [
41
41
  "sqlalchemy[asyncio]>=2.0.22",
42
42
  ]
43
43
  sql = [
44
- "jinja2sql>=0.11.0",
44
+ "jinja2sql>=0.12.0",
45
45
  ]
46
46
  debug = [
47
47
  "sqlparse>=0.6.0",
@@ -61,7 +61,7 @@ default-groups = ["dev", "docs"]
61
61
  [dependency-groups]
62
62
  dev = [
63
63
  "pytest>=9.1.1",
64
- "ty>=0.0.79",
64
+ "ty>=0.0.80",
65
65
  "ruff>=0.16.6",
66
66
  "poethepoet>=0.48.0",
67
67
  "anyio>=4.14.2",
@@ -72,7 +72,7 @@ dev = [
72
72
  "httpx>=0.28.1",
73
73
  "httpx2>=2.10.0",
74
74
  "sqlmodel>=0.0.39",
75
- "jinja2sql>=0.11.0",
75
+ "jinja2sql>=0.12.0",
76
76
  "pydantic>=2.13.4",
77
77
  "sqlparse>=0.6.0",
78
78
  "rich>=15.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,
@@ -60,6 +61,7 @@ from .types import (
60
61
  SessionArgs,
61
62
  TemplatesLike,
62
63
  UrlParts,
64
+ ValidationArgs,
63
65
  )
64
66
 
65
67
  __all__ = [
@@ -121,7 +123,9 @@ __all__ = [
121
123
  "UnknownImportPathError",
122
124
  "UnknownOrderFieldError",
123
125
  "UnorderedPageError",
126
+ "UnregisteredDatabaseError",
124
127
  "UrlParts",
128
+ "ValidationArgs",
125
129
  "db",
126
130
  "import_models",
127
131
  "import_string",
@@ -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:
@@ -42,7 +42,13 @@ else:
42
42
  except ImportError: # pragma: no cover - pydantic is installed in CI
43
43
  BaseModel = TypeAdapter = None
44
44
 
45
- __all__ = ["BaseSQLQuery", "Templates", "require_pydantic", "templates_of"]
45
+ __all__ = [
46
+ "BaseSQLQuery",
47
+ "Filter",
48
+ "Templates",
49
+ "require_pydantic",
50
+ "templates_of",
51
+ ]
46
52
 
47
53
  _preparer: ContextVar[Any] = ContextVar("sqlakit.identifier_preparer")
48
54
  """The preparer of the database a template is rendering for."""
@@ -57,6 +63,38 @@ PathLike = str | Path
57
63
  """Where templates are looked for: one directory, or several."""
58
64
 
59
65
 
66
+ class Filter:
67
+ """A template filter, registered the way jinja2sql registers one.
68
+
69
+ ```python
70
+ Templates("app/sql", filters={"in_span": Filter(in_span, bind=True)})
71
+ ```
72
+
73
+ ``bind=True`` calls the filter with a jinja2sql `Binder` as its first
74
+ argument, so a filter writing SQL of its own binds the values through it:
75
+
76
+ ```python
77
+ def in_span(binder, span):
78
+ start, end = span
79
+ return binder.raw(
80
+ f"BETWEEN {binder.bind('span', start)} AND {binder.bind('span', end)}"
81
+ )
82
+ ```
83
+
84
+ A plain function needs none of this and goes in as it is: whatever it
85
+ returns is bound as one more value of the statement.
86
+ """
87
+
88
+ __slots__ = ("bind", "func")
89
+
90
+ def __init__(self, func: Callable[..., Any], *, bind: bool = False) -> None:
91
+ self.func = func
92
+ self.bind = bind
93
+
94
+ def __repr__(self) -> str:
95
+ return f"{type(self).__name__}({self.func!r}, bind={self.bind})"
96
+
97
+
60
98
  class Templates:
61
99
  """The directory a database's SQL templates live in, and how they render.
62
100
 
@@ -70,6 +108,10 @@ class Templates:
70
108
  development server wants and a production one does not. ``filters`` and
71
109
  ``globals`` are handed to the Jinja environment, and are refused if they have
72
110
  to be awaited: rendering makes a string, in both APIs.
111
+
112
+ A filter is a plain function, whose return value is bound as one more value
113
+ of the statement. `Filter(func, bind=True)` registers one that writes SQL of
114
+ its own instead, and is handed a binder for the values inside it.
73
115
  """
74
116
 
75
117
  def __init__(
@@ -77,7 +119,7 @@ class Templates:
77
119
  path: PathLike | Sequence[PathLike] = (),
78
120
  *,
79
121
  auto_reload: bool = False,
80
- filters: Mapping[str, Callable[..., Any]] | None = None,
122
+ filters: Mapping[str, Callable[..., Any] | Filter] | None = None,
81
123
  globals: Mapping[str, Any] | None = None, # noqa: A002
82
124
  ) -> None:
83
125
  self.paths = (
@@ -87,7 +129,8 @@ class Templates:
87
129
  self.filters = dict(filters or {})
88
130
  self.globals = dict(globals or {})
89
131
  for name, value in (*self.filters.items(), *self.globals.items()):
90
- if iscoroutinefunction(value):
132
+ called = value.func if isinstance(value, Filter) else value
133
+ if iscoroutinefunction(called):
91
134
  raise AsyncFilterError(name)
92
135
 
93
136
  def __repr__(self) -> str:
@@ -109,17 +152,15 @@ class Templates:
109
152
  autoescape=True,
110
153
  )
111
154
  environment.globals.update(self.globals)
112
- # Always named parameters: what comes back is handed to `text()`,
113
- # which reads `:name` and nothing else. The driver's own style is
114
- # SQLAlchemy's business, and a template that picked one would be wrong
115
- # on the next database.
155
+ # Named parameters: `text()` reads `:name` and nothing else.
116
156
  renderer = jinja2sql(environment, param_style=_placeholder)
117
- # Ours rather than jinja2sql's: the preparer of the database in hand
118
- # knows both how it quotes and when it has to, which is the difference
119
- # between `"name"` and `name` on Oracle.
157
+ # Ours quotes through the dialect's preparer, jinja2sql's through one char.
120
158
  renderer.register_filter("identifier", _identifier)
121
159
  for name, filter_ in self.filters.items():
122
- renderer.register_filter(name, filter_)
160
+ if isinstance(filter_, Filter):
161
+ renderer.register_filter(name, filter_.func, bind=filter_.bind)
162
+ else:
163
+ renderer.register_filter(name, filter_)
123
164
  return renderer
124
165
 
125
166
  def render(
@@ -152,8 +193,7 @@ class Templates:
152
193
  raise TemplateNotFoundError(source, self.paths) from None
153
194
  finally:
154
195
  _preparer.reset(token)
155
- # Always a mapping: the parameters are named, and only a positional
156
- # style would hand back a sequence.
196
+ # Named parameters come back as a mapping, positional ones as a sequence.
157
197
  return sql, cast("Mapping[str, Any]", params)
158
198
 
159
199
  def check(self) -> None:
@@ -188,6 +228,7 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
188
228
  inline: bool = False,
189
229
  type_: type[Any] | None = None,
190
230
  scalar: bool = False,
231
+ validation: Mapping[str, Any] | None = None,
191
232
  ) -> None:
192
233
  self.db = db
193
234
  self.source = source
@@ -195,6 +236,7 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
195
236
  self.inline = inline
196
237
  self.type = type_
197
238
  self.scalar = scalar
239
+ self.validation = dict(validation or {})
198
240
 
199
241
  def __repr__(self) -> str:
200
242
  return f"{type(self).__name__}({self.source!r})"
@@ -237,6 +279,7 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
237
279
  "inline": self.inline,
238
280
  "type_": self.type,
239
281
  "scalar": self.scalar,
282
+ "validation": self.validation,
240
283
  **changes,
241
284
  }
242
285
  return query(self.db, self.source, self.context, **arguments)
@@ -245,12 +288,17 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
245
288
  if self.type is None:
246
289
  return rows
247
290
  adapter = _adapter(self.type)
248
- return [adapter.validate_python(_as_python(row, self.type)) for row in rows]
291
+ return [
292
+ adapter.validate_python(_as_python(row, self.type), **self.validation)
293
+ for row in rows
294
+ ]
249
295
 
250
296
  def _shaped_one(self, row: Any) -> Any: # noqa: ANN401
251
297
  if self.type is None or row is None:
252
298
  return row
253
- return _adapter(self.type).validate_python(_as_python(row, self.type))
299
+ return _adapter(self.type).validate_python(
300
+ _as_python(row, self.type), **self.validation
301
+ )
254
302
 
255
303
  def _executable(self, *, size: int | None = None) -> Executable:
256
304
  if size 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:
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any, TypeVar, cast
4
4
 
5
5
  import sqlalchemy as sa
6
+ from typing_extensions import Unpack
6
7
 
7
8
  from sqlakit._sql import (
8
9
  BaseSQLQuery,
10
+ Filter,
9
11
  Templates,
10
12
  require_pydantic,
11
13
  templates_of,
@@ -18,9 +20,11 @@ if TYPE_CHECKING:
18
20
  from sqlalchemy.ext.asyncio import AsyncConnection
19
21
  from sqlalchemy.sql import Executable
20
22
 
23
+ from sqlakit.types import ValidationArgs
24
+
21
25
  from ._db import Database
22
26
 
23
- __all__ = ["SQL", "SQLQuery", "SQLRows", "Templates"]
27
+ __all__ = ["SQL", "Filter", "SQLQuery", "SQLRows", "Templates"]
24
28
 
25
29
  RowT = TypeVar("RowT")
26
30
  OtherT = TypeVar("OtherT")
@@ -189,11 +193,13 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
189
193
  same way and carry no further say, so each is asked once.
190
194
  """
191
195
 
192
- def typed(self, type_: type[OtherT], /) -> SQLRows[OtherT]:
196
+ def typed(
197
+ self, type_: type[OtherT], /, **validation: Unpack[ValidationArgs]
198
+ ) -> SQLRows[OtherT]:
193
199
  """Read the rows as this type, one row at a time.
194
200
 
195
201
  ```python
196
- await db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
202
+ db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
197
203
  ```
198
204
 
199
205
  The type says what one row becomes, and the terminal decides the container.
@@ -202,12 +208,21 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
202
208
  from columns is given the whole row, and anything else is given the
203
209
  first column, so `SELECT count(*)` with `typed(int)` reads as an `int`.
204
210
 
211
+ Keyword arguments go to pydantic's `validate_python`, `context` and
212
+ `strict` among them:
213
+
214
+ ```python
215
+ db.sql("reports/by_team.sql").typed(TeamReport, context={"tz": tz}).all()
216
+ ```
217
+
205
218
  Raises:
206
219
  MissingDependencyError: if pydantic is not installed.
207
220
 
208
221
  """
209
222
  require_pydantic()
210
- return cast("SQLRows[OtherT]", self._as(SQLRows, type_=type_))
223
+ return cast(
224
+ "SQLRows[OtherT]", self._as(SQLRows, type_=type_, validation=validation)
225
+ )
211
226
 
212
227
  def scalars(self) -> SQLRows[Any]:
213
228
  """Read the first column of each row instead of whole rows.
@@ -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,
@@ -286,9 +333,16 @@ def _sqlakit_transaction(
286
333
  ) -> Iterator[None]:
287
334
  with ExitStack() as stack:
288
335
  for block in _rolled_back(sqlakit_db, _asked_for(request)):
336
+ if hasattr(block, "__aenter__"):
337
+ pytest.fail(
338
+ f"`{request.node.name}` runs on an async database, whose "
339
+ "blocks are awaited. Write the test as `async def`.",
340
+ pytrace=False,
341
+ )
289
342
  stack.enter_context(block)
290
343
  if _hidden(request):
291
- stack.enter_context(sqlakit_db.unbound())
344
+ for one in _each(sqlakit_db):
345
+ stack.enter_context(one.unbound())
292
346
  with _reported(request, sqlakit_db):
293
347
  yield
294
348
 
@@ -301,9 +355,15 @@ async def _sqlakit_async_transaction(
301
355
  ) -> AsyncIterator[None]:
302
356
  async with AsyncExitStack() as stack:
303
357
  for block in _rolled_back(sqlakit_db, _asked_for(request)):
304
- await stack.enter_async_context(block)
358
+ # A synchronous database under a test that awaits something else,
359
+ # a handler it runs in a worker thread among them.
360
+ if hasattr(block, "__aenter__"):
361
+ await stack.enter_async_context(block)
362
+ else:
363
+ stack.enter_context(block)
305
364
  if _hidden(request):
306
- stack.enter_context(sqlakit_db.unbound())
365
+ for one in _each(sqlakit_db):
366
+ stack.enter_context(one.unbound())
307
367
  with _reported(request, sqlakit_db):
308
368
  yield
309
369
 
@@ -320,7 +380,12 @@ def _reported(request: pytest.FixtureRequest, db: Any) -> Iterator[None]: # noq
320
380
  return
321
381
  node = request.node
322
382
  skip = request.config.getini("sqlakit_skip_queries_from")
323
- 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
+ )
324
389
  yield
325
390
  request.config.stash[REPORT].append(
326
391
  as_payload(
@@ -373,6 +438,17 @@ def _schema_blocks(
373
438
  gets one block per alias. Without it there is one metadata, and one
374
439
  database to put it on.
375
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]
376
452
  if base is not None:
377
453
  # A database of its own is where the models are pinned, and the alias
378
454
  # a registry knows it by is not how the base reaches it.
@@ -392,7 +468,11 @@ def _schema_blocks(
392
468
  def _asked_for(request: pytest.FixtureRequest) -> tuple[Any, ...]:
393
469
  """Return the databases the marker asks for, by name or in person."""
394
470
  marker = request.node.get_closest_marker(MARKER)
395
- 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."""
396
476
  if using is None:
397
477
  return ()
398
478
  if isinstance(using, str) or not isinstance(using, (list, tuple, set, frozenset)):
@@ -413,6 +493,67 @@ def _hidden(request: pytest.FixtureRequest) -> bool:
413
493
  return bool(asked)
414
494
 
415
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
+
416
557
  def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
417
558
  """Return the blocks that undo what a test writes.
418
559
 
@@ -420,15 +561,7 @@ def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
420
561
  the one they have. Naming one is how a project on several stops paying for
421
562
  a connection to each in the tests that read one.
422
563
  """
423
- if not using:
424
- # A registry opens every database it holds, and one database opens
425
- # itself. `transactions` is the one a registry has.
426
- return [
427
- db.transactions(rollback=True)
428
- if hasattr(db, "transactions")
429
- else db.transaction(rollback=True)
430
- ]
431
- return [
432
- (db[one] if isinstance(one, str) else one).transaction(rollback=True)
433
- for one in using
434
- ]
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)]
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any, TypeVar, cast
4
4
 
5
5
  import sqlalchemy as sa
6
+ from typing_extensions import Unpack
6
7
 
7
8
  from ._sql import (
8
9
  BaseSQLQuery,
10
+ Filter,
9
11
  Templates,
10
12
  require_pydantic,
11
13
  templates_of,
@@ -18,8 +20,9 @@ if TYPE_CHECKING:
18
20
  from sqlalchemy.sql import Executable
19
21
 
20
22
  from ._db import Database
23
+ from .types import ValidationArgs
21
24
 
22
- __all__ = ["SQL", "SQLQuery", "SQLRows", "Templates"]
25
+ __all__ = ["SQL", "Filter", "SQLQuery", "SQLRows", "Templates"]
23
26
 
24
27
  RowT = TypeVar("RowT")
25
28
  OtherT = TypeVar("OtherT")
@@ -178,7 +181,9 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
178
181
  same way and carry no further say, so each is asked once.
179
182
  """
180
183
 
181
- def typed(self, type_: type[OtherT], /) -> SQLRows[OtherT]:
184
+ def typed(
185
+ self, type_: type[OtherT], /, **validation: Unpack[ValidationArgs]
186
+ ) -> SQLRows[OtherT]:
182
187
  """Read the rows as this type, one row at a time.
183
188
 
184
189
  ```python
@@ -191,12 +196,21 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
191
196
  from columns is given the whole row, and anything else is given the
192
197
  first column, so `SELECT count(*)` with `typed(int)` reads as an `int`.
193
198
 
199
+ Keyword arguments go to pydantic's `validate_python`, `context` and
200
+ `strict` among them:
201
+
202
+ ```python
203
+ db.sql("reports/by_team.sql").typed(TeamReport, context={"tz": tz}).all()
204
+ ```
205
+
194
206
  Raises:
195
207
  MissingDependencyError: if pydantic is not installed.
196
208
 
197
209
  """
198
210
  require_pydantic()
199
- return cast("SQLRows[OtherT]", self._as(SQLRows, type_=type_))
211
+ return cast(
212
+ "SQLRows[OtherT]", self._as(SQLRows, type_=type_, validation=validation)
213
+ )
200
214
 
201
215
  def scalars(self) -> SQLRows[Any]:
202
216
  """Read the first column of each row instead of whole rows.
@@ -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,14 +14,17 @@ 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",
22
25
  "TemplatesLike",
23
26
  "UrlParts",
27
+ "ValidationArgs",
24
28
  ]
25
29
 
26
30
  # Quoted, so importing this module never reaches `Templates` and the
@@ -66,6 +70,22 @@ class EngineArgs(TypedDict, total=False, extra_items=Any):
66
70
  use_insertmanyvalues: bool
67
71
 
68
72
 
73
+ class ValidationArgs(TypedDict, total=False, extra_items=Any):
74
+ """Keyword arguments accepted by [`pydantic.TypeAdapter.validate_python`](https://docs.pydantic.dev/latest/api/type_adapter/#pydantic.type_adapter.TypeAdapter.validate_python).
75
+
76
+ `typed()` hands them to pydantic for every row it reads. A keyword a later
77
+ pydantic adds is allowed too.
78
+ """
79
+
80
+ strict: bool | None
81
+ extra: Literal["allow", "ignore", "forbid"] | None
82
+ from_attributes: bool | None
83
+ context: Any
84
+ experimental_allow_partial: bool | Literal["off", "on", "trailing-strings"]
85
+ by_alias: bool | None
86
+ by_name: bool | None
87
+
88
+
69
89
  class SessionArgs(TypedDict, total=False, extra_items=Any):
70
90
  """Keyword arguments accepted by `sqlalchemy.orm.sessionmaker`.
71
91
 
@@ -113,6 +133,32 @@ class QueryStats(TypedDict):
113
133
  label: str | None
114
134
 
115
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
+
116
162
  class DatabaseConfig(UrlParts, total=False):
117
163
  """One database in a configuration keyed by alias.
118
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