sqlakit 0.11.1__tar.gz → 0.13.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.11.1 → sqlakit-0.13.0}/PKG-INFO +2 -2
  2. {sqlakit-0.11.1 → sqlakit-0.13.0}/pyproject.toml +4 -4
  3. {sqlakit-0.11.1 → sqlakit-0.13.0}/pyproject.toml.orig +4 -4
  4. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/__init__.py +2 -0
  5. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_base.py +49 -2
  6. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_sql.py +63 -15
  7. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/asyncio/sql.py +19 -4
  8. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/exceptions.py +8 -0
  9. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/pytest_plugin.py +41 -2
  10. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/sql.py +17 -3
  11. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/types.py +17 -0
  12. {sqlakit-0.11.1 → sqlakit-0.13.0}/LICENSE +0 -0
  13. {sqlakit-0.11.1 → sqlakit-0.13.0}/README.md +0 -0
  14. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_cli.py +0 -0
  15. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_db.py +0 -0
  16. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_debugserver.py +0 -0
  17. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_discovery.py +0 -0
  18. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_model.py +0 -0
  19. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_query.py +0 -0
  20. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_recording.py +0 -0
  21. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_registry.py +0 -0
  22. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/_routing.py +0 -0
  23. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/asyncio/__init__.py +0 -0
  24. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/asyncio/_db.py +0 -0
  25. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/asyncio/_registry.py +0 -0
  26. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/asyncio/orm.py +0 -0
  27. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/debugserver.html +0 -0
  28. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/orm.py +0 -0
  29. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/py.typed +0 -0
  30. {sqlakit-0.11.1 → sqlakit-0.13.0}/sqlakit/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.11.1
3
+ Version: 0.13.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.11.1"
3
+ version = "0.13.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.11.1"
3
+ version = "0.13.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",
@@ -60,6 +60,7 @@ from .types import (
60
60
  SessionArgs,
61
61
  TemplatesLike,
62
62
  UrlParts,
63
+ ValidationArgs,
63
64
  )
64
65
 
