sqlakit 0.1.0__tar.gz → 0.2.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.1.0 → sqlakit-0.2.0}/PKG-INFO +16 -16
- {sqlakit-0.1.0 → sqlakit-0.2.0}/README.md +14 -14
- {sqlakit-0.1.0 → sqlakit-0.2.0}/pyproject.toml +2 -2
- {sqlakit-0.1.0 → sqlakit-0.2.0}/pyproject.toml.orig +2 -7
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_base.py +111 -15
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_db.py +99 -12
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_query.py +17 -7
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_sql.py +6 -1
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/_db.py +129 -12
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/sql.py +1 -1
- {sqlakit-0.1.0 → sqlakit-0.2.0}/LICENSE +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/__init__.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/exceptions.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/orm.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/sql.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/testing.py +0 -0
- {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlakit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A toolkit for SQLAlchemy applications.
|
|
5
5
|
Keywords: sqlalchemy,database,orm,sql,asyncio
|
|
6
6
|
Author: Anton Ruhlov
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
18
18
|
Classifier: Topic :: Database
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
|
-
Requires-Dist: sqlalchemy[asyncio]>=2.0.
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.22
|
|
22
22
|
Requires-Dist: sqlparse>=0.6.0 ; extra == 'debug'
|
|
23
23
|
Requires-Dist: jinja2sql>=0.11.0 ; extra == 'sql'
|
|
24
24
|
Requires-Python: >=3.11
|
|
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
|
|
31
31
|
# SQLAKit
|
|
32
32
|
|
|
33
|
-
SQLAKit removes the boilerplate from `SQLAlchemy` applications. It manages
|
|
33
|
+
`SQLAKit` removes the boilerplate from `SQLAlchemy` applications. It manages
|
|
34
34
|
sessions and transactions for you, and adds a query builder with pagination
|
|
35
35
|
built in, `SQL` templates, an optional `Active Record` layer, debugging and
|
|
36
36
|
testing tools, etc. It supports both sync and async APIs and works with any
|
|
@@ -65,7 +65,7 @@ def get_or_create_user(email: str, name: str) -> User:
|
|
|
65
65
|
return user
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Both functions use the same session
|
|
68
|
+
Both functions use the same session, and you don't pass it between them. The
|
|
69
69
|
`@db.transaction` decorator opens it, and commits when the function returns.
|
|
70
70
|
|
|
71
71
|
Outside a block there is no session: `db.session` raises `MissingSessionError`
|
|
@@ -92,8 +92,8 @@ with db.autocommit(): # AUTOCOMMIT, no transaction held open
|
|
|
92
92
|
Templates are `Jinja` files, so they can hold anything from a one-line query to
|
|
93
93
|
a report with window functions or a recursive CTE.
|
|
94
94
|
[jinja2sql](https://github.com/antonrh/jinja2sql) turns every `{{ name }}` into
|
|
95
|
-
a bound parameter (`:name__1`), so values never
|
|
96
|
-
|
|
95
|
+
a bound parameter (`:name__1`), so values never reach the SQL text and there
|
|
96
|
+
is no way to inject anything. Requires the `sqlakit[sql]` extra.
|
|
97
97
|
|
|
98
98
|
### From a file
|
|
99
99
|
|
|
@@ -125,8 +125,8 @@ db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
|
125
125
|
`templates=` sets the directory to load templates from, and `typed()` sets the
|
|
126
126
|
type each row is returned as.
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
`SQLAKit` adds the template name to the SQL as a comment, so a slow query log
|
|
129
|
+
shows the source file of each query right away.
|
|
130
130
|
|
|
131
131
|
### From a string
|
|
132
132
|
|
|
@@ -150,8 +150,8 @@ db.query(User).where(User.is_active).order_by(User.name).all()
|
|
|
150
150
|
### Ordering by a string
|
|
151
151
|
|
|
152
152
|
`order_by` accepts a `field.direction` string, for example straight from a
|
|
153
|
-
query parameter.
|
|
154
|
-
|
|
153
|
+
query parameter. `SQLAKit` checks the field name against the model before it
|
|
154
|
+
builds any SQL, so an unknown field never reaches the database. Instead you get
|
|
155
155
|
`UnknownOrderFieldError`, and its message lists the fields the model allows:
|
|
156
156
|
|
|
157
157
|
```python
|
|
@@ -173,7 +173,7 @@ page.has_next
|
|
|
173
173
|
### Cursor pagination
|
|
174
174
|
|
|
175
175
|
`cursor_page()` continues from a cursor, so it stays fast at any depth. There
|
|
176
|
-
is no total
|
|
176
|
+
is no total. Instead you get cursors to the next and previous pages:
|
|
177
177
|
|
|
178
178
|
```python
|
|
179
179
|
feed = db.query(User).order_by("created_at.desc").cursor_page(limit=20)
|
|
@@ -223,8 +223,8 @@ entirely.
|
|
|
223
223
|
|
|
224
224
|
## Testing
|
|
225
225
|
|
|
226
|
-
A test runs inside a transaction that
|
|
227
|
-
|
|
226
|
+
A test runs inside a transaction that rolls back at the end, so nothing the
|
|
227
|
+
code under test writes is actually committed. `assert_queries` checks how
|
|
228
228
|
many statements a block runs:
|
|
229
229
|
|
|
230
230
|
```python
|
|
@@ -249,7 +249,7 @@ record.milliseconds
|
|
|
249
249
|
record.duplicates
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
-
With `logger=` one line
|
|
252
|
+
With `logger=` `SQLAKit` logs one line at the end of the block. The log level
|
|
253
253
|
depends on the numbers: more statements and more repeats mean a higher level.
|
|
254
254
|
|
|
255
255
|
With `echo=True` the block prints each statement, formatted and with repeats
|
|
@@ -394,8 +394,8 @@ Use the `Database` from `sqlakit.asyncio` here. With the sync one the block
|
|
|
394
394
|
closes before the async handler runs, and the handler fails with
|
|
395
395
|
`MissingConnectionError`.
|
|
396
396
|
|
|
397
|
-
There is nothing to open at startup: the engine
|
|
398
|
-
shutdown, `dispose()` closes the pool.
|
|
397
|
+
There is nothing to open at startup: `SQLAKit` creates the engine on first use.
|
|
398
|
+
On shutdown, `dispose()` closes the pool.
|
|
399
399
|
|
|
400
400
|
## Documentation
|
|
401
401
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# SQLAKit
|
|
2
2
|
|
|
3
|
-
SQLAKit removes the boilerplate from `SQLAlchemy` applications. It manages
|
|
3
|
+
`SQLAKit` removes the boilerplate from `SQLAlchemy` applications. It manages
|
|
4
4
|
sessions and transactions for you, and adds a query builder with pagination
|
|
5
5
|
built in, `SQL` templates, an optional `Active Record` layer, debugging and
|
|
6
6
|
testing tools, etc. It supports both sync and async APIs and works with any
|
|
@@ -35,7 +35,7 @@ def get_or_create_user(email: str, name: str) -> User:
|
|
|
35
35
|
return user
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Both functions use the same session
|
|
38
|
+
Both functions use the same session, and you don't pass it between them. The
|
|
39
39
|
`@db.transaction` decorator opens it, and commits when the function returns.
|
|
40
40
|
|
|
41
41
|
Outside a block there is no session: `db.session` raises `MissingSessionError`
|
|
@@ -62,8 +62,8 @@ with db.autocommit(): # AUTOCOMMIT, no transaction held open
|
|
|
62
62
|
Templates are `Jinja` files, so they can hold anything from a one-line query to
|
|
63
63
|
a report with window functions or a recursive CTE.
|
|
64
64
|
[jinja2sql](https://github.com/antonrh/jinja2sql) turns every `{{ name }}` into
|
|
65
|
-
a bound parameter (`:name__1`), so values never
|
|
66
|
-
|
|
65
|
+
a bound parameter (`:name__1`), so values never reach the SQL text and there
|
|
66
|
+
is no way to inject anything. Requires the `sqlakit[sql]` extra.
|
|
67
67
|
|
|
68
68
|
### From a file
|
|
69
69
|
|
|
@@ -95,8 +95,8 @@ db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
|
95
95
|
`templates=` sets the directory to load templates from, and `typed()` sets the
|
|
96
96
|
type each row is returned as.
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
`SQLAKit` adds the template name to the SQL as a comment, so a slow query log
|
|
99
|
+
shows the source file of each query right away.
|
|
100
100
|
|
|
101
101
|
### From a string
|
|
102
102
|
|
|
@@ -120,8 +120,8 @@ db.query(User).where(User.is_active).order_by(User.name).all()
|
|
|
120
120
|
### Ordering by a string
|
|
121
121
|
|
|
122
122
|
`order_by` accepts a `field.direction` string, for example straight from a
|
|
123
|
-
query parameter.
|
|
124
|
-
|
|
123
|
+
query parameter. `SQLAKit` checks the field name against the model before it
|
|
124
|
+
builds any SQL, so an unknown field never reaches the database. Instead you get
|
|
125
125
|
`UnknownOrderFieldError`, and its message lists the fields the model allows:
|
|
126
126
|
|
|
127
127
|
```python
|
|
@@ -143,7 +143,7 @@ page.has_next
|
|
|
143
143
|
### Cursor pagination
|
|
144
144
|
|
|
145
145
|
`cursor_page()` continues from a cursor, so it stays fast at any depth. There
|
|
146
|
-
is no total
|
|
146
|
+
is no total. Instead you get cursors to the next and previous pages:
|
|
147
147
|
|
|
148
148
|
```python
|
|
149
149
|
feed = db.query(User).order_by("created_at.desc").cursor_page(limit=20)
|
|
@@ -193,8 +193,8 @@ entirely.
|
|
|
193
193
|
|
|
194
194
|
## Testing
|
|
195
195
|
|
|
196
|
-
A test runs inside a transaction that
|
|
197
|
-
|
|
196
|
+
A test runs inside a transaction that rolls back at the end, so nothing the
|
|
197
|
+
code under test writes is actually committed. `assert_queries` checks how
|
|
198
198
|
many statements a block runs:
|
|
199
199
|
|
|
200
200
|
```python
|
|
@@ -219,7 +219,7 @@ record.milliseconds
|
|
|
219
219
|
record.duplicates
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
With `logger=` one line
|
|
222
|
+
With `logger=` `SQLAKit` logs one line at the end of the block. The log level
|
|
223
223
|
depends on the numbers: more statements and more repeats mean a higher level.
|
|
224
224
|
|
|
225
225
|
With `echo=True` the block prints each statement, formatted and with repeats
|
|
@@ -364,8 +364,8 @@ Use the `Database` from `sqlakit.asyncio` here. With the sync one the block
|
|
|
364
364
|
closes before the async handler runs, and the handler fails with
|
|
365
365
|
`MissingConnectionError`.
|
|
366
366
|
|
|
367
|
-
There is nothing to open at startup: the engine
|
|
368
|
-
shutdown, `dispose()` closes the pool.
|
|
367
|
+
There is nothing to open at startup: `SQLAKit` creates the engine on first use.
|
|
368
|
+
On shutdown, `dispose()` closes the pool.
|
|
369
369
|
|
|
370
370
|
## Documentation
|
|
371
371
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
28
|
requires-python = ">=3.11"
|
|
29
|
-
dependencies = ["sqlalchemy[asyncio]>=2.0.
|
|
29
|
+
dependencies = ["sqlalchemy[asyncio]>=2.0.22"]
|
|
30
30
|
|
|
31
31
|
[[project.authors]]
|
|
32
32
|
name = "Anton Ruhlov"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
requires-python = ">=3.11"
|
|
24
24
|
dependencies = [
|
|
25
|
-
"sqlalchemy[asyncio]>=2.0.
|
|
25
|
+
"sqlalchemy[asyncio]>=2.0.22",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[project.urls]
|
|
@@ -79,17 +79,12 @@ docs = [
|
|
|
79
79
|
"mkdocs>=1.6.1,<2",
|
|
80
80
|
"mkdocs-material>=9.7.7",
|
|
81
81
|
"mkdocstrings[python]>=1.0.6",
|
|
82
|
-
# mkdocstrings lays out long signatures with it, and leaves them on one
|
|
83
|
-
# line without it, so the built site would differ from a local build.
|
|
84
82
|
"ruff>=0.16.3",
|
|
85
83
|
]
|
|
86
84
|
|
|
87
85
|
[tool.pytest.ini_options]
|
|
88
86
|
filterwarnings = [
|
|
89
87
|
"error",
|
|
90
|
-
# Python 3.13 made `sqlite3.Connection` warn when the garbage collector
|
|
91
|
-
# finalises it instead of a `close()`. The tests build throwaway databases
|
|
92
|
-
# by the dozen and let them go, so this says nothing about the code.
|
|
93
88
|
"ignore:unclosed database:ResourceWarning",
|
|
94
89
|
]
|
|
95
90
|
# `pytester` runs the conftest the docs show, in a session of its own.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
3
5
|
import random
|
|
4
6
|
import threading
|
|
5
7
|
import time
|
|
@@ -7,6 +9,7 @@ from collections.abc import Callable, Mapping
|
|
|
7
9
|
from contextlib import ExitStack, contextmanager
|
|
8
10
|
from contextvars import ContextVar
|
|
9
11
|
from dataclasses import dataclass
|
|
12
|
+
from functools import cache
|
|
10
13
|
from typing import (
|
|
11
14
|
TYPE_CHECKING,
|
|
12
15
|
Any,
|
|
@@ -93,6 +96,45 @@ ConnectionT = TypeVar("ConnectionT")
|
|
|
93
96
|
SessionT = TypeVar("SessionT")
|
|
94
97
|
|
|
95
98
|
|
|
99
|
+
class _Lazy(Generic[ConnectionT]):
|
|
100
|
+
"""A connection checkout that has not happened yet.
|
|
101
|
+
|
|
102
|
+
A lazy ``session_factory()`` block binds one instead of a connection.
|
|
103
|
+
``get`` and ``aget`` check out once, and cache what they opened.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
__slots__ = ("_alock", "_lock", "connection", "open")
|
|
107
|
+
|
|
108
|
+
def __init__(self, open: Callable[[], Any]) -> None: # noqa: A002
|
|
109
|
+
self.open = open
|
|
110
|
+
self.connection: ConnectionT | None = None
|
|
111
|
+
self._lock = threading.Lock()
|
|
112
|
+
self._alock: asyncio.Lock | None = None
|
|
113
|
+
|
|
114
|
+
def get(self) -> ConnectionT:
|
|
115
|
+
"""Materialize the connection, once, and return it."""
|
|
116
|
+
with self._lock:
|
|
117
|
+
if self.connection is None:
|
|
118
|
+
self.connection = self.open()
|
|
119
|
+
return self.connection
|
|
120
|
+
|
|
121
|
+
async def aget(self) -> ConnectionT:
|
|
122
|
+
"""Materialize the connection, once, awaited."""
|
|
123
|
+
if self.connection is not None:
|
|
124
|
+
return self.connection
|
|
125
|
+
# `asyncio` because the async engine is asyncio-only, and on the cell
|
|
126
|
+
# rather than the database, which outlives any one loop.
|
|
127
|
+
if self._alock is None:
|
|
128
|
+
self._alock = asyncio.Lock()
|
|
129
|
+
async with self._alock:
|
|
130
|
+
if self.connection is None:
|
|
131
|
+
connection = self.open()
|
|
132
|
+
if inspect.isawaitable(connection):
|
|
133
|
+
connection = await connection
|
|
134
|
+
self.connection = connection
|
|
135
|
+
return self.connection
|
|
136
|
+
|
|
137
|
+
|
|
96
138
|
@dataclass(slots=True)
|
|
97
139
|
class _Scope(Generic[ConnectionT, SessionT]):
|
|
98
140
|
"""The connection bound to a context, and the session opened on top of it.
|
|
@@ -100,10 +142,16 @@ class _Scope(Generic[ConnectionT, SessionT]):
|
|
|
100
142
|
The context variable holds this object, not the session, so a session
|
|
101
143
|
opened later, including in a task that copies the context, still
|
|
102
144
|
belongs to the block that bound the connection.
|
|
145
|
+
|
|
146
|
+
A lazy ``session_factory()`` block binds a ``checkout`` and no connection.
|
|
147
|
+
The connection lands here once something uses it.
|
|
103
148
|
"""
|
|
104
149
|
|
|
105
|
-
connection: ConnectionT
|
|
150
|
+
connection: ConnectionT | None
|
|
106
151
|
session: SessionT | None = None
|
|
152
|
+
checkout: _Lazy[ConnectionT] | None = None
|
|
153
|
+
autocommit: bool = False
|
|
154
|
+
"""Whether the connection is in ``AUTOCOMMIT``, where no transaction runs."""
|
|
107
155
|
|
|
108
156
|
|
|
109
157
|
@dataclass(slots=True)
|
|
@@ -196,16 +244,15 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
196
244
|
def __repr__(self) -> str:
|
|
197
245
|
return f"{type(self).__name__}({self.url.render_as_string()!r})"
|
|
198
246
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
"""The connection bound to the current context.
|
|
247
|
+
def _current_scope(self) -> _Scope[ConnectionT, SessionT]:
|
|
248
|
+
"""Return the scope bound to the current context.
|
|
202
249
|
|
|
203
250
|
Raises:
|
|
204
|
-
MissingConnectionError: if no
|
|
251
|
+
MissingConnectionError: if no block is open.
|
|
205
252
|
|
|
206
253
|
"""
|
|
207
254
|
try:
|
|
208
|
-
return self._scope.get()
|
|
255
|
+
return self._scope.get()
|
|
209
256
|
except LookupError:
|
|
210
257
|
raise MissingConnectionError from None
|
|
211
258
|
|
|
@@ -226,7 +273,12 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
226
273
|
except LookupError:
|
|
227
274
|
raise MissingSessionError from None
|
|
228
275
|
if scope.session is None:
|
|
229
|
-
scope.
|
|
276
|
+
if scope.connection is None and scope.checkout is not None:
|
|
277
|
+
scope.session = self._lazy_session(scope.checkout)
|
|
278
|
+
else:
|
|
279
|
+
scope.session = self._create_session(
|
|
280
|
+
cast("ConnectionT", scope.connection)
|
|
281
|
+
)
|
|
230
282
|
return scope.session
|
|
231
283
|
|
|
232
284
|
@contextmanager
|
|
@@ -386,6 +438,9 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
386
438
|
def _create_session(self, connection: ConnectionT) -> SessionT:
|
|
387
439
|
raise NotImplementedError # pragma: no cover - the subclass has it
|
|
388
440
|
|
|
441
|
+
def _lazy_session(self, cell: _Lazy[ConnectionT]) -> SessionT:
|
|
442
|
+
raise NotImplementedError # pragma: no cover - the subclass has it
|
|
443
|
+
|
|
389
444
|
def _session_args_for(self, connection: ConnectionT) -> dict[str, Any]:
|
|
390
445
|
"""How a session joins the transaction already open on ``connection``."""
|
|
391
446
|
outer = self._outer.get(None)
|
|
@@ -415,31 +470,47 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
415
470
|
outer = self._outer_to_join()
|
|
416
471
|
return outer.connection if outer is not None else None
|
|
417
472
|
|
|
418
|
-
def
|
|
419
|
-
"""Return the bound
|
|
473
|
+
def _scope_to_reuse(self) -> _Scope[ConnectionT, SessionT] | None:
|
|
474
|
+
"""Return the bound scope a new block reuses, if it may.
|
|
420
475
|
|
|
421
476
|
One connection per context: a block that only needs a connection takes
|
|
422
477
|
the one already bound, whether a transaction, ``autocommit()`` or
|
|
423
|
-
another ``connect()`` opened it. ``join_nested=False`` opts out.
|
|
478
|
+
another ``connect()`` opened it. ``join_nested=False`` opts out. The
|
|
479
|
+
caller materializes a lazy scope, awaited or not.
|
|
424
480
|
"""
|
|
425
481
|
outer = self._outer.get(None)
|
|
426
482
|
if outer is not None and not outer.join_nested:
|
|
427
483
|
return None
|
|
428
|
-
|
|
429
|
-
|
|
484
|
+
return self._scope.get(None)
|
|
485
|
+
|
|
486
|
+
def _scope_to_borrow(self) -> _Scope[ConnectionT, SessionT] | None:
|
|
487
|
+
"""Return the scope whose connection a transaction runs on, if it may.
|
|
488
|
+
|
|
489
|
+
``connect()`` and ``session_factory()`` lend theirs. ``autocommit()``
|
|
490
|
+
does not: no transaction runs on an ``AUTOCOMMIT`` connection.
|
|
491
|
+
"""
|
|
492
|
+
scope = self._scope_to_reuse()
|
|
493
|
+
if scope is None or scope.autocommit:
|
|
494
|
+
return None
|
|
495
|
+
return scope
|
|
430
496
|
|
|
431
497
|
@contextmanager
|
|
432
498
|
def _bind(
|
|
433
499
|
self,
|
|
434
|
-
connection: ConnectionT,
|
|
500
|
+
connection: ConnectionT | None,
|
|
501
|
+
checkout: _Lazy[ConnectionT] | None = None,
|
|
502
|
+
*,
|
|
503
|
+
autocommit: bool = False,
|
|
435
504
|
) -> Iterator[_Scope[ConnectionT, SessionT]]:
|
|
436
505
|
"""Bind a scope holding ``connection`` to the current context.
|
|
437
506
|
|
|
438
507
|
Every block gets a scope, and so a session, of its own. Ending that
|
|
439
508
|
session is left to the caller, which knows whether it takes an
|
|
440
|
-
``await``.
|
|
509
|
+
``await``. A lazy block passes ``checkout`` instead of a connection.
|
|
441
510
|
"""
|
|
442
|
-
scope = _Scope[ConnectionT, SessionT](
|
|
511
|
+
scope = _Scope[ConnectionT, SessionT](
|
|
512
|
+
connection, checkout=checkout, autocommit=autocommit
|
|
513
|
+
)
|
|
443
514
|
token = self._scope.set(scope)
|
|
444
515
|
try:
|
|
445
516
|
yield scope
|
|
@@ -911,6 +982,31 @@ def url_from_config(config: DatabaseConfig) -> str | sa.URL:
|
|
|
911
982
|
return sa.URL.create(**parts) # ty: ignore[invalid-argument-type]
|
|
912
983
|
|
|
913
984
|
|
|
985
|
+
class _LazyBind:
|
|
986
|
+
"""A session that checks its connection out on first real use.
|
|
987
|
+
|
|
988
|
+
Mixed over the session class of a lazy ``session_factory()`` block, which
|
|
989
|
+
creates it unbound. ``get_bind`` checks the connection out on a flush or a
|
|
990
|
+
query, not on ``add()``.
|
|
991
|
+
"""
|
|
992
|
+
|
|
993
|
+
_sqlakit_checkout: Callable[[], Any] | None = None
|
|
994
|
+
bind: Any
|
|
995
|
+
|
|
996
|
+
def get_bind(self, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
997
|
+
if self.bind is None and self._sqlakit_checkout is not None:
|
|
998
|
+
self.bind = self._sqlakit_checkout()
|
|
999
|
+
return super().get_bind(*args, **kwargs) # ty: ignore[unresolved-attribute]
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
@cache
|
|
1003
|
+
def lazy_session_class(base: type) -> type:
|
|
1004
|
+
"""Return ``base`` with `_LazyBind` mixed in, built once per base."""
|
|
1005
|
+
if issubclass(base, _LazyBind):
|
|
1006
|
+
return base
|
|
1007
|
+
return type(f"Lazy{base.__name__}", (_LazyBind, base), {})
|
|
1008
|
+
|
|
1009
|
+
|
|
914
1010
|
class BaseRetryingTransaction:
|
|
915
1011
|
"""What ``transaction()`` returns when given ``retry_on``.
|
|
916
1012
|
|
|
@@ -20,8 +20,11 @@ from sqlalchemy.orm import Session, sessionmaker
|
|
|
20
20
|
from ._base import (
|
|
21
21
|
BaseDatabase,
|
|
22
22
|
BaseRetryingTransaction,
|
|
23
|
+
_Lazy,
|
|
24
|
+
_Scope,
|
|
23
25
|
default_backoff,
|
|
24
26
|
fix_sqlite_transactions,
|
|
27
|
+
lazy_session_class,
|
|
25
28
|
)
|
|
26
29
|
from .exceptions import TransactionRolledBackError
|
|
27
30
|
|
|
@@ -110,6 +113,19 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
110
113
|
fix_sqlite_transactions(engine)
|
|
111
114
|
return engine
|
|
112
115
|
|
|
116
|
+
@property
|
|
117
|
+
def connection(self) -> sa.Connection:
|
|
118
|
+
"""The connection bound to the current context.
|
|
119
|
+
|
|
120
|
+
In a [`session_factory`][sqlakit.Database.session_factory] block,
|
|
121
|
+
reading it checks the connection out.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
MissingConnectionError: if no connection is bound.
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
return self._reused(self._current_scope())
|
|
128
|
+
|
|
113
129
|
def _create_session(self, connection: sa.Connection) -> Session:
|
|
114
130
|
if self._sessionmaker is None:
|
|
115
131
|
self._sessionmaker = sessionmaker(**self.session_args)
|
|
@@ -118,12 +134,26 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
118
134
|
**self._session_args_for(connection),
|
|
119
135
|
)
|
|
120
136
|
|
|
137
|
+
def _lazy_session(self, cell: _Lazy[sa.Connection]) -> Session:
|
|
138
|
+
args: dict[str, Any] = dict(self.session_args)
|
|
139
|
+
session_class = lazy_session_class(args.pop("class_", Session))
|
|
140
|
+
session = session_class(**args)
|
|
141
|
+
session._sqlakit_checkout = cell.get # noqa: SLF001
|
|
142
|
+
return session
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _reused(scope: _Scope[sa.Connection, Session]) -> sa.Connection:
|
|
146
|
+
"""Return the scope's connection, checking it out first if lazy."""
|
|
147
|
+
if scope.connection is None and scope.checkout is not None:
|
|
148
|
+
scope.connection = scope.checkout.get()
|
|
149
|
+
return cast("sa.Connection", scope.connection)
|
|
150
|
+
|
|
121
151
|
@contextmanager
|
|
122
152
|
def connect(self) -> Iterator[sa.Connection]:
|
|
123
153
|
"""Open a connection and bind it, or reuse the one already bound."""
|
|
124
|
-
|
|
125
|
-
if
|
|
126
|
-
with self._bound(
|
|
154
|
+
reuse = self._scope_to_reuse()
|
|
155
|
+
if reuse is not None:
|
|
156
|
+
with self._bound(self._reused(reuse)) as connection:
|
|
127
157
|
yield connection
|
|
128
158
|
return
|
|
129
159
|
with self.engine.connect() as opened, self._bound(opened) as connection:
|
|
@@ -170,7 +200,10 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
170
200
|
return
|
|
171
201
|
with self.engine.connect() as opened:
|
|
172
202
|
opened.execution_options(isolation_level="AUTOCOMMIT")
|
|
173
|
-
with
|
|
203
|
+
with (
|
|
204
|
+
self._set_outer(None),
|
|
205
|
+
self._bound(opened, commit=True, autocommit=True) as connection,
|
|
206
|
+
):
|
|
174
207
|
yield connection
|
|
175
208
|
|
|
176
209
|
@overload
|
|
@@ -284,9 +317,29 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
284
317
|
|
|
285
318
|
@contextmanager
|
|
286
319
|
def session_factory(self) -> Iterator[Session]:
|
|
287
|
-
"""Open a
|
|
288
|
-
|
|
289
|
-
|
|
320
|
+
"""Open a session for the block, and bind it.
|
|
321
|
+
|
|
322
|
+
The session arrives at once, the connection on its first query or
|
|
323
|
+
flush, as ``sessionmaker()`` does it. Inside another block it runs on
|
|
324
|
+
the connection already bound.
|
|
325
|
+
"""
|
|
326
|
+
reuse = self._scope_to_reuse()
|
|
327
|
+
if reuse is not None:
|
|
328
|
+
with self._bound(self._reused(reuse)):
|
|
329
|
+
yield self.session
|
|
330
|
+
return
|
|
331
|
+
# The lambda defers `self.engine` too: no engine until first use.
|
|
332
|
+
cell: _Lazy[sa.Connection] = _Lazy(lambda: self.engine.connect()) # noqa: PLW0108
|
|
333
|
+
with self._bind(None, checkout=cell) as scope:
|
|
334
|
+
try:
|
|
335
|
+
yield self.session
|
|
336
|
+
finally:
|
|
337
|
+
try:
|
|
338
|
+
if scope.session is not None:
|
|
339
|
+
scope.session.close()
|
|
340
|
+
finally:
|
|
341
|
+
if cell.connection is not None:
|
|
342
|
+
cell.connection.close()
|
|
290
343
|
|
|
291
344
|
@contextmanager
|
|
292
345
|
def _bound(
|
|
@@ -294,6 +347,7 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
294
347
|
connection: sa.Connection,
|
|
295
348
|
*,
|
|
296
349
|
commit: bool = False,
|
|
350
|
+
autocommit: bool = False,
|
|
297
351
|
) -> Iterator[sa.Connection]:
|
|
298
352
|
"""Bind ``connection`` for the block, ending the session it opened.
|
|
299
353
|
|
|
@@ -301,7 +355,7 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
301
355
|
itself with a savepoint rolls back to it, discarding what the blocks
|
|
302
356
|
below committed.
|
|
303
357
|
"""
|
|
304
|
-
with self._bind(connection) as scope:
|
|
358
|
+
with self._bind(connection, autocommit=autocommit) as scope:
|
|
305
359
|
done = False
|
|
306
360
|
try:
|
|
307
361
|
yield connection
|
|
@@ -364,6 +418,21 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
364
418
|
self.dispose()
|
|
365
419
|
|
|
366
420
|
|
|
421
|
+
def _owned(
|
|
422
|
+
connection: sa.Connection,
|
|
423
|
+
scope: _Scope[sa.Connection, Session],
|
|
424
|
+
) -> tuple[Session | None, sa.Transaction | None]:
|
|
425
|
+
"""Return what a borrowing block ends: a session's transaction, or its own.
|
|
426
|
+
|
|
427
|
+
A session that began one goes on using it, so that one ends through the
|
|
428
|
+
session. Anything else is the block's to end.
|
|
429
|
+
"""
|
|
430
|
+
session = scope.session
|
|
431
|
+
if session is not None and session.in_transaction():
|
|
432
|
+
return session, None
|
|
433
|
+
return None, connection.get_transaction() or connection.begin()
|
|
434
|
+
|
|
435
|
+
|
|
367
436
|
class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
368
437
|
"""What [`Database.transaction`][sqlakit.Database.transaction] returns.
|
|
369
438
|
|
|
@@ -410,6 +479,7 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
410
479
|
savepoint=self.savepoint,
|
|
411
480
|
rollback=self.rollback,
|
|
412
481
|
)
|
|
482
|
+
owner: Session | None = None
|
|
413
483
|
if outer is not None:
|
|
414
484
|
connection = outer.connection
|
|
415
485
|
# Without a savepoint the block only takes part in the
|
|
@@ -419,11 +489,16 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
419
489
|
# must not add a second one on the same connection.
|
|
420
490
|
session_savepoint = outer.session_savepoint and not savepoint
|
|
421
491
|
else:
|
|
422
|
-
|
|
423
|
-
|
|
492
|
+
borrowed = self.db._scope_to_borrow() # noqa: SLF001
|
|
493
|
+
if borrowed is not None:
|
|
494
|
+
connection = self.db._reused(borrowed) # noqa: SLF001
|
|
495
|
+
owner, transaction = _owned(connection, borrowed)
|
|
496
|
+
else:
|
|
497
|
+
connection = stack.enter_context(self.db.engine.connect())
|
|
498
|
+
owner, transaction = None, connection.begin()
|
|
424
499
|
session_savepoint = savepoint
|
|
425
500
|
# Unwound in reverse: session, context, transaction, connection.
|
|
426
|
-
stack.push(self._finish(transaction))
|
|
501
|
+
stack.push(self._finish(transaction, owner))
|
|
427
502
|
bound = stack.enter_context(
|
|
428
503
|
self.db._set_outer( # noqa: SLF001
|
|
429
504
|
connection,
|
|
@@ -473,7 +548,11 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
473
548
|
|
|
474
549
|
return close_session
|
|
475
550
|
|
|
476
|
-
def _finish(
|
|
551
|
+
def _finish(
|
|
552
|
+
self,
|
|
553
|
+
transaction: sa.Transaction | None,
|
|
554
|
+
owner: Session | None = None,
|
|
555
|
+
) -> Callable[..., None]:
|
|
477
556
|
"""Commit or roll back, unless this block only takes part in another."""
|
|
478
557
|
|
|
479
558
|
def finish(
|
|
@@ -481,6 +560,14 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
481
560
|
exc: BaseException | None,
|
|
482
561
|
_traceback: object,
|
|
483
562
|
) -> None:
|
|
563
|
+
if owner is not None:
|
|
564
|
+
# The lending block's session holds the transaction. Ending it
|
|
565
|
+
# through the session leaves that session able to go on.
|
|
566
|
+
if self._keeps(exc) and not self.rollback:
|
|
567
|
+
owner.commit()
|
|
568
|
+
else:
|
|
569
|
+
owner.rollback()
|
|
570
|
+
return
|
|
484
571
|
if transaction is None:
|
|
485
572
|
return
|
|
486
573
|
if not transaction.is_active:
|
|
@@ -239,6 +239,9 @@ class BaseQuery(Generic[ModelT]):
|
|
|
239
239
|
self._statement: Any = None
|
|
240
240
|
self.filtered = True
|
|
241
241
|
self.deleted = HIDDEN
|
|
242
|
+
# A page reads the same keyset ordering three times: for the statement
|
|
243
|
+
# and for the cursor at either end.
|
|
244
|
+
self._keyset_cache: tuple[list[_Ordering], str] | None = None
|
|
242
245
|
|
|
243
246
|
def using(self, target: str | Any) -> Self: # noqa: ANN401
|
|
244
247
|
"""Run this query on another database, named or handed over.
|
|
@@ -283,6 +286,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
283
286
|
)
|
|
284
287
|
query = self._copy()
|
|
285
288
|
query._select = select # noqa: SLF001 - a copy of this class
|
|
289
|
+
query._keyset_cache = None # noqa: SLF001 - the ordering may differ
|
|
286
290
|
return query
|
|
287
291
|
|
|
288
292
|
def _copy(self) -> Self:
|
|
@@ -420,9 +424,9 @@ class BaseQuery(Generic[ModelT]):
|
|
|
420
424
|
) -> Self:
|
|
421
425
|
"""Order the rows, by columns or by the sort strings a request carries.
|
|
422
426
|
|
|
423
|
-
A string is `name`, `name.desc`, or `name.desc.nulls_last
|
|
424
|
-
|
|
425
|
-
rather than turned into SQL:
|
|
427
|
+
A string is `name`, `name.desc`, or `name.desc.nulls_last`, and several of
|
|
428
|
+
them order by each in turn. Names are looked up in what the model offers, so
|
|
429
|
+
a field nobody meant to sort by is refused rather than turned into SQL:
|
|
426
430
|
|
|
427
431
|
```python
|
|
428
432
|
User.query.order_by(request.sort).page(limit=20)
|
|
@@ -640,8 +644,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
640
644
|
"""
|
|
641
645
|
self._reject_statement("cursor_page")
|
|
642
646
|
self._require_ordering()
|
|
643
|
-
ordering = self.
|
|
644
|
-
ordering_key = _ordering_key(ordering)
|
|
647
|
+
ordering, ordering_key = self._keyset()
|
|
645
648
|
backwards = cursor is not None and _is_backwards(cursor)
|
|
646
649
|
if backwards:
|
|
647
650
|
ordering = [
|
|
@@ -668,6 +671,13 @@ class BaseQuery(Generic[ModelT]):
|
|
|
668
671
|
if not self.is_ordered:
|
|
669
672
|
raise UnorderedPageError
|
|
670
673
|
|
|
674
|
+
def _keyset(self) -> tuple[list[_Ordering], str]:
|
|
675
|
+
"""Return the cursor's ordering and its fingerprint, worked out once."""
|
|
676
|
+
if self._keyset_cache is None:
|
|
677
|
+
ordering = self._keyset_ordering()
|
|
678
|
+
self._keyset_cache = (ordering, _ordering_key(ordering))
|
|
679
|
+
return self._keyset_cache
|
|
680
|
+
|
|
671
681
|
def _keyset_ordering(self) -> list[_Ordering]:
|
|
672
682
|
"""Return the ordering a cursor walks: what was asked, then the key.
|
|
673
683
|
|
|
@@ -788,14 +798,14 @@ class BaseQuery(Generic[ModelT]):
|
|
|
788
798
|
not carry, such as one belonging to a joined table.
|
|
789
799
|
|
|
790
800
|
"""
|
|
791
|
-
ordering = self.
|
|
801
|
+
ordering, ordering_key = self._keyset()
|
|
792
802
|
values = []
|
|
793
803
|
for item in ordering:
|
|
794
804
|
value = getattr(row, item.attribute)
|
|
795
805
|
if value is None:
|
|
796
806
|
raise NullCursorValueError(item.attribute)
|
|
797
807
|
values.append(value)
|
|
798
|
-
return _encode(values, backwards=backwards, ordering=
|
|
808
|
+
return _encode(values, backwards=backwards, ordering=ordering_key)
|
|
799
809
|
|
|
800
810
|
|
|
801
811
|
def orderable(model: type[Any]) -> Mapping[str, Any]:
|
|
@@ -337,7 +337,12 @@ def _statement(
|
|
|
337
337
|
# `*/` in a name would end the comment early and leak into the SQL.
|
|
338
338
|
sql = f"/* {label.replace('*/', '* /')} */\n{sql}"
|
|
339
339
|
clause = sa.text(sql)
|
|
340
|
-
|
|
340
|
+
named = {
|
|
341
|
+
element.key
|
|
342
|
+
for element in clause.get_children()
|
|
343
|
+
if isinstance(element, sa.BindParameter)
|
|
344
|
+
}
|
|
345
|
+
stray = named - set(params)
|
|
341
346
|
if stray:
|
|
342
347
|
raise StrayParameterError(sorted(stray), label)
|
|
343
348
|
return clause.bindparams(*(_bound(name, value) for name, value in params.items()))
|
|
@@ -22,14 +22,19 @@ from sqlalchemy.ext.asyncio import (
|
|
|
22
22
|
async_sessionmaker,
|
|
23
23
|
create_async_engine,
|
|
24
24
|
)
|
|
25
|
+
from sqlalchemy.orm import Session
|
|
26
|
+
from sqlalchemy.util import await_only
|
|
25
27
|
|
|
26
28
|
from sqlakit._base import (
|
|
27
29
|
BaseDatabase,
|
|
28
30
|
BaseRetryingTransaction,
|
|
31
|
+
_Lazy,
|
|
32
|
+
_Scope,
|
|
29
33
|
default_backoff,
|
|
30
34
|
fix_sqlite_transactions,
|
|
35
|
+
lazy_session_class,
|
|
31
36
|
)
|
|
32
|
-
from sqlakit.exceptions import TransactionRolledBackError
|
|
37
|
+
from sqlakit.exceptions import MissingConnectionError, TransactionRolledBackError
|
|
33
38
|
|
|
34
39
|
if TYPE_CHECKING:
|
|
35
40
|
from collections.abc import AsyncIterator, Callable, Coroutine, Sequence
|
|
@@ -112,6 +117,36 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
112
117
|
fix_sqlite_transactions(engine.sync_engine)
|
|
113
118
|
return engine
|
|
114
119
|
|
|
120
|
+
@property
|
|
121
|
+
def connection(self) -> AsyncConnection:
|
|
122
|
+
"""The connection bound to the current context.
|
|
123
|
+
|
|
124
|
+
In a [`session_factory`][sqlakit.asyncio.Database.session_factory]
|
|
125
|
+
block it raises until the session's first use: a property cannot await
|
|
126
|
+
the checkout.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
MissingConnectionError: if no connection is bound.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
scope = self._current_scope()
|
|
133
|
+
if scope.connection is None:
|
|
134
|
+
cell = scope.checkout
|
|
135
|
+
if cell is not None and cell.connection is not None:
|
|
136
|
+
scope.connection = cell.connection
|
|
137
|
+
else:
|
|
138
|
+
message = (
|
|
139
|
+
"No connection is open in this `session_factory()` block "
|
|
140
|
+
"yet. Use `db.session`, or open a `connect()` block if "
|
|
141
|
+
"you need the connection."
|
|
142
|
+
)
|
|
143
|
+
raise MissingConnectionError(message)
|
|
144
|
+
return scope.connection
|
|
145
|
+
|
|
146
|
+
async def _aconnection(self) -> AsyncConnection:
|
|
147
|
+
"""Return the bound connection, checking it out first if lazy."""
|
|
148
|
+
return await self._areused(self._current_scope())
|
|
149
|
+
|
|
115
150
|
def _create_session(self, connection: AsyncConnection) -> AsyncSession:
|
|
116
151
|
if self._sessionmaker is None:
|
|
117
152
|
self._sessionmaker = async_sessionmaker(**self.session_args)
|
|
@@ -120,12 +155,39 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
120
155
|
**self._session_args_for(connection),
|
|
121
156
|
)
|
|
122
157
|
|
|
158
|
+
def _lazy_session(self, cell: _Lazy[AsyncConnection]) -> AsyncSession:
|
|
159
|
+
args: dict[str, Any] = dict(self.session_args)
|
|
160
|
+
session_class = args.pop("class_", AsyncSession)
|
|
161
|
+
args["sync_session_class"] = lazy_session_class(
|
|
162
|
+
args.pop("sync_session_class", Session)
|
|
163
|
+
)
|
|
164
|
+
session = session_class(**args)
|
|
165
|
+
|
|
166
|
+
def checkout() -> sa.Connection | None:
|
|
167
|
+
# Already materialized, by a nested block: no greenlet needed.
|
|
168
|
+
connection = cell.connection
|
|
169
|
+
if connection is None:
|
|
170
|
+
connection = await_only(cell.aget())
|
|
171
|
+
return connection.sync_connection
|
|
172
|
+
|
|
173
|
+
session.sync_session._sqlakit_checkout = checkout # noqa: SLF001
|
|
174
|
+
return session
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
async def _areused(
|
|
178
|
+
scope: _Scope[AsyncConnection, AsyncSession],
|
|
179
|
+
) -> AsyncConnection:
|
|
180
|
+
"""Return the scope's connection, checking it out first if lazy."""
|
|
181
|
+
if scope.connection is None and scope.checkout is not None:
|
|
182
|
+
scope.connection = await scope.checkout.aget()
|
|
183
|
+
return cast("AsyncConnection", scope.connection)
|
|
184
|
+
|
|
123
185
|
@asynccontextmanager
|
|
124
186
|
async def connect(self) -> AsyncIterator[AsyncConnection]:
|
|
125
187
|
"""Open a connection and bind it, or reuse the one already bound."""
|
|
126
|
-
|
|
127
|
-
if
|
|
128
|
-
async with self._bound(
|
|
188
|
+
reuse = self._scope_to_reuse()
|
|
189
|
+
if reuse is not None:
|
|
190
|
+
async with self._bound(await self._areused(reuse)) as connection:
|
|
129
191
|
yield connection
|
|
130
192
|
return
|
|
131
193
|
async with self.engine.connect() as opened, self._bound(opened) as connection:
|
|
@@ -173,7 +235,9 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
173
235
|
async with self.engine.connect() as opened:
|
|
174
236
|
await opened.execution_options(isolation_level="AUTOCOMMIT")
|
|
175
237
|
with self._set_outer(None):
|
|
176
|
-
async with self._bound(
|
|
238
|
+
async with self._bound(
|
|
239
|
+
opened, commit=True, autocommit=True
|
|
240
|
+
) as connection:
|
|
177
241
|
yield connection
|
|
178
242
|
|
|
179
243
|
@overload
|
|
@@ -287,9 +351,29 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
287
351
|
|
|
288
352
|
@asynccontextmanager
|
|
289
353
|
async def session_factory(self) -> AsyncIterator[AsyncSession]:
|
|
290
|
-
"""Open a
|
|
291
|
-
|
|
292
|
-
|
|
354
|
+
"""Open a session for the block, and bind it.
|
|
355
|
+
|
|
356
|
+
The session arrives at once, the connection on its first query or
|
|
357
|
+
flush, as ``async_sessionmaker()`` does it. Inside another block it
|
|
358
|
+
runs on the connection already bound.
|
|
359
|
+
"""
|
|
360
|
+
reuse = self._scope_to_reuse()
|
|
361
|
+
if reuse is not None:
|
|
362
|
+
async with self._bound(await self._areused(reuse)):
|
|
363
|
+
yield self.session
|
|
364
|
+
return
|
|
365
|
+
# The lambda defers `self.engine` too: no engine until first use.
|
|
366
|
+
cell: _Lazy[AsyncConnection] = _Lazy(lambda: self.engine.connect()) # noqa: PLW0108
|
|
367
|
+
with self._bind(None, checkout=cell) as scope:
|
|
368
|
+
try:
|
|
369
|
+
yield self.session
|
|
370
|
+
finally:
|
|
371
|
+
try:
|
|
372
|
+
if scope.session is not None:
|
|
373
|
+
await scope.session.close()
|
|
374
|
+
finally:
|
|
375
|
+
if cell.connection is not None:
|
|
376
|
+
await cell.connection.close()
|
|
293
377
|
|
|
294
378
|
@asynccontextmanager
|
|
295
379
|
async def _bound(
|
|
@@ -297,6 +381,7 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
297
381
|
connection: AsyncConnection,
|
|
298
382
|
*,
|
|
299
383
|
commit: bool = False,
|
|
384
|
+
autocommit: bool = False,
|
|
300
385
|
) -> AsyncIterator[AsyncConnection]:
|
|
301
386
|
"""Bind ``connection`` for the block, ending the session it opened.
|
|
302
387
|
|
|
@@ -304,7 +389,7 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
304
389
|
itself with a savepoint rolls back to it, discarding what the blocks
|
|
305
390
|
below committed.
|
|
306
391
|
"""
|
|
307
|
-
with self._bind(connection) as scope:
|
|
392
|
+
with self._bind(connection, autocommit=autocommit) as scope:
|
|
308
393
|
done = False
|
|
309
394
|
try:
|
|
310
395
|
yield connection
|
|
@@ -367,6 +452,21 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
367
452
|
await self.dispose()
|
|
368
453
|
|
|
369
454
|
|
|
455
|
+
async def _owned(
|
|
456
|
+
connection: AsyncConnection,
|
|
457
|
+
scope: _Scope[AsyncConnection, AsyncSession],
|
|
458
|
+
) -> tuple[AsyncSession | None, AsyncTransaction | None]:
|
|
459
|
+
"""Return what a borrowing block ends: a session's transaction, or its own.
|
|
460
|
+
|
|
461
|
+
A session that began one goes on using it, so that one ends through the
|
|
462
|
+
session. Anything else is the block's to end.
|
|
463
|
+
"""
|
|
464
|
+
session = scope.session
|
|
465
|
+
if session is not None and session.in_transaction():
|
|
466
|
+
return session, None
|
|
467
|
+
return None, connection.get_transaction() or await connection.begin()
|
|
468
|
+
|
|
469
|
+
|
|
370
470
|
class Transaction(
|
|
371
471
|
AsyncContextDecorator,
|
|
372
472
|
AbstractAsyncContextManager["AsyncConnection"],
|
|
@@ -419,6 +519,7 @@ class Transaction(
|
|
|
419
519
|
savepoint=self.savepoint,
|
|
420
520
|
rollback=self.rollback,
|
|
421
521
|
)
|
|
522
|
+
owner: AsyncSession | None = None
|
|
422
523
|
if outer is not None:
|
|
423
524
|
connection = outer.connection
|
|
424
525
|
# Without a savepoint the block only takes part in the
|
|
@@ -428,11 +529,18 @@ class Transaction(
|
|
|
428
529
|
# must not add a second one on the same connection.
|
|
429
530
|
session_savepoint = outer.session_savepoint and not savepoint
|
|
430
531
|
else:
|
|
431
|
-
|
|
432
|
-
|
|
532
|
+
borrowed = self.db._scope_to_borrow() # noqa: SLF001
|
|
533
|
+
if borrowed is not None:
|
|
534
|
+
connection = await self.db._areused(borrowed) # noqa: SLF001
|
|
535
|
+
owner, transaction = await _owned(connection, borrowed)
|
|
536
|
+
else:
|
|
537
|
+
connection = await stack.enter_async_context(
|
|
538
|
+
self.db.engine.connect()
|
|
539
|
+
)
|
|
540
|
+
transaction = await connection.begin()
|
|
433
541
|
session_savepoint = savepoint
|
|
434
542
|
# Unwound in reverse: session, context, transaction, connection.
|
|
435
|
-
stack.push_async_exit(self._finish(transaction))
|
|
543
|
+
stack.push_async_exit(self._finish(transaction, owner))
|
|
436
544
|
bound = stack.enter_context(
|
|
437
545
|
self.db._set_outer( # noqa: SLF001
|
|
438
546
|
connection,
|
|
@@ -485,6 +593,7 @@ class Transaction(
|
|
|
485
593
|
def _finish(
|
|
486
594
|
self,
|
|
487
595
|
transaction: AsyncTransaction | None,
|
|
596
|
+
owner: AsyncSession | None = None,
|
|
488
597
|
) -> Callable[..., Coroutine[None, None, None]]:
|
|
489
598
|
"""Commit or roll back, unless this block only takes part in another."""
|
|
490
599
|
|
|
@@ -493,6 +602,14 @@ class Transaction(
|
|
|
493
602
|
exc: BaseException | None,
|
|
494
603
|
_traceback: object,
|
|
495
604
|
) -> None:
|
|
605
|
+
if owner is not None:
|
|
606
|
+
# The lending block's session holds the transaction. Ending it
|
|
607
|
+
# through the session leaves that session able to go on.
|
|
608
|
+
if self._keeps(exc) and not self.rollback:
|
|
609
|
+
await owner.commit()
|
|
610
|
+
else:
|
|
611
|
+
await owner.rollback()
|
|
612
|
+
return
|
|
496
613
|
if transaction is None:
|
|
497
614
|
return
|
|
498
615
|
if not transaction.is_active:
|
|
@@ -179,7 +179,7 @@ class SQLRows(BaseSQLQuery[RowT, "Database"]):
|
|
|
179
179
|
"""Return this block's connection, with any pending ORM writes on it."""
|
|
180
180
|
if self.db.in_session():
|
|
181
181
|
await self.db.session.flush()
|
|
182
|
-
return self.db.
|
|
182
|
+
return await self.db._aconnection() # noqa: SLF001
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
class SQLQuery(SQLRows[sa.Row[Any]]):
|
|
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
|