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.
- {sqlakit-0.12.0 → sqlakit-0.14.0}/PKG-INFO +2 -2
- {sqlakit-0.12.0 → sqlakit-0.14.0}/pyproject.toml +4 -4
- {sqlakit-0.12.0 → sqlakit-0.14.0}/pyproject.toml.orig +4 -4
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/__init__.py +4 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_base.py +81 -25
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_db.py +25 -7
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_sql.py +63 -15
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/_db.py +25 -7
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/sql.py +19 -4
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/exceptions.py +12 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/pytest_plugin.py +152 -19
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/sql.py +17 -3
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/testing.py +2 -1
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/types.py +47 -1
- {sqlakit-0.12.0 → sqlakit-0.14.0}/LICENSE +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/README.md +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_cli.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_debugserver.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_query.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/debugserver.html +0 -0
- {sqlakit-0.12.0 → sqlakit-0.14.0}/sqlakit/orm.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
of
|
|
188
|
-
|
|
187
|
+
scope: The block's own scope, whose session the connection is bound to.
|
|
188
|
+
owner: The scope whose session owns the savepoints of this connection.
|
|
189
|
+
That session joins with a savepoint of its own, and a block below
|
|
190
|
+
takes its savepoint through it, so the two are released in the
|
|
191
|
+
order they were taken. One owner per connection: two of them
|
|
192
|
+
release each other's savepoints out of order.
|
|
189
193
|
|
|
190
194
|
"""
|
|
191
195
|
|
|
192
196
|
connection: ConnectionT
|
|
193
197
|
join_nested: bool = True
|
|
194
198
|
savepoint: bool = False
|
|
195
|
-
session_savepoint: bool = False
|
|
196
199
|
scope: Any = None
|
|
200
|
+
owner: Any = None
|
|
201
|
+
|
|
202
|
+
def savepoint_owner(self) -> Any: # noqa: ANN401 - a session of either API
|
|
203
|
+
"""Return the session that owns the savepoints here, if one does."""
|
|
204
|
+
return None if self.owner is None else self.owner.session
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _Binding(Generic[ValueT]):
|
|
208
|
+
"""A value bound to a context variable for as long as a block is open.
|
|
209
|
+
|
|
210
|
+
The generator `@contextmanager` builds costs more than the two calls it
|
|
211
|
+
saves, and every block binds two of these.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
__slots__ = ("_token", "_value", "_var")
|
|
215
|
+
|
|
216
|
+
def __init__(self, var: ContextVar[Any], value: ValueT) -> None:
|
|
217
|
+
self._var = var
|
|
218
|
+
self._value = value
|
|
219
|
+
|
|
220
|
+
def __enter__(self) -> ValueT:
|
|
221
|
+
self._token = self._var.set(self._value)
|
|
222
|
+
return self._value
|
|
223
|
+
|
|
224
|
+
def __exit__(self, *_: object) -> None:
|
|
225
|
+
self._var.reset(self._token)
|
|
197
226
|
|
|
198
227
|
|
|
199
228
|
class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
@@ -561,12 +590,25 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
561
590
|
outer = self._outer.get(None)
|
|
562
591
|
if outer is None or outer.connection is not connection:
|
|
563
592
|
return {}
|
|
564
|
-
if outer.
|
|
593
|
+
if outer.owner is not None and outer.owner is self._scope.get(None):
|
|
565
594
|
return {"join_transaction_mode": "create_savepoint"}
|
|
566
595
|
# Spelled out: SQLAlchemy's default would open a savepoint of its own
|
|
567
596
|
# whenever the connection is already inside one.
|
|
568
597
|
return {"join_transaction_mode": "rollback_only"}
|
|
569
598
|
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _owns_savepoints(outer: _Outer[Any] | None, *, savepoint: bool) -> bool:
|
|
601
|
+
"""Whether this block's session owns the savepoints of its connection.
|
|
602
|
+
|
|
603
|
+
A block rolled back at the end gives its session one, so a test or the
|
|
604
|
+
code under it may commit that session without ending the block. A block
|
|
605
|
+
that took a savepoint of its own does not: the owner stays the one
|
|
606
|
+
above, and blocks below take their savepoints through it.
|
|
607
|
+
"""
|
|
608
|
+
if outer is None:
|
|
609
|
+
return savepoint
|
|
610
|
+
return not savepoint and outer.owner is not None
|
|
611
|
+
|
|
570
612
|
def _plan(self, *, savepoint: bool, rollback: bool) -> tuple[_Outer | None, bool]:
|
|
571
613
|
"""Decide what a transaction joins, and whether it is a savepoint.
|
|
572
614
|
|
|
@@ -609,38 +651,34 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
609
651
|
return None
|
|
610
652
|
return scope
|
|
611
653
|
|
|
612
|
-
@contextmanager
|
|
613
654
|
def _bind(
|
|
614
655
|
self,
|
|
615
656
|
connection: ConnectionT | None,
|
|
616
657
|
checkout: _Lazy[ConnectionT] | None = None,
|
|
617
658
|
*,
|
|
618
659
|
autocommit: bool = False,
|
|
619
|
-
) ->
|
|
660
|
+
) -> _Binding[_Scope[ConnectionT, SessionT]]:
|
|
620
661
|
"""Bind a scope holding ``connection`` to the current context.
|
|
621
662
|
|
|
622
663
|
Every block gets a scope, and so a session, of its own. Ending that
|
|
623
664
|
session is left to the caller, which knows whether it takes an
|
|
624
665
|
``await``. A lazy block passes ``checkout`` instead of a connection.
|
|
625
666
|
"""
|
|
626
|
-
|
|
667
|
+
# Unparameterized: subscripting a generic builds an alias and calls
|
|
668
|
+
# through it, and every block binds a scope.
|
|
669
|
+
scope: _Scope[ConnectionT, SessionT] = _Scope(
|
|
627
670
|
connection, checkout=checkout, autocommit=autocommit
|
|
628
671
|
)
|
|
629
|
-
|
|
630
|
-
try:
|
|
631
|
-
yield scope
|
|
632
|
-
finally:
|
|
633
|
-
self._scope.reset(token)
|
|
672
|
+
return _Binding(self._scope, scope)
|
|
634
673
|
|
|
635
|
-
@contextmanager
|
|
636
674
|
def _set_outer(
|
|
637
675
|
self,
|
|
638
676
|
connection: ConnectionT | None,
|
|
639
677
|
*,
|
|
640
678
|
join_nested: bool = True,
|
|
641
679
|
savepoint: bool = False,
|
|
642
|
-
|
|
643
|
-
) ->
|
|
680
|
+
owner: Any = None, # noqa: ANN401 - the scope whose session owns them
|
|
681
|
+
) -> _Binding[_Outer[ConnectionT] | None]:
|
|
644
682
|
"""Make ``connection`` the outer one for this context. See `_Outer`.
|
|
645
683
|
|
|
646
684
|
``None`` leaves this context without an outer transaction at all, as
|
|
@@ -652,16 +690,12 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
652
690
|
connection,
|
|
653
691
|
join_nested=join_nested,
|
|
654
692
|
savepoint=savepoint,
|
|
655
|
-
|
|
693
|
+
owner=owner,
|
|
656
694
|
)
|
|
657
695
|
if connection is not None
|
|
658
696
|
else None
|
|
659
697
|
)
|
|
660
|
-
|
|
661
|
-
try:
|
|
662
|
-
yield outer
|
|
663
|
-
finally:
|
|
664
|
-
self._outer.reset(token)
|
|
698
|
+
return _Binding(self._outer, outer)
|
|
665
699
|
|
|
666
700
|
|
|
667
701
|
DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
|
|
@@ -928,8 +962,11 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
928
962
|
db._name = alias # noqa: SLF001
|
|
929
963
|
return db
|
|
930
964
|
|
|
931
|
-
def using(self,
|
|
932
|
-
"""Return
|
|
965
|
+
def using(self, target: str | DatabaseT) -> _Using:
|
|
966
|
+
"""Return that database, standing in for the default one.
|
|
967
|
+
|
|
968
|
+
Named or handed over, as `recording(using=...)` and a query's `using()`
|
|
969
|
+
take it.
|
|
933
970
|
|
|
934
971
|
The block opens on it, and models that live on the default database resolve
|
|
935
972
|
there for as long as it is open:
|
|
@@ -945,12 +982,31 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
945
982
|
|
|
946
983
|
Raises:
|
|
947
984
|
UnknownDatabaseError: if nothing is configured under that alias.
|
|
985
|
+
UnregisteredDatabaseError: if the database is not one this registry
|
|
986
|
+
holds, since the redirection works by the name it holds it under.
|
|
948
987
|
|
|
949
988
|
"""
|
|
989
|
+
alias = target if isinstance(target, str) else self._alias_of(target)
|
|
950
990
|
if alias not in self:
|
|
951
991
|
raise UnknownDatabaseError(alias, self.aliases)
|
|
952
992
|
return _Using(self[alias], self._using, alias)
|
|
953
993
|
|
|
994
|
+
def _alias_of(self, db: DatabaseT) -> str:
|
|
995
|
+
"""Return the alias this registry holds a database under.
|
|
996
|
+
|
|
997
|
+
Raises:
|
|
998
|
+
UnregisteredDatabaseError: if it holds it under none.
|
|
999
|
+
|
|
1000
|
+
"""
|
|
1001
|
+
# A configured registry is the default database itself, and a
|
|
1002
|
+
# registered one holds it.
|
|
1003
|
+
if db is self or db is self._default:
|
|
1004
|
+
return DEFAULT_ALIAS
|
|
1005
|
+
for alias, held in self._aliased.items():
|
|
1006
|
+
if held is db:
|
|
1007
|
+
return alias
|
|
1008
|
+
raise UnregisteredDatabaseError(self.aliases)
|
|
1009
|
+
|
|
954
1010
|
def route(self, *routers: Router | RouterFunction | str) -> None:
|
|
955
1011
|
"""Say which database a model lives on, for models that do not say it.
|
|
956
1012
|
|
|
@@ -482,12 +482,23 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
482
482
|
owner: Session | None = None
|
|
483
483
|
if outer is not None:
|
|
484
484
|
connection = outer.connection
|
|
485
|
+
holder = outer.savepoint_owner()
|
|
485
486
|
# Without a savepoint the block only takes part in the
|
|
486
487
|
# transaction around it, which commits it.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
488
|
+
if not savepoint:
|
|
489
|
+
transaction = None
|
|
490
|
+
elif holder is not None:
|
|
491
|
+
# The session holding this connection's savepoints takes
|
|
492
|
+
# this one too, so a commit of its own releases them in the
|
|
493
|
+
# order they were taken.
|
|
494
|
+
transaction = holder.begin_nested()
|
|
495
|
+
# It opens the savepoint when it next reaches the
|
|
496
|
+
# connection, which may be after this block has written
|
|
497
|
+
# through a session of its own.
|
|
498
|
+
holder.connection()
|
|
499
|
+
else:
|
|
500
|
+
transaction = connection.begin_nested()
|
|
501
|
+
held_by = outer.owner
|
|
491
502
|
else:
|
|
492
503
|
borrowed = self.db._scope_to_borrow() # noqa: SLF001
|
|
493
504
|
if borrowed is not None:
|
|
@@ -496,20 +507,22 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
496
507
|
else:
|
|
497
508
|
connection = stack.enter_context(self.db.engine.connect())
|
|
498
509
|
owner, transaction = None, connection.begin()
|
|
499
|
-
|
|
510
|
+
held_by = None
|
|
500
511
|
# Unwound in reverse: session, context, transaction, connection.
|
|
501
|
-
stack.push(self._finish(transaction, owner))
|
|
512
|
+
stack.push(self._finish(transaction, owner, connection))
|
|
502
513
|
bound = stack.enter_context(
|
|
503
514
|
self.db._set_outer( # noqa: SLF001
|
|
504
515
|
connection,
|
|
505
516
|
join_nested=self.join_nested,
|
|
506
517
|
savepoint=savepoint,
|
|
507
|
-
|
|
518
|
+
owner=held_by,
|
|
508
519
|
)
|
|
509
520
|
)
|
|
510
521
|
scope = stack.enter_context(self.db._bind(connection)) # noqa: SLF001
|
|
511
522
|
if bound is not None:
|
|
512
523
|
bound.scope = scope
|
|
524
|
+
if self.db._owns_savepoints(outer, savepoint=savepoint): # noqa: SLF001
|
|
525
|
+
bound.owner = scope
|
|
513
526
|
stack.push(self._close_session(scope))
|
|
514
527
|
except BaseException:
|
|
515
528
|
stack.close()
|
|
@@ -552,6 +565,7 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
552
565
|
self,
|
|
553
566
|
transaction: sa.Transaction | None,
|
|
554
567
|
owner: Session | None = None,
|
|
568
|
+
connection: sa.Connection | None = None,
|
|
555
569
|
) -> Callable[..., None]:
|
|
556
570
|
"""Commit or roll back, unless this block only takes part in another."""
|
|
557
571
|
|
|
@@ -571,6 +585,10 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
571
585
|
if transaction is None:
|
|
572
586
|
return
|
|
573
587
|
if not transaction.is_active:
|
|
588
|
+
if connection is not None and connection.in_transaction():
|
|
589
|
+
# The session of an enclosing block ended this savepoint by
|
|
590
|
+
# committing, and the transaction around it holds the work.
|
|
591
|
+
return
|
|
574
592
|
# Rolled back from inside the block. Say so, unless an
|
|
575
593
|
# exception is already on its way out with the reason.
|
|
576
594
|
if exc is None:
|
|
@@ -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__ = [
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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 [
|
|
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(
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
528
|
+
if not savepoint:
|
|
529
|
+
transaction = None
|
|
530
|
+
elif holder is not None:
|
|
531
|
+
# The session holding this connection's savepoints takes
|
|
532
|
+
# this one too, so a commit of its own releases them in the
|
|
533
|
+
# order they were taken.
|
|
534
|
+
transaction = await holder.begin_nested()
|
|
535
|
+
# It opens the savepoint when it next reaches the
|
|
536
|
+
# connection, which may be after this block has written
|
|
537
|
+
# through a session of its own.
|
|
538
|
+
await holder.connection()
|
|
539
|
+
else:
|
|
540
|
+
transaction = await connection.begin_nested()
|
|
541
|
+
held_by = outer.owner
|
|
531
542
|
else:
|
|
532
543
|
borrowed = self.db._scope_to_borrow() # noqa: SLF001
|
|
533
544
|
if borrowed is not None:
|
|
@@ -538,20 +549,22 @@ class Transaction(
|
|
|
538
549
|
self.db.engine.connect()
|
|
539
550
|
)
|
|
540
551
|
transaction = await connection.begin()
|
|
541
|
-
|
|
552
|
+
held_by = None
|
|
542
553
|
# Unwound in reverse: session, context, transaction, connection.
|
|
543
|
-
stack.push_async_exit(self._finish(transaction, owner))
|
|
554
|
+
stack.push_async_exit(self._finish(transaction, owner, connection))
|
|
544
555
|
bound = stack.enter_context(
|
|
545
556
|
self.db._set_outer( # noqa: SLF001
|
|
546
557
|
connection,
|
|
547
558
|
join_nested=self.join_nested,
|
|
548
559
|
savepoint=savepoint,
|
|
549
|
-
|
|
560
|
+
owner=held_by,
|
|
550
561
|
)
|
|
551
562
|
)
|
|
552
563
|
scope = stack.enter_context(self.db._bind(connection)) # noqa: SLF001
|
|
553
564
|
if bound is not None:
|
|
554
565
|
bound.scope = scope
|
|
566
|
+
if self.db._owns_savepoints(outer, savepoint=savepoint): # noqa: SLF001
|
|
567
|
+
bound.owner = scope
|
|
555
568
|
stack.push_async_exit(self._close_session(scope))
|
|
556
569
|
except BaseException:
|
|
557
570
|
await stack.aclose()
|
|
@@ -594,6 +607,7 @@ class Transaction(
|
|
|
594
607
|
self,
|
|
595
608
|
transaction: AsyncTransaction | None,
|
|
596
609
|
owner: AsyncSession | None = None,
|
|
610
|
+
connection: AsyncConnection | None = None,
|
|
597
611
|
) -> Callable[..., Coroutine[None, None, None]]:
|
|
598
612
|
"""Commit or roll back, unless this block only takes part in another."""
|
|
599
613
|
|
|
@@ -613,6 +627,10 @@ class Transaction(
|
|
|
613
627
|
if transaction is None:
|
|
614
628
|
return
|
|
615
629
|
if not transaction.is_active:
|
|
630
|
+
if connection is not None and connection.in_transaction():
|
|
631
|
+
# The session of an enclosing block ended this savepoint by
|
|
632
|
+
# committing, and the transaction around it holds the work.
|
|
633
|
+
return
|
|
616
634
|
# Rolled back from inside the block. Say so, unless an
|
|
617
635
|
# exception is already on its way out with the reason.
|
|
618
636
|
if exc is None:
|
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|