65
66
  __all__ = [
@@ -122,6 +123,7 @@ __all__ = [
122
123
  "UnknownOrderFieldError",
123
124
  "UnorderedPageError",
124
125
  "UrlParts",
126
+ "ValidationArgs",
125
127
  "db",
126
128
  "import_models",
127
129
  "import_string",
@@ -8,7 +8,7 @@ import time
8
8
  from collections.abc import Callable, Mapping
9
9
  from contextlib import ExitStack, contextmanager
10
10
  from contextvars import ContextVar
11
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, replace
12
12
  from functools import cache
13
13
  from typing import (
14
14
  TYPE_CHECKING,
@@ -39,6 +39,7 @@ from ._recording import (
39
39
  from ._routing import Router, as_router
40
40
  from .exceptions import (
41
41
  DEFAULT_ALIAS,
42
+ HIDDEN_BLOCK,
42
43
  REGISTERED_DEFAULT,
43
44
  AliasInUseError,
44
45
  ConflictingDatabaseUrlError,
@@ -169,6 +170,9 @@ class _Scope(Generic[ConnectionT, SessionT]):
169
170
  autocommit: bool = False
170
171
  """Whether the connection is in ``AUTOCOMMIT``, where no transaction runs."""
171
172
 
173
+ hidden: bool = False
174
+ """Whether `unbound()` hides this scope from the code running under it."""
175
+
172
176
 
173
177
  @dataclass(slots=True)
174
178
  class _Outer(Generic[ConnectionT]):
@@ -279,9 +283,14 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
279
283
 
280
284
  """
281
285
  try:
282
- return self._scope.get()
286
+ scope = self._scope.get()
283
287
  except LookupError:
284
288
  raise MissingConnectionError from None
289
+ if scope.hidden:
290
+ raise MissingConnectionError(
291
+ HIDDEN_BLOCK.format(what="connection")
292
+ ) from None
293
+ return scope
285
294
 
286
295
  @property
287
296
  def session(self) -> SessionT:
@@ -299,6 +308,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
299
308
  scope = self._scope.get()
300
309
  except LookupError:
301
310
  raise MissingSessionError from None
311
+ if scope.hidden:
312
+ raise MissingSessionError(HIDDEN_BLOCK.format(what="session")) from None
302
313
  if scope.session is None:
303
314
  if scope.connection is None and scope.checkout is not None:
304
315
  scope.session = self._lazy_session(scope.checkout)
@@ -506,6 +517,34 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
506
517
  scope = self._scope.get(None)
507
518
  return scope is not None and scope.session is not None
508
519
 
520
+ @contextmanager
521
+ def unbound(self) -> Iterator[None]:
522
+ """Hide the block open around this one, for the code inside to open its own.
523
+
524
+ A test opens a transaction for the whole test, so code that reaches for
525
+ `session` without a block of its own borrows one and passes, where in
526
+ production it raises `MissingSessionError`. Wrap the call under test:
527
+
528
+ ```python
529
+ @pytest.mark.db
530
+ def test_the_handler_opens_a_block() -> None:
531
+ with db.unbound():
532
+ handle(event)
533
+ ```
534
+
535
+ A block opened inside still joins the transaction around it, and rolls
536
+ back with it.
537
+ """
538
+ scope = self._scope.get(None)
539
+ if scope is None:
540
+ yield
541
+ return
542
+ token = self._scope.set(replace(scope, session=None, hidden=True))
543
+ try:
544
+ yield
545
+ finally:
546
+ self._scope.reset(token)
547
+
509
548
  @property
510
549
  def engine(self) -> Any: # noqa: ANN401
511
550
  """The engine underneath, which the subclass makes."""
@@ -866,6 +905,14 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
866
905
  if debugserver is not None:
867
906
  send_recording(together, debugserver)
868
907
 
908
+ @contextmanager
909
+ def unbound(self) -> Iterator[None]:
910
+ """Hide the block open on every database this registry holds."""
911
+ with ExitStack() as stack:
912
+ for alias in self.aliases:
913
+ stack.enter_context(BaseDatabase.unbound(self[alias]))
914
+ yield
915
+
869
916
  def _recorded(
870
917
  self, using: str | DatabaseT | Sequence[str | DatabaseT] | None
871
918
  ) -> tuple[Any, ...]:
@@ -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:
@@ -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.
@@ -85,6 +85,14 @@ class RetryNotSupportedError(SQLAKitError, TypeError):
85
85
  super().__init__(message)
86
86
 
87
87
 
88
+ HIDDEN_BLOCK = (
89
+ "No {what} is bound: the block around this one is hidden, so the code "
90
+ "under it opens its own. `with db.transaction():` to write, "
91
+ "`with db.connect():` to read. A test hides its block with "
92
+ "`sqlakit_unbound`, `db(unbound=True)` or `db.unbound()`."
93
+ )
94
+ """What the two errors above say under a hidden block, which a test opens."""
95
+
88
96
  REGISTERED_DEFAULT = (
89
97
  "This registry did not build the default database, it was registered, so "
90
98
  "the settings it was built from live on it. Read them from `db['default']`."
@@ -19,13 +19,18 @@ up in, or the one they were given in person. A project with no model layer
19
19
  defines `sqlakit_db` and `sqlakit_metadata` instead.
20
20
 
21
21
  A test marked `db` runs in a transaction that rolls back, on every database.
22
- `using` narrows that to the ones a test works on:
22
+ `using` narrows that to the ones a test works on, and `unbound` hides the
23
+ test's own block from the code it calls, as production has none:
23
24
 
24
25
  ```python
25
26
  @pytest.mark.db(using="warehouse")
26
27
  @pytest.mark.db(using=["default", "warehouse"])
28
+ @pytest.mark.db(unbound=True)
27
29
  ```
28
30
 
31
+ `sqlakit_unbound = true` says the last one for the whole suite, and
32
+ `db(unbound=False)` gives one test its block back.
33
+
29
34
  Every other test connects to nothing, and reaching for a session there raises
30
35
  `MissingSessionError` rather than opening one.
31
36
  """
@@ -66,6 +71,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
66
71
  type="bool",
67
72
  default=False,
68
73
  )
74
+ parser.addini(
75
+ "sqlakit_unbound",
76
+ "hide the test's own block, so the code it calls opens one as in production",
77
+ type="bool",
78
+ default=False,
79
+ )
69
80
  parser.addini(
70
81
  "sqlakit_skip_queries_from",
71
82
  "the queries these files run stay out of the report: a factory, a helper",
@@ -275,7 +286,15 @@ def _sqlakit_transaction(
275
286
  ) -> Iterator[None]:
276
287
  with ExitStack() as stack:
277
288
  for block in _rolled_back(sqlakit_db, _asked_for(request)):
289
+ if hasattr(block, "__aenter__"):
290
+ pytest.fail(
291
+ f"`{request.node.name}` runs on an async database, whose "
292
+ "blocks are awaited. Write the test as `async def`.",
293
+ pytrace=False,
294
+ )
278
295
  stack.enter_context(block)
296
+ if _hidden(request):
297
+ stack.enter_context(sqlakit_db.unbound())
279
298
  with _reported(request, sqlakit_db):
280
299
  yield
281
300
 
@@ -288,7 +307,14 @@ async def _sqlakit_async_transaction(
288
307
  ) -> AsyncIterator[None]:
289
308
  async with AsyncExitStack() as stack:
290
309
  for block in _rolled_back(sqlakit_db, _asked_for(request)):
291
- await stack.enter_async_context(block)
310
+ # A synchronous database under a test that awaits something else,
311
+ # a handler it runs in a worker thread among them.
312
+ if hasattr(block, "__aenter__"):
313
+ await stack.enter_async_context(block)
314
+ else:
315
+ stack.enter_context(block)
316
+ if _hidden(request):
317
+ stack.enter_context(sqlakit_db.unbound())
292
318
  with _reported(request, sqlakit_db):
293
319
  yield
294
320
 
@@ -385,6 +411,19 @@ def _asked_for(request: pytest.FixtureRequest) -> tuple[Any, ...]:
385
411
  return tuple(using)
386
412
 
387
413
 
414
+ def _hidden(request: pytest.FixtureRequest) -> bool:
415
+ """Whether the test's own block is hidden from the code it calls.
416
+
417
+ `sqlakit_unbound` says it for the suite, and `db(unbound=...)` on a test
418
+ says it for that one.
419
+ """
420
+ marker = request.node.get_closest_marker(MARKER)
421
+ asked = None if marker is None else marker.kwargs.get("unbound")
422
+ if asked is None:
423
+ return bool(request.config.getini("sqlakit_unbound"))
424
+ return bool(asked)
425
+
426
+
388
427
  def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
389
428
  """Return the blocks that undo what a test writes.
390
429
 
@@ -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.
@@ -21,6 +21,7 @@ __all__ = [
21
21
  "SessionArgs",
22
22
  "TemplatesLike",
23
23
  "UrlParts",
24
+ "ValidationArgs",
24
25
  ]
25
26
 
26
27
  # Quoted, so importing this module never reaches `Templates` and the
@@ -66,6 +67,22 @@ class EngineArgs(TypedDict, total=False, extra_items=Any):
66
67
  use_insertmanyvalues: bool
67
68
 
68
69
 
70
+ class ValidationArgs(TypedDict, total=False, extra_items=Any):
71
+ """Keyword arguments accepted by [`pydantic.TypeAdapter.validate_python`](https://docs.pydantic.dev/latest/api/type_adapter/#pydantic.type_adapter.TypeAdapter.validate_python).
72
+
73
+ `typed()` hands them to pydantic for every row it reads. A keyword a later
74
+ pydantic adds is allowed too.
75
+ """
76
+
77
+ strict: bool | None
78
+ extra: Literal["allow", "ignore", "forbid"] | None
79
+ from_attributes: bool | None
80
+ context: Any
81
+ experimental_allow_partial: bool | Literal["off", "on", "trailing-strings"]
82
+ by_alias: bool | None
83
+ by_name: bool | None
84
+
85
+
69
86
  class SessionArgs(TypedDict, total=False, extra_items=Any):
70
87
  """Keyword arguments accepted by `sqlalchemy.orm.sessionmaker`.
71
88
 
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