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.
Files changed (26) hide show
  1. {sqlakit-0.1.0 → sqlakit-0.2.0}/PKG-INFO +16 -16
  2. {sqlakit-0.1.0 → sqlakit-0.2.0}/README.md +14 -14
  3. {sqlakit-0.1.0 → sqlakit-0.2.0}/pyproject.toml +2 -2
  4. {sqlakit-0.1.0 → sqlakit-0.2.0}/pyproject.toml.orig +2 -7
  5. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_base.py +111 -15
  6. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_db.py +99 -12
  7. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_query.py +17 -7
  8. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_sql.py +6 -1
  9. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/_db.py +129 -12
  10. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/sql.py +1 -1
  11. {sqlakit-0.1.0 → sqlakit-0.2.0}/LICENSE +0 -0
  12. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/__init__.py +0 -0
  13. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_discovery.py +0 -0
  14. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_model.py +0 -0
  15. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_recording.py +0 -0
  16. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_registry.py +0 -0
  17. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/_routing.py +0 -0
  18. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/__init__.py +0 -0
  19. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/_registry.py +0 -0
  20. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/asyncio/orm.py +0 -0
  21. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/exceptions.py +0 -0
  22. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/orm.py +0 -0
  23. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/py.typed +0 -0
  24. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/sql.py +0 -0
  25. {sqlakit-0.1.0 → sqlakit-0.2.0}/sqlakit/testing.py +0 -0
  26. {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.1.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.43
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 without passing it around. The
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 end up in the SQL text and
96
- there is no way to inject anything. Requires the `sqlakit[sql]` extra.
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
- The template name is added to the SQL as a comment, so a slow query log shows
129
- right away which file a query came from.
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. The field name is checked against the model before any SQL is
154
- built, so an unknown field never reaches the database. Instead you get
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; instead you get cursors to the next and previous pages:
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 is rolled back at the end, so nothing
227
- the code under test writes is actually committed. `assert_queries` checks how
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 is logged at the end of the block. The log level
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 is created on first use. On
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 without passing it around. The
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 end up in the SQL text and
66
- there is no way to inject anything. Requires the `sqlakit[sql]` extra.
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
- The template name is added to the SQL as a comment, so a slow query log shows
99
- right away which file a query came from.
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. The field name is checked against the model before any SQL is
124
- built, so an unknown field never reaches the database. Instead you get
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; instead you get cursors to the next and previous pages:
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 is rolled back at the end, so nothing
197
- the code under test writes is actually committed. `assert_queries` checks how
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 is logged at the end of the block. The log level
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 is created on first use. On
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.1.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.43"]
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.1.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.43",
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
- @property
200
- def connection(self) -> ConnectionT:
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 connection is bound.
251
+ MissingConnectionError: if no block is open.
205
252
 
206
253
  """
207
254
  try:
208
- return self._scope.get().connection
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.session = self._create_session(scope.connection)
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 _connection_to_reuse(self) -> ConnectionT | None:
419
- """Return the bound connection a new block reuses, if it may.
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
- scope = self._scope.get(None)
429
- return scope.connection if scope is not None else None
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](connection)
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
- outer = self._connection_to_reuse()
125
- if outer is not None:
126
- with self._bound(outer) as connection:
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 self._set_outer(None), self._bound(opened, commit=True) as connection:
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 new connection and a session on top of it, and bind both."""
288
- with self.connect():
289
- yield self.session
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
- connection = stack.enter_context(self.db.engine.connect())
423
- transaction = connection.begin()
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(self, transaction: sa.Transaction | None) -> Callable[..., None]:
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`. Names are looked
424
- up in what the model offers, so a field nobody meant to sort by is refused
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._keyset_ordering()
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._keyset_ordering()
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=_ordering_key(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
- stray = set(clause._bindparams) - set(params) # noqa: SLF001
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
- outer = self._connection_to_reuse()
127
- if outer is not None:
128
- async with self._bound(outer) as connection:
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(opened, commit=True) as connection:
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 new connection and a session on top of it, and bind both."""
291
- async with self.connect():
292
- yield self.session
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
- connection = await stack.enter_async_context(self.db.engine.connect())
432
- transaction = await connection.begin()
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.connection
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