From 6ad47cc4884873a30596669785f4bbedc3719062 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 16:38:37 +0200 Subject: [PATCH 01/29] feat(async): add asynchronous execution scaffolding --- docs/rfc-async-execution.md | 142 +++++++++++++++++++++++++++++++++ pyproject.toml | 14 ++++ src/typedal/core.py | 104 ++++++++++++++++++++++++ src/typedal/query_builder.py | 98 +++++++++++++++++++++++ src/typedal/tables.py | 144 ++++++++++++++++++++++++++++++++++ tests/test_async_execution.py | 108 +++++++++++++++++++++++++ 6 files changed, 610 insertions(+) create mode 100644 docs/rfc-async-execution.md create mode 100644 tests/test_async_execution.py diff --git a/docs/rfc-async-execution.md b/docs/rfc-async-execution.md new file mode 100644 index 0000000..4790a39 --- /dev/null +++ b/docs/rfc-async-execution.md @@ -0,0 +1,142 @@ +# RFC: async execution path for TypeDAL + +**Status:** feasibility confirmed, implementation not started. +**Scope decided:** Postgres + SQLite, all five ops (`select`/`insert`/`update`/`delete`/`count`). +**Constraints:** pydal is never forked or patched; TypeDAL's public API is unchanged (new methods only). + +pydal tested: `20260520.0` (satisfies TypeDAL's `pydal>=20251012.3` pin). Drivers tested: +psycopg2 2.9.12 (sync baseline), psycopg 3.3.4 (async), asyncpg 0.31.0. + +## The question + +pydal's per-query work is a sandwich: build SQL (pure, microseconds) → execute (the only I/O) +→ parse rows (pure). If that split is reachable from outside pydal, TypeDAL can let pydal build +and parse as it always has, and only replace the execute step with a real async driver — no +greenlets, no pydal rewrite. + +## Verdict: clean + +Not just "a subclass can intercept `execute`/`parse`" — pydal already keeps build, execute, and +parse as three separate method calls with nothing to split. `Set.select()` +(`pydal/objects.py:2961-2971`) calls `adapter.tables()` → `adapter.expand_all()` → +`adapter.select()`. `SQLAdapter.select()` (`adapters/base.py:905-910`) calls +`self._select_wcols()` (pure, returns `(colnames, sql)`) then `_select_aux()` +(`base.py:864-891`), which does `execute(sql)` + `cursor.fetchall()` (the only I/O), then +`self.parse(rows, fields, colnames)` (pure). `_select_wcols` is called directly by +`Set.select()` itself — it isn't a hidden internal, it's part of the normal call graph. An +outside caller can call the build half, do its own I/O, and call `parse()` on the result, +skipping `select()`/`_select_aux()`/`execute()` entirely: + +```python +colnames, sql = adapter._select_wcols(query, fields, **attributes) # build (pydal, unmodified) +rows = await async_driver.execute_and_fetch(sql) # our I/O +result = adapter.parse(rows, fields, colnames, cacheable=...) # parse (pydal, unmodified) +``` + +No pydal method body is copied or patched. Verified live in the PoC (below): output is +field-for-field identical to `db(...).select()` run synchronously, and a formalized non-blocking +test shows the event loop keeps ticking at ~5ms while real queries run. + +**Per operation:** + +| op | Postgres | SQLite | +|---|---|---| +| `select` / `count` / `executesql` | clean sandwich | clean sandwich | +| `insert` | clean; one round trip in the standard case — `INSERT ... RETURNING id` is sent once, `cursor.fetchone()` just reads the row already returned by that statement (`adapters/postgres.py:142-162`). A second real round trip (`SELECT currval(...)`) only happens for tables with a custom `_primarykey` where the pk value wasn't supplied. | clean | +| `update` | clean sandwich | clean sandwich | +| `delete` | clean sandwich (`adapters/base.py:604-610`) | **not a sandwich** — `SQLite.delete()` (`adapters/sqlite.py:93-104`) runs a nested `SELECT` then recurses into `.delete()` per cascaded FK. Needs its own async reimplementation of the cascade, not a wrapped execute call. | + +SQLite's `select()` also has a side effect the base class doesn't: `for_update=True` triggers a +real `BEGIN IMMEDIATE TRANSACTION` *before* the build step (`adapters/sqlite.py:88-91`) — the +async wrapper has to special-case this, it can't assume every adapter's `select()` is side-effect +free just because the base one is. + +## Hypothesis B (greenlet bridge) — not needed, killed early + +`pydal/_globals.py:4`: `THREAD_LOCAL = threading.local()`, imported **by value** into six modules +(`connection.py:8`, `base.py:148`, `helpers/classes.py:17`, `adapters/postgres.py:5`, +`adapters/snowflake.py`, `adapters/google.py`). `ConnectionPool` closes over that name directly — +no subclass hook exists to redirect it to a contextvars-backed registry. Doable only as a +monkeypatch across all six modules, before first import, version-fragile. Since hypothesis A +worked, this wasn't built out further (no fake driver module, no contextvars registry). + +## Secondary findings + +1. **Two connections per request (confirmed hazard, no guard built yet).** Only the ops we + reroute touch the async connection; DDL, `commit()`/`rollback()` (`base.py:849-855`), and lazy + `Reference` resolution still go through the thread-local sync connection via + `@with_connection_or_raise`. A write on one connection is invisible to a read on the other + until commit. Mitigation is procedural (one path per request) until a guard is written. + +2. **Driver type mapping — verified, not inferred.** psycopg3 async matches psycopg2 exactly on + everything tested (int/str/Decimal/jsonb→dict). **asyncpg returns jsonb as raw `str`**, not + dict — pydal's `Postgre._config_json()` picks `PostgreAutoJSONParser` + (`parsers/postgre.py:12-13`, no json handler, expects the driver to have already decoded it), + which would silently leave jsonb as a string with asyncpg unless `self.parser` is forced to + the string-expecting variant. **Recommend psycopg3** for this reason — compatibility over + throughput, as scoped. Not tested: UUID, arrays, tstz, intervals. + +3. **Parameterisation — confirmed and quantified.** `adapt()` (`adapters/base.py:442-443`) + splices literals into the SQL text; every ORM call site (`select`/`insert`/`update`/`delete`) + calls `execute(sql)` with no extra args, so no DB-API placeholders are ever used outside + `executesql(..., placeholders=...)`. Measured (localhost, 400 iterations): literal-interpolated + vs. server-prepared identical queries — no measurable difference (0.078ms vs 0.084ms/query). + Not tested at scale or over a real network. + +4. **Lazy `Reference` access — confirmed, unresolved.** `Reference.__allocate()` + (`helpers/classes.py:189-196`) fires a blocking query from plain attribute access. `Reference` + is constructed directly in two modules (`parsers/base.py`, `adapters/base.py:561`), same + by-value-import fragility as `THREAD_LOCAL`. TypeDAL's own `Reference`/`Row` + (`src/typedal/types.py:197-198`) are mypy-only stubs today with no runtime behavior to hook + into. No clean fix; mitigation is documentation (eager-load via joins) + convention, not code. + +5. **SQLite — the easy case, and why.** `aiosqlite` wraps the same stdlib `sqlite3` module + pydal already relies on (`register_converter`/`PARSE_DECLTYPES`, + `adapters/sqlite.py:38,42-43`; `parsers/sqlite.py:20-28` expects native `date`/`datetime` + already). No driver-swap problem. The real SQLite complications are the `for_update` and + `delete()` items above, not type mapping. + +## PoC + +`select_async()` — build via `adapter._select_wcols`, execute via `psycopg` async, parse via +`adapter.parse`, zero pydal code touched — proved live against a disposable Postgres container: +field-for-field equal to `db(...).select()`, and a ticker interleaved with 20 real async queries +stays at ~5ms gaps (event loop never blocked). Script was a throwaway spike, not committed; +available on request / can be recreated from this doc in ~30 min. + +## Size estimate (confirmed scope: Postgres + SQLite, all five ops) + +- Shared async execution primitives (both backends, incl. SQLite's `for_update`/cascade + special-cases): 1–2 days +- `collect_async`/`select_async`/`first_async` on top of `QueryBuilder.collect()` + (`query_builder.py:611-674` already separates build/execute/shape into three steps — the async + twin reuses steps 1 and 3 as-is, replaces step 2): 1 day +- Async connection/pool lifecycle per `TypeDAL` instance, `self.parser` override if asyncpg is + ever added, SQLite custom-function registration (`create_function` equivalent to + `after_connection()`): 1–2 days +- Relationship/join queries + `insert`/`update`/`delete` async twins incl. SQLite delete cascade: + 2–3 days +- Hardening + tests (parity per backend, two-connections guard, `Reference` docs): 2–3 days + +**Total: ~1.5–2 weeks.** Excludes a durable fix for lazy `Reference` access (unresolved by +design) and full type-matrix verification beyond json/decimal/int/str/None. + +## Next steps + +Test-first, deliberately: constraint 2 (no public API change) means the test *is* the design +decision for the new methods' shape. Writing it before the implementation exists pins that down +instead of letting it drift out of implementation convenience. + +1. Add `pytest-asyncio` as a dev dependency — no async test runner exists in this repo yet + (`pyproject.toml` has no `asyncio`/`anyio` entry; `tests/conftest.py:7-30` is sync-only). +2. Write the test against the existing `dal_psql` fixture (`tests/conftest.py:23-30`): same query + via `.collect()` vs `.collect_async()`, asserting parity on the two divergence points this + spike actually found (jsonb→dict, decimal→Decimal), plus a formalized non-blocking/interleave + assertion. This fails (method doesn't exist) until step 3. + (Correction from an earlier draft of this doc: TypeDAL's `QueryBuilder.select()`, + `query_builder.py:172-202`, is a lazy builder step — it returns a new `QueryBuilder` and does + no I/O. `collect()`/`execute()` are the actual execution points, so those are what get async + twins, not `select()`.) +3. Implement `collect_async` for Postgres in `src/typedal/` — ported from the PoC's + `select_async()` helper, not a rewrite — to turn step 2 green. +4. Extend to SQLite and the remaining ops once the Postgres/select path is green in CI. diff --git a/pyproject.toml b/pyproject.toml index 31b753f..3eda790 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,14 @@ typescript = [ "pydal2sql>=1.3.5", ] +postgres-async = [ + "psycopg[binary,pool]", +] + +sqlite-async = [ + "aiosqlite", +] + all = [ "py4web", "typtyp < 1", @@ -82,6 +90,9 @@ all = [ "questionary", "tomlkit", "pydal2sql[all]>=1.3.5", + # async: + "psycopg[binary,pool]", + "aiosqlite", ] dev = [ @@ -93,11 +104,14 @@ dev = [ "python-semantic-release < 8", # "pytest-mypy-testing", "pytest-typing", + "pytest-asyncio", "pyright < 1.1.400", "contextlib-chdir", "testcontainers", "pydantic < 3", + "psycopg[binary,pool]", "psycopg2-binary", + "aiosqlite", # depends on -> "requests<2.32", # mypy: diff --git a/src/typedal/core.py b/src/typedal/core.py index 10a5991..d66a2de 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -582,6 +582,110 @@ def executesql( return rows + # ------------------------------------------------------------------ + # Async execution path (not implemented yet). + # See docs/rfc-async-execution.md for the feasibility spike this is + # scaffolding. These are the low-level primitives QueryBuilder's and + # TypedTable's `_async` methods build on, mirroring pydal's own split + # of `db(query).select(...)` / `.count(...)` / `.update(...)` / + # `.delete(...)` and `table.insert(...)`, since `db(query)` returns a + # plain pydal `Set` at runtime (TypedSet is a typing-only stub, see + # rows.py:521-546) rather than something we can attach methods to + # directly. + # ------------------------------------------------------------------ + + async def select_async( + self, + query: pydal.objects.Query, + *fields: t.Any, + **attributes: t.Any, + ) -> pydal.objects.Rows: + """ + Async twin of `db(query).select(*fields, **attributes)`. + """ + raise NotImplementedError + + async def count_async( + self, + query: pydal.objects.Query, + distinct: t.Optional[bool] = None, + ) -> int: + """ + Async twin of `db(query).count(distinct)`. + """ + raise NotImplementedError + + async def update_async( + self, + query: pydal.objects.Query, + **fields: t.Any, + ) -> int: + """ + Async twin of `db(query).update(**fields)`. + """ + raise NotImplementedError + + async def delete_async( + self, + query: pydal.objects.Query, + ) -> int: + """ + Async twin of `db(query).delete()`. + + On SQLite, `SQLite.delete()` (pydal adapters/sqlite.py:93-104) is not a plain + build/execute/parse call: it selects affected ids first and recurses for + ON DELETE CASCADE. This has to replicate that cascade, not just wrap one + execute call. + """ + raise NotImplementedError + + async def insert_async( + self, + table: pydal.objects.Table, + **fields: t.Any, + ) -> pydal.helpers.classes.Reference: + """ + Async twin of `table.insert(**fields)`. + """ + raise NotImplementedError + + async def executesql_async( + self, + query: str | Template, + placeholders: t.Iterable[str] | dict[str, str] | None = None, + as_dict: bool = False, + fields: t.Iterable[Field | TypedField[t.Any]] | None = None, + colnames: t.Iterable[str] | None = None, + as_ordered_dict: bool = False, + ) -> list[t.Any]: + """ + Async twin of `executesql(...)`. + """ + raise NotImplementedError + + async def commit_async(self) -> None: + """ + Commit the transaction on the async connection. + + Deliberately does not touch `commit()`/the sync connection: queries executed via + `select_async`/`insert_async`/etc. run on a separate connection (see RFC secondary + finding 1 - two connections per request), so committing one says nothing about the + other. + """ + raise NotImplementedError + + async def rollback_async(self) -> None: + """ + Roll back the transaction on the async connection. See `commit_async`. + """ + raise NotImplementedError + + async def close_async(self) -> None: + """ + Close/release the async connection (or return it to the pool). + """ + raise NotImplementedError + def sql_expression( self, sql_fragment: str | Template, diff --git a/src/typedal/query_builder.py b/src/typedal/query_builder.py index 4dd07ad..38e645b 100644 --- a/src/typedal/query_builder.py +++ b/src/typedal/query_builder.py @@ -505,6 +505,12 @@ def _delete(self) -> str: db = self._get_db() return str(db(self.query)._delete()) + async def delete_async(self) -> list[int]: + """ + Async twin of `delete()`. + """ + raise NotImplementedError + def update(self, **fields: t.Any) -> list[int]: """ Based on the current query, update `fields` and return a list of updated IDs. @@ -523,6 +529,12 @@ def _update(self, **fields: t.Any) -> str: db = self._get_db() return str(db(self.query)._update(**fields)) + async def update_async(self, **fields: t.Any) -> list[int]: + """ + Async twin of `update(**fields)`. + """ + raise NotImplementedError + def _before_query(self, mut_metadata: Metadata, add_id: bool = True) -> tuple[Query, list[t.Any], SelectKwargs]: select_args = [self._select_arg_convert(_) for _ in self.select_args] or [self.model.ALL] select_kwargs = self.select_kwargs.copy() @@ -608,6 +620,12 @@ def execute(self, add_id: bool = False) -> Rows: return rows + async def execute_async(self, add_id: bool = False) -> Rows: + """ + Async twin of `execute()`. + """ + raise NotImplementedError + def collect( self, verbose: bool = False, @@ -673,6 +691,20 @@ def collect( # only saves if requested in metadata: return save_to_cache(typed_rows, rows) + async def collect_async( + self, + verbose: bool = False, + _to: t.Type["TypedRows[t.Any]"] = None, + add_id: bool = True, + _into: t.Type[_TypedTable] | None = None, + _init: t.Callable[[_TypedTable, Row], None] | None = None, + ) -> TypedRows[T_MetaInstance]: + """ + Async twin of `collect()`. Primary target of the RFC's PoC (docs/rfc-async-execution.md): + same shape as `collect()`, only the execute step in the middle is async. + """ + raise NotImplementedError + def collect_into[T_Into: _TypedTable]( self, into: t.Type[T_Into], @@ -691,6 +723,18 @@ def collect_into[T_Into: _TypedTable]( rows = query.collect(verbose=verbose, add_id=add_id, _into=into, _init=_init) return t.cast(TypedRows[T_Into], rows) + async def collect_into_async[T_Into: _TypedTable]( + self, + into: t.Type[T_Into], + verbose: bool = False, + add_id: bool = True, + init: t.Callable[[T_Into, Row], None] | None = None, + ) -> TypedRows[T_Into]: + """ + Async twin of `collect_into()`. Thin wrapper: builds on `collect_async()`. + """ + raise NotImplementedError + def _validate_collect_into_model(self, into: t.Type[t.Any]) -> None: if not isinstance(into, TableMeta): raise TypeError("collect_into expects a TypedTable class") @@ -734,6 +778,12 @@ def column[T: t.Any](self, field: TypedField[T] | T, **options: t.Unpack[SelectK """ return self.select(field, **options).execute().column(field) + async def column_async[T: t.Any](self, field: TypedField[T] | T, **options: t.Unpack[SelectKwargs]) -> list[T]: + """ + Async twin of `column()`. Thin wrapper: `.select(field).execute_async()` then `.column(field)`. + """ + raise NotImplementedError + def _handle_relationships_pre_select( self, query: Query, @@ -1181,6 +1231,12 @@ def collect_or_fail(self, exception: t.Optional[Exception] = None) -> TypedRows[ """ return self.collect() or throw(exception or ValueError("Nothing found!")) + async def collect_or_fail_async(self, exception: t.Optional[Exception] = None) -> TypedRows[T_MetaInstance]: + """ + Async twin of `collect_or_fail()`. Thin wrapper: builds on `collect_async()`. + """ + raise NotImplementedError + def __iter__(self) -> t.Generator[T_MetaInstance, None, None]: """ You can start iterating a Query Builder object before calling collect, for ease of use. @@ -1225,6 +1281,12 @@ def count(self, distinct: t.Optional[bool] = None) -> int: return db(query).count(distinct) + async def count_async(self, distinct: t.Optional[bool] = None) -> int: + """ + Async twin of `count()`. + """ + raise NotImplementedError + def _count(self, distinct: t.Optional[bool] = None) -> str: """ Return the SQL for .count(). @@ -1246,6 +1308,12 @@ def exists(self) -> bool: require_permission(self._permissions, "read") return bool(self.count()) + async def exists_async(self) -> bool: + """ + Async twin of `exists()`. Thin wrapper: builds on `count_async()`. + """ + raise NotImplementedError + def __pagination_count(self) -> int: if not self.relationships: return self.count() @@ -1291,6 +1359,15 @@ def paginate(self, limit: int, page: int = 1, verbose: bool = False) -> "Paginat rows._query_builder = builder return rows + async def paginate_async(self, limit: int, page: int = 1, verbose: bool = False) -> "PaginatedRows[T_MetaInstance]": + """ + Async twin of `paginate()`. Thin wrapper: builds on `collect_async()`. + + Note: `__pagination_count()` (the row-count step done before paginating) also hits the + DB and needs its own async path internally - not exposed as a separate public method. + """ + raise NotImplementedError + def _paginate( self, limit: int, @@ -1321,6 +1398,13 @@ def chunk(self, chunk_size: int) -> t.Generator[TypedRows[T_MetaInstance], t.Any yield rows page += 1 + async def chunk_async(self, chunk_size: int) -> t.AsyncGenerator[TypedRows[T_MetaInstance], None]: + """ + Async twin of `chunk()`. An async generator (`async for`), built on `collect_async()`. + """ + raise NotImplementedError + yield # pragma: no cover # makes this an async generator for type-checking purposes + def first(self, verbose: bool = False) -> T_MetaInstance | None: """ Get the first row matching the currently built query. @@ -1338,6 +1422,12 @@ def first(self, verbose: bool = False) -> T_MetaInstance | None: return self.model.from_row(row) + async def first_async(self, verbose: bool = False) -> T_MetaInstance | None: + """ + Async twin of `first()`. Thin wrapper: builds on `paginate_async()`. + """ + raise NotImplementedError + def _first(self) -> str: return self._paginate(page=1, limit=1) @@ -1350,6 +1440,14 @@ def first_or_fail(self, exception: t.Optional[BaseException] = None, verbose: bo require_permission(self._permissions, "read") return self.first(verbose=verbose) or throw(exception or ValueError("Nothing found!")) + async def first_or_fail_async( + self, exception: t.Optional[BaseException] = None, verbose: bool = False + ) -> T_MetaInstance: + """ + Async twin of `first_or_fail()`. Thin wrapper: builds on `first_async()`. + """ + raise NotImplementedError + # note: these imports exist at the bottom of this file to prevent circular import issues: diff --git a/src/typedal/tables.py b/src/typedal/tables.py index 4d941d3..5dd2683 100644 --- a/src/typedal/tables.py +++ b/src/typedal/tables.py @@ -185,6 +185,12 @@ def all(self: t.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]": """ return self.collect() + async def all_async(self: t.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]": + """ + Async twin of `all()`. Thin wrapper: builds on `collect_async()`. + """ + raise NotImplementedError + def get_relationships(self) -> dict[str, Relationship[t.Any]]: """ Return the registered relationships of the current model. @@ -214,6 +220,12 @@ def insert(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance: # it already is an int but mypy doesn't understand that return self(result) + async def insert_async(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance: + """ + Async twin of `insert()`. + """ + raise NotImplementedError + def _insert(self, **fields: t.Any) -> str: table = self._ensure_table_defined() @@ -228,6 +240,12 @@ def bulk_insert(self: t.Type[T_MetaInstance], items: list[AnyDict]) -> "TypedRow result = table.bulk_insert(items) return self.where(lambda row: row.id.belongs(result)).collect() + async def bulk_insert_async(self: t.Type[T_MetaInstance], items: list[AnyDict]) -> "TypedRows[T_MetaInstance]": + """ + Async twin of `bulk_insert()`. + """ + raise NotImplementedError + def update_or_insert( self: t.Type[T_MetaInstance], query: T_Query | AnyDict = DEFAULT, @@ -253,6 +271,16 @@ def update_or_insert( record.update_record(**values) return self(record) + async def update_or_insert_async( + self: t.Type[T_MetaInstance], + query: T_Query | AnyDict = DEFAULT, + **values: t.Any, + ) -> T_MetaInstance: + """ + Async twin of `update_or_insert()`. + """ + raise NotImplementedError + def validate_and_insert( self: t.Type[T_MetaInstance], **fields: t.Any, @@ -270,6 +298,15 @@ def validate_and_insert( else: return None, result.get("errors") + async def validate_and_insert_async( + self: t.Type[T_MetaInstance], + **fields: t.Any, + ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]: + """ + Async twin of `validate_and_insert()`. + """ + raise NotImplementedError + def validate_and_update( self: t.Type[T_MetaInstance], query: Query, @@ -293,6 +330,16 @@ def validate_and_update( # update on query without result (shouldnt happen) return None, None + async def validate_and_update_async( + self: t.Type[T_MetaInstance], + query: Query, + **fields: t.Any, + ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]: + """ + Async twin of `validate_and_update()`. + """ + raise NotImplementedError + def validate_and_update_or_insert( self: t.Type[T_MetaInstance], query: Query, @@ -321,6 +368,16 @@ def validate_and_update_or_insert( # update on query without result (shouldnt happen) return None, None + async def validate_and_update_or_insert_async( + self: t.Type[T_MetaInstance], + query: Query, + **fields: t.Any, + ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]: + """ + Async twin of `validate_and_update_or_insert()`. + """ + raise NotImplementedError + def select(self: t.Type[T_MetaInstance], *a: t.Any, **kw: t.Any) -> "QueryBuilder[T_MetaInstance]": """ See QueryBuilder.select! @@ -339,18 +396,45 @@ def column[T: t.Any, T_MetaInstance: _TypedTable]( """ return QueryBuilder(self).select(field, **options).execute().column(field) + async def column_async[T: t.Any, T_MetaInstance: _TypedTable]( + self: t.Type[T_MetaInstance], + field: T | TypedField[T], + **options: t.Unpack[SelectKwargs], + ) -> list[T]: + """ + See QueryBuilder.column_async! + """ + raise NotImplementedError + def paginate(self: t.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]": """ See QueryBuilder.paginate! """ return QueryBuilder(self).paginate(limit=limit, page=page) + async def paginate_async( + self: t.Type[T_MetaInstance], limit: int, page: int = 1 + ) -> "PaginatedRows[T_MetaInstance]": + """ + See QueryBuilder.paginate_async! + """ + raise NotImplementedError + def chunk(self: t.Type[T_MetaInstance], chunk_size: int) -> t.Generator["TypedRows[T_MetaInstance]", t.Any, None]: """ See QueryBuilder.chunk! """ return QueryBuilder(self).chunk(chunk_size) + async def chunk_async( + self: t.Type[T_MetaInstance], chunk_size: int + ) -> t.AsyncGenerator["TypedRows[T_MetaInstance]", None]: + """ + See QueryBuilder.chunk_async! + """ + raise NotImplementedError + yield # pragma: no cover # makes this an async generator for type-checking purposes + def where(self: t.Type[T_MetaInstance], *a: t.Any, **kw: t.Any) -> "QueryBuilder[T_MetaInstance]": """ See QueryBuilder.where! @@ -395,24 +479,48 @@ def count(self: t.Type[T_MetaInstance]) -> int: """ return QueryBuilder(self).count() + async def count_async(self: t.Type[T_MetaInstance]) -> int: + """ + See QueryBuilder.count_async! + """ + raise NotImplementedError + def exists(self: t.Type[T_MetaInstance]) -> bool: """ See QueryBuilder.exists! """ return QueryBuilder(self).exists() + async def exists_async(self: t.Type[T_MetaInstance]) -> bool: + """ + See QueryBuilder.exists_async! + """ + raise NotImplementedError + def first(self: t.Type[T_MetaInstance]) -> T_MetaInstance | None: """ See QueryBuilder.first! """ return QueryBuilder(self).first() + async def first_async(self: t.Type[T_MetaInstance]) -> T_MetaInstance | None: + """ + See QueryBuilder.first_async! + """ + raise NotImplementedError + def first_or_fail(self: t.Type[T_MetaInstance]) -> T_MetaInstance: """ See QueryBuilder.first_or_fail! """ return QueryBuilder(self).first_or_fail() + async def first_or_fail_async(self: t.Type[T_MetaInstance]) -> T_MetaInstance: + """ + See QueryBuilder.first_or_fail_async! + """ + raise NotImplementedError + def join( self: t.Type[T_MetaInstance], *fields: str | t.Type[TypedTable] | Relationship[t.Any], @@ -432,6 +540,12 @@ def collect(self: t.Type[T_MetaInstance], verbose: bool = False) -> "TypedRows[T """ return QueryBuilder(self).collect(verbose=verbose) + async def collect_async(self: t.Type[T_MetaInstance], verbose: bool = False) -> "TypedRows[T_MetaInstance]": + """ + See QueryBuilder.collect_async! + """ + raise NotImplementedError + def collect_into[T_Into: _TypedTable]( self: t.Type[_TypedTable], into: t.Type[T_Into], @@ -443,6 +557,17 @@ def collect_into[T_Into: _TypedTable]( """ return QueryBuilder(self).collect_into(into=into, verbose=verbose, init=init) + async def collect_into_async[T_Into: _TypedTable]( + self: t.Type[_TypedTable], + into: t.Type[T_Into], + verbose: bool = False, + init: t.Callable[[T_Into, Row], None] | None = None, + ) -> "TypedRows[T_Into]": + """ + See QueryBuilder.collect_into_async! + """ + raise NotImplementedError + @property def ALL(cls) -> pydal.objects.SQLALL: """ @@ -1294,6 +1419,13 @@ def update(cls: t.Type[T_MetaInstance], query: Query, **fields: t.Any) -> T_Meta else: return None + @classmethod + async def update_async(cls: t.Type[T_MetaInstance], query: Query, **fields: t.Any) -> T_MetaInstance | None: + """ + Async twin of `update()`. Thin wrapper: builds on `update_record_async()`. + """ + raise NotImplementedError + def _update(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: require_permission(getattr(self, "_permissions", None), "update") row = self._ensure_matching_row() @@ -1316,6 +1448,12 @@ def update_record(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: # p """ return self._update_record(**fields) + async def update_record_async(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: + """ + Async twin of `update_record()`. + """ + raise NotImplementedError + def _delete_record(self) -> int: """ Actual logic in `pydal.helpers.classes.RecordDeleter`. @@ -1338,6 +1476,12 @@ def delete_record(self) -> int: # pragma: no cover """ return self._delete_record() + async def delete_record_async(self) -> int: + """ + Async twin of `delete_record()`. + """ + raise NotImplementedError + # __del__ is also called on the end of a scope so don't remove records on every del!! # pickling: diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py new file mode 100644 index 0000000..139890d --- /dev/null +++ b/tests/test_async_execution.py @@ -0,0 +1,108 @@ +""" +Test-first spec for the async execution path (see docs/rfc-async-execution.md). + +`collect_async()` does not exist yet — these tests are RED by design: they pin down the +expected shape (`await Sometable.where(...).collect_async()` returns a `TypedRows`, field-for- +field equal to the synchronous `.collect()`) before the implementation exists, per the RFC's +constraint that the public API doesn't change and this is the only new surface being added. + +Covers the two concrete divergence points the feasibility spike found (see RFC): + - jsonb -> dict (pydal's Postgres parser expects the driver to have already decoded it) + - decimal(10,2) -> Decimal +and the actual point of the exercise: the event loop is not blocked while the query runs. +""" +import asyncio +import time +from decimal import Decimal + +import pytest + +from src.typedal import TypeDAL, TypedField, TypedTable +from src.typedal.fields import DecimalField, JSONField + + +@pytest.mark.asyncio +async def test_collect_async_matches_sync_collect(dal_psql: TypeDAL): + """The core parity claim: async-executed rows must equal sync-executed rows, field for field.""" + db = dal_psql + + @db.define() + class AsyncThingParity(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + AsyncThingParity.insert(name="widget", qty=3) + AsyncThingParity.insert(name="gadget", qty=7) + db.commit() + + sync_rows = AsyncThingParity.where(AsyncThingParity.qty > 0).collect() + async_rows = await AsyncThingParity.where(AsyncThingParity.qty > 0).collect_async() + + assert len(async_rows) == len(sync_rows) == 2 + + sync_by_id = {row.id: row for row in sync_rows} + async_by_id = {row.id: row for row in async_rows} + assert sync_by_id.keys() == async_by_id.keys() + + for row_id, sync_row in sync_by_id.items(): + async_row = async_by_id[row_id] + assert async_row.name == sync_row.name + assert async_row.qty == sync_row.qty + + +@pytest.mark.asyncio +async def test_collect_async_preserves_types(dal_psql: TypeDAL): + """The two divergence points the spike actually found: decimal and jsonb.""" + db = dal_psql + + @db.define() + class AsyncThingTypes(TypedTable): + name: TypedField[str] + price = DecimalField(10, 2) + meta = JSONField() + + AsyncThingTypes.insert(name="widget", price=Decimal("19.99"), meta={"a": 1, "b": [1, 2, 3]}) + db.commit() + + rows = await AsyncThingTypes.where(AsyncThingTypes.name == "widget").collect_async() + row = rows.first() + + assert isinstance(row.price, Decimal) + assert row.price == Decimal("19.99") + + assert isinstance(row.meta, dict) # not a raw jsonb string + assert row.meta == {"a": 1, "b": [1, 2, 3]} + + +@pytest.mark.asyncio +async def test_collect_async_does_not_block_event_loop(dal_psql: TypeDAL): + """ + The actual point of building this: a query in flight must not stall other coroutines. + A ticker sleeping every 5ms should keep ticking at ~5ms while queries run concurrently; + a blocking implementation would show gaps close to the total query time instead. + """ + db = dal_psql + + @db.define() + class AsyncThingBlocking(TypedTable): + qty: TypedField[int] + + AsyncThingBlocking.insert(qty=1) + db.commit() + + ticks: list[float] = [] + + async def ticker(): + for _ in range(20): + ticks.append(time.perf_counter()) + await asyncio.sleep(0.005) + + async def repeated_query(): + for _ in range(20): + await AsyncThingBlocking.where(AsyncThingBlocking.qty > 0).collect_async() + + await asyncio.gather(ticker(), repeated_query()) + + gaps = [b - a for a, b in zip(ticks, ticks[1:])] + # generous margin over the 5ms sleep interval; a blocking call would blow well past this + assert max(gaps) < 0.05, f"event loop was blocked: max gap between ticks was {max(gaps) * 1000:.1f}ms" From 49b1fe1f32de80a1509822a0b6f5f9ca140dc92e Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 16:38:51 +0200 Subject: [PATCH 02/29] chore(docs): reformat Python examples --- README.md | 17 +++++++++-------- docs/1_getting_started.md | 16 +++++----------- docs/2_defining_tables.md | 5 ++--- docs/3_building_queries.md | 12 ++++++------ docs/4_relationships.md | 35 ++++++++++++++++++++++------------- docs/5_py4web.md | 8 +++----- docs/8_mixins.md | 13 +++++++------ docs/9_memoization.md | 9 ++++++--- 8 files changed, 60 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 9a1835a..c68cecb 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ db = TypeDAL("sqlite:memory") # db = TypeDAL("mysql://user:password@localhost:3306/mydb") # ... + @db.define() class User(TypedTable): name: str @@ -143,11 +144,12 @@ db = TypeDAL(...) ```python -db.define_table("table_name", - Field("fieldname", "string", required=True), - Field("otherfield", "float"), - Field("yet_another", "text", default="Something") - ) +db.define_table( + "table_name", + Field("fieldname", "string", required=True), + Field("otherfield", "float"), + Field("yet_another", "text", default="Something"), +) ``` @@ -236,7 +238,6 @@ all_rows = TableName.collect() # or .all() rows = TableName.select(Tablename.id).where(TableName.id > 5).where(TableName.id < 50).collect() # one: row = TableName(id=1) # or .where(...).first() - ``` @@ -301,11 +302,11 @@ These helpers are useful for scenarios where direct access to the PyDAL objects An example of this is when you need to do a `db.commit()` but you can't import `db` directly: ```python -from typedal.helpers import get_db #, get_table, get_field +from typedal.helpers import get_db # , get_table, get_field MyTable.insert(...) db = get_db(MyTable) -db.commit() # this is usually done automatically but sometimes you want to manually commit. +db.commit() # this is usually done automatically but sometimes you want to manually commit. ``` ## Caveats diff --git a/docs/1_getting_started.md b/docs/1_getting_started.md index 8be5550..1a2a5e7 100644 --- a/docs/1_getting_started.md +++ b/docs/1_getting_started.md @@ -20,6 +20,7 @@ pip install typedal[py4web] ```python from typedal import TypeDAL + # or, if in py4web: from typedal.for_py4web import TypeDAL @@ -55,15 +56,11 @@ Or use the `placeholders` argument with positional or named parameters: ```python # Positional -rows = db.executesql( - "SELECT * FROM some_table WHERE name = %s AND age > %s", - placeholders=[name, 18] -) +rows = db.executesql("SELECT * FROM some_table WHERE name = %s AND age > %s", placeholders=[name, 18]) # Named rows = db.executesql( - "SELECT * FROM some_table WHERE name = %(name)s AND age > %(age)s", - placeholders={"name": name, "age": 18} + "SELECT * FROM some_table WHERE name = %(name)s AND age > %(age)s", placeholders={"name": name, "age": 18} ) ``` @@ -73,14 +70,11 @@ By default, `executesql()` returns rows as tuples. To map results to specific fi Field/TypedField objects) or `colnames` (takes column name strings): ```python -rows = db.executesql( - "SELECT id, name FROM some_table", - colnames=["id", "name"] -) +rows = db.executesql("SELECT id, name FROM some_table", colnames=["id", "name"]) rows = db.executesql( "SELECT id, name FROM some_table", - fields=[some_table.id, some_table.name] # Requires table definition + fields=[some_table.id, some_table.name], # Requires table definition ) ``` diff --git a/docs/2_defining_tables.md b/docs/2_defining_tables.md index 36a0cc0..efa22f8 100644 --- a/docs/2_defining_tables.md +++ b/docs/2_defining_tables.md @@ -6,7 +6,7 @@ The syntax for creating a table is very different, but built on the same princip from pydal import Field # pydal: -db.define_table('my_table', Field('my_field')) +db.define_table("my_table", Field("my_field")) ``` ```python @@ -121,8 +121,7 @@ from typedal import TypedTable from typedal.types import OpRow, Reference, Set -class MyTable(TypedTable): - ... +class MyTable(TypedTable): ... def my_before_insert(row: MyTable): diff --git a/docs/3_building_queries.md b/docs/3_building_queries.md index 76a8e23..dd1fc39 100644 --- a/docs/3_building_queries.md +++ b/docs/3_building_queries.md @@ -84,7 +84,7 @@ Here you can enter any number of fields as arguments: database columns by name ( other (e.g. Table.ALL), or Expression objects. ```python -Person.select('id', Person.name, Person.ALL) # defaults to Person.ALL if select is omitted. +Person.select("id", Person.name, Person.ALL) # defaults to Person.ALL if select is omitted. ``` You can also specify extra options as keyword arguments. Supported options are: `orderby`, `groupby`, `limitby`, @@ -131,10 +131,7 @@ Person.where(expr).select() # Named arguments expr = db.sql_expression( - "EXTRACT(year FROM %(date_col)s) = %(year)s", - date_col="created_at", - year=2023, - output_type="boolean" + "EXTRACT(year FROM %(date_col)s) = %(year)s", date_col="created_at", year=2023, output_type="boolean" ) Person.where(expr).select() ``` @@ -152,7 +149,7 @@ By default, the `method` defined in the relationship is used. This can be overwritten with the `method` keyword argument (left or inner) ```python -Person.join('articles', method='inner') # will only yield persons that have related articles +Person.join("articles", method="inner") # will only yield persons that have related articles ``` For more details about relationships and joins, see [4. Relationships](./4_relationships.md). @@ -277,15 +274,18 @@ class User(TypedTable): password_hash: str is_active: bool + class PublicUser(TypedTable): id: int email: str is_active: bool profile_url: str | None = None + def enrich_profile_url(row: PublicUser, _raw): row.profile_url = f"/users/{row.id}" + rows = User.where(is_active=True).collect_into( PublicUser, # note: `init` is optional: diff --git a/docs/4_relationships.md b/docs/4_relationships.md index f3a4989..db6061f 100644 --- a/docs/4_relationships.md +++ b/docs/4_relationships.md @@ -20,8 +20,10 @@ class Post(TypedTable): author: Author -authors_with_roles = Author.join('roles').collect() -posts_with_author = Post.join().collect() # join can be called without arguments to join all relationships (in this case only 'author') +authors_with_roles = Author.join("roles").collect() +posts_with_author = ( + Post.join().collect() +) # join can be called without arguments to join all relationships (in this case only 'author') post_deep = Post.join("author.roles").collect() # nested relationship, accessible via post.author.roles ``` @@ -127,32 +129,39 @@ owner: "User" Setting up a relationship that uses a junction/pivot table is slightly harder. ```python - # with `unique_alias()` which is better if you have multiple joins: + @db.define() class Post(TypedTable): title: str author: Author - tags = relationship(list["Tag"], on=lambda post, tag: [ - # post and tag already have a unique alias, create one for tagged here: - tagged := Tagged.unique_alias(), - tagged.on(tagged.post == post.id), - tag.on(tag.id == tagged.tag), - ]) + tags = relationship( + list["Tag"], + on=lambda post, tag: [ + # post and tag already have a unique alias, create one for tagged here: + tagged := Tagged.unique_alias(), + tagged.on(tagged.post == post.id), + tag.on(tag.id == tagged.tag), + ], + ) # without unique alias: + @db.define() class Tag(TypedTable): name: str - posts = relationship(list["Post"], on=lambda tag, posts: [ - Tagged.on(Tagged.tag == tag.id), - posts.on(posts.id == Tagged.post), - ]) + posts = relationship( + list["Post"], + on=lambda tag, posts: [ + Tagged.on(Tagged.tag == tag.id), + posts.on(posts.id == Tagged.post), + ], + ) @db.define() diff --git a/docs/5_py4web.md b/docs/5_py4web.md index e0a3fa6..64869aa 100644 --- a/docs/5_py4web.md +++ b/docs/5_py4web.md @@ -8,10 +8,7 @@ This library also has some py4web/web2py-specific enhancements. # common.py from typedal.for_py4web import DAL -db = DAL( - settings.DB_URI, - ... -) +db = DAL(settings.DB_URI, ...) ``` This version of the `DAL` is also a py4web Fixture that manages database connections `on_request`, just as py4web's own @@ -27,6 +24,7 @@ from .common import db # you can now customize auth user: + class AuthUser(_AuthUser): bookmarks = relationship(list["Bookmark"], ...) @@ -35,7 +33,6 @@ db.define(AuthUser, redefine=True) # or if you don't want to customize auth user: setup_py4web_tables(db) - ``` TypeDAL also provides an `AuthUser` class based on `db.auth_user`. @@ -53,6 +50,7 @@ from .common import db # you can now customize auth user: + class AuthUser(_AuthUser): bookmarks = relationship(list["Bookmark"], ...) diff --git a/docs/8_mixins.md b/docs/8_mixins.md index 91c0af5..43b0f5b 100644 --- a/docs/8_mixins.md +++ b/docs/8_mixins.md @@ -23,6 +23,7 @@ class MyTable(TypedTable, TimestampsMixin): # Define your table fields here pass + # Now, whenever you create or update a record in MyTable, the 'created_at' and 'updated_at' timestamps will be automatically managed. ``` @@ -44,6 +45,7 @@ class MyTable(TypedTable, SlugMixin, slug_field="title"): title: str # Assuming 'title' is a field in your table # Define other fields here + # Now, whenever you insert a record into MyTable, the 'slug' field will be automatically generated based on the 'title' field. ``` @@ -101,6 +103,7 @@ from fastapi import FastAPI app = FastAPI() + @app.get("/books/{book_id}") def get_book(book_id: int) -> Book: return Book.where(id=book_id).join("author").first() @@ -154,9 +157,10 @@ class HasImageMixin(Mixin): # Now you can use HasImageMixin in your table definitions along with other mixins or base classes. + class Article(TypedTable, TimestampsMixin, HasImageMixin): title: str - + # this could also be a class method of Timestamps Mixin: @classmethod def recently_updated(cls, hours: int = 24) -> QueryBuilder[t.Self]: @@ -164,16 +168,13 @@ class Article(TypedTable, TimestampsMixin, HasImageMixin): cutoff = dt.datetime.now() - dt.timedelta(hours=hours) return QueryBuilder(cls).where(cls.updated_at >= cutoff) + # Retrieve a record and use the custom method article = Article(id=1) article.img() # -> # Use the classmethod to get recently updated articles -recent_articles = ( - Article.recently_updated(hours=12) - .where(published=True) - .collect() -) +recent_articles = Article.recently_updated(hours=12).where(published=True).collect() ``` > **Note:** The `img()` example uses py4web utilities (URL, IMG), but the mixin itself works identically in any setup. diff --git a/docs/9_memoization.md b/docs/9_memoization.md index 2f50020..fd51c2b 100644 --- a/docs/9_memoization.md +++ b/docs/9_memoization.md @@ -18,9 +18,10 @@ def process_articles(articles: TypedRows[Article]) -> dict: # dummy example, normally you'd use .join() of course for article in articles: comments = Comment.where(article=article).collect() - result[article.id] = comments + result[article.id] = comments return result + articles = Article.where(published=True).collect() result, status = db.memoize(process_articles, articles) @@ -53,6 +54,7 @@ When any tracked row is updated, inserted, or deleted, the cached result is inva def something_slow(): return list(User.join()) + result, status = db.memoize(something_slow) assert status == "fresh" @@ -114,9 +116,11 @@ TypeDAL provides `before_collect`/`before_execute` and `after_collect`/`after_ex def print_query(qb: QueryBuilder): print("going to run", qb.to_sql()) + def print_duration(_qb: QueryBuilder, rows, _raw): print("took", rows.metadata["select_duration"]) + db.before_collect.append(print_query) db.after_collect.append(print_duration) @@ -143,8 +147,7 @@ If you need to disable cache invalidation hooks for a specific table: ```python @db.define(cache_dependency=False) -class SpecialTable(TypedTable): - ... +class SpecialTable(TypedTable): ... ``` **Warning:** Disabling this may break caching functionality for queries involving this table. From 49f42be223d1494e655b76939016b6c01d6a43e1 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 17:18:04 +0200 Subject: [PATCH 03/29] feat(async): add async query execution for SQLite and Postgres --- src/typedal/async_execution.py | 103 +++++++++++++++++++++ src/typedal/core.py | 75 ++++++++++++--- src/typedal/query_builder.py | 162 +++++++++++++++++++++++++-------- src/typedal/tables.py | 2 +- tests/test_async_execution.py | 74 ++++++++++++--- 5 files changed, 350 insertions(+), 66 deletions(-) create mode 100644 src/typedal/async_execution.py diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py new file mode 100644 index 0000000..b0e6378 --- /dev/null +++ b/src/typedal/async_execution.py @@ -0,0 +1,103 @@ +""" +Backend-specific plumbing for TypeDAL's async execution path. + +`TypeDAL` (core.py) owns the actual `_async` methods (`select_async`, `_get_async_pool`, ...) - +those are legitimately DAL-instance behavior. This module only holds the per-backend detail of +"how do you get an async connection for this dbengine", kept out of core.py so that stays about +the `TypeDAL` class itself, not about psycopg/aiosqlite specifics. + +One factory per backend, registered by pydal's `adapter.dbengine` name in +`_ASYNC_POOL_FACTORIES`, rather than an if/elif chain - adding a new backend (e.g. MySQL) means +adding a function + a registry entry here, not editing branching logic in `TypeDAL._get_async_pool`. +""" + +from __future__ import annotations + +import contextlib +import typing as t + +if t.TYPE_CHECKING: + from .core import TypeDAL + + +class AsyncConnectionPool(t.Protocol): + """ + Common shape `select_async()` etc. need from either a real connection pool (Postgres) or a + single-connection stand-in (SQLite). + """ + + def connection(self) -> t.AsyncContextManager[t.Any]: ... + + async def close(self) -> None: ... + + +class SqliteAsyncConnection: + """ + Minimal pool-like wrapper around a single aiosqlite connection. + + SQLite has no real concept of a connection pool the way Postgres does - pydal itself sets + `pool_size = 0` for SQLite (adapters/sqlite.py:26), one connection is all there is. This + just gives it the same `.connection()`/`.close()` shape as `psycopg_pool.AsyncConnectionPool` + so `select_async()` doesn't need to branch on backend. + """ + + def __init__(self, conn: t.Any) -> None: + self._conn = conn + + @contextlib.asynccontextmanager + async def connection(self) -> t.AsyncIterator[t.Any]: + yield self._conn + + async def close(self) -> None: + await self._conn.close() + + +async def open_postgres_async_pool(db: "TypeDAL") -> AsyncConnectionPool: + """ + Async pool factory for Postgres (registered in `_ASYNC_POOL_FACTORIES`). + """ + try: + import psycopg_pool + except ImportError as e: # pragma: no cover + raise RuntimeError( + "The async execution path requires `psycopg[binary,pool]`. Install via `typedal[postgres-async]`.", + ) from e + + # pydal accepts 'postgres://', psycopg wants the standard 'postgresql://': + uri = db._uri.replace("postgres://", "postgresql://", 1) + pool = psycopg_pool.AsyncConnectionPool(uri, open=False) + await pool.open() + return t.cast(AsyncConnectionPool, pool) + + +async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: + """ + Async connection factory for SQLite (registered in `_ASYNC_POOL_FACTORIES`). + """ + try: + import aiosqlite + except ImportError as e: # pragma: no cover + raise RuntimeError( + "The async execution path requires `aiosqlite`. Install with `pip install typedal[sqlite-async]`.", + ) from e + + adapter = db._adapter + # Reuse pydal's own path/URI resolution and connect kwargs (adapters/sqlite.py:25-38) - in + # particular the memory-mode shared-cache URI, so this connection sees the same in-memory + # database as pydal's own sync connection. + conn = await aiosqlite.connect(adapter.dbpath, **adapter.driver_args) + + # Mirror SQLite.after_connection() (adapters/sqlite.py:82-86): custom functions and PRAGMA + # are per-connection state, and this connection is not the one pydal set those up on. + await conn.create_function("web2py_extract", 2, adapter.web2py_extract) + await conn.create_function("REGEXP", 2, adapter.web2py_regexp) + if adapter.adapter_args.get("foreign_keys", True): + await conn.execute("PRAGMA foreign_keys=ON;") + + return SqliteAsyncConnection(conn) + + +ASYNC_POOL_FACTORIES: dict[str, t.Callable[["TypeDAL"], t.Awaitable[AsyncConnectionPool]]] = { + "postgres": open_postgres_async_pool, + "sqlite": open_sqlite_async_connection, +} diff --git a/src/typedal/core.py b/src/typedal/core.py index d66a2de..d702a59 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -14,6 +14,7 @@ import pydal +from .async_execution import ASYNC_POOL_FACTORIES, AsyncConnectionPool from .config import LazyPolicy, TypeDALConfig, load_config from .helpers import ( SYSTEM_SUPPORTS_TEMPLATES, @@ -293,6 +294,7 @@ def __init__( self._after_collect = [] self._before_execute = [] self._after_execute = [] + self._async_pool: AsyncConnectionPool | None = None # lazily-created; see _get_async_pool if config.folder: Path(config.folder).mkdir(exist_ok=True) @@ -583,17 +585,33 @@ def executesql( return rows # ------------------------------------------------------------------ - # Async execution path (not implemented yet). - # See docs/rfc-async-execution.md for the feasibility spike this is - # scaffolding. These are the low-level primitives QueryBuilder's and - # TypedTable's `_async` methods build on, mirroring pydal's own split - # of `db(query).select(...)` / `.count(...)` / `.update(...)` / - # `.delete(...)` and `table.insert(...)`, since `db(query)` returns a - # plain pydal `Set` at runtime (TypedSet is a typing-only stub, see - # rows.py:521-546) rather than something we can attach methods to - # directly. + # Async execution path. # ------------------------------------------------------------------ + async def _get_async_pool(self) -> AsyncConnectionPool: + """ + Lazily create the async connection (a real pool for Postgres, a single wrapped + connection for SQLite) for this instance, via `ASYNC_POOL_FACTORIES`. + + One per `TypeDAL` instance, opened on first use. Deliberately a separate connection + from pydal's own thread-local sync connection: they are two independent transactions, + so a write on one is invisible to a read on the other until committed, and + commit()/rollback() on one says nothing about the other. + """ + if self._async_pool is None: + dbengine = self._adapter.dbengine + try: + factory = ASYNC_POOL_FACTORIES[dbengine] + except KeyError: + raise NotImplementedError( + f"The async execution path is only implemented for " + f"{', '.join(ASYNC_POOL_FACTORIES)}, not {dbengine!r}.", + ) from None + + self._async_pool = await factory(self) + + return self._async_pool + async def select_async( self, query: pydal.objects.Query, @@ -602,8 +620,34 @@ async def select_async( ) -> pydal.objects.Rows: """ Async twin of `db(query).select(*fields, **attributes)`. + + Mirrors `Set.select()` (pydal objects.py:2961-2971) and `SQLAdapter.select()`/ + `_select_aux()` (adapters/base.py:905-910, 864-891): build via pydal's own + `tables()`/`expand_all()`/`_select_wcols()` (pure, no I/O), execute via the async + driver for this backend (the only I/O, on our own connection, not pydal's; see + `ASYNC_POOL_FACTORIES`), parse via pydal's own `parse()` (pure). """ - raise NotImplementedError + adapter = self._adapter + + tablenames = adapter.tables( + query, + attributes.get("join"), + attributes.get("left"), + attributes.get("orderby"), + attributes.get("groupby"), + ) + expanded_fields = adapter.expand_all(fields, tablenames) + colnames, sql = adapter._select_wcols(query, expanded_fields, **attributes) + + pool = await self._get_async_pool() + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql) + rows = await cur.fetchall() + + limitby = attributes.get("limitby") or (0,) + rows = adapter.rowslice(rows, limitby[0], None) + cacheable = attributes.get("cacheable", False) + return t.cast(pydal.objects.Rows, adapter.parse(rows, expanded_fields, colnames, cacheable=cacheable)) async def count_async( self, @@ -668,9 +712,8 @@ async def commit_async(self) -> None: Commit the transaction on the async connection. Deliberately does not touch `commit()`/the sync connection: queries executed via - `select_async`/`insert_async`/etc. run on a separate connection (see RFC secondary - finding 1 - two connections per request), so committing one says nothing about the - other. + `select_async`/`insert_async`/etc. run on a separate connection, so committing one + says nothing about the other. """ raise NotImplementedError @@ -682,9 +725,11 @@ async def rollback_async(self) -> None: async def close_async(self) -> None: """ - Close/release the async connection (or return it to the pool). + Close the async connection pool, if one was ever opened. """ - raise NotImplementedError + if self._async_pool is not None: + await self._async_pool.close() + self._async_pool = None def sql_expression( self, diff --git a/src/typedal/query_builder.py b/src/typedal/query_builder.py index 38e645b..20fc013 100644 --- a/src/typedal/query_builder.py +++ b/src/typedal/query_builder.py @@ -600,23 +600,34 @@ def _collect_cached( return load_from_cache(key, self._get_db()) - def execute(self, add_id: bool = False) -> Rows: + @staticmethod + def _run_hooks(hooks: t.Iterable[t.Callable[..., t.Any]], *args: t.Any) -> None: """ - Raw version of .collect which only executes the SQL, without performing t.Any magic afterwards. + Run a list of before/after hooks in order. Return values are ignored (matches existing + `_before_collect`/`_after_collect`/`_before_execute`/`_after_execute` semantics). + Shared by `execute()`/`execute_async()`/`collect()`/`collect_async()`. + """ + for hook in hooks: + hook(*args) + + def _execute_prepare(self, metadata: Metadata, add_id: bool) -> tuple[TypeDAL, Query, list[t.Any], SelectKwargs]: + """ + Shared setup for `execute()`/`execute_async()`: permission check, query building. """ require_permission(self._permissions, "read") db = self._get_db() - metadata: Metadata = self.metadata.copy() - query, select_args, select_kwargs = self._before_query(metadata, add_id=add_id) + return db, query, select_args, select_kwargs - for fn_before in db._before_execute: - fn_before(self) + def execute(self, add_id: bool = False) -> Rows: + """ + Raw version of .collect which only executes the SQL, without performing t.Any magic afterwards. + """ + db, query, select_args, select_kwargs = self._execute_prepare(self.metadata.copy(), add_id) + self._run_hooks(db._before_execute, self) rows: Rows = db(query).select(*select_args, **select_kwargs) - - for fn_after in db._after_execute: - fn_after(self, rows) + self._run_hooks(db._after_execute, self, rows) return rows @@ -624,7 +635,66 @@ async def execute_async(self, add_id: bool = False) -> Rows: """ Async twin of `execute()`. """ - raise NotImplementedError + db, query, select_args, select_kwargs = self._execute_prepare(self.metadata.copy(), add_id) + + self._run_hooks(db._before_execute, self) + rows: Rows = await db.select_async(query, *select_args, **select_kwargs) + self._run_hooks(db._after_execute, self, rows) + + return rows + + def _collect_prepare( + self, + metadata: Metadata, + add_id: bool, + into: t.Type[t.Any], + ) -> "TypedRows[T_MetaInstance] | tuple[TypeDAL, Query, list[t.Any], SelectKwargs]": + """ + Shared setup for `collect()`/`collect_async()`, up to (not including) the actual select. + + Returns a `TypedRows` directly if a cache hit short-circuits everything else, + otherwise the `(db, query, select_args, select_kwargs)` needed to perform the fetch. + """ + require_permission(self._permissions, "read") + db = self._get_db() + self._run_hooks(db._before_collect, self) + + if metadata.get("cache", {}).get("enabled") and (result := self._collect_cached(metadata, into)): + return result + + query, select_args, select_kwargs = self._before_query(metadata, add_id=add_id) + metadata["sql"] = db(query)._select(*select_args, **select_kwargs) + + return db, query, select_args, select_kwargs + + @staticmethod + def _record_fetch_metadata( + metadata: Metadata, + query: Query, + select_args: list[t.Any], + select_kwargs: SelectKwargs, + duration: float, + ) -> None: + """ + Shared metadata bookkeeping after a fetch, for `collect()`/`collect_async()`. + """ + metadata["final_query"] = str(query) + metadata["final_args"] = [str(_) for _ in select_args] + metadata["final_kwargs"] = select_kwargs + metadata["select_duration"] = duration + + def _finalize_collect( + self, + typed_rows: TypedRows[T_MetaInstance], + rows: Rows, + db: TypeDAL, + ) -> TypedRows[T_MetaInstance]: + """ + Shared tail of `collect()`/`collect_async()`: after_collect hooks + cache save. + """ + self._run_hooks(db._after_collect, self, typed_rows, rows) + # only saves if requested in metadata: + return save_to_cache(typed_rows, rows) def collect( self, @@ -637,7 +707,6 @@ def collect( """ Execute the built query and turn it into model instances, while handling relationships. """ - require_permission(self._permissions, "read") if _to is None: _to = TypedRows into = _into or self.model @@ -647,31 +716,18 @@ def collect( # fallback to execute: return self.execute(add_id=add_id) - db = self._get_db() - - for fn_before in db._before_collect: - fn_before(self) - metadata: Metadata = self.metadata.copy() - - if metadata.get("cache", {}).get("enabled") and (result := self._collect_cached(metadata, into)): - return result - - query, select_args, select_kwargs = self._before_query(metadata, add_id=add_id) - - metadata["sql"] = db(query)._select(*select_args, **select_kwargs) + prepared = self._collect_prepare(metadata, add_id, into) + if not isinstance(prepared, tuple): + return prepared + db, query, select_args, select_kwargs = prepared if verbose: # pragma: no cover print(metadata["sql"]) start_time = time.perf_counter() rows: Rows = db(query).select(*select_args, **select_kwargs) - duration = time.perf_counter() - start_time - - metadata["final_query"] = str(query) - metadata["final_args"] = [str(_) for _ in select_args] - metadata["final_kwargs"] = select_kwargs - metadata["select_duration"] = duration + self._record_fetch_metadata(metadata, query, select_args, select_kwargs, time.perf_counter() - start_time) if verbose: # pragma: no cover print(rows) @@ -685,11 +741,7 @@ def collect( # if that's not the case, return default behavior again typed_rows = self._collect_with_relationships(rows, metadata=metadata, _to=_to, _into=into, _init=_init) - for fn_after in db._after_collect: - fn_after(self, typed_rows, rows) - - # only saves if requested in metadata: - return save_to_cache(typed_rows, rows) + return self._finalize_collect(typed_rows, rows, db) async def collect_async( self, @@ -700,10 +752,46 @@ async def collect_async( _init: t.Callable[[_TypedTable, Row], None] | None = None, ) -> TypedRows[T_MetaInstance]: """ - Async twin of `collect()`. Primary target of the RFC's PoC (docs/rfc-async-execution.md): - same shape as `collect()`, only the execute step in the middle is async. + Async twin of `collect()`: same shape, only the execute step in the middle is async. + + Relationships/joins are not implemented yet: `_collect_with_relationships()` issues + further synchronous sub-queries that would need their own async twins first. """ - raise NotImplementedError + if _to is None: + _to = TypedRows + into = _into or self.model + + if not isinstance(self.model, TableMeta): + # tried to use querybuilder with a non-typedal table, + # fallback to execute: + return await self.execute_async(add_id=add_id) + + metadata: Metadata = self.metadata.copy() + prepared = self._collect_prepare(metadata, add_id, into) + if not isinstance(prepared, tuple): + return prepared + db, query, select_args, select_kwargs = prepared + + if self.relationships: + raise NotImplementedError( + "collect_async() with relationships/joins is not implemented yet - " + "_collect_with_relationships() issues further synchronous queries that " + "would need their own async twins first.", + ) + + if verbose: # pragma: no cover + print(metadata["sql"]) + + start_time = time.perf_counter() + rows: Rows = await db.select_async(query, *select_args, **select_kwargs) + self._record_fetch_metadata(metadata, query, select_args, select_kwargs, time.perf_counter() - start_time) + + if verbose: # pragma: no cover + print(rows) + + typed_rows = _to.from_rows(rows, self.model, metadata=metadata, into=into, init=_init) + + return self._finalize_collect(typed_rows, rows, db) def collect_into[T_Into: _TypedTable]( self, diff --git a/src/typedal/tables.py b/src/typedal/tables.py index 5dd2683..0b5696f 100644 --- a/src/typedal/tables.py +++ b/src/typedal/tables.py @@ -544,7 +544,7 @@ async def collect_async(self: t.Type[T_MetaInstance], verbose: bool = False) -> """ See QueryBuilder.collect_async! """ - raise NotImplementedError + return await QueryBuilder(self).collect_async(verbose=verbose) def collect_into[T_Into: _TypedTable]( self: t.Type[_TypedTable], diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 139890d..030b24a 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -1,30 +1,78 @@ """ -Test-first spec for the async execution path (see docs/rfc-async-execution.md). +Test-first spec for TypeDAL's async execution path. -`collect_async()` does not exist yet — these tests are RED by design: they pin down the -expected shape (`await Sometable.where(...).collect_async()` returns a `TypedRows`, field-for- -field equal to the synchronous `.collect()`) before the implementation exists, per the RFC's -constraint that the public API doesn't change and this is the only new surface being added. +Scope is Postgres AND SQLite together, not sequenced - `db_async` is parametrized over both +backends so every test below runs against each, proving the same async surface works +identically rather than "works for Postgres, TODO for SQLite". -Covers the two concrete divergence points the feasibility spike found (see RFC): +Covers two concrete Postgres divergence points found while building this: - jsonb -> dict (pydal's Postgres parser expects the driver to have already decoded it) - decimal(10,2) -> Decimal and the actual point of the exercise: the event loop is not blocked while the query runs. """ import asyncio +import contextlib +import tempfile import time +import typing as t from decimal import Decimal import pytest +import pytest_asyncio from src.typedal import TypeDAL, TypedField, TypedTable from src.typedal.fields import DecimalField, JSONField +@contextlib.asynccontextmanager +async def _postgres_db(dal_psql: TypeDAL) -> t.AsyncIterator[TypeDAL]: + try: + yield dal_psql + finally: + await dal_psql.close_async() + + +@contextlib.asynccontextmanager +async def _sqlite_db(dal_psql: TypeDAL) -> t.AsyncIterator[TypeDAL]: + with tempfile.TemporaryDirectory() as d: + db = TypeDAL("sqlite:memory", enable_typedal_caching=False, folder=d) + try: + yield db + finally: + await db.close_async() + db.close() + + +# One factory per backend the async execution path targets. Adding a new backend (e.g. MySQL) +# is adding a function + an entry here, not editing branching logic in the fixture below. +# (Every factory currently takes `dal_psql` as input for simplicity; a backend needing a +# differently-shaped upstream fixture - e.g. its own testcontainer - would need its factory +# signature adjusted accordingly, but the registry/dispatch shape stays the same.) +_ASYNC_DB_FACTORIES: dict[str, t.Callable[[TypeDAL], t.AsyncContextManager[TypeDAL]]] = { + "postgres": _postgres_db, + "sqlite": _sqlite_db, +} + + +@pytest_asyncio.fixture(params=list(_ASYNC_DB_FACTORIES)) +async def db_async(request: pytest.FixtureRequest, dal_psql: TypeDAL) -> t.AsyncIterator[TypeDAL]: + """ + A `TypeDAL` instance for each backend the async execution path targets, with a guaranteed- + closed async connection pool afterwards. + + Without the teardown, a lazily-opened async pool/connection outlives the test's event loop + (pytest-asyncio gives each test function its own loop by default) and the *next* test hangs + trying to use pool internals (locks/tasks) bound to an already-closed loop. + """ + factory = _ASYNC_DB_FACTORIES[request.param] + async with factory(dal_psql) as db: + yield db + + @pytest.mark.asyncio -async def test_collect_async_matches_sync_collect(dal_psql: TypeDAL): +async def test_collect_async_matches_sync_collect(db_async: TypeDAL): """The core parity claim: async-executed rows must equal sync-executed rows, field for field.""" - db = dal_psql + db = db_async @db.define() class AsyncThingParity(TypedTable): @@ -51,9 +99,9 @@ class AsyncThingParity(TypedTable): @pytest.mark.asyncio -async def test_collect_async_preserves_types(dal_psql: TypeDAL): +async def test_collect_async_preserves_types(db_async: TypeDAL): """The two divergence points the spike actually found: decimal and jsonb.""" - db = dal_psql + db = db_async @db.define() class AsyncThingTypes(TypedTable): @@ -70,18 +118,18 @@ class AsyncThingTypes(TypedTable): assert isinstance(row.price, Decimal) assert row.price == Decimal("19.99") - assert isinstance(row.meta, dict) # not a raw jsonb string + assert isinstance(row.meta, dict) # not a raw jsonb/json string assert row.meta == {"a": 1, "b": [1, 2, 3]} @pytest.mark.asyncio -async def test_collect_async_does_not_block_event_loop(dal_psql: TypeDAL): +async def test_collect_async_does_not_block_event_loop(db_async: TypeDAL): """ The actual point of building this: a query in flight must not stall other coroutines. A ticker sleeping every 5ms should keep ticking at ~5ms while queries run concurrently; a blocking implementation would show gaps close to the total query time instead. """ - db = dal_psql + db = db_async @db.define() class AsyncThingBlocking(TypedTable): From a4c15b0715dd22d11328e7f398f70e70e2ba18a8 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 17:47:25 +0200 Subject: [PATCH 04/29] feat(async): implement async CRUD and query execution --- src/typedal/async_execution.py | 154 +++++++++++++++++++++++++++- src/typedal/core.py | 178 +++++++++++++++++++++++++++++---- src/typedal/query_builder.py | 80 ++++++++++++++- src/typedal/tables.py | 37 ++++++- tests/test_async_execution.py | 107 ++++++++++++++++++++ 5 files changed, 524 insertions(+), 32 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index b0e6378..a2d0c09 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -24,21 +24,69 @@ class AsyncConnectionPool(t.Protocol): """ Common shape `select_async()` etc. need from either a real connection pool (Postgres) or a single-connection stand-in (SQLite). + + `commit()`/`rollback()` are part of this shape (not left to `TypeDAL.commit_async()` to + figure out per backend) because what they need to do genuinely differs: psycopg_pool's + `connection()` already commits/rolls back on context exit for every call (see + `PostgresAsyncPool`), so there is never anything left open to commit; aiosqlite's default + transaction mode does not auto-commit, so `SqliteAsyncConnection.commit()` has real work + to do. Keeping both behind the same two methods keeps that difference out of core.py. """ def connection(self) -> t.AsyncContextManager[t.Any]: ... + async def commit(self) -> None: ... + + async def rollback(self) -> None: ... + async def close(self) -> None: ... +class PostgresAsyncPool: + """ + Thin wrapper around `psycopg_pool.AsyncConnectionPool` giving it the same + `commit()`/`rollback()` shape as `SqliteAsyncConnection`, even though there is nothing to + do there: `pool.connection()` already applies "the normal connection context behaviour" + (psycopg_pool's own docs) - commit on success, rollback on error - on every single + `async with pool.connection() as conn:` use, so no transaction is ever left open between + calls for these to act on. This means each `select_async`/`insert_async`/etc. call is its + own committed transaction; there is currently no way to span one transaction across + multiple async calls (a real limitation, not just an implementation gap - see the + "two connections per request" hazard: since this and pydal's own sync connection are + already separate, spanning transactions here as well would need its own connection + checkout API, not built here). + """ + + def __init__(self, pool: t.Any) -> None: + self._pool = pool + + def connection(self) -> t.AsyncContextManager[t.Any]: + return t.cast(t.AsyncContextManager[t.Any], self._pool.connection()) + + async def commit(self) -> None: + pass + + async def rollback(self) -> None: + pass + + async def close(self) -> None: + await self._pool.close() + + class SqliteAsyncConnection: """ Minimal pool-like wrapper around a single aiosqlite connection. SQLite has no real concept of a connection pool the way Postgres does - pydal itself sets `pool_size = 0` for SQLite (adapters/sqlite.py:26), one connection is all there is. This - just gives it the same `.connection()`/`.close()` shape as `psycopg_pool.AsyncConnectionPool` - so `select_async()` doesn't need to branch on backend. + gives it the same `.connection()`/`.commit()`/`.rollback()`/`.close()` shape as + `PostgresAsyncPool` so `select_async()` etc. don't need to branch on backend. + + `connection()` commits on clean exit and rolls back on exception - unlike psycopg, + aiosqlite does not do this on its own, and without it a write would still be open (and the + table still locked for other readers/writers, including pydal's own sync connection) by + the time an `_async` method returns. This makes every `_async` call its own committed + transaction, matching what `PostgresAsyncPool` already gets for free from psycopg_pool. """ def __init__(self, conn: t.Any) -> None: @@ -46,7 +94,19 @@ def __init__(self, conn: t.Any) -> None: @contextlib.asynccontextmanager async def connection(self) -> t.AsyncIterator[t.Any]: - yield self._conn + try: + yield self._conn + except BaseException: + await self._conn.rollback() + raise + else: + await self._conn.commit() + + async def commit(self) -> None: + await self._conn.commit() + + async def rollback(self) -> None: + await self._conn.rollback() async def close(self) -> None: await self._conn.close() @@ -67,7 +127,7 @@ async def open_postgres_async_pool(db: "TypeDAL") -> AsyncConnectionPool: uri = db._uri.replace("postgres://", "postgresql://", 1) pool = psycopg_pool.AsyncConnectionPool(uri, open=False) await pool.open() - return t.cast(AsyncConnectionPool, pool) + return PostgresAsyncPool(pool) async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: @@ -101,3 +161,89 @@ async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: "postgres": open_postgres_async_pool, "sqlite": open_sqlite_async_connection, } + + +async def postgres_lastrowid_async(adapter: t.Any, table: t.Any, cursor: t.Any) -> t.Any: + """ + Async twin of `Postgre.lastrowid()` (pydal adapters/postgres.py:142-147). + + `adapter._last_insert` was already set as a side effect of the `_insert()` call that built + the INSERT statement (postgres.py:149-162, sets it whenever the table has a standard `_id` + column) - if so, the id is already in the RETURNING result of the statement just executed, + read here with a plain `fetchone()`, no extra round trip. Otherwise (tables with a custom + `_primarykey` not covered by RETURNING), fall back to `currval()`, a real second query. + """ + if getattr(adapter, "_last_insert", None): + row = await cursor.fetchone() + return int(row[0]) + + sequence_name = table._sequence_name + await cursor.execute("SELECT currval(%s);" % adapter.adapt(sequence_name)) + row = await cursor.fetchone() + return int(row[0]) + + +async def sqlite_lastrowid_async(adapter: t.Any, table: t.Any, cursor: t.Any) -> t.Any: + """ + Async twin of the base `SQLAdapter.lastrowid()` (pydal adapters/base.py:529-530), used by + SQLite (no override there). `cursor.lastrowid` is a plain attribute, not awaitable. + """ + return cursor.lastrowid + + +# One lastrowid strategy per backend, mirroring `ASYNC_POOL_FACTORIES` - `insert_async()` looks +# this up by `adapter.dbengine` rather than branching, same reasoning as the pool factories above. +LASTROWID_STRATEGIES: dict[str, t.Callable[[t.Any, t.Any, t.Any], t.Awaitable[t.Any]]] = { + "postgres": postgres_lastrowid_async, + "sqlite": sqlite_lastrowid_async, +} + + +async def base_delete_async(db: "TypeDAL", table: t.Any, query: t.Any) -> t.Any: + """ + Async twin of the base `SQLAdapter.delete()` (pydal adapters/base.py:604-610): plain + build/execute sandwich, no cascade handling. Used directly for Postgres (no override + there), and internally by `sqlite_delete_async` for the actual delete statement - + mirroring how `SQLite.delete()` itself calls `super().delete()` for that part. + """ + adapter = db._adapter + sql = adapter._delete(table, query) + + pool = await db._get_async_pool() + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql) + try: + return cur.rowcount + except Exception: # noqa: BLE001 + return None + + +async def sqlite_delete_async(db: "TypeDAL", table: t.Any, query: t.Any) -> t.Any: + """ + Async twin of `SQLite.delete()` (pydal adapters/sqlite.py:93-104) - NOT a plain sandwich: + selects affected ids first, deletes, then recurses per cascaded FK with + `ondelete=CASCADE`. Recursion goes through `db.delete_async()` again (not this function + directly), so a cascaded delete on another table gets the dbengine-appropriate treatment + too, same as the original. + """ + id_rows = await db.select_async(query, table._id) + deleted = [row[table._id.name] for row in id_rows] + + counter = await base_delete_async(db, table, query) + + if counter: + for field in table._referenced_by: + if field.type == "reference " + table._dalname and field.ondelete == "CASCADE": + cascade_query = field.belongs(deleted) + cascade_table = db._adapter.get_table(cascade_query) + await db.delete_async(cascade_table, cascade_query) + + return counter + + +# One delete strategy per backend, same reasoning as `ASYNC_POOL_FACTORIES`/`LASTROWID_STRATEGIES` +# - SQLite's isn't a plain sandwich (see `sqlite_delete_async`), Postgres's is. +DELETE_STRATEGIES: dict[str, t.Callable[["TypeDAL", t.Any, t.Any], t.Awaitable[t.Any]]] = { + "postgres": base_delete_async, + "sqlite": sqlite_delete_async, +} diff --git a/src/typedal/core.py b/src/typedal/core.py index d702a59..de5aaec 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -5,6 +5,7 @@ from __future__ import annotations # noinspection PyUnusedImports +import collections import datetime as dt import sys import typing as t @@ -14,7 +15,7 @@ import pydal -from .async_execution import ASYNC_POOL_FACTORIES, AsyncConnectionPool +from .async_execution import ASYNC_POOL_FACTORIES, DELETE_STRATEGIES, LASTROWID_STRATEGIES, AsyncConnectionPool from .config import LazyPolicy, TypeDALConfig, load_config from .helpers import ( SYSTEM_SUPPORTS_TEMPLATES, @@ -656,42 +657,101 @@ async def count_async( ) -> int: """ Async twin of `db(query).count(distinct)`. + + Mirrors `SQLAdapter.count()` (adapters/base.py:937-939): build via pydal's own + `_count()` (pure), execute via the async driver for this backend, read the first + column of the first (only) row. """ - raise NotImplementedError + adapter = self._adapter + sql = adapter._count(query, distinct) + + pool = await self._get_async_pool() + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql) + row = await cur.fetchone() + + return t.cast(int, row[0]) async def update_async( self, + table: pydal.objects.Table, query: pydal.objects.Query, - **fields: t.Any, - ) -> int: + fields: list[tuple[pydal.objects.Field, t.Any]], + ) -> t.Optional[int]: """ - Async twin of `db(query).update(**fields)`. + Async twin of the adapter-level step of `Set.update()` + (`adapter.update()`, adapters/base.py:581-593). + + `fields` is the already-normalized `[(Field, value), ...]` list (`row.op_values()`), + same shape as `insert_async`'s `fields` - the before_update/after_update hooks and + validation stay in `QueryBuilder.update_async()`, this is only the execute step. """ - raise NotImplementedError + adapter = self._adapter + sql = adapter._update(table, query, fields) + + pool = await self._get_async_pool() + async with pool.connection() as conn, conn.cursor() as cur: + try: + await cur.execute(sql) + except Exception as e: + if hasattr(table, "_on_update_error"): + return t.cast(t.Optional[int], table._on_update_error(table, query, fields, e)) + raise + try: + return t.cast(int, cur.rowcount) + except Exception: # noqa: BLE001 + return None async def delete_async( self, + table: pydal.objects.Table, query: pydal.objects.Query, - ) -> int: + ) -> t.Any: """ - Async twin of `db(query).delete()`. + Async twin of `Set.delete()`'s adapter-level step (`adapter.delete()`). - On SQLite, `SQLite.delete()` (pydal adapters/sqlite.py:93-104) is not a plain - build/execute/parse call: it selects affected ids first and recurses for - ON DELETE CASCADE. This has to replicate that cascade, not just wrap one - execute call. + Dispatches per backend via `DELETE_STRATEGIES`: SQLite's isn't a plain + build/execute/parse call - it selects affected ids first and recurses for + ON DELETE CASCADE (adapters/sqlite.py:93-104) - Postgres's is. """ - raise NotImplementedError + return await DELETE_STRATEGIES[self._adapter.dbengine](self, table, query) async def insert_async( self, table: pydal.objects.Table, - **fields: t.Any, - ) -> pydal.helpers.classes.Reference: + fields: list[tuple[pydal.objects.Field, t.Any]], + ) -> t.Any: """ - Async twin of `table.insert(**fields)`. + Async twin of the adapter-level step of `table.insert(**fields)` + (`adapter.insert()`, adapters/base.py:541-563). + + `fields` is the already-normalized `[(Field, value), ...]` list (`row.op_values()`), + the same shape pydal's own `Table.insert()` passes to the adapter - the field-name-to- + value normalization, `_before_insert`/`_after_insert` hooks, and validation all stay in + `TypedTable.insert_async()` (tables.py), not here; this is only the execute step. """ - raise NotImplementedError + adapter = self._adapter + query = adapter._insert(table, fields) + + pool = await self._get_async_pool() + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(query) + + if hasattr(table, "_primarykey"): + pkdict = {k[0].name: k[1] for k in fields if k[0].name in table._primarykey} + if pkdict: + return pkdict + + id_ = await LASTROWID_STRATEGIES[adapter.dbengine](adapter, table, cur) + + if hasattr(table, "_primarykey") and len(table._primarykey) == 1: + id_ = {table._primarykey[0]: id_} + if not isinstance(id_, int): + return id_ + + rid = pydal.helpers.classes.Reference(id_) + rid._table, rid._record = table, None + return rid async def executesql_async( self, @@ -704,8 +764,79 @@ async def executesql_async( ) -> list[t.Any]: """ Async twin of `executesql(...)`. + + Mirrors pydal's own `DAL.executesql()` (base.py:872-990): execute via the async + driver for this backend (the only I/O), then the same as_dict/fields/colnames + branching pydal itself does, calling pydal's own `adapter.parse()` (pure) for the + fields/colnames case, unmodified. Only the plain-tuples path (no as_dict, no + fields/colnames) is covered by tests so far. """ - raise NotImplementedError + if SYSTEM_SUPPORTS_TEMPLATES and isinstance(query, Template): # pragma: no cover + query = sql_escape_template(self, query) + + adapter = self._adapter + pool = await self._get_async_pool() + async with pool.connection() as conn, conn.cursor() as cur: + if placeholders: + await cur.execute(query, placeholders) + else: + await cur.execute(query) + + if as_dict or as_ordered_dict: + if not hasattr(cur, "description"): + raise RuntimeError("database does not support executesql_async(...,as_dict=True)") + + columns = cur.description + result_fields = list(colnames) if colnames else [col[0] for col in columns] + if len(result_fields) != len(set(result_fields)): + raise RuntimeError( + "Result set includes duplicate column names. " + "Specify unique column names using the 'colnames' argument", + ) + if columns: + for i in range(len(result_fields)): + if isinstance(result_fields[i], bytes): + result_fields[i] = result_fields[i].decode("utf8") + + data = await cur.fetchall() + _dict = collections.OrderedDict if as_ordered_dict else dict + return [_dict(zip(result_fields, row)) for row in data] + + try: + data = await cur.fetchall() + except Exception: # noqa: BLE001 + return None + + if fields or colnames: + fields = [] if fields is None else list(fields) + extracted_fields = [] + for field in fields: + if isinstance(field, pydal.objects.Table): + extracted_fields.extend(list(field)) + else: + extracted_fields.append(field) + if not colnames: + resolved_colnames = [f.sqlsafe for f in extracted_fields] + else: + col_fields = [] + newcolnames = [] + for tf in colnames: + if "." in tf: + t_f = tf.split(".") + tf = ".".join(adapter.dialect.quote(f) for f in t_f) + else: + t_f = None + if not extracted_fields: + col_fields.append(t_f) + newcolnames.append(tf) + resolved_colnames = newcolnames + data = adapter.parse( + data, + fields=extracted_fields or [tf and self[tf[0]][tf[1]] for tf in col_fields], + colnames=resolved_colnames, + ) + + return t.cast(list[t.Any], data) async def commit_async(self) -> None: """ @@ -713,15 +844,20 @@ async def commit_async(self) -> None: Deliberately does not touch `commit()`/the sync connection: queries executed via `select_async`/`insert_async`/etc. run on a separate connection, so committing one - says nothing about the other. + says nothing about the other. For Postgres this is currently a no-op in practice - + `PostgresAsyncPool` already commits every call on its own - but calling it is still + the right thing to do: it keeps callers backend-agnostic, and it's the one that + actually matters for SQLite (see `AsyncConnectionPool` in async_execution.py). """ - raise NotImplementedError + pool = await self._get_async_pool() + await pool.commit() async def rollback_async(self) -> None: """ Roll back the transaction on the async connection. See `commit_async`. """ - raise NotImplementedError + pool = await self._get_async_pool() + await pool.rollback() async def close_async(self) -> None: """ diff --git a/src/typedal/query_builder.py b/src/typedal/query_builder.py index 20fc013..0ef70d3 100644 --- a/src/typedal/query_builder.py +++ b/src/typedal/query_builder.py @@ -508,8 +508,33 @@ def _delete(self) -> str: async def delete_async(self) -> list[int]: """ Async twin of `delete()`. + + `delete()` delegates the before_delete/after_delete hook dance to pydal's own + `Set.delete()` (objects.py:3010-3017); since pydal has no async version of that to + delegate to, it's replicated here, same reasoning as `insert_async` - only the + adapter-level execute step (`db.delete_async(...)`) is async. """ - raise NotImplementedError + require_permission(self._permissions, "delete") + db = self._get_db() + + removed_rows = await db.select_async(self.query, "id") + removed_ids = [row.id for row in removed_rows] + + pydal_set = db(self.query) + table = db._adapter.get_table(self.query) + + if any(f(pydal_set) for f in table._before_delete): + return [] + + result = await db.delete_async(table, self.query) + + if result: + # success! + for f in table._after_delete: + f(pydal_set) + return removed_ids + + return [] def update(self, **fields: t.Any) -> list[int]: """ @@ -532,8 +557,37 @@ def _update(self, **fields: t.Any) -> str: async def update_async(self, **fields: t.Any) -> list[int]: """ Async twin of `update(**fields)`. + + `update()` delegates the before_update/after_update hook dance to pydal's own + `Set.update()` (objects.py: `_build_update_row`/`_apply_update`); since pydal has no + async version of that to delegate to, it's replicated here, same reasoning as + `insert_async`/`delete_async` - only the adapter-level execute step + (`db.update_async(...)`) is async. """ - raise NotImplementedError + require_permission(self._permissions, "update") + db = self._get_db() + + updated_rows = await db.select_async(self.query, "id") + updated_ids = [row.id for row in updated_rows] + + pydal_set = db(self.query) + table = db._adapter.get_table(self.query) + row = table._fields_and_values_for_update(fields) + if not row._values: + raise ValueError("No fields to update") + + if any(f(pydal_set, row) for f in table._before_update): + return [] + + result = await db.update_async(table, self.query, row.op_values()) + + if result: + # success! + for f in table._after_update: + f(pydal_set, row) + return updated_ids + + return [] def _before_query(self, mut_metadata: Metadata, add_id: bool = True) -> tuple[Query, list[t.Any], SelectKwargs]: select_args = [self._select_arg_convert(_) for _ in self.select_args] or [self.model.ALL] @@ -773,6 +827,7 @@ async def collect_async( db, query, select_args, select_kwargs = prepared if self.relationships: + # FIXME(async): Add async relationship loading once related sub-queries support it. raise NotImplementedError( "collect_async() with relationships/joins is not implemented yet - " "_collect_with_relationships() issues further synchronous queries that " @@ -821,6 +876,7 @@ async def collect_into_async[T_Into: _TypedTable]( """ Async twin of `collect_into()`. Thin wrapper: builds on `collect_async()`. """ + # FIXME(async): Implement this wrapper after `collect_async()` supports all required inputs. raise NotImplementedError def _validate_collect_into_model(self, into: t.Type[t.Any]) -> None: @@ -870,6 +926,7 @@ async def column_async[T: t.Any](self, field: TypedField[T] | T, **options: t.Un """ Async twin of `column()`. Thin wrapper: `.select(field).execute_async()` then `.column(field)`. """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def _handle_relationships_pre_select( @@ -1323,6 +1380,7 @@ async def collect_or_fail_async(self, exception: t.Optional[Exception] = None) - """ Async twin of `collect_or_fail()`. Thin wrapper: builds on `collect_async()`. """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def __iter__(self) -> t.Generator[T_MetaInstance, None, None]: @@ -1359,21 +1417,28 @@ def __count( return query - def count(self, distinct: t.Optional[bool] = None) -> int: + def _count_prepare(self, distinct: t.Optional[bool] = None) -> tuple[TypeDAL, Query]: """ - Return the amount of rows matching the current query. + Shared setup for `count()`/`count_async()`. """ require_permission(self._permissions, "read") db = self._get_db() query = self.__count(db, distinct=distinct) + return db, query + def count(self, distinct: t.Optional[bool] = None) -> int: + """ + Return the amount of rows matching the current query. + """ + db, query = self._count_prepare(distinct) return db(query).count(distinct) async def count_async(self, distinct: t.Optional[bool] = None) -> int: """ Async twin of `count()`. """ - raise NotImplementedError + db, query = self._count_prepare(distinct) + return await db.count_async(query, distinct) def _count(self, distinct: t.Optional[bool] = None) -> str: """ @@ -1400,6 +1465,7 @@ async def exists_async(self) -> bool: """ Async twin of `exists()`. Thin wrapper: builds on `count_async()`. """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def __pagination_count(self) -> int: @@ -1454,6 +1520,7 @@ async def paginate_async(self, limit: int, page: int = 1, verbose: bool = False) Note: `__pagination_count()` (the row-count step done before paginating) also hits the DB and needs its own async path internally - not exposed as a separate public method. """ + # FIXME(async): Implement pagination, including its asynchronous count step. raise NotImplementedError def _paginate( @@ -1490,6 +1557,7 @@ async def chunk_async(self, chunk_size: int) -> t.AsyncGenerator[TypedRows[T_Met """ Async twin of `chunk()`. An async generator (`async for`), built on `collect_async()`. """ + # FIXME(async): Implement this async generator on top of `collect_async()`. raise NotImplementedError yield # pragma: no cover # makes this an async generator for type-checking purposes @@ -1514,6 +1582,7 @@ async def first_async(self, verbose: bool = False) -> T_MetaInstance | None: """ Async twin of `first()`. Thin wrapper: builds on `paginate_async()`. """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def _first(self) -> str: @@ -1534,6 +1603,7 @@ async def first_or_fail_async( """ Async twin of `first_or_fail()`. Thin wrapper: builds on `first_async()`. """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError diff --git a/src/typedal/tables.py b/src/typedal/tables.py index 0b5696f..11ec27d 100644 --- a/src/typedal/tables.py +++ b/src/typedal/tables.py @@ -189,6 +189,7 @@ async def all_async(self: t.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]" """ Async twin of `all()`. Thin wrapper: builds on `collect_async()`. """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def get_relationships(self) -> dict[str, Relationship[t.Any]]: @@ -223,8 +224,25 @@ def insert(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance: async def insert_async(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance: """ Async twin of `insert()`. + + Mirrors pydal's `Table.insert()` (objects.py:960-968): the field normalization + (`_fields_and_values_for_insert`) and `_before_insert`/`_after_insert` hooks stay + exactly as they are (pure/sync), only the adapter-level execute step + (`table._db.insert_async(...)`) is async. """ - raise NotImplementedError + table = self._ensure_table_defined() + require_permission(self._permissions, "insert") + + row = table._fields_and_values_for_insert(fields) + if any(f(row) for f in table._before_insert): + result = 0 + else: + result = await table._db.insert_async(table, row.op_values()) + if result and table._after_insert: + for f in table._after_insert: + f(row, result) + + return self(result) def _insert(self, **fields: t.Any) -> str: table = self._ensure_table_defined() @@ -244,6 +262,7 @@ async def bulk_insert_async(self: t.Type[T_MetaInstance], items: list[AnyDict]) """ Async twin of `bulk_insert()`. """ + # FIXME(async): Implement bulk insertion and async result collection. raise NotImplementedError def update_or_insert( @@ -279,6 +298,7 @@ async def update_or_insert_async( """ Async twin of `update_or_insert()`. """ + # FIXME(async): Implement this wrapper using async lookup, update, and insert paths. raise NotImplementedError def validate_and_insert( @@ -305,6 +325,7 @@ async def validate_and_insert_async( """ Async twin of `validate_and_insert()`. """ + # FIXME(async): Implement validation and insertion through the async path. raise NotImplementedError def validate_and_update( @@ -338,6 +359,7 @@ async def validate_and_update_async( """ Async twin of `validate_and_update()`. """ + # FIXME(async): Implement validation and update through the async path. raise NotImplementedError def validate_and_update_or_insert( @@ -376,6 +398,7 @@ async def validate_and_update_or_insert_async( """ Async twin of `validate_and_update_or_insert()`. """ + # FIXME(async): Implement this wrapper using async validation paths. raise NotImplementedError def select(self: t.Type[T_MetaInstance], *a: t.Any, **kw: t.Any) -> "QueryBuilder[T_MetaInstance]": @@ -404,6 +427,7 @@ async def column_async[T: t.Any, T_MetaInstance: _TypedTable]( """ See QueryBuilder.column_async! """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def paginate(self: t.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]": @@ -418,6 +442,7 @@ async def paginate_async( """ See QueryBuilder.paginate_async! """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def chunk(self: t.Type[T_MetaInstance], chunk_size: int) -> t.Generator["TypedRows[T_MetaInstance]", t.Any, None]: @@ -432,6 +457,7 @@ async def chunk_async( """ See QueryBuilder.chunk_async! """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError yield # pragma: no cover # makes this an async generator for type-checking purposes @@ -483,7 +509,7 @@ async def count_async(self: t.Type[T_MetaInstance]) -> int: """ See QueryBuilder.count_async! """ - raise NotImplementedError + return await QueryBuilder(self).count_async() def exists(self: t.Type[T_MetaInstance]) -> bool: """ @@ -495,6 +521,7 @@ async def exists_async(self: t.Type[T_MetaInstance]) -> bool: """ See QueryBuilder.exists_async! """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def first(self: t.Type[T_MetaInstance]) -> T_MetaInstance | None: @@ -507,6 +534,7 @@ async def first_async(self: t.Type[T_MetaInstance]) -> T_MetaInstance | None: """ See QueryBuilder.first_async! """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def first_or_fail(self: t.Type[T_MetaInstance]) -> T_MetaInstance: @@ -519,6 +547,7 @@ async def first_or_fail_async(self: t.Type[T_MetaInstance]) -> T_MetaInstance: """ See QueryBuilder.first_or_fail_async! """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError def join( @@ -566,6 +595,7 @@ async def collect_into_async[T_Into: _TypedTable]( """ See QueryBuilder.collect_into_async! """ + # FIXME(async): Implement this thin async wrapper. raise NotImplementedError @property @@ -1424,6 +1454,7 @@ async def update_async(cls: t.Type[T_MetaInstance], query: Query, **fields: t.An """ Async twin of `update()`. Thin wrapper: builds on `update_record_async()`. """ + # FIXME(async): Implement this wrapper using async record lookup and update. raise NotImplementedError def _update(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: @@ -1452,6 +1483,7 @@ async def update_record_async(self: T_MetaInstance, **fields: t.Any) -> T_MetaIn """ Async twin of `update_record()`. """ + # FIXME(async): Implement record updates on the async connection. raise NotImplementedError def _delete_record(self) -> int: @@ -1480,6 +1512,7 @@ async def delete_record_async(self) -> int: """ Async twin of `delete_record()`. """ + # FIXME(async): Implement record deletion on the async connection. raise NotImplementedError # __del__ is also called on the end of a scope so don't remove records on every del!! diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 030b24a..4773328 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -122,6 +122,113 @@ class AsyncThingTypes(TypedTable): assert row.meta == {"a": 1, "b": [1, 2, 3]} +@pytest.mark.asyncio +async def test_count_async_matches_sync_count(db_async: TypeDAL): + """count_async must return the same count as the sync count().""" + db = db_async + + @db.define() + class AsyncThingCount(TypedTable): + qty: TypedField[int] + + AsyncThingCount.insert(qty=1) + AsyncThingCount.insert(qty=2) + AsyncThingCount.insert(qty=3) + db.commit() + + sync_count = AsyncThingCount.where(AsyncThingCount.qty > 1).count() + async_count = await AsyncThingCount.where(AsyncThingCount.qty > 1).count_async() + + assert async_count == sync_count == 2 + + +@pytest.mark.asyncio +async def test_insert_async_matches_sync_insert(db_async: TypeDAL): + """insert_async must return a usable id, and the row must actually be committed and visible.""" + db = db_async + + @db.define() + class AsyncThingInsert(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + new_id = await AsyncThingInsert.insert_async(name="widget", qty=5) + await db.commit_async() + + assert int(new_id) > 0 + + row = AsyncThingInsert.where(AsyncThingInsert.id == int(new_id)).first() + assert row is not None + assert row.name == "widget" + assert row.qty == 5 + + +@pytest.mark.asyncio +async def test_update_async_matches_sync_update(db_async: TypeDAL): + """update_async must update the same rows as the sync update() and return matching ids.""" + db = db_async + + @db.define() + class AsyncThingUpdate(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + AsyncThingUpdate.insert(name="widget", qty=1) + AsyncThingUpdate.insert(name="gadget", qty=2) + db.commit() + + updated_ids = await AsyncThingUpdate.where(AsyncThingUpdate.qty > 0).update_async(qty=99) + await db.commit_async() + + assert len(updated_ids) == 2 + + rows = AsyncThingUpdate.where(AsyncThingUpdate.qty == 99).collect() + assert len(rows) == 2 + + +@pytest.mark.asyncio +async def test_delete_async_matches_sync_delete(db_async: TypeDAL): + """delete_async must delete the same rows as the sync delete() and return matching ids.""" + db = db_async + + @db.define() + class AsyncThingDelete(TypedTable): + qty: TypedField[int] + + AsyncThingDelete.insert(qty=1) + AsyncThingDelete.insert(qty=2) + db.commit() + + deleted_ids = await AsyncThingDelete.where(AsyncThingDelete.qty > 0).delete_async() + await db.commit_async() + + assert len(deleted_ids) == 2 + + remaining = AsyncThingDelete.where(AsyncThingDelete.qty > 0).count() + assert remaining == 0 + + +@pytest.mark.asyncio +async def test_executesql_async_matches_sync_executesql(db_async: TypeDAL): + """executesql_async must return the same raw rows as the sync executesql().""" + db = db_async + + @db.define() + class AsyncThingRaw(TypedTable): + qty: TypedField[int] + + AsyncThingRaw.insert(qty=1) + AsyncThingRaw.insert(qty=2) + db.commit() + + query = f"SELECT qty FROM {AsyncThingRaw._table._rname} ORDER BY qty;" + + sync_rows = db.executesql(query) + async_rows = await db.executesql_async(query) + + assert list(async_rows) == list(sync_rows) == [(1,), (2,)] + + @pytest.mark.asyncio async def test_collect_async_does_not_block_event_loop(db_async: TypeDAL): """ From 570c24e568b9c2b1eb1dc0cdfacf66d50126bc0e Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 17:51:35 +0200 Subject: [PATCH 05/29] test: add tests for the remaining async stubs --- tests/test_async_execution.py | 386 ++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 4773328..2f62d5a 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -229,6 +229,392 @@ class AsyncThingRaw(TypedTable): assert list(async_rows) == list(sync_rows) == [(1,), (2,)] +@pytest.mark.asyncio +async def test_collect_async_raises_on_relationships(db_async: TypeDAL): + """ + Deliberate, documented limitation, not a forgotten stub: collect_async() with + relationships/joins raises, because _collect_with_relationships() would need further + synchronous sub-queries reimplemented async first. This locks that behavior in as tested. + """ + db = db_async + + @db.define() + class AsyncThingRelOther(TypedTable): + name: TypedField[str] + + @db.define() + class AsyncThingRelMain(TypedTable): + name: TypedField[str] + other: AsyncThingRelOther + + other_id = AsyncThingRelOther.insert(name="parent") + AsyncThingRelMain.insert(name="child", other=other_id) + db.commit() + + with pytest.raises(NotImplementedError): + await AsyncThingRelMain.join("other").collect_async() + + +@pytest.mark.asyncio +async def test_all_async_matches_sync_all(db_async: TypeDAL): + """all_async must return the same rows as the sync all().""" + db = db_async + + @db.define() + class AsyncThingAll(TypedTable): + qty: TypedField[int] + + AsyncThingAll.insert(qty=1) + AsyncThingAll.insert(qty=2) + db.commit() + + sync_rows = AsyncThingAll.all() + async_rows = await AsyncThingAll.all_async() + + assert len(async_rows) == len(sync_rows) == 2 + + +@pytest.mark.asyncio +async def test_exists_async_matches_sync_exists(db_async: TypeDAL): + """exists_async (QueryBuilder and the TypedTable shortcut) must match the sync exists().""" + db = db_async + + @db.define() + class AsyncThingExists(TypedTable): + qty: TypedField[int] + + assert not await AsyncThingExists.where(AsyncThingExists.qty > 0).exists_async() + assert not await AsyncThingExists.exists_async() + + AsyncThingExists.insert(qty=1) + db.commit() + + assert AsyncThingExists.where(AsyncThingExists.qty > 0).exists() is True + assert await AsyncThingExists.where(AsyncThingExists.qty > 0).exists_async() is True + assert await AsyncThingExists.exists_async() is True + + +@pytest.mark.asyncio +async def test_first_async_and_first_or_fail_async_match_sync(db_async: TypeDAL): + """first_async/first_or_fail_async (QueryBuilder and TypedTable shortcuts) must match sync.""" + db = db_async + + @db.define() + class AsyncThingFirst(TypedTable): + qty: TypedField[int] + + assert await AsyncThingFirst.where(AsyncThingFirst.qty > 0).first_async() is None + with pytest.raises(ValueError): + await AsyncThingFirst.where(AsyncThingFirst.qty > 0).first_or_fail_async() + + AsyncThingFirst.insert(qty=5) + db.commit() + + sync_row = AsyncThingFirst.where(AsyncThingFirst.qty > 0).first() + async_row = await AsyncThingFirst.where(AsyncThingFirst.qty > 0).first_async() + assert async_row is not None and sync_row is not None + assert async_row.qty == sync_row.qty == 5 + + async_row_2 = await AsyncThingFirst.where(AsyncThingFirst.qty > 0).first_or_fail_async() + assert async_row_2.qty == 5 + + # TypedTable-level shortcuts (no explicit .where(...)): + async_row_3 = await AsyncThingFirst.first_async() + assert async_row_3 is not None + assert async_row_3.qty == 5 + async_row_4 = await AsyncThingFirst.first_or_fail_async() + assert async_row_4.qty == 5 + + +@pytest.mark.asyncio +async def test_paginate_async_matches_sync_paginate(db_async: TypeDAL): + """paginate_async (QueryBuilder and the TypedTable shortcut) must match sync paginate().""" + db = db_async + + @db.define() + class AsyncThingPaginate(TypedTable): + qty: TypedField[int] + + for i in range(5): + AsyncThingPaginate.insert(qty=i) + db.commit() + + sync_page = AsyncThingPaginate.where(AsyncThingPaginate.qty >= 0).paginate(limit=2, page=2) + async_page = await AsyncThingPaginate.where(AsyncThingPaginate.qty >= 0).paginate_async(limit=2, page=2) + + assert len(async_page) == len(sync_page) == 2 + assert async_page.pagination["current_page"] == sync_page.pagination["current_page"] == 2 + assert async_page.pagination["rows"] == sync_page.pagination["rows"] == 5 + + async_page_2 = await AsyncThingPaginate.paginate_async(limit=2, page=1) + assert len(async_page_2) == 2 + + +@pytest.mark.asyncio +async def test_chunk_async_matches_sync_chunk(db_async: TypeDAL): + """chunk_async (QueryBuilder and the TypedTable shortcut) must yield the same chunks as sync chunk().""" + db = db_async + + @db.define() + class AsyncThingChunk(TypedTable): + qty: TypedField[int] + + for i in range(5): + AsyncThingChunk.insert(qty=i) + db.commit() + + sync_chunks = [len(chunk) for chunk in AsyncThingChunk.where(AsyncThingChunk.qty >= 0).chunk(2)] + + async_chunks = [] + async for chunk in AsyncThingChunk.where(AsyncThingChunk.qty >= 0).chunk_async(2): + async_chunks.append(len(chunk)) + + assert async_chunks == sync_chunks == [2, 2, 1] + + async_chunks_2 = [len(chunk) async for chunk in AsyncThingChunk.chunk_async(2)] + assert async_chunks_2 == [2, 2, 1] + + +@pytest.mark.asyncio +async def test_column_async_matches_sync_column(db_async: TypeDAL): + """column_async (QueryBuilder and the TypedTable shortcut) must match sync column().""" + db = db_async + + @db.define() + class AsyncThingColumn(TypedTable): + qty: TypedField[int] + + AsyncThingColumn.insert(qty=1) + AsyncThingColumn.insert(qty=2) + db.commit() + + sync_values = AsyncThingColumn.where(AsyncThingColumn.qty > 0).column(AsyncThingColumn.qty) + async_values = await AsyncThingColumn.where(AsyncThingColumn.qty > 0).column_async(AsyncThingColumn.qty) + + assert sorted(async_values) == sorted(sync_values) == [1, 2] + + async_values_2 = await AsyncThingColumn.column_async(AsyncThingColumn.qty) + assert sorted(async_values_2) == [1, 2] + + +@pytest.mark.asyncio +async def test_collect_into_async_matches_sync_collect_into(db_async: TypeDAL): + """collect_into_async (QueryBuilder and the TypedTable shortcut) must match sync collect_into().""" + db = db_async + + @db.define() + class AsyncThingIntoSource(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + # collect_into reshapes rows from the SAME table into a different Python representation - + # it is not for copying between two distinct tables. These stay undefined (no @db.define()): + # _validate_collect_into_model binds each one to the source's table on first use. + class AsyncThingIntoTargetSync(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + class AsyncThingIntoTargetAsync(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + class AsyncThingIntoTargetAsyncBare(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + AsyncThingIntoSource.insert(name="widget", qty=1) + db.commit() + + sync_rows = AsyncThingIntoSource.where(AsyncThingIntoSource.qty > 0).collect_into(AsyncThingIntoTargetSync) + async_rows = await AsyncThingIntoSource.where(AsyncThingIntoSource.qty > 0).collect_into_async( + AsyncThingIntoTargetAsync, + ) + + assert len(async_rows) == len(sync_rows) == 1 + assert isinstance(async_rows.first(), AsyncThingIntoTargetAsync) + + async_rows_2 = await AsyncThingIntoSource.collect_into_async(AsyncThingIntoTargetAsyncBare) + assert len(async_rows_2) == 1 + + +@pytest.mark.asyncio +async def test_collect_or_fail_async_matches_sync_collect_or_fail(db_async: TypeDAL): + """collect_or_fail_async must match sync collect_or_fail(): rows when present, raise when empty.""" + db = db_async + + @db.define() + class AsyncThingCollectOrFail(TypedTable): + qty: TypedField[int] + + with pytest.raises(ValueError): + await AsyncThingCollectOrFail.where(AsyncThingCollectOrFail.qty > 0).collect_or_fail_async() + + AsyncThingCollectOrFail.insert(qty=1) + db.commit() + + sync_rows = AsyncThingCollectOrFail.where(AsyncThingCollectOrFail.qty > 0).collect_or_fail() + async_rows = await AsyncThingCollectOrFail.where(AsyncThingCollectOrFail.qty > 0).collect_or_fail_async() + + assert len(async_rows) == len(sync_rows) == 1 + + +@pytest.mark.asyncio +async def test_bulk_insert_async_matches_sync_bulk_insert(db_async: TypeDAL): + """bulk_insert_async must insert the same rows as the sync bulk_insert().""" + db = db_async + + @db.define() + class AsyncThingBulkInsert(TypedTable): + qty: TypedField[int] + + rows = await AsyncThingBulkInsert.bulk_insert_async([{"qty": 1}, {"qty": 2}, {"qty": 3}]) + await db.commit_async() + + assert len(rows) == 3 + assert sorted(r.qty for r in rows) == [1, 2, 3] + assert AsyncThingBulkInsert.count() == 3 + + +@pytest.mark.asyncio +async def test_update_or_insert_async_matches_sync(db_async: TypeDAL): + """update_or_insert_async must insert when no match exists, and update when one does.""" + db = db_async + + @db.define() + class AsyncThingUpsert(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + # insert branch: no matching row yet + inserted = await AsyncThingUpsert.update_or_insert_async({"name": "widget"}, name="widget", qty=1) + await db.commit_async() + assert inserted.qty == 1 + assert AsyncThingUpsert.count() == 1 + + # update branch: matching row exists + updated = await AsyncThingUpsert.update_or_insert_async({"name": "widget"}, name="widget", qty=2) + await db.commit_async() + assert updated.qty == 2 + assert AsyncThingUpsert.count() == 1 + + +@pytest.mark.asyncio +async def test_validate_and_insert_async_matches_sync(db_async: TypeDAL): + """validate_and_insert_async must match sync validate_and_insert(): row on success, errors on failure.""" + db = db_async + + @db.define() + class AsyncThingValidateInsert(TypedTable): + qty: TypedField[int] + + row, errors = await AsyncThingValidateInsert.validate_and_insert_async(qty=5) + await db.commit_async() + assert errors is None + assert row is not None + assert row.qty == 5 + + _row, errors = await AsyncThingValidateInsert.validate_and_insert_async(qty="not-a-number") + assert errors is not None + + +@pytest.mark.asyncio +async def test_validate_and_update_async_matches_sync(db_async: TypeDAL): + """validate_and_update_async must match sync validate_and_update(): row on success, errors on failure.""" + db = db_async + + @db.define() + class AsyncThingValidateUpdate(TypedTable): + qty: TypedField[int] + + existing_id = AsyncThingValidateUpdate.insert(qty=1) + db.commit() + + row, errors = await AsyncThingValidateUpdate.validate_and_update_async( + AsyncThingValidateUpdate.id == int(existing_id), qty=9, + ) + await db.commit_async() + assert errors is None + assert row is not None + assert row.qty == 9 + + _row, errors = await AsyncThingValidateUpdate.validate_and_update_async( + AsyncThingValidateUpdate.id == int(existing_id), qty="not-a-number", + ) + assert errors is not None + + +@pytest.mark.asyncio +async def test_validate_and_update_or_insert_async_matches_sync(db_async: TypeDAL): + """validate_and_update_or_insert_async must insert when no match exists, update when one does.""" + db = db_async + + @db.define() + class AsyncThingValidateUpsert(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + inserted, errors = await AsyncThingValidateUpsert.validate_and_update_or_insert_async( + AsyncThingValidateUpsert.name == "widget", name="widget", qty=1, + ) + await db.commit_async() + assert errors is None + assert inserted.qty == 1 + assert AsyncThingValidateUpsert.count() == 1 + + updated, errors = await AsyncThingValidateUpsert.validate_and_update_or_insert_async( + AsyncThingValidateUpsert.name == "widget", name="widget", qty=2, + ) + await db.commit_async() + assert errors is None + assert updated.qty == 2 + assert AsyncThingValidateUpsert.count() == 1 + + +@pytest.mark.asyncio +async def test_classmethod_update_async_matches_sync(db_async: TypeDAL): + """The classmethod update_async(query, **fields) shortcut must match sync update().""" + db = db_async + + @db.define() + class AsyncThingClsUpdate(TypedTable): + qty: TypedField[int] + + existing_id = AsyncThingClsUpdate.insert(qty=1) + db.commit() + + updated = await AsyncThingClsUpdate.update_async(AsyncThingClsUpdate.id == int(existing_id), qty=42) + await db.commit_async() + + assert updated is not None + assert updated.qty == 42 + + +@pytest.mark.asyncio +async def test_update_record_async_and_delete_record_async_match_sync(db_async: TypeDAL): + """Instance-level update_record_async/delete_record_async must match their sync twins.""" + db = db_async + + @db.define() + class AsyncThingRecord(TypedTable): + qty: TypedField[int] + + row_id = AsyncThingRecord.insert(qty=1) + db.commit() + + row = AsyncThingRecord.where(AsyncThingRecord.id == int(row_id)).first() + updated_row = await row.update_record_async(qty=7) + await db.commit_async() + assert updated_row.qty == 7 + + fresh = AsyncThingRecord.where(AsyncThingRecord.id == int(row_id)).first() + assert fresh.qty == 7 + + deleted_count = await fresh.delete_record_async() + await db.commit_async() + assert deleted_count == 1 + assert AsyncThingRecord.count() == 0 + + @pytest.mark.asyncio async def test_collect_async_does_not_block_event_loop(db_async: TypeDAL): """ From 784f46c4f1b5dbb1300709b1a274aae07dc009f7 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 19:42:19 +0200 Subject: [PATCH 06/29] feat(async): implement remaining async query and table operations --- src/typedal/query_builder.py | 132 +++++++++++++++++++++-------- src/typedal/tables.py | 160 +++++++++++++++++++++++++++-------- 2 files changed, 226 insertions(+), 66 deletions(-) diff --git a/src/typedal/query_builder.py b/src/typedal/query_builder.py index 0ef70d3..532bd2c 100644 --- a/src/typedal/query_builder.py +++ b/src/typedal/query_builder.py @@ -808,8 +808,11 @@ async def collect_async( """ Async twin of `collect()`: same shape, only the execute step in the middle is async. - Relationships/joins are not implemented yet: `_collect_with_relationships()` issues - further synchronous sub-queries that would need their own async twins first. + Relationships/joins included: nothing on that path executes a second query. The joins + are built into the one query by `_before_query()` (already shared via `_collect_prepare`), + `_apply_limitby_optimization()` only *generates* SQL (`db(query)._select(...)`, inlined + as a subquery - no execution), and `_collect_with_relationships()` maps rows already + fetched here into instances. So the same tail works for both paths. """ if _to is None: _to = TypedRows @@ -826,14 +829,6 @@ async def collect_async( return prepared db, query, select_args, select_kwargs = prepared - if self.relationships: - # FIXME(async): Add async relationship loading once related sub-queries support it. - raise NotImplementedError( - "collect_async() with relationships/joins is not implemented yet - " - "_collect_with_relationships() issues further synchronous queries that " - "would need their own async twins first.", - ) - if verbose: # pragma: no cover print(metadata["sql"]) @@ -844,7 +839,12 @@ async def collect_async( if verbose: # pragma: no cover print(rows) - typed_rows = _to.from_rows(rows, self.model, metadata=metadata, into=into, init=_init) + if not self.relationships: + # easy + typed_rows = _to.from_rows(rows, self.model, metadata=metadata, into=into, init=_init) + else: + # harder: try to match rows to the belonging objects + typed_rows = self._collect_with_relationships(rows, metadata=metadata, _to=_to, _into=into, _init=_init) return self._finalize_collect(typed_rows, rows, db) @@ -876,8 +876,13 @@ async def collect_into_async[T_Into: _TypedTable]( """ Async twin of `collect_into()`. Thin wrapper: builds on `collect_async()`. """ - # FIXME(async): Implement this wrapper after `collect_async()` supports all required inputs. - raise NotImplementedError + self._validate_collect_into_model(into) + query = self + if not self.select_args: + query = self.select(*self._collect_into_default_fields(into)) + _init = t.cast(t.Callable[[_TypedTable, Row], None] | None, init) + rows = await query.collect_async(verbose=verbose, add_id=add_id, _into=into, _init=_init) + return t.cast(TypedRows[T_Into], rows) def _validate_collect_into_model(self, into: t.Type[t.Any]) -> None: if not isinstance(into, TableMeta): @@ -926,8 +931,8 @@ async def column_async[T: t.Any](self, field: TypedField[T] | T, **options: t.Un """ Async twin of `column()`. Thin wrapper: `.select(field).execute_async()` then `.column(field)`. """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + rows = await self.select(field, **options).execute_async() + return t.cast(list[T], rows.column(field)) def _handle_relationships_pre_select( self, @@ -1380,8 +1385,7 @@ async def collect_or_fail_async(self, exception: t.Optional[Exception] = None) - """ Async twin of `collect_or_fail()`. Thin wrapper: builds on `collect_async()`. """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await self.collect_async() or throw(exception or ValueError("Nothing found!")) def __iter__(self) -> t.Generator[T_MetaInstance, None, None]: """ @@ -1465,24 +1469,47 @@ async def exists_async(self) -> bool: """ Async twin of `exists()`. Thin wrapper: builds on `count_async()`. """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + require_permission(self._permissions, "read") + return bool(await self.count_async()) + + def __pagination_count_query(self) -> tuple[TypeDAL, Query]: + """ + Shared setup for `__pagination_count()`/`__pagination_count_async()`, for the + relationship case only (without relationships both just defer to `count()`). + """ + db = self._get_db() + query = self.__count(db, distinct=self.model.id, include_left_for_distinct=False) + return db, query def __pagination_count(self) -> int: if not self.relationships: return self.count() - db = self._get_db() - query = self.__count(db, distinct=self.model.id, include_left_for_distinct=False) + db, query = self.__pagination_count_query() return db(query).count(self.model.id) - def __paginate( + async def __pagination_count_async(self) -> int: + """ + Async twin of `__pagination_count()`: the row-count step `paginate_async()` needs + before it can know `max_page`. + """ + if not self.relationships: + return await self.count_async() + + db, query = self.__pagination_count_query() + return await db.count_async(query, self.model.id) + + def __paginate_builder( self, + available: int, limit: int, page: int = 1, ) -> "QueryBuilder[T_MetaInstance]": - available = self.__pagination_count() - + """ + Shared tail of `__paginate()`/`paginate_async()`: turn an already-determined row count + into a limitby-extended builder. Split out because the count step differs (sync vs async), + the metadata bookkeeping around it does not. + """ _from = limit * (page - 1) _to = (limit * page) if limit else available @@ -1498,6 +1525,20 @@ def __paginate( return self._extend(select_kwargs={"limitby": (_from, _to)}, metadata=metadata) + def __paginate( + self, + limit: int, + page: int = 1, + ) -> "QueryBuilder[T_MetaInstance]": + return self.__paginate_builder(self.__pagination_count(), limit, page) + + async def __paginate_async( + self, + limit: int, + page: int = 1, + ) -> "QueryBuilder[T_MetaInstance]": + return self.__paginate_builder(await self.__pagination_count_async(), limit, page) + def paginate(self, limit: int, page: int = 1, verbose: bool = False) -> "PaginatedRows[T_MetaInstance]": """ Paginate transforms the more readable `page` and `limit` to pydals internal limit and offset. @@ -1520,8 +1561,16 @@ async def paginate_async(self, limit: int, page: int = 1, verbose: bool = False) Note: `__pagination_count()` (the row-count step done before paginating) also hits the DB and needs its own async path internally - not exposed as a separate public method. """ - # FIXME(async): Implement pagination, including its asynchronous count step. - raise NotImplementedError + require_permission(self._permissions, "read") + builder = await self.__paginate_async(limit, page) + + rows = t.cast( + PaginatedRows[T_MetaInstance], + await builder.collect_async(verbose=verbose, _to=PaginatedRows), + ) + + rows._query_builder = builder + return rows def _paginate( self, @@ -1557,9 +1606,17 @@ async def chunk_async(self, chunk_size: int) -> t.AsyncGenerator[TypedRows[T_Met """ Async twin of `chunk()`. An async generator (`async for`), built on `collect_async()`. """ - # FIXME(async): Implement this async generator on top of `collect_async()`. - raise NotImplementedError - yield # pragma: no cover # makes this an async generator for type-checking purposes + require_permission(self._permissions, "read") + page = 1 + + while True: + builder = await self.__paginate_async(chunk_size, page) + rows = await builder.collect_async() + if not rows: + return + + yield rows + page += 1 def first(self, verbose: bool = False) -> T_MetaInstance | None: """ @@ -1582,8 +1639,17 @@ async def first_async(self, verbose: bool = False) -> T_MetaInstance | None: """ Async twin of `first()`. Thin wrapper: builds on `paginate_async()`. """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + require_permission(self._permissions, "read") + paginated = await self.paginate_async(page=1, limit=1, verbose=verbose) + row = paginated.first() + if not row: + return None + + if not isinstance(self.model, TableMeta): + # old-style pydal table: keep pydal semantics and return raw Row + return row + + return self.model.from_row(row) def _first(self) -> str: return self._paginate(page=1, limit=1) @@ -1603,8 +1669,8 @@ async def first_or_fail_async( """ Async twin of `first_or_fail()`. Thin wrapper: builds on `first_async()`. """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + require_permission(self._permissions, "read") + return await self.first_async(verbose=verbose) or throw(exception or ValueError("Nothing found!")) # note: these imports exist at the bottom of this file to prevent circular import issues: diff --git a/src/typedal/tables.py b/src/typedal/tables.py index 11ec27d..0651288 100644 --- a/src/typedal/tables.py +++ b/src/typedal/tables.py @@ -189,8 +189,7 @@ async def all_async(self: t.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]" """ Async twin of `all()`. Thin wrapper: builds on `collect_async()`. """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await self.collect_async() def get_relationships(self) -> dict[str, Relationship[t.Any]]: """ @@ -261,9 +260,19 @@ def bulk_insert(self: t.Type[T_MetaInstance], items: list[AnyDict]) -> "TypedRow async def bulk_insert_async(self: t.Type[T_MetaInstance], items: list[AnyDict]) -> "TypedRows[T_MetaInstance]": """ Async twin of `bulk_insert()`. + + pydal's `Table.bulk_insert()` (objects.py:1113-1124) only exists to hand the whole batch + to `adapter.bulk_insert()`, which for every backend TypeDAL supports asynchronously is + itself a loop over `insert()` - so looping `insert_async()` here loses nothing and keeps + the hook/normalization dance in one place. """ - # FIXME(async): Implement bulk insertion and async result collection. - raise NotImplementedError + self._ensure_table_defined() + require_permission(self._permissions, "insert") + + inserted = [await self.insert_async(**item) for item in items] + ids = [row.id for row in inserted] + + return await self.where(lambda row: row.id.belongs(ids)).collect_async() def update_or_insert( self: t.Type[T_MetaInstance], @@ -297,9 +306,42 @@ async def update_or_insert_async( ) -> T_MetaInstance: """ Async twin of `update_or_insert()`. + + The sync version leans on pydal's `table(...)` call syntax for the lookup, which is a + synchronous select; `_lookup_query()` turns the same three input shapes into a plain + Query so the lookup can go through `first_async()` instead. """ - # FIXME(async): Implement this wrapper using async lookup, update, and insert paths. - raise NotImplementedError + record = await QueryBuilder(self).where(self._lookup_query(query, values)).first_async() + + if not record: + return await self.insert_async(**values) + + return await record.update_record_async(**values) + + def _lookup_query( + self: t.Type[T_MetaInstance], + query: T_Query | AnyDict | None, + values: AnyDict, + ) -> Query: + """ + Turn `update_or_insert`'s three input shapes (DEFAULT / dict / Query) into one Query. + + Mirrors pydal's `Table.update_or_insert()` (objects.py:1067-1073): no query means + "match on the values you were going to write", a dict means "match on these fields". + """ + table = self._ensure_table_defined() + + if query is not DEFAULT and not isinstance(query, dict): + return t.cast(Query, query) + + criteria = values if query is DEFAULT else t.cast(AnyDict, query) + + result = None + for key, value in criteria.items(): + condition = table[key] == value + result = condition if result is None else (result & condition) + + return t.cast(Query, result) def validate_and_insert( self: t.Type[T_MetaInstance], @@ -324,9 +366,18 @@ async def validate_and_insert_async( ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]: """ Async twin of `validate_and_insert()`. + + Mirrors pydal's `Table.validate_and_insert()` (objects.py:1039-1042): `_validate_fields()` + is pure (no I/O), so only the insert step needs an async twin. """ - # FIXME(async): Implement validation and insertion through the async path. - raise NotImplementedError + table = self._ensure_table_defined() + require_permission(self._permissions, "insert") + + errors, new_fields = table._validate_fields(fields) + if errors: + return None, errors + + return await self.insert_async(**new_fields), None def validate_and_update( self: t.Type[T_MetaInstance], @@ -358,9 +409,24 @@ async def validate_and_update_async( ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]: """ Async twin of `validate_and_update()`. + + Mirrors pydal's `Table.validate_and_update()` (objects.py:1044-1065): fetch the record, + validate against it (pure), then update. Both DB steps go through the async path. """ - # FIXME(async): Implement validation and update through the async path. - raise NotImplementedError + table = self._ensure_table_defined() + require_permission(self._permissions, "update") + + record = await QueryBuilder(self).where(query).first_async() + + errors, new_fields = table._validate_fields(fields, record._row if record else None) + if errors: + return None, errors + + if not record: # pragma: no cover + # update on query without result (shouldnt happen) + return None, None + + return await record.update_record_async(**new_fields), None def validate_and_update_or_insert( self: t.Type[T_MetaInstance], @@ -398,8 +464,10 @@ async def validate_and_update_or_insert_async( """ Async twin of `validate_and_update_or_insert()`. """ - # FIXME(async): Implement this wrapper using async validation paths. - raise NotImplementedError + if await QueryBuilder(self).where(query).exists_async(): + return await self.validate_and_update_async(query, **fields) + + return await self.validate_and_insert_async(**fields) def select(self: t.Type[T_MetaInstance], *a: t.Any, **kw: t.Any) -> "QueryBuilder[T_MetaInstance]": """ @@ -427,8 +495,7 @@ async def column_async[T: t.Any, T_MetaInstance: _TypedTable]( """ See QueryBuilder.column_async! """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await QueryBuilder(self).column_async(field, **options) def paginate(self: t.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]": """ @@ -442,8 +509,7 @@ async def paginate_async( """ See QueryBuilder.paginate_async! """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await QueryBuilder(self).paginate_async(limit=limit, page=page) def chunk(self: t.Type[T_MetaInstance], chunk_size: int) -> t.Generator["TypedRows[T_MetaInstance]", t.Any, None]: """ @@ -457,9 +523,8 @@ async def chunk_async( """ See QueryBuilder.chunk_async! """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError - yield # pragma: no cover # makes this an async generator for type-checking purposes + async for rows in QueryBuilder(self).chunk_async(chunk_size): + yield rows def where(self: t.Type[T_MetaInstance], *a: t.Any, **kw: t.Any) -> "QueryBuilder[T_MetaInstance]": """ @@ -521,8 +586,7 @@ async def exists_async(self: t.Type[T_MetaInstance]) -> bool: """ See QueryBuilder.exists_async! """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await QueryBuilder(self).exists_async() def first(self: t.Type[T_MetaInstance]) -> T_MetaInstance | None: """ @@ -534,8 +598,7 @@ async def first_async(self: t.Type[T_MetaInstance]) -> T_MetaInstance | None: """ See QueryBuilder.first_async! """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await QueryBuilder(self).first_async() def first_or_fail(self: t.Type[T_MetaInstance]) -> T_MetaInstance: """ @@ -547,8 +610,7 @@ async def first_or_fail_async(self: t.Type[T_MetaInstance]) -> T_MetaInstance: """ See QueryBuilder.first_or_fail_async! """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await QueryBuilder(self).first_or_fail_async() def join( self: t.Type[T_MetaInstance], @@ -595,8 +657,7 @@ async def collect_into_async[T_Into: _TypedTable]( """ See QueryBuilder.collect_into_async! """ - # FIXME(async): Implement this thin async wrapper. - raise NotImplementedError + return await QueryBuilder(self).collect_into_async(into=into, verbose=verbose, init=init) @property def ALL(cls) -> pydal.objects.SQLALL: @@ -885,6 +946,7 @@ class _TypedTable(metaclass=TableMeta): _after_update: list[t.Callable[[Set, t.Self], t.Optional[bool]] | t.Callable[[Set, OpRow], t.Optional[bool]]] _before_delete: list[t.Callable[[Set], t.Optional[bool]]] _after_delete: list[t.Callable[[Set], t.Optional[bool]]] + _row: Row | None _rows: tuple[Row, ...] _with: list[str] @@ -925,6 +987,10 @@ def update_record(self: t.Self, **fields: t.Any) -> t.Self: # Declared here for generic update flows; real behavior is implemented in TypedTable. raise NotImplementedError # pragma: no cover + async def update_record_async(self: t.Self, **fields: t.Any) -> t.Self: + # Declared here for generic async update flows; real behavior is implemented in TypedTable. + raise NotImplementedError # pragma: no cover + def as_dict(self, *args: t.Any, **kwargs: t.Any) -> AnyDict: # Broad signature keeps class/instance serialization overrides LSP-compatible. raise NotImplementedError # pragma: no cover @@ -1454,8 +1520,10 @@ async def update_async(cls: t.Type[T_MetaInstance], query: Query, **fields: t.An """ Async twin of `update()`. Thin wrapper: builds on `update_record_async()`. """ - # FIXME(async): Implement this wrapper using async record lookup and update. - raise NotImplementedError + if record := await QueryBuilder(cls).where(query).first_async(): + return await record.update_record_async(**fields) + else: + return None def _update(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: require_permission(getattr(self, "_permissions", None), "update") @@ -1482,9 +1550,21 @@ def update_record(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: # p async def update_record_async(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: """ Async twin of `update_record()`. + + Mirrors pydal's `RecordUpdater` (helpers/classes.py:349-359): drop anything that isn't a + writable column of this table, update by primary key, then mirror the new values onto the + in-memory row/instance - `_update()` does that last part for both the sync and async path. """ - # FIXME(async): Implement record updates on the async connection. - raise NotImplementedError + require_permission(getattr(self, "_permissions", None), "update") + row = self._ensure_matching_row() + cls = type(self) + table = cls._ensure_table_defined() + + new_fields = {k: v for k, v in fields.items() if k in table.fields and table[k].type != "id"} + + await QueryBuilder(cls).where(table._id == row[table._id.name]).update_async(**new_fields) + + return self._update(**new_fields) def _delete_record(self) -> int: """ @@ -1511,9 +1591,23 @@ def delete_record(self) -> int: # pragma: no cover async def delete_record_async(self) -> int: """ Async twin of `delete_record()`. + + Mirrors pydal's `RecordDeleter` (helpers/classes.py:362-364) plus `_delete_record()`'s + own bookkeeping: the instance is emptied afterwards, since the row is no more. """ - # FIXME(async): Implement record deletion on the async connection. - raise NotImplementedError + require_permission(getattr(self, "_permissions", None), "delete") + row = self._ensure_matching_row() + cls = type(self) + table = cls._ensure_table_defined() + + deleted = await QueryBuilder(cls).where(table._id == row[table._id.name]).delete_async() + + self.__dict__ = {} # empty self, since row is no more. + self._row = None # just to be sure + self._setup_instance_methods() + # ^ instance methods might've been deleted by emptying dict, + # but we still want .as_dict to show an error, not the table's as_dict. + return len(deleted) # __del__ is also called on the end of a scope so don't remove records on every del!! From c0dbd4c230253627e26a65f3ce671d9b322437ca Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 19:42:27 +0200 Subject: [PATCH 07/29] test(async): expand async execution parity and defect coverage --- tests/test_async_execution.py | 243 +++++++++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 7 deletions(-) diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 2f62d5a..e30a15a 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -21,6 +21,7 @@ import pytest_asyncio from src.typedal import TypeDAL, TypedField, TypedTable +from src.typedal.async_execution import ASYNC_POOL_FACTORIES from src.typedal.fields import DecimalField, JSONField @@ -230,11 +231,11 @@ class AsyncThingRaw(TypedTable): @pytest.mark.asyncio -async def test_collect_async_raises_on_relationships(db_async: TypeDAL): +async def test_collect_async_with_relationships_matches_sync(db_async: TypeDAL): """ - Deliberate, documented limitation, not a forgotten stub: collect_async() with - relationships/joins raises, because _collect_with_relationships() would need further - synchronous sub-queries reimplemented async first. This locks that behavior in as tested. + Relationships/joins must load through the async path too. Nothing on that path executes a + second query: the joins are in the single query built by `_before_query()` and + `_collect_with_relationships()` only maps already-fetched rows, so async parity is expected. """ db = db_async @@ -251,8 +252,20 @@ class AsyncThingRelMain(TypedTable): AsyncThingRelMain.insert(name="child", other=other_id) db.commit() - with pytest.raises(NotImplementedError): - await AsyncThingRelMain.join("other").collect_async() + sync_rows = AsyncThingRelMain.join("other").collect() + async_rows = await AsyncThingRelMain.join("other").collect_async() + + assert len(async_rows) == len(sync_rows) == 1 + + sync_row = sync_rows.first() + async_row = async_rows.first() + assert async_row.name == sync_row.name == "child" + assert async_row.other.name == sync_row.other.name == "parent" + + # and with a limitby, which routes through `_apply_limitby_optimization()`'s id-subquery: + paginated = await AsyncThingRelMain.join("other").paginate_async(limit=1, page=1) + assert len(paginated) == 1 + assert paginated.first().other.name == "parent" @pytest.mark.asyncio @@ -344,7 +357,7 @@ class AsyncThingPaginate(TypedTable): assert len(async_page) == len(sync_page) == 2 assert async_page.pagination["current_page"] == sync_page.pagination["current_page"] == 2 - assert async_page.pagination["rows"] == sync_page.pagination["rows"] == 5 + assert async_page.pagination["total_items"] == sync_page.pagination["total_items"] == 5 async_page_2 = await AsyncThingPaginate.paginate_async(limit=2, page=1) assert len(async_page_2) == 2 @@ -647,3 +660,219 @@ async def repeated_query(): gaps = [b - a for a, b in zip(ticks, ticks[1:])] # generous margin over the 5ms sleep interval; a blocking call would blow well past this assert max(gaps) < 0.05, f"event loop was blocked: max gap between ticks was {max(gaps) * 1000:.1f}ms" + + +# --------------------------------------------------------------------------- +# Known defects in the async execution path. +# +# Each test below asserts the behaviour the async path SHOULD have - in every case parity +# with the sync path it is a twin of. They fail against the current implementation; they are +# reproductions, not a regression net, and should go green as the defects are fixed. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_insert_async_honors_on_insert_error_hook(db_async: TypeDAL): + """ + pydal's `adapter.insert()` routes a failing INSERT through `table._on_insert_error` and + returns the hook's value (adapters/base.py:541-549). `db.insert_async()` does not, so the + same table diverges between sync and async on a constraint violation - while the sibling + `update_async()` twenty lines up already does honour `_on_update_error` (core.py:694-699). + """ + db = db_async + + @db.define() + class AsyncThingInsertError(TypedTable): + name = TypedField(str, unique=True) + + table = AsyncThingInsertError._ensure_table_defined() + table._on_insert_error = lambda _table, _fields, _e: "handled" + + AsyncThingInsertError.insert(name="dup") + db.commit() + + # sync: the hook swallows the integrity error and its return value comes back out + assert table.insert(name="dup") == "handled" + db.rollback() # the failed statement aborted the sync transaction (postgres) + + # async must do the same: + duplicate = table._fields_and_values_for_insert({"name": "dup"}).op_values() + assert await db.insert_async(table, duplicate) == "handled" + + +@pytest.mark.asyncio +async def test_get_async_pool_is_opened_once_under_concurrency( + db_async: TypeDAL, + monkeypatch: pytest.MonkeyPatch, +): + """ + `_get_async_pool()` checks `self._async_pool is None`, awaits the factory, then assigns + (core.py:602-612). Two coroutines whose first DB use overlaps both pass the check and both + open one: a second psycopg pool, or on SQLite a second aiosqlite connection. Only one is + stored; the other is dropped without `close()`, leaking the connection (and, for aiosqlite, + its background thread). + + The stand-in factory suspends before doing the real work. Both real factories contain + awaits, but *whether* a given one actually yields to the loop is a driver detail rather + than a guarantee - `aiosqlite.connect()` does, `psycopg_pool`'s `open()` currently does + not - and this is a test of `_get_async_pool()`'s check-then-assign, not of which drivers + happen to make it observable today. + """ + db = db_async + + # start from "never opened", whatever earlier tests on this session-scoped DAL did: + await db.close_async() + + dbengine = db._adapter.dbengine + real_factory = ASYNC_POOL_FACTORIES[dbengine] + opened = [] + + async def counting_factory(dal: TypeDAL): + await asyncio.sleep(0) # any await inside a factory is enough to open the window + pool = await real_factory(dal) + opened.append(pool) + return pool + + monkeypatch.setitem(ASYNC_POOL_FACTORIES, dbengine, counting_factory) + + try: + first, second = await asyncio.gather(db._get_async_pool(), db._get_async_pool()) + + assert first is second, "concurrent first use handed out two different pools" + assert len(opened) == 1, f"opened {len(opened)}, so {len(opened) - 1} was leaked unclosed" + finally: + # don't let this test's own leak poison the rest of the session: + for pool in opened: + if pool is not db._async_pool: + await pool.close() + + +@pytest.mark.asyncio +async def test_update_record_async_ignores_common_filters_like_sync(db_async: TypeDAL): + """ + pydal's `RecordUpdater` writes by primary key with `ignore_common_filters=True` + (helpers/classes.py:357), so a record you already hold can always be written back. + `update_record_async()` rebuilds that update through `QueryBuilder.update_async()` without + the flag, so `adapter._update()` re-applies the table's common filter (base.py:566-568 via + `use_common_filters`, helpers/methods.py:49-54) and a row the filter excludes - a + soft-deleted one, say - silently updates zero rows. + + Also reached by `validate_and_update_async()` and the update branch of + `update_or_insert_async()`, which both route through `update_record_async()`. + """ + db = db_async + + @db.define() + class AsyncThingCommonFilter(TypedTable): + name: TypedField[str] + archived: TypedField[bool] + + table = AsyncThingCommonFilter._ensure_table_defined() + + row_id = int(AsyncThingCommonFilter.insert(name="original", archived=True)) + db.commit() + + # hold the record from before the filter exists, as a soft-delete flow would + record = AsyncThingCommonFilter.where(AsyncThingCommonFilter.id == row_id).first() + + table._common_filter = lambda _query: table.archived == False # noqa: E712 + + try: + # sync twin writes straight through the filter: + record.update_record(name="sync-updated") + db.commit() + + # async twin must too: + await record.update_record_async(name="async-updated") + await db.commit_async() + finally: + table._common_filter = None + + fresh = AsyncThingCommonFilter.where(AsyncThingCommonFilter.id == row_id).first() + assert fresh.name == "async-updated" + + +@pytest.mark.asyncio +async def test_insert_async_lastrowid_does_not_read_shared_last_insert( + db_async: TypeDAL, + monkeypatch: pytest.MonkeyPatch, +): + """ + `postgres_lastrowid_async()` decides whether the INSERT it just ran carried a RETURNING + clause by reading `adapter._last_insert` (async_execution.py:176) - a property over + `THREAD_LOCAL._pydal_last_insert_` (pydal adapters/postgres.py:128-133). Coroutines share + one thread, so that thread-local provides no isolation whatsoever here: for the async path + it is effectively a global. + + `insert_async()` sets it via `adapter._insert()` (core.py:734) and reads it several awaits + later (core.py:745); any other insert landing in that window overwrites it. The window is + made deterministic here rather than raced: the statement built is a `DEFAULT VALUES` insert + (no fields -> no RETURNING, pydal postgres.py:149-162), while a concurrent normal insert + leaves the flag truthy - so lastrowid tries to `fetchone()` a result that does not exist. + """ + db = db_async + if db._adapter.dbengine != "postgres": + pytest.skip("only the postgres lastrowid strategy consults _last_insert") + + @db.define() + class AsyncThingLastInsert(TypedTable): + name = TypedField(str, notnull=False) + + table = AsyncThingLastInsert._ensure_table_defined() + adapter = db._adapter + real_get_pool = db._get_async_pool + + async def racing_get_pool(): + pool = await real_get_pool() + # stand-in for a concurrent insert_async() finishing its own adapter._insert(): + adapter._last_insert = (table._id, 1) + return pool + + monkeypatch.setattr(db, "_get_async_pool", racing_get_pool) + + # no fields -> INSERT INTO ... DEFAULT VALUES, which has no RETURNING clause + result = await db.insert_async(table, []) + + assert int(result) > 0 + + +@pytest.mark.asyncio +async def test_async_connection_is_not_shared_between_concurrent_coroutines(db_async: TypeDAL): + """ + `SqliteAsyncConnection.connection()` yields the single connection it wraps to every caller + (async_execution.py:95-103) and commits on clean exit / rolls back on exception. Two + coroutines inside it simultaneously are therefore in the *same* transaction, and whichever + exits first decides for both: a clean writer's row gets discarded by an unrelated failure, + or a failed writer's row gets committed by an unrelated success. + + `SqliteAsyncConnection`'s docstring promises every `_async` call is its own committed + transaction; that only holds while calls never overlap. Postgres passes this test, since + psycopg_pool hands out distinct connections. + """ + db = db_async + + @db.define() + class AsyncThingIsolation(TypedTable): + name: TypedField[str] + + tablename = str(AsyncThingIsolation) + pool = await db._get_async_pool() + both_inside = asyncio.Barrier(2) + + async def committing_writer(): + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('keep')") # noqa: S608 + await both_inside.wait() + # clean exit -> this row must survive + + async def failing_writer(): + with contextlib.suppress(RuntimeError): + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('discard')") # noqa: S608 + await both_inside.wait() + raise RuntimeError("boom") # -> this row must be rolled back + + await asyncio.gather(committing_writer(), failing_writer()) + + rows = await AsyncThingIsolation.collect_async() + assert sorted(row.name for row in rows) == ["keep"] From 24e96e262f54ac42851a1aefa1bc3b31d1643a5a Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 19:42:34 +0200 Subject: [PATCH 08/29] chore(testing): configure source-only coverage --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3eda790..226394e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,10 @@ upload_to_repository = false upload_to_release = false build_command = "hatch build" +[tool.edwh.test] +# measure coverage over src/ only, matching su6 +directory = "src" + ### required in every su6 pyproject: ### [tool.su6] directory = "src" From c0252fa9900fb526b25e816092915b40ca4a4e3c Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 21:05:34 +0200 Subject: [PATCH 09/29] fix(async): serialize SQLite execution and align async operations with pydal --- src/typedal/async_execution.py | 163 ++++++++-- src/typedal/core.py | 170 ++++++---- src/typedal/tables.py | 52 +++- tests/test_async_execution.py | 547 +++++++++++++++++++++++++++++++-- 4 files changed, 804 insertions(+), 128 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index a2d0c09..f990d6e 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -13,13 +13,77 @@ from __future__ import annotations +import asyncio import contextlib import typing as t +import pydal.objects + if t.TYPE_CHECKING: + from pydal.adapters.base import SQLAdapter + from .core import TypeDAL +# What pydal's `adapter._insert()` leaves behind to record whether the statement it just built +# carries a RETURNING clause: `(table._id, 1)` when it does, `None` when it does not +# (adapters/postgres.py:149-158). Backends without the concept never set it at all, hence None. +type LastInsert = tuple[pydal.objects.Field, int] | None + + +class AsyncCursor(t.Protocol): + """ + The slice of a psycopg / aiosqlite cursor that the async execution path actually uses. + + A Protocol rather than the real driver cursor types, because both drivers are *optional* + dependencies (`typedal[postgres-async]` / `typedal[sqlite-async]`): naming either one in a + signature would make type-checking TypeDAL require it to be installed. Structural typing + gets the checking without the dependency. + + Read-only properties rather than plain attributes so that both drivers match - psycopg and + aiosqlite both expose `rowcount`/`lastrowid`/`description` as properties, and a Protocol + declaring them as mutable attributes would reject exactly that. + """ + + @property + def rowcount(self) -> int: ... + + @property + def lastrowid(self) -> int | None: ... + + @property + def description(self) -> t.Any: ... + + async def execute(self, sql: str, parameters: t.Any = ..., /) -> t.Any: ... + + async def fetchone(self) -> t.Any: ... + + # `Iterable`, not `Sequence`: aiosqlite declares `fetchall() -> Iterable[sqlite3.Row]` + # (aiosqlite/cursor.py:66), so requiring a Sequence here would reject it. + async def fetchall(self) -> t.Iterable[t.Any]: ... + + +class AsyncConnection(t.Protocol): + """ + The slice of a psycopg / aiosqlite connection the async execution path uses. Same reasoning + as `AsyncCursor`. + + `cursor()` is typed as returning a context manager, not a cursor or an awaitable, because + that is the one shape both drivers share: psycopg's `cursor()` returns an `AsyncCursor` + that doubles as an async context manager, while aiosqlite's is decorated to return a + `Result[Cursor]` (aiosqlite/context.py) which is both awaitable *and* an async context + manager. `async with conn.cursor() as cur` is what works for both. + """ + + def cursor(self) -> t.AsyncContextManager[AsyncCursor]: ... + + async def commit(self) -> None: ... + + async def rollback(self) -> None: ... + + async def close(self) -> None: ... + + class AsyncConnectionPool(t.Protocol): """ Common shape `select_async()` etc. need from either a real connection pool (Postgres) or a @@ -33,7 +97,7 @@ class AsyncConnectionPool(t.Protocol): to do. Keeping both behind the same two methods keeps that difference out of core.py. """ - def connection(self) -> t.AsyncContextManager[t.Any]: ... + def connection(self) -> t.AsyncContextManager[AsyncConnection]: ... async def commit(self) -> None: ... @@ -60,8 +124,8 @@ class PostgresAsyncPool: def __init__(self, pool: t.Any) -> None: self._pool = pool - def connection(self) -> t.AsyncContextManager[t.Any]: - return t.cast(t.AsyncContextManager[t.Any], self._pool.connection()) + def connection(self) -> t.AsyncContextManager[AsyncConnection]: + return t.cast(t.AsyncContextManager[AsyncConnection], self._pool.connection()) async def commit(self) -> None: pass @@ -87,26 +151,44 @@ class SqliteAsyncConnection: table still locked for other readers/writers, including pydal's own sync connection) by the time an `_async` method returns. This makes every `_async` call its own committed transaction, matching what `PostgresAsyncPool` already gets for free from psycopg_pool. + + That promise only holds if calls do not overlap, hence `_lock`: a transaction belongs to + the *connection*, and there is only one, so two coroutines inside `connection()` at the + same time would share one transaction and the first to exit would decide for both - + committing the other's half-finished write, or rolling back a write that had succeeded. + psycopg_pool avoids this by handing out a different connection per caller; that is not an + option here (pydal itself runs SQLite at `pool_size = 0`, adapters/sqlite.py:26), and for + `sqlite:memory` it would actively break, since shared-cache mode answers a second + concurrent writer with SQLITE_LOCKED, which no busy-timeout retries. Serializing costs + concurrency SQLite does not have for writes anyway - it allows exactly one writer. """ - def __init__(self, conn: t.Any) -> None: + def __init__(self, conn: AsyncConnection) -> None: self._conn = conn + # created here rather than bound eagerly: asyncio.Lock() only attaches to a loop on + # first acquire, and this object is built inside `open_sqlite_async_connection()`. + self._lock = asyncio.Lock() @contextlib.asynccontextmanager - async def connection(self) -> t.AsyncIterator[t.Any]: - try: - yield self._conn - except BaseException: - await self._conn.rollback() - raise - else: - await self._conn.commit() + async def connection(self) -> t.AsyncIterator[AsyncConnection]: + async with self._lock: + try: + yield self._conn + except BaseException: + await self._conn.rollback() + raise + else: + await self._conn.commit() async def commit(self) -> None: - await self._conn.commit() + # also under the lock: committing mid-way through another coroutine's `connection()` + # block would commit its partial work, the same bug from the other direction. + async with self._lock: + await self._conn.commit() async def rollback(self) -> None: - await self._conn.rollback() + async with self._lock: + await self._conn.rollback() async def close(self) -> None: await self._conn.close() @@ -163,17 +245,28 @@ async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: } -async def postgres_lastrowid_async(adapter: t.Any, table: t.Any, cursor: t.Any) -> t.Any: +async def postgres_lastrowid_async( + adapter: SQLAdapter, + table: pydal.objects.Table, + cursor: AsyncCursor, + last_insert: LastInsert, +) -> int | None: """ Async twin of `Postgre.lastrowid()` (pydal adapters/postgres.py:142-147). - `adapter._last_insert` was already set as a side effect of the `_insert()` call that built - the INSERT statement (postgres.py:149-162, sets it whenever the table has a standard `_id` - column) - if so, the id is already in the RETURNING result of the statement just executed, - read here with a plain `fetchone()`, no extra round trip. Otherwise (tables with a custom - `_primarykey` not covered by RETURNING), fall back to `currval()`, a real second query. + `last_insert` is the value `adapter._insert()` set as a side effect of building the INSERT + statement (postgres.py:149-162, set whenever the table has a standard `_id` column), passed + in by `insert_async()` rather than read back off the adapter here. It has to be passed: + `adapter._last_insert` is a property over `THREAD_LOCAL._pydal_last_insert_` + (postgres.py:128-133), and every coroutine on this path shares one thread, so reading it + after the intervening awaits would see whichever insert touched it last. + + Truthy means the id is already in the RETURNING result of the statement just executed, read + here with a plain `fetchone()`, no extra round trip. Otherwise (a custom `_primarykey` not + covered by RETURNING, or a `DEFAULT VALUES` insert) fall back to `currval()`, a real second + query - on this same connection, so it sees this insert's sequence value. """ - if getattr(adapter, "_last_insert", None): + if last_insert: row = await cursor.fetchone() return int(row[0]) @@ -183,23 +276,32 @@ async def postgres_lastrowid_async(adapter: t.Any, table: t.Any, cursor: t.Any) return int(row[0]) -async def sqlite_lastrowid_async(adapter: t.Any, table: t.Any, cursor: t.Any) -> t.Any: +async def sqlite_lastrowid_async( + _adapter: SQLAdapter, + _table: pydal.objects.Table, + cursor: AsyncCursor, + _last_insert: LastInsert, +) -> int | None: """ Async twin of the base `SQLAdapter.lastrowid()` (pydal adapters/base.py:529-530), used by - SQLite (no override there). `cursor.lastrowid` is a plain attribute, not awaitable. + SQLite (no override there). `cursor.lastrowid` is a plain attribute, not awaitable, and + needs no `last_insert` - it takes the argument only to share one strategy signature. """ return cursor.lastrowid # One lastrowid strategy per backend, mirroring `ASYNC_POOL_FACTORIES` - `insert_async()` looks # this up by `adapter.dbengine` rather than branching, same reasoning as the pool factories above. -LASTROWID_STRATEGIES: dict[str, t.Callable[[t.Any, t.Any, t.Any], t.Awaitable[t.Any]]] = { +LASTROWID_STRATEGIES: dict[ + str, + t.Callable[[SQLAdapter, pydal.objects.Table, AsyncCursor, LastInsert], t.Awaitable[int | None]], +] = { "postgres": postgres_lastrowid_async, "sqlite": sqlite_lastrowid_async, } -async def base_delete_async(db: "TypeDAL", table: t.Any, query: t.Any) -> t.Any: +async def base_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: pydal.objects.Query) -> int | None: """ Async twin of the base `SQLAdapter.delete()` (pydal adapters/base.py:604-610): plain build/execute sandwich, no cascade handling. Used directly for Postgres (no override @@ -214,11 +316,13 @@ async def base_delete_async(db: "TypeDAL", table: t.Any, query: t.Any) -> t.Any: await cur.execute(sql) try: return cur.rowcount - except Exception: # noqa: BLE001 + except Exception: # pragma: no cover + # defensive, mirroring `adapter.delete()` (adapters/base.py:607-610): + # neither driver's `rowcount` actually raises, it is a plain property. return None -async def sqlite_delete_async(db: "TypeDAL", table: t.Any, query: t.Any) -> t.Any: +async def sqlite_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: pydal.objects.Query) -> int | None: """ Async twin of `SQLite.delete()` (pydal adapters/sqlite.py:93-104) - NOT a plain sandwich: selects affected ids first, deletes, then recurses per cascaded FK with @@ -243,7 +347,10 @@ async def sqlite_delete_async(db: "TypeDAL", table: t.Any, query: t.Any) -> t.An # One delete strategy per backend, same reasoning as `ASYNC_POOL_FACTORIES`/`LASTROWID_STRATEGIES` # - SQLite's isn't a plain sandwich (see `sqlite_delete_async`), Postgres's is. -DELETE_STRATEGIES: dict[str, t.Callable[["TypeDAL", t.Any, t.Any], t.Awaitable[t.Any]]] = { +DELETE_STRATEGIES: dict[ + str, + t.Callable[["TypeDAL", pydal.objects.Table, pydal.objects.Query], t.Awaitable[int | None]], +] = { "postgres": base_delete_async, "sqlite": sqlite_delete_async, } diff --git a/src/typedal/core.py b/src/typedal/core.py index de5aaec..23ad1f1 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -5,13 +5,13 @@ from __future__ import annotations # noinspection PyUnusedImports +import asyncio import collections import datetime as dt import sys import typing as t import warnings from pathlib import Path -from typing import Optional import pydal @@ -34,7 +34,7 @@ from annotationlib import ForwardRef except ImportError: # pragma: no cover # python 3.13- - from typing import ForwardRef + from typing import ForwardRef # special case, keep `from typing` if t.TYPE_CHECKING: from .fields import TypedField @@ -101,7 +101,7 @@ def evaluate_forward_reference_312(fw_ref: ForwardRef, namespace: dict[str, type """ return t.cast( type, - fw_ref._evaluate( + fw_ref._evaluate( # ty: ignore[deprecated] localns=locals(), globalns=globals() | namespace, recursive_guard=frozenset(), @@ -117,7 +117,7 @@ def evaluate_forward_reference_313(fw_ref: ForwardRef, namespace: dict[str, type """ return t.cast( type, - fw_ref._evaluate( + fw_ref._evaluate( # ty: ignore[deprecated] localns=locals(), globalns=globals() | namespace, recursive_guard=frozenset(), @@ -165,7 +165,7 @@ def resolve_annotation_313(ftype: str, namespace: dict[str, type] | None = None) Variant for Python 3.13 """ - fw_ref: ForwardRef = t.get_args(t.Type[ftype])[0] + fw_ref: ForwardRef = t.get_args(t.Type[ftype])[0] # ty: ignore[invalid-type-form] return evaluate_forward_reference(fw_ref, namespace=namespace) @@ -240,34 +240,34 @@ class TypeDAL(_TypeDALBase): def __init__( self, - uri: Optional[str] = None, # default from config or 'sqlite:memory' - pool_size: int = None, # default 1 if sqlite else 3 - folder: Optional[str | Path] = None, # default 'databases' in config + uri: str | None = None, # default from config or 'sqlite:memory' + pool_size: int | None = None, # default 1 if sqlite else 3 + folder: str | Path | None = None, # default 'databases' in config db_codec: str = "UTF-8", - check_reserved: Optional[list[str]] = None, - migrate: Optional[bool] = None, # default True by config - fake_migrate: Optional[bool] = None, # default False by config + check_reserved: list[str] | None = None, + migrate: bool | None = None, # default True by config + fake_migrate: bool | None = None, # default False by config migrate_enabled: bool = True, fake_migrate_all: bool = False, decode_credentials: bool = False, - driver_args: Optional[AnyDict] = None, - adapter_args: Optional[AnyDict] = None, + driver_args: AnyDict | None = None, + adapter_args: AnyDict | None = None, attempts: int = 5, auto_import: bool = False, bigint_id: bool = False, debug: bool = False, lazy_tables: bool = False, - db_uid: Optional[str] = None, - after_connection: t.Callable[..., t.Any] = None, - tables: Optional[list[str]] = None, + db_uid: str | None = None, + after_connection: t.Callable[..., t.Any] | None = None, + tables: list[str] | None = None, ignore_field_case: bool = True, entity_quoting: bool = True, - table_hash: Optional[str] = None, - enable_typedal_caching: bool = None, + table_hash: str | None = None, + enable_typedal_caching: bool | None = None, use_pyproject: bool | str = True, use_env: bool | str = True, - connection: Optional[str] = None, - config: Optional[TypeDALConfig] = None, + connection: str | None = None, + config: TypeDALConfig | None = None, lazy_policy: LazyPolicy | None = None, ) -> None: """ @@ -296,6 +296,8 @@ def __init__( self._before_execute = [] self._after_execute = [] self._async_pool: AsyncConnectionPool | None = None # lazily-created; see _get_async_pool + self._async_pool_lock: asyncio.Lock | None = None # guards that creation; see _get_async_lock + self._async_pool_lock_loop: asyncio.AbstractEventLoop | None = None if config.folder: Path(config.folder).mkdir(exist_ok=True) @@ -334,7 +336,7 @@ def close(self) -> None: """Close the database connection and unbind all defined TypedTable models.""" adapter = self._adapter try: - super().close() + super().close() # ty: ignore[unresolved-attribute] finally: for model in set(self._builder.class_map.values()): model.unbind() @@ -461,7 +463,7 @@ def wrapper(cls: t.Type[T]) -> t.Type[T]: return wrapper - def __call__(self, *_args: T_Query, **kwargs: t.Any) -> "TypedSet": + def __call__(self, *_args: T_Query, **kwargs: t.Any) -> "TypedSet": # ty: ignore[invalid-method-override] """ A db instance can be called directly to perform a query. @@ -494,7 +496,7 @@ def __getitem__(self, key: str) -> "Table": Example: db['users'] -> user """ - return t.cast(Table, super().__getitem__(str(key))) + return t.cast(Table, super().__getitem__(str(key))) # ty: ignore[unresolved-attribute] def find_model(self, table_name: str) -> t.Type["TypedTable"] | None: """ @@ -541,7 +543,7 @@ def executesql( fields: t.Iterable[Field | TypedField[t.Any]] | None = None, colnames: t.Iterable[str] | None = None, as_ordered_dict: bool = False, - ) -> list[t.Any]: + ) -> list[t.Any] | None: """ Executes a raw SQL statement or a TypeDAL template query. @@ -574,7 +576,7 @@ def executesql( if SYSTEM_SUPPORTS_TEMPLATES and isinstance(query, Template): # pragma: no cover query = sql_escape_template(self, query) - rows: list[t.Any] = super().executesql( + rows: list[t.Any] = super().executesql( # ty: ignore[unresolved-attribute] query, placeholders=placeholders, as_dict=as_dict, @@ -589,6 +591,24 @@ def executesql( # Async execution path. # ------------------------------------------------------------------ + def _get_async_pool_lock(self) -> asyncio.Lock: + """ + The lock guarding lazy pool creation, bound to the loop currently running. + + Not created once in `__init__`: an `asyncio.Lock` binds to the loop it is first used on + and refuses use from another one, while a `TypeDAL` instance can outlive a loop (every + pytest-asyncio test gets a fresh one, and `close_async()` explicitly supports reopening). + Re-created when the loop changed - which is safe to decide here because this method + never awaits, so two coroutines on the same loop cannot interleave inside it and always + come away with the same lock object. + """ + loop = asyncio.get_running_loop() + if self._async_pool_lock is None or self._async_pool_lock_loop is not loop: + self._async_pool_lock = asyncio.Lock() + self._async_pool_lock_loop = loop + + return self._async_pool_lock + async def _get_async_pool(self) -> AsyncConnectionPool: """ Lazily create the async connection (a real pool for Postgres, a single wrapped @@ -598,18 +618,29 @@ async def _get_async_pool(self) -> AsyncConnectionPool: from pydal's own thread-local sync connection: they are two independent transactions, so a write on one is invisible to a read on the other until committed, and commit()/rollback() on one says nothing about the other. - """ - if self._async_pool is None: - dbengine = self._adapter.dbengine - try: - factory = ASYNC_POOL_FACTORIES[dbengine] - except KeyError: - raise NotImplementedError( - f"The async execution path is only implemented for " - f"{', '.join(ASYNC_POOL_FACTORIES)}, not {dbengine!r}.", - ) from None - self._async_pool = await factory(self) + Creation is done under a lock with the check repeated inside it: the factories await, + so a plain `if self._async_pool is None: ... = await factory(self)` lets two coroutines + whose first use overlaps both pass the check and both open one. Only one could be + stored, and the other would be dropped without `close()` - a leaked pool, or on SQLite + a leaked connection and its background thread. + """ + if self._async_pool is not None: + # fast path: already open, no need to take the lock at all + return self._async_pool + + async with self._get_async_pool_lock(): + if self._async_pool is None: + dbengine = self._adapter.dbengine + try: + factory = ASYNC_POOL_FACTORIES[dbengine] + except KeyError: + raise NotImplementedError( + f"The async execution path is only implemented for " + f"{', '.join(ASYNC_POOL_FACTORIES)}, not {dbengine!r}.", + ) from None + + self._async_pool = await factory(self) return self._async_pool @@ -695,11 +726,13 @@ async def update_async( await cur.execute(sql) except Exception as e: if hasattr(table, "_on_update_error"): - return t.cast(t.Optional[int], table._on_update_error(table, query, fields, e)) + return t.cast(t.Optional[int], table._on_update_error(table, query, fields, e)) # ty: ignore[call-non-callable] raise try: - return t.cast(int, cur.rowcount) - except Exception: # noqa: BLE001 + return cur.rowcount + except Exception: # pragma: no cover + # defensive, mirroring `adapter.update()` (adapters/base.py:590-593): + # neither driver's `rowcount` actually raises, it is a plain property. return None async def delete_async( @@ -733,25 +766,48 @@ async def insert_async( adapter = self._adapter query = adapter._insert(table, fields) + # Capture `_last_insert` here, synchronously, right after the `_insert()` that set it: + # on Postgres it is a property over `THREAD_LOCAL._pydal_last_insert_` (pydal + # adapters/postgres.py:128-133), and coroutines share one thread, so that thread-local + # provides no isolation at all on this path. Reading it after the awaits below would + # read whichever concurrent insert_async() touched it last, not our own. + last_insert = getattr(adapter, "_last_insert", None) + pool = await self._get_async_pool() async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(query) + try: + await cur.execute(query) + except Exception as e: + # mirrors `adapter.insert()` (adapters/base.py:544-549), same as `update_async`: + if hasattr(table, "_on_insert_error"): + return table._on_insert_error(table, fields, e) # ty: ignore[call-non-callable] + raise if hasattr(table, "_primarykey"): - pkdict = {k[0].name: k[1] for k in fields if k[0].name in table._primarykey} + pkdict = {k[0].name: k[1] for k in fields if k[0].name in table._primarykey} # ty: ignore[unsupported-operator] if pkdict: return pkdict - id_ = await LASTROWID_STRATEGIES[adapter.dbengine](adapter, table, cur) + row_id = await LASTROWID_STRATEGIES[adapter.dbengine](adapter, table, cur, last_insert) + + # a table with a single custom primarykey reports its id as a `{name: value}` dict + # instead of a bare int, matching `adapter.insert()` (adapters/base.py:556-563): + primarykey = getattr(table, "_primarykey", None) + if primarykey is not None and len(primarykey) == 1: # pragma: no cover + # unreachable on both supported backends: pydal makes `_primarykey` columns NOT + # NULL, so an insert omitting the pk fails in the database before the id it would + # have filled in here could ever be read back. Kept to match `adapter.insert()` + # (adapters/base.py:556-559) for backends that can generate one. + return {table._primarykey[0]: row_id} # ty: ignore[not-subscriptable] - if hasattr(table, "_primarykey") and len(table._primarykey) == 1: - id_ = {table._primarykey[0]: id_} - if not isinstance(id_, int): - return id_ + if not isinstance(row_id, int): # pragma: no cover + # a driver reporting no lastrowid at all; neither supported backend does. + return row_id - rid = pydal.helpers.classes.Reference(id_) - rid._table, rid._record = table, None - return rid + reference = pydal.helpers.classes.Reference(row_id) # ty: ignore[possibly-missing-submodule] + reference._table = table + reference._record = None + return reference async def executesql_async( self, @@ -761,7 +817,7 @@ async def executesql_async( fields: t.Iterable[Field | TypedField[t.Any]] | None = None, colnames: t.Iterable[str] | None = None, as_ordered_dict: bool = False, - ) -> list[t.Any]: + ) -> list[t.Any] | None: """ Async twin of `executesql(...)`. @@ -783,7 +839,9 @@ async def executesql_async( await cur.execute(query) if as_dict or as_ordered_dict: - if not hasattr(cur, "description"): + if not hasattr(cur, "description"): # pragma: no cover + # both supported drivers always expose it; guard kept for parity with + # pydal's own `executesql`. raise RuntimeError("database does not support executesql_async(...,as_dict=True)") columns = cur.description @@ -795,8 +853,10 @@ async def executesql_async( ) if columns: for i in range(len(result_fields)): - if isinstance(result_fields[i], bytes): - result_fields[i] = result_fields[i].decode("utf8") + if isinstance(result_fields[i], bytes): # pragma: no cover + # psycopg and aiosqlite both report column names as str; this is + # for drivers that hand back bytes, as pydal's `executesql` allows. + result_fields[i] = result_fields[i].decode("utf8") # ty: ignore[unresolved-attribute] data = await cur.fetchall() _dict = collections.OrderedDict if as_ordered_dict else dict @@ -804,7 +864,7 @@ async def executesql_async( try: data = await cur.fetchall() - except Exception: # noqa: BLE001 + except Exception: return None if fields or colnames: @@ -915,7 +975,7 @@ def memoize[T: t.Any]( Returns: Cached result or fresh computation """ - return memoize(self, func, *args, key=key, ttl=ttl, **kwargs) + return memoize(self, func, *args, key=key, ttl=ttl, **kwargs) # ty: ignore[invalid-argument-type] def as_typescript(self, *tables: str | type[TypedTable]) -> str: """ diff --git a/src/typedal/tables.py b/src/typedal/tables.py index 0651288..c23f27f 100644 --- a/src/typedal/tables.py +++ b/src/typedal/tables.py @@ -15,6 +15,7 @@ import pydal.objects from pydal._globals import DEFAULT +from pydal.helpers.classes import SQLCallableList from .constants import JOIN_OPTIONS from .core import TypeDAL @@ -71,7 +72,7 @@ def reorder_fields( # Start with desired fields, then append the rest new_order.extend(f for f in table._fields if f not in desired) - table._fields = new_order + table._fields = t.cast(SQLCallableList, new_order) class TableMeta(type): @@ -276,7 +277,7 @@ async def bulk_insert_async(self: t.Type[T_MetaInstance], items: list[AnyDict]) def update_or_insert( self: t.Type[T_MetaInstance], - query: T_Query | AnyDict = DEFAULT, + query: T_Query | AnyDict | t.Callable[[], None] = DEFAULT, **values: t.Any, ) -> T_MetaInstance: """ @@ -301,7 +302,7 @@ def update_or_insert( async def update_or_insert_async( self: t.Type[T_MetaInstance], - query: T_Query | AnyDict = DEFAULT, + query: T_Query | AnyDict | t.Callable[[], None] = DEFAULT, **values: t.Any, ) -> T_MetaInstance: """ @@ -320,7 +321,7 @@ async def update_or_insert_async( def _lookup_query( self: t.Type[T_MetaInstance], - query: T_Query | AnyDict | None, + query: T_Query | AnyDict | t.Callable[[], None] | None, values: AnyDict, ) -> Query: """ @@ -708,11 +709,11 @@ def drop_index(self, name: str, if_exists: bool = False) -> bool: def import_from_csv_file( self, csvfile: t.TextIO, - id_map: dict[str, str] = None, + id_map: dict[str, str] | None = None, null: t.Any = "", unique: str = "uuid", - id_offset: dict[str, int] = None, # id_offset used only when id_map is None - transform: t.Callable[[dict[t.Any, t.Any]], dict[t.Any, t.Any]] = None, + id_offset: dict[str, int] | None = None, # id_offset used only when id_map is None + transform: t.Callable[[dict[t.Any, t.Any]], dict[t.Any, t.Any]] | None = None, validate: bool = False, encoding: str = "utf-8", delimiter: str = ",", @@ -919,6 +920,7 @@ def reorder_fields(cls, *fields: str | Field | TypedField[t.Any], keep_others: b - True (default): keep other fields at the end, in their original order. - False: remove other fields (only keep what's specified). """ + assert cls._table is not None, "TypedTable.reorder_fields() requires a bound table" return reorder_fields(cls._table, fields, keep_others=keep_others) @@ -1155,7 +1157,7 @@ def _setup_instance_methods(self) -> None: def __new__( cls, - row_or_id: t.Union[Row, Query, pydal.objects.Set, int, str, None, "TypedTable"] = None, + row_or_id: t.Union[Row, Query, pydal.objects.Set, int, str, "TypedTable", None] = None, **filters: t.Any, ) -> t.Self: """ @@ -1186,7 +1188,7 @@ def __new__( if not row: return None # type: ignore - inst._row = row + inst._row = t.cast(Row, row) if hasattr(row, "id"): inst.__dict__.update(row) @@ -1205,7 +1207,7 @@ def __iter__(self) -> t.Generator[t.Any, None, None]: row = self._ensure_matching_row() yield from iter(row) - def __getitem__(self, item: str) -> t.Any: + def __getitem__(self, item: str) -> t.Any: # ty: ignore[invalid-method-override] """ Allows dictionary notation to get columns. """ @@ -1476,8 +1478,8 @@ def asdict_method(obj: t.Any) -> t.Any: # pragma: no cover def _as_json( self, - default: t.Callable[[t.Any], t.Any] = None, - indent: t.Optional[int] = None, + default: t.Callable[[t.Any], t.Any] | None = None, + indent: int | None = None, **kwargs: t.Any, ) -> str: data = self._as_dict() @@ -1554,6 +1556,12 @@ async def update_record_async(self: T_MetaInstance, **fields: t.Any) -> T_MetaIn Mirrors pydal's `RecordUpdater` (helpers/classes.py:349-359): drop anything that isn't a writable column of this table, update by primary key, then mirror the new values onto the in-memory row/instance - `_update()` does that last part for both the sync and async path. + + Including `ignore_common_filters=True`, which `RecordUpdater` passes (classes.py:357): + a record you already hold must always be writable back, even when the table has a + common filter that excludes it - a soft-deleted row, say. Without it `adapter._update()` + re-applies that filter (adapters/base.py:566-568 via `use_common_filters`) and the + update silently matches zero rows. """ require_permission(getattr(self, "_permissions", None), "update") row = self._ensure_matching_row() @@ -1562,7 +1570,12 @@ async def update_record_async(self: T_MetaInstance, **fields: t.Any) -> T_MetaIn new_fields = {k: v for k, v in fields.items() if k in table.fields and table[k].type != "id"} - await QueryBuilder(cls).where(table._id == row[table._id.name]).update_async(**new_fields) + query = t.cast(Query, table._id == row[table._id.name]) + # what `db(query, ignore_common_filters=True)` does under the hood (objects.py:2775-2779); + # set on the Query itself because that object is what reaches `adapter._update()`: + query.ignore_common_filters = True + + await QueryBuilder(cls, query).update_async(**new_fields) return self._update(**new_fields) @@ -1653,9 +1666,9 @@ def _sql(cls) -> str: except ImportError as e: # pragma: no cover raise RuntimeError("Can not generate SQL without the 'migration' extra or `pydal2sql` installed!") from e - return pydal2sql.generate_sql(cls) + return pydal2sql.generate_sql(cls) # ty: ignore[invalid-argument-type] - def render(self, fields: list[Field] = None, compact: bool = False) -> t.Self: + def render(self, fields: list[Field] | None = None, compact: bool = False) -> t.Self: """ Renders a copy of the object with potentially modified values. @@ -1666,6 +1679,9 @@ def render(self, fields: list[Field] = None, compact: bool = False) -> t.Self: Returns: A copy of the object with potentially modified values. """ + assert self._db is not None, "TypedTable.render() requires a bound database" + assert self._table is not None, "TypedTable.render() requires a bound table" + assert self._relationships is not None, "TypedTable.render() requires relationship metadata" row = copy.deepcopy(self) keys = list(row) if not fields: @@ -1687,6 +1703,7 @@ def render(self, fields: list[Field] = None, compact: bool = False) -> t.Self: relation_table = relation.table if isinstance(relation_table, str): relation_table = self._db[relation_table] + assert relation_table is not None, f"Relationship {relation_name!r} has no table" relation_row = row[relation_name] @@ -1699,7 +1716,7 @@ def render(self, fields: list[Field] = None, compact: bool = False) -> t.Self: for related_og in relation_row: related = copy.deepcopy(related_og) for fieldname in related: - field = relation_table[fieldname] + field = relation_table[fieldname] # ty: ignore[not-subscriptable] related[field.name] = self._db.represent( "rows_render", field, @@ -1711,8 +1728,9 @@ def render(self, fields: list[Field] = None, compact: bool = False) -> t.Self: row[relation_name] = combined else: # 1 row + assert relation_row is not None, f"Relationship {relation_name!r} has no row" for fieldname in relation_row: - field = relation_table[fieldname] + field = relation_table[fieldname] # ty: ignore[not-subscriptable] row[relation_name][fieldname] = self._db.represent( "rows_render", field, diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index e30a15a..23ae197 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -10,19 +10,23 @@ - decimal(10,2) -> Decimal and the actual point of the exercise: the event loop is not blocked while the query runs. """ + import asyncio +import collections import contextlib import tempfile import time import typing as t from decimal import Decimal +import pydal.objects import pytest import pytest_asyncio from src.typedal import TypeDAL, TypedField, TypedTable from src.typedal.async_execution import ASYNC_POOL_FACTORIES from src.typedal.fields import DecimalField, JSONField +from src.typedal.query_builder import QueryBuilder @contextlib.asynccontextmanager @@ -543,7 +547,8 @@ class AsyncThingValidateUpdate(TypedTable): db.commit() row, errors = await AsyncThingValidateUpdate.validate_and_update_async( - AsyncThingValidateUpdate.id == int(existing_id), qty=9, + AsyncThingValidateUpdate.id == int(existing_id), + qty=9, ) await db.commit_async() assert errors is None @@ -551,7 +556,8 @@ class AsyncThingValidateUpdate(TypedTable): assert row.qty == 9 _row, errors = await AsyncThingValidateUpdate.validate_and_update_async( - AsyncThingValidateUpdate.id == int(existing_id), qty="not-a-number", + AsyncThingValidateUpdate.id == int(existing_id), + qty="not-a-number", ) assert errors is not None @@ -567,7 +573,9 @@ class AsyncThingValidateUpsert(TypedTable): qty: TypedField[int] inserted, errors = await AsyncThingValidateUpsert.validate_and_update_or_insert_async( - AsyncThingValidateUpsert.name == "widget", name="widget", qty=1, + AsyncThingValidateUpsert.name == "widget", + name="widget", + qty=1, ) await db.commit_async() assert errors is None @@ -575,7 +583,9 @@ class AsyncThingValidateUpsert(TypedTable): assert AsyncThingValidateUpsert.count() == 1 updated, errors = await AsyncThingValidateUpsert.validate_and_update_or_insert_async( - AsyncThingValidateUpsert.name == "widget", name="widget", qty=2, + AsyncThingValidateUpsert.name == "widget", + name="widget", + qty=2, ) await db.commit_async() assert errors is None @@ -794,7 +804,7 @@ class AsyncThingCommonFilter(TypedTable): @pytest.mark.asyncio async def test_insert_async_lastrowid_does_not_read_shared_last_insert( - db_async: TypeDAL, + dal_psql: TypeDAL, monkeypatch: pytest.MonkeyPatch, ): """ @@ -809,31 +819,34 @@ async def test_insert_async_lastrowid_does_not_read_shared_last_insert( made deterministic here rather than raced: the statement built is a `DEFAULT VALUES` insert (no fields -> no RETURNING, pydal postgres.py:149-162), while a concurrent normal insert leaves the flag truthy - so lastrowid tries to `fetchone()` a result that does not exist. + + Takes the Postgres fixture directly instead of the parametrized `db_async`: SQLite has + no equivalent flag at all - `sqlite_lastrowid_async` ignores `last_insert` and returns + `cursor.lastrowid` - so a SQLite run would race against something nothing reads and + pass for reasons unrelated to the defect. """ - db = db_async - if db._adapter.dbengine != "postgres": - pytest.skip("only the postgres lastrowid strategy consults _last_insert") + async with _postgres_db(dal_psql) as db: - @db.define() - class AsyncThingLastInsert(TypedTable): - name = TypedField(str, notnull=False) + @db.define() + class AsyncThingLastInsert(TypedTable): + name = TypedField(str, notnull=False) - table = AsyncThingLastInsert._ensure_table_defined() - adapter = db._adapter - real_get_pool = db._get_async_pool + table = AsyncThingLastInsert._ensure_table_defined() + adapter = db._adapter + real_get_pool = db._get_async_pool - async def racing_get_pool(): - pool = await real_get_pool() - # stand-in for a concurrent insert_async() finishing its own adapter._insert(): - adapter._last_insert = (table._id, 1) - return pool + async def racing_get_pool(): + pool = await real_get_pool() + # stand-in for a concurrent insert_async() finishing its own adapter._insert(): + adapter._last_insert = (table._id, 1) + return pool - monkeypatch.setattr(db, "_get_async_pool", racing_get_pool) + monkeypatch.setattr(db, "_get_async_pool", racing_get_pool) - # no fields -> INSERT INTO ... DEFAULT VALUES, which has no RETURNING clause - result = await db.insert_async(table, []) + # no fields -> INSERT INTO ... DEFAULT VALUES, which has no RETURNING clause + result = await db.insert_async(table, []) - assert int(result) > 0 + assert int(result) > 0 @pytest.mark.asyncio @@ -848,6 +861,18 @@ async def test_async_connection_is_not_shared_between_concurrent_coroutines(db_a `SqliteAsyncConnection`'s docstring promises every `_async` call is its own committed transaction; that only holds while calls never overlap. Postgres passes this test, since psycopg_pool hands out distinct connections. + + Do NOT rewrite this with an `asyncio.Barrier`: it deadlocks, and not because of a bug. + SQLite cannot fix this by handing each caller its own connection the way psycopg_pool does + - two aiosqlite connections to pydal's `sqlite:memory` (shared-cache, `uri: True`) answer + the second concurrent writer with `OperationalError: database table is locked`, which no + busy-timeout retries. So the fix has to *serialize* callers, and a barrier demands the one + thing the fix exists to prevent: two coroutines inside `connection()` at the same time. + + Instead each writer signals that it is inside and waits a bounded time for the other. That + forces the overlap where one is possible (the unfixed, shared-connection code) and simply + times out where it is not (serialized), so the assertion below is about the transactional + outcome either way, on both backends. """ db = db_async @@ -857,22 +882,488 @@ class AsyncThingIsolation(TypedTable): tablename = str(AsyncThingIsolation) pool = await db._get_async_pool() - both_inside = asyncio.Barrier(2) + keeper_inside = asyncio.Event() + failer_inside = asyncio.Event() + + async def wait_briefly(event: asyncio.Event) -> None: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(event.wait(), timeout=0.25) async def committing_writer(): async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('keep')") # noqa: S608 - await both_inside.wait() + await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('keep')") + keeper_inside.set() + await wait_briefly(failer_inside) # clean exit -> this row must survive async def failing_writer(): with contextlib.suppress(RuntimeError): async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('discard')") # noqa: S608 - await both_inside.wait() + await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('discard')") + failer_inside.set() + await wait_briefly(keeper_inside) raise RuntimeError("boom") # -> this row must be rolled back await asyncio.gather(committing_writer(), failing_writer()) rows = await AsyncThingIsolation.collect_async() assert sorted(row.name for row in rows) == ["keep"] + + +# --------------------------------------------------------------------------- +# Coverage for async paths the parity tests above never reach: rollback, the pydal +# hook/abort branches, error hooks, cascades and the unsupported-backend guard. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_rollback_async_is_usable_on_every_backend(db_async: TypeDAL): + """ + `rollback_async()` is a no-op for Postgres (psycopg_pool already rolled back on context + exit) and real work for SQLite, but it must be callable and leave the connection usable + on both - that is the whole point of putting it on `AsyncConnectionPool`. + """ + db = db_async + + @db.define() + class AsyncThingRollback(TypedTable): + qty: TypedField[int] + + await AsyncThingRollback.insert_async(qty=1) + await db.commit_async() + + await db.rollback_async() + + # every `_async` call is its own committed transaction, so the row survives and the + # connection still works afterwards: + assert await AsyncThingRollback.count_async() == 1 + + +@pytest.mark.asyncio +async def test_delete_async_cascades_to_referencing_rows(db_async: TypeDAL): + """ + `sqlite_delete_async` re-implements `SQLite.delete()`'s cascade (adapters/sqlite.py:93-104): + select ids, delete, then recurse per FK with `ondelete=CASCADE`. Postgres leaves that to + the database. Either way the children must be gone. + """ + db = db_async + + @db.define() + class AsyncCascadeParent(TypedTable): + name: TypedField[str] + + @db.define() + class AsyncCascadeChild(TypedTable): + parent: AsyncCascadeParent + + parent_id = int(AsyncCascadeParent.insert(name="parent")) + AsyncCascadeChild.insert(parent=parent_id) + AsyncCascadeChild.insert(parent=parent_id) + db.commit() + + assert AsyncCascadeChild.count() == 2 + + await AsyncCascadeParent.where(AsyncCascadeParent.id == parent_id).delete_async() + await db.commit_async() + + assert await AsyncCascadeParent.count_async() == 0 + assert await AsyncCascadeChild.count_async() == 0 + + +@pytest.mark.asyncio +async def test_update_async_honors_on_update_error_hook(db_async: TypeDAL): + """ + Twin of `test_insert_async_honors_on_insert_error_hook`: `update_async` routes a failing + UPDATE through `table._on_update_error`, mirroring `adapter.update()` (base.py:585-589). + """ + db = db_async + + @db.define() + class AsyncThingUpdateError(TypedTable): + name = TypedField(str, unique=True) + + table = AsyncThingUpdateError._ensure_table_defined() + table._on_update_error = lambda _table, _query, _fields, _e: -1 + + first = int(AsyncThingUpdateError.insert(name="a")) + AsyncThingUpdateError.insert(name="b") + db.commit() + + # renaming 'a' to 'b' violates the unique constraint: + row = table._fields_and_values_for_update({"name": "b"}) + result = await db.update_async(table, table.id == first, row.op_values()) + + assert result == -1 + + +@pytest.mark.asyncio +async def test_get_async_pool_rejects_unsupported_backend(db_async: TypeDAL, monkeypatch: pytest.MonkeyPatch): + """ + A dbengine with no entry in `ASYNC_POOL_FACTORIES` must fail loudly and name what is + supported, rather than KeyError-ing out of `_get_async_pool()`. + """ + db = db_async + await db.close_async() + + monkeypatch.setattr(db._adapter, "dbengine", "oracle", raising=False) + + with pytest.raises(NotImplementedError, match="only implemented for"): + await db._get_async_pool() + + +@pytest.mark.asyncio +async def test_insert_async_runs_pydal_insert_hooks(db_async: TypeDAL): + """ + `TypedTable.insert_async()` keeps pydal's `Table.insert()` hook dance (objects.py:960-968): + a truthy `_before_insert` aborts the insert, and `_after_insert` sees the new id. + """ + db = db_async + + @db.define() + class AsyncThingInsertHooks(TypedTable): + qty: TypedField[int] + + table = AsyncThingInsertHooks._ensure_table_defined() + seen: list[t.Any] = [] + + table._after_insert.append(lambda _row, result: seen.append(result)) + await AsyncThingInsertHooks.insert_async(qty=1) + await db.commit_async() + assert len(seen) == 1 + assert await AsyncThingInsertHooks.count_async() == 1 + + # a truthy _before_insert aborts, so nothing is written and no id comes back: + table._before_insert.append(lambda _row: True) + await AsyncThingInsertHooks.insert_async(qty=2) + await db.commit_async() + assert await AsyncThingInsertHooks.count_async() == 1 + assert len(seen) == 1 + + +@pytest.mark.asyncio +async def test_delete_async_runs_pydal_delete_hooks(db_async: TypeDAL): + """ + `QueryBuilder.delete_async()` replicates `Set.delete()`'s hooks (objects.py:3010-3017), + since pydal has no async version to delegate to: a truthy `_before_delete` aborts and + returns no ids, `_after_delete` runs on success, and a query matching nothing returns []. + """ + db = db_async + + @db.define() + class AsyncThingDeleteHooks(TypedTable): + qty: TypedField[int] + + table = AsyncThingDeleteHooks._ensure_table_defined() + AsyncThingDeleteHooks.insert(qty=1) + db.commit() + + # matches nothing -> no ids, and the after hooks must not fire + assert await AsyncThingDeleteHooks.where(AsyncThingDeleteHooks.qty > 99).delete_async() == [] + + # aborted by a truthy _before_delete + aborter = table._before_delete.append(lambda _set: True) or table._before_delete[-1] + assert await AsyncThingDeleteHooks.where(AsyncThingDeleteHooks.qty > 0).delete_async() == [] + assert await AsyncThingDeleteHooks.count_async() == 1 + table._before_delete.remove(aborter) + + # and the success path runs _after_delete + after: list[t.Any] = [] + table._after_delete.append(lambda pydal_set: after.append(pydal_set)) + assert len(await AsyncThingDeleteHooks.where(AsyncThingDeleteHooks.qty > 0).delete_async()) == 1 + assert len(after) == 1 + + +@pytest.mark.asyncio +async def test_update_async_runs_pydal_update_hooks(db_async: TypeDAL): + """ + Same as the delete twin, for `QueryBuilder.update_async()`: no fields is an error, a truthy + `_before_update` aborts, `_after_update` runs on success, and a no-match query returns []. + """ + db = db_async + + @db.define() + class AsyncThingUpdateHooks(TypedTable): + qty: TypedField[int] + + table = AsyncThingUpdateHooks._ensure_table_defined() + AsyncThingUpdateHooks.insert(qty=1) + db.commit() + + with pytest.raises(ValueError, match="No fields to update"): + await AsyncThingUpdateHooks.where(AsyncThingUpdateHooks.qty > 0).update_async() + + # matches nothing -> no ids + assert await AsyncThingUpdateHooks.where(AsyncThingUpdateHooks.qty > 99).update_async(qty=5) == [] + + aborter = table._before_update.append(lambda _set, _row: True) or table._before_update[-1] + assert await AsyncThingUpdateHooks.where(AsyncThingUpdateHooks.qty > 0).update_async(qty=7) == [] + assert await AsyncThingUpdateHooks.count_async() == 1 + table._before_update.remove(aborter) + + after: list[t.Any] = [] + table._after_update.append(lambda pydal_set, _row: after.append(pydal_set)) + assert len(await AsyncThingUpdateHooks.where(AsyncThingUpdateHooks.qty > 0).update_async(qty=7)) == 1 + assert len(after) == 1 + + +@pytest.mark.asyncio +async def test_table_level_count_and_update_or_insert_with_query(db_async: TypeDAL): + """ + Two thin shortcuts the parity tests reach only through a QueryBuilder: `Table.count_async()` + without a `.where(...)`, and `update_or_insert_async()` given a real Query rather than the + DEFAULT/dict forms (`_lookup_query`'s pass-through branch). + """ + db = db_async + + @db.define() + class AsyncThingShortcuts(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + assert await AsyncThingShortcuts.count_async() == 0 + + created = await AsyncThingShortcuts.update_or_insert_async( + AsyncThingShortcuts.name == "widget", + name="widget", + qty=1, + ) + await db.commit_async() + assert created.qty == 1 + assert await AsyncThingShortcuts.count_async() == 1 + + updated = await AsyncThingShortcuts.update_or_insert_async( + AsyncThingShortcuts.name == "widget", + name="widget", + qty=2, + ) + await db.commit_async() + assert updated.qty == 2 + assert await AsyncThingShortcuts.count_async() == 1 + + +@pytest.mark.asyncio +async def test_insert_and_update_async_reraise_without_error_hook(db_async: TypeDAL): + """ + The other half of the `_on_insert_error`/`_on_update_error` branches: with no hook + registered the driver exception must propagate, exactly as pydal's adapter does. + """ + db = db_async + + @db.define() + class AsyncThingNoHook(TypedTable): + name = TypedField(str, unique=True) + + table = AsyncThingNoHook._ensure_table_defined() + first = int(AsyncThingNoHook.insert(name="a")) + AsyncThingNoHook.insert(name="b") + db.commit() + + with pytest.raises(Exception, match=r"(?i)unique"): + await db.insert_async(table, table._fields_and_values_for_insert({"name": "a"}).op_values()) + + with pytest.raises(Exception, match=r"(?i)unique"): + row = table._fields_and_values_for_update({"name": "b"}) + await db.update_async(table, table.id == first, row.op_values()) + + +@pytest.mark.asyncio +async def test_insert_async_with_custom_primarykey(db_async: TypeDAL): + """ + Tables with a `_primarykey` instead of pydal's standard `_id` report the new row as a + `{name: value}` dict rather than a `Reference` (adapters/base.py:550-563). + """ + db = db_async + + table = db.define_table( + "async_pk_thing", + pydal.objects.Field("code", "string"), + pydal.objects.Field("val", "string"), + primarykey=["code"], + ) + db.commit() + + supplied = await db.insert_async(table, [(table.code, "abc"), (table.val, "x")]) + assert supplied == {"code": "abc"} + + # the sibling branch - a keyed table whose pk is *generated* - is unreachable on both + # backends: pydal makes `_primarykey` columns NOT NULL, so an insert that omits the pk + # fails in the database before it could ever be filled in from lastrowid. + + +@pytest.mark.asyncio +async def test_executesql_async_placeholders_and_dict_shapes(db_async: TypeDAL): + """ + `executesql_async` mirrors pydal's `executesql` surface: bound placeholders, `as_dict` / + `as_ordered_dict`, `colnames` overrides, and the duplicate-column guard. + """ + db = db_async + + @db.define() + class AsyncThingSql(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + AsyncThingSql.insert(name="widget", qty=1) + AsyncThingSql.insert(name="gadget", qty=2) + db.commit() + + tablename = str(AsyncThingSql) + placeholder = "%s" if db._adapter.dbengine == "postgres" else "?" + + bound = await db.executesql_async(f"SELECT qty FROM {tablename} WHERE qty > {placeholder}", (1,)) + assert [row[0] for row in bound] == [2] + + as_dicts = await db.executesql_async(f"SELECT name, qty FROM {tablename} ORDER BY qty", as_dict=True) + assert as_dicts == [{"name": "widget", "qty": 1}, {"name": "gadget", "qty": 2}] + + ordered = await db.executesql_async(f"SELECT name, qty FROM {tablename} ORDER BY qty", as_ordered_dict=True) + assert type(ordered[0]) is collections.OrderedDict + assert list(ordered[0]) == ["name", "qty"] + + renamed = await db.executesql_async( + f"SELECT name FROM {tablename} ORDER BY qty", + as_dict=True, + colnames=["label"], + ) + assert renamed[0] == {"label": "widget"} + + with pytest.raises(RuntimeError, match="duplicate column names"): + await db.executesql_async(f"SELECT qty, qty FROM {tablename}", as_dict=True) + + +@pytest.mark.asyncio +async def test_executesql_async_with_fields_and_colnames(db_async: TypeDAL): + """ + Passing `fields` (or `colnames`) routes the raw rows back through `adapter.parse()`, so + values come out typed rather than as driver primitives. + """ + db = db_async + + @db.define() + class AsyncThingParse(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + AsyncThingParse.insert(name="widget", qty=1) + db.commit() + + table = AsyncThingParse._ensure_table_defined() + tablename = str(AsyncThingParse) + + # a whole Table as `fields` expands to its columns... + parsed = await db.executesql_async( + f"SELECT {tablename}.id, {tablename}.name, {tablename}.qty FROM {tablename}", + fields=[table], + ) + assert parsed[0].name == "widget" + assert parsed[0].qty == 1 + + # ...and individual Fields are taken as-is + per_field = await db.executesql_async( + f"SELECT {tablename}.name, {tablename}.qty FROM {tablename}", + fields=[table.name, table.qty], + ) + assert per_field[0].qty == 1 + + # `colnames` without fields resolves the table.column names itself + by_colname = await db.executesql_async( + f"SELECT {tablename}.name FROM {tablename}", + fields=[], + colnames=[f"{tablename}.name"], + ) + assert by_colname[0].name == "widget" + + # a colname without a `table.` prefix is passed through unquoted + bare_colname = await db.executesql_async( + f"SELECT {tablename}.name FROM {tablename}", + fields=[table.name], + colnames=["name"], + ) + assert bare_colname[0].name == "widget" + + +@pytest.mark.asyncio +async def test_executesql_async_on_statement_without_result_set(db_async: TypeDAL): + """ + A statement that produces no rows: psycopg raises on `fetchall()` (caught, -> None) while + sqlite just yields an empty list. Both are acceptable; neither may blow up. + """ + db = db_async + + @db.define() + class AsyncThingNoResult(TypedTable): + qty: TypedField[int] + + db.commit() + + result = await db.executesql_async(f"DELETE FROM {AsyncThingNoResult} WHERE qty < 0") + assert result in (None, []) + + +@pytest.mark.asyncio +async def test_async_query_builder_falls_back_for_plain_pydal_tables(db_async: TypeDAL): + """ + `QueryBuilder` also accepts an old-style pydal table. There is no model to instantiate from + the rows, so `collect_async()` degrades to `execute_async()` and `first_async()` hands back + the raw pydal Row - the async twins of the fallbacks `collect()`/`first()` already have. + """ + db = db_async + + table = db.define_table("async_plain_thing", pydal.objects.Field("qty", "integer")) + table.insert(qty=1) + db.commit() + + rows = await QueryBuilder(table).collect_async() + assert len(rows) == 1 + + row = await QueryBuilder(table).first_async() + assert row is not None + assert row.qty == 1 + + +@pytest.mark.asyncio +async def test_classmethod_update_async_returns_none_when_nothing_matches(db_async: TypeDAL): + """`Model.update_async(query, ...)` mirrors the sync `update()`: no matching row -> None.""" + db = db_async + + @db.define() + class AsyncThingClsUpdateMiss(TypedTable): + qty: TypedField[int] + + db.commit() + + assert await AsyncThingClsUpdateMiss.update_async(AsyncThingClsUpdateMiss.id == 404, qty=1) is None + + +class AsyncThingCached(TypedTable): + """ + Defined at module level, unlike every other model here: the cache pickles the rows, and a + class defined inside a test function is not picklable. + """ + + qty: TypedField[int] + + +@pytest.mark.asyncio +async def test_collect_async_serves_cached_rows(): + """ + A cache hit short-circuits `collect_async()` in `_collect_prepare()`, before it ever reaches + the database. Not parametrized over `db_async`: that fixture disables TypeDAL caching. + """ + with tempfile.TemporaryDirectory() as directory: + db = TypeDAL("sqlite:memory", folder=directory) + try: + db.define(AsyncThingCached) + + AsyncThingCached.insert(qty=1) + db.commit() + + fresh = await AsyncThingCached.where(AsyncThingCached.qty > 0).cache().collect_async() + cached = await AsyncThingCached.where(AsyncThingCached.qty > 0).cache().collect_async() + + assert len(fresh) == len(cached) == 1 + assert fresh.metadata["cache"]["status"] == "fresh" + assert cached.metadata["cache"]["status"] == "cached" + finally: + await db.close_async() + db.close() From 9b4502b1a0260579f22bb86bd26f77457bd7f7c1 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 21:05:46 +0200 Subject: [PATCH 10/29] chore(typing): configure ty and tighten project annotations --- pyproject.toml | 8 ++ src/typedal/__init__.py | 2 +- src/typedal/caching.py | 14 +++- src/typedal/cli.py | 112 +++++++++++++------------- src/typedal/define.py | 10 +-- src/typedal/fields.py | 16 ++-- src/typedal/for_py4web.py | 12 +-- src/typedal/for_web2py.py | 5 +- src/typedal/helpers.py | 4 +- src/typedal/mixins.py | 8 +- src/typedal/query_builder.py | 68 ++++++++-------- src/typedal/relationships.py | 9 +-- src/typedal/rows.py | 40 ++++----- src/typedal/serializers/typescript.py | 15 ++-- src/typedal/types.py | 6 +- src/typedal/web2py_py4web_shared.py | 5 +- 16 files changed, 179 insertions(+), 155 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 226394e..6e2b1a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,6 +212,14 @@ target-version = "py314" line-length = 120 extend-exclude = ["*.bak/", "venv*/", "tests/", "example_*.py"] + +[tool.ty.src] +include = ["src"] + +[tool.ty.environment] +python = "./venv" +python-version = "3.14" + [tool.ruff.lint] select = [ diff --git a/src/typedal/__init__.py b/src/typedal/__init__.py index 446dea2..7264a3d 100644 --- a/src/typedal/__init__.py +++ b/src/typedal/__init__.py @@ -15,7 +15,7 @@ try: from .for_py4web import DAL as P4W_DAL except ImportError: # pragma: no cover - P4W_DAL = None # type: ignore + P4W_DAL = None __all__ = [ "PaginatedRows", diff --git a/src/typedal/caching.py b/src/typedal/caching.py index 9d01b4e..fd31170 100644 --- a/src/typedal/caching.py +++ b/src/typedal/caching.py @@ -22,6 +22,13 @@ from .query_builder import QueryBuilder +class FunctionWithMetadata[T](t.Protocol): + __name__: str + __qualname__: str + + def __call__(self, *args: t.Any, **kwargs: t.Any) -> T: ... + + def get_now(tz: dt.timezone = dt.timezone.utc) -> dt.datetime: """ Get the default datetime, optionally in a specific timezone. @@ -306,7 +313,8 @@ def _fetch_cached_payload(key: str) -> tuple[t.Any, t.Any] | None: now = get_now() # Ensure comparison is offset-aware if the row has a timestamp - expires = row.expires_at.replace(tzinfo=dt.timezone.utc) if row.expires_at else None + expires_at = t.cast(dt.datetime | None, row.expires_at) + expires = expires_at.replace(tzinfo=dt.timezone.utc) if expires_at else None if expires and now >= expires: row.delete_record() @@ -531,7 +539,7 @@ def calculate_stats(db: "TypeDAL") -> Stats[GenericStats]: def memoize[T: t.Any]( db: "TypeDAL", - func: t.Callable[..., T], + func: FunctionWithMetadata[T], *args: TypedRows[t.Any] | TypedTable, key: str | None = None, ttl: int | dt.timedelta | dt.datetime | None = None, @@ -568,7 +576,7 @@ def memoize[T: t.Any]( for row in arg: deps.add((str(row._table), row.id)) elif isinstance(arg, TypedTable): - deps.add((str(arg._table), arg.id)) + deps.add((str(arg._table), t.cast(int, arg.id))) # Generate cache key _, hashed_key = create_and_hash_cache_key(key, *[getattr(arg, "id", None) for arg in args], kwargs) diff --git a/src/typedal/cli.py b/src/typedal/cli.py index 5da52f7..20519dc 100644 --- a/src/typedal/cli.py +++ b/src/typedal/cli.py @@ -3,10 +3,9 @@ """ import sys -import typing +import typing as t import warnings from pathlib import Path -from typing import Optional import tomli from configuraptor import asdict @@ -34,7 +33,6 @@ ) exit(127) # command not found -from typing import Never from pydal2sql.typer_support import IS_DEBUG, with_exit_code from pydal2sql.types import ( @@ -59,12 +57,12 @@ no_args_is_help=True, ) -questionary_types: dict[typing.Hashable, Optional[AnyDict]] = { +questionary_types: dict[t.Hashable, AnyDict | None] = { str: { "type": "text", "validate": lambda text: True if len(text) > 0 else "Please enter a value", }, - Optional[str]: { + str | None: { "type": "text", # no validate because it's optional }, @@ -109,11 +107,11 @@ notfound = object() -def _get_question[T](prop: str, annotation: typing.Type[T]) -> Optional[AnyDict]: # pragma: no cover +def _get_question[T](prop: str, annotation: t.Type[T]) -> AnyDict | None: # pragma: no cover question = questionary_types.get(prop, notfound) if question is notfound: # None means skip the question, notfound means use the type default! - question = questionary_types.get(annotation) # type: ignore + question = questionary_types.get(annotation) if not question: return None @@ -121,7 +119,7 @@ def _get_question[T](prop: str, annotation: typing.Type[T]) -> Optional[AnyDict] return question.copy() # type: ignore -def get_question[T](prop: str, annotation: typing.Type[T], default: T | None) -> Optional[T]: # pragma: no cover +def get_question[T](prop: str, annotation: t.Type[T], default: T | None) -> T | None: # pragma: no cover """ Generate a question based on a config property and prompt the user for it. """ @@ -130,19 +128,19 @@ def get_question[T](prop: str, annotation: typing.Type[T], default: T | None) -> question["name"] = prop question["message"] = question.get("message", f"{prop}? ") - default = typing.cast(T, default or question.get("default") or "") + default = t.cast(T, default or question.get("default") or "") if annotation is int: - default = typing.cast(T, str(default)) + default = t.cast(T, str(default)) response = questionary.unsafe_prompt([question], default=default)[prop] - return typing.cast(T, response) + return t.cast(T, response) @app.command() @with_exit_code(hide_tb=IS_DEBUG) def setup( - config_file: typing.Annotated[Optional[str], typer.Option("--config", "-c")] = None, + config_file: t.Annotated[str | None, typer.Option("--config", "-c")] = None, minimal: bool = False, ) -> None: # pragma: no cover """ @@ -202,7 +200,7 @@ def setup( _fill_defaults(data, prop, data.get(prop)) default_value = data.get(prop, None) - answer: typing.Any = get_question(prop, annotation, default_value) + answer: t.Any = get_question(prop, annotation, default_value) if isinstance(answer, str): answer = answer.strip() @@ -212,7 +210,7 @@ def setup( elif annotation is int: answer = int(answer) - config.update(**{prop: answer}) + config.update(**{prop: t.cast(t.Any, answer)}) data[prop] = answer for prop in TypeDALConfig.__annotations__: @@ -239,16 +237,16 @@ def setup( @app.command(name="migrations.generate") @with_exit_code(hide_tb=IS_DEBUG) def generate_migrations( - connection: typing.Annotated[str, typer.Option("--connection", "-c")] = None, + connection: t.Annotated[str | None, typer.Option("--connection", "-c")] = None, filename_before: OptionalArgument[str] = None, filename_after: OptionalArgument[str] = None, dialect: DBType_Option = None, tables: Tables_Option = None, - magic: Optional[bool] = None, - noop: Optional[bool] = None, - function: Optional[str] = None, + magic: bool | None = None, + noop: bool | None = None, + function: str | None = None, output_format: OutputFormat_Option = None, - output_file: Optional[str] = None, + output_file: str | None = None, dry_run: bool = False, ) -> bool: # pragma: no cover """ @@ -310,18 +308,18 @@ def generate_migrations( @app.command(name="migrations.run") @with_exit_code(hide_tb=IS_DEBUG) def run_migrations( - connection: typing.Annotated[str, typer.Option("--connection", "-c")] = None, + connection: t.Annotated[str | None, typer.Option("--connection", "-c")] = None, migrations_file: OptionalArgument[str] = None, - db_uri: Optional[str] = None, - db_folder: Optional[str] = None, - schema_version: Optional[str] = None, - redis_host: Optional[str] = None, - migrate_cat_command: Optional[str] = None, - database_to_restore: Optional[str] = None, - migrate_table: Optional[str] = None, - flag_location: Optional[str] = None, - schema: Optional[str] = None, - create_flag_location: Optional[bool] = None, + db_uri: str | None = None, + db_folder: str | None = None, + schema_version: str | None = None, + redis_host: str | None = None, + migrate_cat_command: str | None = None, + database_to_restore: str | None = None, + migrate_table: str | None = None, + flag_location: str | None = None, + schema: str | None = None, + create_flag_location: bool | None = None, dry_run: bool = False, ) -> bool: # pragma: no cover """ @@ -368,13 +366,13 @@ def run_migrations( @app.command(name="migrations.fake") @with_exit_code(hide_tb=IS_DEBUG) def fake_migrations( - names: typing.Annotated[list[str], typer.Argument()] = None, + names: t.Annotated[list[str] | None, typer.Argument()] = None, all: bool = False, # noqa: A002 - connection: typing.Annotated[str, typer.Option("--connection", "-c")] = None, - migrations_file: Optional[str] = None, - db_uri: Optional[str] = None, - db_folder: Optional[str] = None, - migrate_table: Optional[str] = None, + connection: t.Annotated[str | None, typer.Option("--connection", "-c")] = None, + migrations_file: str | None = None, + db_uri: str | None = None, + db_folder: str | None = None, + migrate_table: str | None = None, dry_run: bool = False, ) -> int: # pragma: no cover """ @@ -452,12 +450,12 @@ def fake_migrations( @app.command(name="migrations.stub") @with_exit_code(hide_tb=IS_DEBUG) def migrations_stub( - migration_name: typing.Annotated[str, typer.Argument()] = "stub_migration", - connection: typing.Annotated[str, typer.Option("--connection", "-c")] = None, + migration_name: t.Annotated[str, typer.Argument()] = "stub_migration", + connection: t.Annotated[str | None, typer.Option("--connection", "-c")] = None, output_format: OutputFormat_Option = None, - output_file: Optional[str] = None, - dry_run: typing.Annotated[bool, typer.Option("--dry", "--dry-run")] = False, - is_pydal: typing.Annotated[bool, typer.Option("--pydal", "-p")] = False, + output_file: str | None = None, + dry_run: t.Annotated[bool, typer.Option("--dry", "--dry-run")] = False, + is_pydal: t.Annotated[bool, typer.Option("--pydal", "-p")] = False, # defaults to is_typedal of course ) -> int: """ @@ -484,12 +482,12 @@ def migrations_stub( @app.command(name="typescript.generate") @with_exit_code(hide_tb=IS_DEBUG) def generate_typescript( - connection: typing.Annotated[str, typer.Option("--connection", "-c")] = None, + connection: t.Annotated[str | None, typer.Option("--connection", "-c")] = None, filename: OptionalArgument[str] = None, tables: Tables_Option = None, - magic: Optional[bool] = None, - function: Optional[str] = None, - output_file: Optional[str] = None, + magic: bool | None = None, + function: str | None = None, + output_file: str | None = None, ) -> bool: """ Generate TypeScript interfaces from TypeDAL table definitions. @@ -563,10 +561,10 @@ def tabulate_data(data: AnyNestedDict) -> None: print(tabulate(flattened_data, headers="keys")) -type FormatOptions = typing.Literal["plaintext", "json", "yaml", "toml"] +type FormatOptions = t.Literal["plaintext", "json", "yaml", "toml"] -def get_output_format(fmt: FormatOptions) -> typing.Callable[[AnyNestedDict], None]: +def get_output_format(fmt: FormatOptions) -> t.Callable[[AnyNestedDict], None]: """ This function takes a format option as input and \ returns a function that can be used to output data in the specified format. @@ -596,7 +594,7 @@ def output(_data: AnyDict | AnyNestedDict) -> None: print(tomli_w.dumps(_data)) case _: - options = typing.get_args(FormatOptions) + options = t.get_args(FormatOptions) raise ValueError(f"Invalid format '{fmt}'. Please choose one of {options}.") return output @@ -605,11 +603,9 @@ def output(_data: AnyDict | AnyNestedDict) -> None: @app.command(name="cache.stats") @with_exit_code(hide_tb=IS_DEBUG) def cache_stats( - identifier: typing.Annotated[str, typer.Argument()] = "", - connection: typing.Annotated[str, typer.Option("--connection", "-c")] = None, - fmt: typing.Annotated[ - str, typer.Option("--format", "--fmt", "-f", help="plaintext (default) or json") - ] = "plaintext", + identifier: t.Annotated[str, typer.Argument()] = "", + connection: t.Annotated[str | None, typer.Option("--connection", "-c")] = None, + fmt: t.Annotated[str, typer.Option("--format", "--fmt", "-f", help="plaintext (default) or json")] = "plaintext", ) -> None: # pragma: no cover """ Collect caching stats. @@ -622,7 +618,7 @@ def cache_stats( config = load_config(connection) db = TypeDAL(config=config, migrate=False, fake_migrate=False) - output = get_output_format(typing.cast(FormatOptions, fmt)) + output = get_output_format(t.cast(FormatOptions, fmt)) data: AnyDict parts = identifier.split(".") @@ -651,8 +647,8 @@ def cache_stats( @app.command(name="cache.clear") @with_exit_code(hide_tb=IS_DEBUG) def cache_clear( - connection: typing.Annotated[str, typer.Option("--connection", "-c")] = None, - purge: typing.Annotated[bool, typer.Option("--all", "--purge", "-p")] = False, + connection: t.Annotated[str | None, typer.Option("--connection", "-c")] = None, + purge: t.Annotated[bool, typer.Option("--all", "--purge", "-p")] = False, ) -> None: # pragma: no cover """ Clear (expired) items from the cache. @@ -674,7 +670,7 @@ def cache_clear( db.commit() -def version_callback() -> Never: +def version_callback() -> t.Never: """ --version requested! """ @@ -683,7 +679,7 @@ def version_callback() -> Never: raise typer.Exit(0) -def config_callback() -> Never: +def config_callback() -> t.Never: """ --show-config requested. """ diff --git a/src/typedal/define.py b/src/typedal/define.py index f6cb107..8f4e658 100644 --- a/src/typedal/define.py +++ b/src/typedal/define.py @@ -37,7 +37,7 @@ from annotationlib import ForwardRef except ImportError: # pragma: no cover # python 3.13- - from typing import ForwardRef + from typing import ForwardRef # special case, keep `from typing` class IS_IN_ENUM(Validator): @@ -49,7 +49,7 @@ def __init__(self, etype: type[enum.Enum], error_message: str = "value not allow self.etype = etype self.error_message = error_message - def validate(self, value: t.Any, _record_id: int | None = None) -> t.Any: + def validate(self, value: t.Any, _record_id: int | None = None) -> t.Any: # ty: ignore[invalid-method-override] """Validate and normalize an enum-compatible value.""" if value not in self.etype: raise ValidationError(self.translator(self.error_message)) @@ -102,8 +102,8 @@ def define[T: t.Any](self, cls: t.Type[T], **kwargs: t.Unpack[DefineKwargs]) -> relationships |= { k: new_relationship for k in reference_field_keys - if k not in relationships and (new_relationship := to_relationship(cls, k, annotations[k])) - } + if k not in relationships and (new_relationship := to_relationship(cls, k, annotations[k])) # ty: ignore[invalid-argument-type] + } # ty: ignore[unsupported-operator] cache_dependency = self.db._config.caching and kwargs.pop("cache_dependency", True) table: Table = self.db.define_table(tablename, *fields.values(), **kwargs) @@ -131,7 +131,7 @@ def define[T: t.Any](self, cls: t.Type[T], **kwargs: t.Unpack[DefineKwargs]) -> table._after_insert.append(lambda _row, _id: remove_cache_for_table(tablename)) table._before_update.append(lambda s, _: _remove_cache(s, tablename)) - table._before_delete.append(lambda s: _remove_cache(s, tablename)) + table._before_delete.append(lambda s: _remove_cache(s, tablename)) # ty: ignore[invalid-argument-type] return cls diff --git a/src/typedal/fields.py b/src/typedal/fields.py index 672368c..f2fbe70 100644 --- a/src/typedal/fields.py +++ b/src/typedal/fields.py @@ -195,7 +195,7 @@ def bind(self, field: pydal.objects.Field, table: pydal.objects.Table) -> None: Bind the right db/table/field info to this class, so queries can be made using `Class.field == ...`. """ self._table = table - self._field = field + self._field = t.cast(Field, field) def unbind(self) -> None: """Remove references to the pydal objects created during ``bind``.""" @@ -219,37 +219,37 @@ def __getattr__(self, key: str) -> t.Any: # try on actual field: return getattr(self._field, key) - def __eq__(self, other: t.Any) -> Query: + def __eq__(self, other: t.Any) -> Query: # ty: ignore[invalid-method-override] """ Performing == on a Field will result in a Query. """ return t.cast(Query, self._field == other) - def __ne__(self, other: t.Any) -> Query: + def __ne__(self, other: t.Any) -> Query: # ty: ignore[invalid-method-override] """ Performing != on a Field will result in a Query. """ return t.cast(Query, self._field != other) - def __gt__(self, other: t.Any) -> Query: + def __gt__(self, other: t.Any) -> Query: # ty: ignore[invalid-method-override] """ Performing > on a Field will result in a Query. """ return t.cast(Query, self._field > other) - def __lt__(self, other: t.Any) -> Query: + def __lt__(self, other: t.Any) -> Query: # ty: ignore[invalid-method-override] """ Performing < on a Field will result in a Query. """ return t.cast(Query, self._field < other) - def __ge__(self, other: t.Any) -> Query: + def __ge__(self, other: t.Any) -> Query: # ty: ignore[invalid-method-override] """ Performing >= on a Field will result in a Query. """ return t.cast(Query, self._field >= other) - def __le__(self, other: t.Any) -> Query: + def __le__(self, other: t.Any) -> Query: # ty: ignore[invalid-method-override] """ Performing <= on a Field will result in a Query. """ @@ -604,7 +604,7 @@ def safe_encode_native_point(value: tuple[str, str] | tuple[float, float] | str) return "" value_tup = tuple(float(x.strip()) for x in value.split(",")) else: - value_tup = value # type: ignore + value_tup = value # Validate and format if len(value_tup) != 2: diff --git a/src/typedal/for_py4web.py b/src/typedal/for_py4web.py index 446b072..885f3d9 100644 --- a/src/typedal/for_py4web.py +++ b/src/typedal/for_py4web.py @@ -2,7 +2,7 @@ ONLY USE IN COMBINATION WITH PY4WEB! """ -import typing +import typing as t import threadsafevariable from py4web.core import ICECUBE @@ -21,9 +21,9 @@ class Fixture(_Fixture): class PY4WEB_DAL_SINGLETON(MetaDAL): - _instances: typing.ClassVar[typing.MutableMapping[str, TypeDAL]] = {} + _instances: t.ClassVar[t.MutableMapping[str, TypeDAL]] = {} - def __call__(cls, uri: typing.Optional[str] = None, *args: typing.Any, **kwargs: typing.Any) -> TypeDAL: + def __call__(cls, uri: str | None = None, *args: t.Any, **kwargs: t.Any) -> TypeDAL: db_uid = kwargs.get("db_uid", hashlib_md5(repr(uri or (args, kwargs))).hexdigest()) if db_uid not in cls._instances: cls._instances[db_uid] = super().__call__(uri, *args, **kwargs) @@ -39,20 +39,20 @@ class DAL(TypeDAL, Fixture, metaclass=PY4WEB_DAL_SINGLETON): # pragma: no cover Fixture similar to the py4web pydal fixture, but for typedal. """ - def on_request(self, _: AnyDict) -> None: + def on_request(self, _: AnyDict) -> None: # ty: ignore[invalid-method-override] """ Make sure there is a database connection when a request comes in. """ self.get_connection_from_pool_or_new() threadsafevariable.ThreadSafeVariable.restore(ICECUBE) - def on_error(self, _: AnyDict) -> None: + def on_error(self, _: AnyDict) -> None: # ty: ignore[invalid-method-override] """ Rollback db on error. """ self.recycle_connection_in_pool_or_close("rollback") - def on_success(self, _: AnyDict) -> None: + def on_success(self, _: AnyDict) -> None: # ty: ignore[invalid-method-override] """ Commit db on success. """ diff --git a/src/typedal/for_web2py.py b/src/typedal/for_web2py.py index 0cd9fd8..2c7a320 100644 --- a/src/typedal/for_web2py.py +++ b/src/typedal/for_web2py.py @@ -3,11 +3,13 @@ """ import datetime as dt +import typing as t from pydal.validators import IS_NOT_IN_DB from . import TypeDAL, TypedField, TypedTable from .fields import TextField +from .types import Validator from .web2py_py4web_shared import AuthUser DAL = TypeDAL # export as DAL for compatibility with py4web @@ -28,7 +30,8 @@ def __on_define__(cls, db: TypeDAL) -> None: """ super().__on_define__(db) - cls.role.requires = IS_NOT_IN_DB(db, "w2p_auth_group.role") + requires = [IS_NOT_IN_DB(db, "w2p_auth_group.role")] + cls.role.requires = t.cast(list[Validator], requires) class AuthMembership(TypedTable): diff --git a/src/typedal/helpers.py b/src/typedal/helpers.py index 40211a1..4f2f87d 100644 --- a/src/typedal/helpers.py +++ b/src/typedal/helpers.py @@ -191,7 +191,7 @@ def filter_out[K, V, T](mut_dict: dict[K, V], _type: type[T]) -> dict[K, T]: Modifies mut_dict and returns everything of type _type. """ - return {k: mut_dict.pop(k) for k, v in list(mut_dict.items()) if looks_like(v, _type)} + return t.cast(dict[K, T], {k: mut_dict.pop(k) for k, v in list(mut_dict.items()) if looks_like(v, _type)}) def unwrap_type(_type: type) -> type: @@ -311,7 +311,7 @@ def get_table(table: "TypedTable | Table") -> "Table": """ Get the underlying pydal table for a typedal table. """ - return t.cast("Table", table._table) + return t.cast("Table", table._table) # ty: ignore[unresolved-attribute] def get_field(field: "TypedField[t.Any] | Field") -> "Field": diff --git a/src/typedal/mixins.py b/src/typedal/mixins.py index dfbf278..01006c6 100644 --- a/src/typedal/mixins.py +++ b/src/typedal/mixins.py @@ -105,7 +105,7 @@ def __init__( """ super().__init__(db, field, error_message) - def validate[T](self, original: T, record_id: t.Optional[int] = None) -> T: + def validate[T](self, original: T, record_id: t.Optional[int] = None) -> T: # ty: ignore[invalid-method-override] """ Performs checks to see if the slug already exists for a different row. """ @@ -261,7 +261,7 @@ def model_dump(self, mode: str = "python", **kwargs: t.Any) -> dict[str, t.Any]: try: from pydantic import BaseModel except ImportError: - BaseModel = BaseModeProtocol # type: ignore + BaseModel = BaseModeProtocol def dump_pydantic[T](values: T, _shallow_nested: bool = False) -> T: @@ -367,7 +367,7 @@ def _pydantic_fields( @staticmethod def _make_instance_converter(model_cls: type, fields: dict[str, t.Any]) -> t.Callable[[t.Any], t.Any]: _PRIMITIVES = (str, float, bool, bytes) - relationship_names = set(model_cls.get_relationships()) if hasattr(model_cls, "get_relationships") else set() + relationship_names = set(model_cls.get_relationships()) if hasattr(model_cls, "get_relationships") else set() # ty: ignore[call-non-callable] def convert(value: t.Any) -> t.Any: if isinstance(value, dict): @@ -534,7 +534,7 @@ def __get_pydantic_json_schema__( handler: t.Any, ) -> dict[str, t.Any]: """Build the JSON schema by delegating to pydantic's handler.""" - return handler(schema) # type: ignore + return handler(schema) def model_dump(self, mode: str = "python", *, _shallow: bool = False) -> dict[str, t.Any]: """Serialize this model to a dict, with optional shallow nested output.""" diff --git a/src/typedal/query_builder.py b/src/typedal/query_builder.py index 532bd2c..d7dfb3a 100644 --- a/src/typedal/query_builder.py +++ b/src/typedal/query_builder.py @@ -61,11 +61,11 @@ class QueryBuilder[T_MetaInstance: _TypedTable]: def __init__( self, model: t.Type[T_MetaInstance], - add_query: t.Optional[Query] = None, - select_args: t.Optional[list[t.Any]] = None, - select_kwargs: t.Optional[SelectKwargs] = None, - relationships: dict[str, Relationship[t.Any]] = None, - metadata: Metadata = None, + add_query: Query | None = None, + select_args: list[t.Any] | None = None, + select_kwargs: SelectKwargs | None = None, + relationships: dict[str, Relationship[t.Any]] | None = None, + metadata: Metadata | None = None, permissions: Permissions | None = None, ): """ @@ -76,7 +76,7 @@ def __init__( """ self.model = model table = self._ensure_table_defined() - default_query: Query = t.cast(Query, table.id > 0) + default_query: Query = t.cast(Query, table.id > 0) # ty: ignore[unresolved-attribute] self.query = add_query or default_query self.select_args = select_args or [] self.select_kwargs = select_kwargs or {} @@ -117,7 +117,7 @@ def __bool__(self) -> bool: Querybuilder is truthy if it has t.Any conditions. """ table = self._ensure_table_defined() - default_query: Query = t.cast(Query, table.id > 0) + default_query: Query = t.cast(Query, table.id > 0) # ty: ignore[unresolved-attribute] return any( [ self.query != default_query, @@ -130,12 +130,12 @@ def __bool__(self) -> bool: def _extend( self, - add_query: t.Optional[Query] = None, - overwrite_query: t.Optional[Query] = None, - select_args: t.Optional[list[t.Any]] = None, - select_kwargs: t.Optional[SelectKwargs] = None, - relationships: dict[str, Relationship[t.Any]] = None, - metadata: Metadata = None, + add_query: Query | None = None, + overwrite_query: Query | None = None, + select_args: list[t.Any] | None = None, + select_kwargs: SelectKwargs | None = None, + relationships: dict[str, Relationship[t.Any]] | None = None, + metadata: Metadata | None = None, permissions: Permissions | None = None, ) -> "QueryBuilder[T_MetaInstance]": return QueryBuilder( @@ -144,7 +144,7 @@ def _extend( (self.select_args + select_args) if select_args else self.select_args, (self.select_kwargs | select_kwargs) if select_kwargs else self.select_kwargs, (self.relationships | relationships) if relationships else self.relationships, - (self.metadata | (metadata or {})) if metadata else self.metadata, + (self.metadata | (metadata or {})) if metadata else self.metadata, # ty: ignore[invalid-argument-type] permissions=merge_permissions(self._permissions, permissions), ) @@ -162,7 +162,7 @@ def _normalize_select_option( return value if isinstance(value, (list, tuple, set)): - return list(self._normalize_select_option(val) for val in value) + return t.cast(list[str], [self._normalize_select_option(val) for val in value]) if rname := getattr(value, "_rname", None): return str(rname) @@ -275,7 +275,7 @@ def where( elif isinstance(query_part, (pydal.objects.Query, Expression, pydal.objects.Expression)): subquery |= t.cast(Query, query_part) elif callable(query_part): - if result := query_part(self.model): + if result := query_part(self.model): # ty: ignore[call-top-callable] subquery |= result elif isinstance(query_part, dict): subsubquery = DummyQuery() @@ -399,10 +399,10 @@ def join( raise ValueError("join(field, on=...) can only be used with exactly one field!") if isinstance(on, pydal.objects.Expression): - on = [on] + on = t.cast(list[Expression], [on]) if isinstance(on, list): - on = as_lambda(on) + on = t.cast(OnQuery, as_lambda(on)) field = fields[0] if isinstance(field, Relationship) and field.name: @@ -748,12 +748,12 @@ def _finalize_collect( """ self._run_hooks(db._after_collect, self, typed_rows, rows) # only saves if requested in metadata: - return save_to_cache(typed_rows, rows) + return save_to_cache(typed_rows, rows) # ty: ignore[invalid-argument-type] def collect( self, verbose: bool = False, - _to: t.Type["TypedRows[t.Any]"] = None, + _to: t.Type["TypedRows[t.Any]"] | None = None, add_id: bool = True, _into: t.Type[_TypedTable] | None = None, _init: t.Callable[[_TypedTable, Row], None] | None = None, @@ -768,7 +768,7 @@ def collect( if not isinstance(self.model, TableMeta): # tried to use querybuilder with a non-typedal table, # fallback to execute: - return self.execute(add_id=add_id) + return t.cast(TypedRows[T_MetaInstance], self.execute(add_id=add_id)) metadata: Metadata = self.metadata.copy() prepared = self._collect_prepare(metadata, add_id, into) @@ -800,7 +800,7 @@ def collect( async def collect_async( self, verbose: bool = False, - _to: t.Type["TypedRows[t.Any]"] = None, + _to: t.Type["TypedRows[t.Any]"] | None = None, add_id: bool = True, _into: t.Type[_TypedTable] | None = None, _init: t.Callable[[_TypedTable, Row], None] | None = None, @@ -821,7 +821,7 @@ async def collect_async( if not isinstance(self.model, TableMeta): # tried to use querybuilder with a non-typedal table, # fallback to execute: - return await self.execute_async(add_id=add_id) + return t.cast(TypedRows[T_MetaInstance], await self.execute_async(add_id=add_id)) metadata: Metadata = self.metadata.copy() prepared = self._collect_prepare(metadata, add_id, into) @@ -1003,10 +1003,10 @@ def _build_inner_joins_recursive( if relation.condition and relation.join == "inner": other = relation.get_table(db) other = other.with_alias(f"{key}_{hash(relation)}") - condition = relation.condition(parent_table, other) + condition = relation.condition(parent_table, other) # ty: ignore[invalid-argument-type] if callable(relation.condition_and): - condition &= relation.condition_and(parent_table, other) + condition &= relation.condition_and(parent_table, other) # ty: ignore[invalid-argument-type] joins.append(other.on(condition)) @@ -1037,7 +1037,7 @@ def _selectable_orderby_fields(self, orderby: OrderBy | t.Iterable[OrderBy] | No return [expression_without_direction if direction.upper() in {"ASC", "DESC"} else orderby] if isinstance(orderby, pydal.objects.Field): - return [orderby] + return t.cast(list[OrderBy], [orderby]) fields = [] first = getattr(orderby, "first", None) @@ -1131,19 +1131,19 @@ def _process_relationship_for_left_join( # Build join condition if relation.on: # Custom .on condition - always left join - on = relation.on(parent_table, other) + on = relation.on(parent_table, other) # ty: ignore[invalid-argument-type] if not isinstance(on, list): on = [on] on = [_ for _ in on if isinstance(_, pydal.objects.Expression)] - left_joins.extend(on) + left_joins.extend(on) # ty: ignore[invalid-argument-type] elif method == "left": # Generate left join condition other = other.with_alias(f"{key}_{hash(relation)}") - condition = t.cast(Query, relation.condition(parent_table, other)) + condition = t.cast(Query, relation.condition(parent_table, other)) # ty: ignore[call-non-callable, invalid-argument-type] if callable(relation.condition_and): - condition &= relation.condition_and(parent_table, other) + condition &= relation.condition_and(parent_table, other) # ty: ignore[invalid-argument-type] left_joins.append(other.on(condition)) else: @@ -1417,7 +1417,7 @@ def __count( other = other.with_alias(f"{key}_{hash(relation)}") if relation.condition is not None: - query &= relation.condition(model, other) + query &= relation.condition(model, other) # ty: ignore[invalid-argument-type] return query @@ -1478,7 +1478,7 @@ def __pagination_count_query(self) -> tuple[TypeDAL, Query]: relationship case only (without relationships both just defer to `count()`). """ db = self._get_db() - query = self.__count(db, distinct=self.model.id, include_left_for_distinct=False) + query = self.__count(db, distinct=self.model.id, include_left_for_distinct=False) # ty: ignore[invalid-argument-type] return db, query def __pagination_count(self) -> int: @@ -1633,7 +1633,7 @@ def first(self, verbose: bool = False) -> T_MetaInstance | None: # old-style pydal table: keep pydal semantics and return raw Row return row - return self.model.from_row(row) + return self.model.from_row(row) # ty: ignore[invalid-argument-type] async def first_async(self, verbose: bool = False) -> T_MetaInstance | None: """ @@ -1649,7 +1649,7 @@ async def first_async(self, verbose: bool = False) -> T_MetaInstance | None: # old-style pydal table: keep pydal semantics and return raw Row return row - return self.model.from_row(row) + return self.model.from_row(row) # ty: ignore[invalid-argument-type] def _first(self) -> str: return self._paginate(page=1, limit=1) diff --git a/src/typedal/relationships.py b/src/typedal/relationships.py index d015b35..309818a 100644 --- a/src/typedal/relationships.py +++ b/src/typedal/relationships.py @@ -5,13 +5,12 @@ import inspect import typing as t import warnings -from typing import ForwardRef import pydal.objects from .config import LazyPolicy from .constants import JOIN_OPTIONS -from .core import TypeDAL, evaluate_forward_reference +from .core import ForwardRef, TypeDAL, evaluate_forward_reference from .fields import TypedField from .helpers import extract_type_optional, looks_like, unwrap_type from .types import Condition, OnQuery, T_Field @@ -48,7 +47,7 @@ def __init__( join: JOIN_OPTIONS = None, on: OnQuery = None, condition_and: Condition = None, - nested: dict[str, t.Self] = None, + nested: dict[str, t.Self] | None = None, lazy: LazyPolicy | None = None, explicit: bool = False, ): @@ -150,7 +149,7 @@ def get_table(self, db: "TypeDAL") -> t.Type["TypedTable"]: # boo, fall back to untyped table but pretend it is typed: return t.cast(t.Type["TypedTable"], db[table]) # eh close enough! - return table + return t.cast(t.Type["TypedTable"], table) def get_db(self) -> TypeDAL | None: """ @@ -552,7 +551,7 @@ def resolve_relationship_type( if any(a is None for a in resolved_args): return None if origin is list: - return list[resolved_args[0]] # type: ignore[valid-type] + return list[resolved_args[0]] # type: ignore[valid-type] # ty: ignore[invalid-type-form] # Other generics: return as-is (already resolvable) return relationship_type diff --git a/src/typedal/rows.py b/src/typedal/rows.py index 0ccb142..830797a 100644 --- a/src/typedal/rows.py +++ b/src/typedal/rows.py @@ -49,9 +49,9 @@ def __init__( self, rows: Rows, model: t.Type[T_MetaInstance], - records: dict[int, T_MetaInstance] = None, - metadata: Metadata = None, - raw: dict[int, list[Row]] = None, + records: dict[int, T_MetaInstance] | None = None, + metadata: Metadata | None = None, + raw: dict[int, list[Row]] | None = None, ) -> None: """ Should not be called manually! @@ -127,7 +127,7 @@ def last(self) -> T_MetaInstance | None: def find( self, f: t.Callable[[T_MetaInstance], Query], - limitby: tuple[int, int] = None, + limitby: tuple[int, int] | None = None, ) -> "TypedRows[T_MetaInstance]": """ Returns a new Rows object, a subset of the original object, filtered by the function `f`. @@ -198,7 +198,7 @@ def __repr__(self) -> str: return mktable(data, headers) - def group_by_value[T: t.Any, T_MetaInstance: _TypedTable]( + def group_by_value[T: t.Any, T_MetaInstance: _TypedTable]( # ty: ignore[shadowed-type-variable] self, *fields: "str | Field | TypedField[T]", one_result: bool = False, @@ -246,8 +246,8 @@ def as_dict( return {k: v.as_dict() for k, v in self.records.items()} - def as_json( - self, default: t.Callable[[t.Any], t.Any] = None, indent: t.Optional[int] = None, **kwargs: t.Any + def as_json( # ty: ignore[invalid-method-override] + self, default: t.Callable[[t.Any], t.Any] | None = None, indent: int | None = None, **kwargs: t.Any ) -> str: """ Turn the data into a dict and then dump to JSON. @@ -256,7 +256,9 @@ def as_json( return as_json.encode(data, default=default, indent=indent, **kwargs) - def json(self, default: t.Callable[[t.Any], t.Any] = None, indent: t.Optional[int] = None, **kwargs: t.Any) -> str: + def json( + self, default: t.Callable[[t.Any], t.Any] | None = None, indent: int | None = None, **kwargs: t.Any + ) -> str: # ty: ignore[invalid-method-override] """ Turn the data into a dict and then dump to JSON. """ @@ -267,7 +269,7 @@ def as_list( compact: bool = False, storage_to_dict: bool = False, datetime_to_str: bool = False, - custom_types: list[type] = None, + custom_types: list[type] | None = None, ) -> list[AnyDict]: """ Get the data in a list of dicts. @@ -277,7 +279,7 @@ def as_list( return [_.as_dict() for _ in self.records.values()] - def __getitem__(self, item: int) -> T_MetaInstance: + def __getitem__(self, item: int) -> T_MetaInstance: # ty: ignore[invalid-method-override] """ You can get a specific row by ID from a typedrows by using rows[idx] notation. @@ -324,10 +326,10 @@ def delete(self) -> bool: def join( self, field: "Field | TypedField[t.Any]", - name: str = None, - constraint: Query = None, - fields: list[str | Field] = None, - orderby: t.Optional[str | Field] = None, + name: str | None = None, + constraint: Query | None = None, + fields: list[str | Field] | None = None, + orderby: str | Field | None = None, ) -> T_MetaInstance: """ This can be used to JOIN with some relationships after the initial select. @@ -345,7 +347,7 @@ def export_to_csv_file( quotechar: str = '"', quoting: int = csv.QUOTE_MINIMAL, represent: bool = False, - colnames: list[str] = None, + colnames: list[str] | None = None, write_colnames: bool = True, *args: t.Any, **kwargs: t.Any, @@ -373,7 +375,7 @@ def from_rows( cls, rows: Rows, model: t.Type[T_MetaInstance], - metadata: Metadata = None, + metadata: Metadata | None = None, into: t.Type[_TypedTable] | None = None, init: t.Callable[[_TypedTable, Row], None] | None = None, ) -> "TypedRows[T_MetaInstance]": @@ -497,7 +499,7 @@ def next(self) -> t.Self: if data["current_page"] >= data["max_page"]: raise StopIteration("Final Page") - return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] + 1) + return t.cast(t.Self, self._query_builder.paginate(limit=data["limit"], page=data["current_page"] + 1)) def previous(self) -> t.Self: """ @@ -507,7 +509,7 @@ def previous(self) -> t.Self: if data["current_page"] <= 1: raise StopIteration("First Page") - return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] - 1) + return t.cast(t.Self, self._query_builder.paginate(limit=data["limit"], page=data["current_page"] - 1)) def as_dict(self, *_: t.Any, **__: t.Any) -> PaginateDict: # type: ignore """ @@ -525,7 +527,7 @@ class TypedSet(pydal.objects.Set): # pragma: no cover This class is not actually used, only 'cast' by TypeDAL.__call__ """ - def count(self, distinct: t.Optional[bool] = None, cache: AnyDict = None) -> int: + def count(self, distinct: bool | None = None, cache: AnyDict | None = None) -> int: """ Count returns an int. """ diff --git a/src/typedal/serializers/typescript.py b/src/typedal/serializers/typescript.py index 39435b4..c9ca109 100644 --- a/src/typedal/serializers/typescript.py +++ b/src/typedal/serializers/typescript.py @@ -9,10 +9,13 @@ from configuraptor import Singleton -try: # optional dependency +if t.TYPE_CHECKING: import typtyp -except ImportError: # pragma: no cover - typtyp = None # type: ignore +else: + try: # optional dependency + import typtyp + except ImportError: # pragma: no cover + typtyp = None def is_supported() -> bool: @@ -34,20 +37,22 @@ def __init__(self) -> None: @property def world(self) -> "typtyp.World | None": """Return the shared typtyp world instance, if typtyp is installed.""" + if typtyp is None: + return None return self._world def get(self, model: type) -> type[dict[str, t.Any]] | None: """Return the registered TypedDict for a model, or None if absent.""" return self._types.get(model) - def create(self, model: type, fields: dict[str, t.Any] = None, name: str = "") -> type[dict[str, t.Any]]: + def create(self, model: type, fields: dict[str, t.Any] | None = None, name: str = "") -> type[dict[str, t.Any]]: """ Create/register a TypedDict for a model and add it to the shared world. If the world is unavailable (typtyp not installed), registration is local only. """ name = name or model.__name__ - raw_typed_dict = t.TypedDict(name, fields or {}) + raw_typed_dict = t.TypedDict(name, fields or {}) # ty: ignore[invalid-argument-type, mismatched-type-name] typed_dict = t.cast(type[dict[str, t.Any]], raw_typed_dict) self._types[model] = typed_dict self.add_to_world(typed_dict, name=name) diff --git a/src/typedal/types.py b/src/typedal/types.py index e3cc6d3..4025372 100644 --- a/src/typedal/types.py +++ b/src/typedal/types.py @@ -27,7 +27,7 @@ try: from string.templatelib import Template as TemplateAlias except ImportError: - TemplateAlias: t.TypeAlias = str # type: ignore + TemplateAlias: t.TypeAlias = str # Internal references if t.TYPE_CHECKING: @@ -71,7 +71,7 @@ def merge_permissions(*permission_sets: Permissions | None) -> Permissions: for key in permission_types: if key in permission_set: - merged[key] = merged[key] and bool(permission_set[key]) # type: ignore + merged[key] = merged[key] and bool(permission_set[key]) return t.cast(Permissions, merged) @@ -404,10 +404,10 @@ class DefineKwargs(t.TypedDict, total=False): "Table", Query, bool, - None, "TypedTable", t.Type["TypedTable"], Expression, + None, ] type T_Field = t.Union["TypedField[t.Any]", "Table", t.Type["TypedTable"]] diff --git a/src/typedal/web2py_py4web_shared.py b/src/typedal/web2py_py4web_shared.py index 91c89bb..6fd1a88 100644 --- a/src/typedal/web2py_py4web_shared.py +++ b/src/typedal/web2py_py4web_shared.py @@ -3,11 +3,13 @@ """ import datetime as dt +import typing as t from pydal.validators import CRYPT, IS_EMAIL, IS_NOT_EMPTY, IS_NOT_IN_DB, IS_STRONG from . import TypeDAL, TypedField, TypedTable from .fields import PasswordField +from .types import Validator class AuthUser(TypedTable): @@ -35,10 +37,11 @@ def __on_define__(cls, db: TypeDAL) -> None: """ super().__on_define__(db) - cls.email.requires = [ + requires = [ IS_EMAIL(), IS_NOT_IN_DB( db, "auth_user.email", ), ] + cls.email.requires = t.cast(list[Validator], requires) From 23aac698452a45c5c94dd6416350bf4fe77d3abb Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 21:07:08 +0200 Subject: [PATCH 11/29] test: get rid of monkeypatch.setattr slop --- tests/test_async_execution.py | 3 --- tests/test_typescript.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 23ae197..18ce1aa 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -743,7 +743,6 @@ async def counting_factory(dal: TypeDAL): opened.append(pool) return pool - monkeypatch.setitem(ASYNC_POOL_FACTORIES, dbengine, counting_factory) try: first, second = await asyncio.gather(db._get_async_pool(), db._get_async_pool()) @@ -841,7 +840,6 @@ async def racing_get_pool(): adapter._last_insert = (table._id, 1) return pool - monkeypatch.setattr(db, "_get_async_pool", racing_get_pool) # no fields -> INSERT INTO ... DEFAULT VALUES, which has no RETURNING clause result = await db.insert_async(table, []) @@ -1005,7 +1003,6 @@ async def test_get_async_pool_rejects_unsupported_backend(db_async: TypeDAL, mon db = db_async await db.close_async() - monkeypatch.setattr(db._adapter, "dbengine", "oracle", raising=False) with pytest.raises(NotImplementedError, match="only implemented for"): await db._get_async_pool() diff --git a/tests/test_typescript.py b/tests/test_typescript.py index 8fc4af0..7f2bc8e 100644 --- a/tests/test_typescript.py +++ b/tests/test_typescript.py @@ -7,6 +7,7 @@ from pydal2sql_core import RenderContext, render_schema_from_code from src.typedal import Ref, TypeDAL, TypedField, TypedTable, relationship +from src.typedal.serializers import typescript from src.typedal.serializers.typescript import TypedDictRegistry db = TypeDAL("sqlite:memory") @@ -126,6 +127,22 @@ class DummyModel: TypedDictRegistry.clear() +def test_registry_world_is_none_without_typtyp(monkeypatch: pytest.MonkeyPatch): + """ + `typtyp` is an optional dependency (`typedal[typescript]`). With it missing the registry has + to degrade to `world is None` instead of raising - the same condition `is_supported()` + reports. Patched rather than skipped, since typtyp *is* installed in the test environment. + """ + TypedDictRegistry.clear() + registry = TypedDictRegistry() + assert registry.world is not None + + assert typescript.is_supported() is False + assert registry.world is None + + TypedDictRegistry.clear() + + def test_registry_get_typescript_without_world_warns(): TypedDictRegistry.clear() registry = TypedDictRegistry() From 6c799b72f13872e482a646de2e1a2f323abbb3a8 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 21:27:18 +0200 Subject: [PATCH 12/29] refactor(async-execution): extract async pool lifecycle into manager --- src/typedal/async_execution.py | 86 ++++++++++++++++++++++- src/typedal/core.py | 65 +++-------------- tests/test_async_execution.py | 123 ++++++++++++++++----------------- 3 files changed, 155 insertions(+), 119 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index f990d6e..42354d0 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -239,12 +239,96 @@ async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: return SqliteAsyncConnection(conn) -ASYNC_POOL_FACTORIES: dict[str, t.Callable[["TypeDAL"], t.Awaitable[AsyncConnectionPool]]] = { +type PoolFactory = t.Callable[["TypeDAL"], t.Awaitable[AsyncConnectionPool]] + +ASYNC_POOL_FACTORIES: dict[str, PoolFactory] = { "postgres": open_postgres_async_pool, "sqlite": open_sqlite_async_connection, } +class AsyncPoolManager: + """ + Owns the lazily-opened async connection for one `TypeDAL`: picking the factory for its + backend, keeping creation single, and closing/reopening. + + Its own object rather than three attributes and two methods on `TypeDAL`, because the + lifecycle has behaviour worth exercising on its own - "opened exactly once even when two + coroutines race for the first use", "an unknown backend fails loudly" - and `factories` as + a constructor argument makes that reachable directly, instead of only through a patched + module global. + """ + + def __init__(self, db: "TypeDAL", factories: dict[str, PoolFactory] | None = None) -> None: + self._db = db + self._factories = ASYNC_POOL_FACTORIES if factories is None else factories + self._pool: AsyncConnectionPool | None = None + self._lock: asyncio.Lock | None = None + self._lock_loop: asyncio.AbstractEventLoop | None = None + + @property + def pool(self) -> AsyncConnectionPool | None: + """ + The connection if one is currently open, else None. Never opens one - use `get()`. + """ + return self._pool + + def _get_lock(self) -> asyncio.Lock: + """ + The lock guarding creation, bound to the loop currently running. + + Not created once in `__init__`: an `asyncio.Lock` binds to the loop it is first used on + and refuses use from another one, while a `TypeDAL` can outlive a loop (every + pytest-asyncio test gets a fresh one, and `close()` explicitly supports reopening). + Re-created when the loop changed - safe to decide here because this method never + awaits, so two coroutines on one loop cannot interleave inside it and always come away + with the same lock. + """ + loop = asyncio.get_running_loop() + if self._lock is None or self._lock_loop is not loop: + self._lock = asyncio.Lock() + self._lock_loop = loop + + return self._lock + + async def get(self) -> AsyncConnectionPool: + """ + The async connection for this db, opening it on first use. + + Creation happens under the lock with the check repeated inside it: the factories await, + so a plain `if self._pool is None: self._pool = await factory(...)` lets two coroutines + whose first use overlaps both pass the check and both open one. Only one could be + stored, and the other would be dropped without `close()` - a leaked pool, or on SQLite + a leaked connection and its background thread. + """ + if self._pool is not None: + # fast path: already open, no need to take the lock at all + return self._pool + + async with self._get_lock(): + if self._pool is None: + dbengine = self._db._adapter.dbengine + try: + factory = self._factories[dbengine] + except KeyError: + raise NotImplementedError( + f"The async execution path is only implemented for " + f"{', '.join(self._factories)}, not {dbengine!r}.", + ) from None + + self._pool = await factory(self._db) + + return self._pool + + async def close(self) -> None: + """ + Close the connection if one was ever opened, leaving this manager reusable. + """ + if self._pool is not None: + await self._pool.close() + self._pool = None + + async def postgres_lastrowid_async( adapter: SQLAdapter, table: pydal.objects.Table, diff --git a/src/typedal/core.py b/src/typedal/core.py index 23ad1f1..c50cf44 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -5,7 +5,6 @@ from __future__ import annotations # noinspection PyUnusedImports -import asyncio import collections import datetime as dt import sys @@ -15,7 +14,7 @@ import pydal -from .async_execution import ASYNC_POOL_FACTORIES, DELETE_STRATEGIES, LASTROWID_STRATEGIES, AsyncConnectionPool +from .async_execution import DELETE_STRATEGIES, LASTROWID_STRATEGIES, AsyncConnectionPool, AsyncPoolManager from .config import LazyPolicy, TypeDALConfig, load_config from .helpers import ( SYSTEM_SUPPORTS_TEMPLATES, @@ -295,9 +294,7 @@ def __init__( self._after_collect = [] self._before_execute = [] self._after_execute = [] - self._async_pool: AsyncConnectionPool | None = None # lazily-created; see _get_async_pool - self._async_pool_lock: asyncio.Lock | None = None # guards that creation; see _get_async_lock - self._async_pool_lock_loop: asyncio.AbstractEventLoop | None = None + self._async_pools = AsyncPoolManager(self) # lazily-opened async connection; see _get_async_pool if config.folder: Path(config.folder).mkdir(exist_ok=True) @@ -591,58 +588,18 @@ def executesql( # Async execution path. # ------------------------------------------------------------------ - def _get_async_pool_lock(self) -> asyncio.Lock: - """ - The lock guarding lazy pool creation, bound to the loop currently running. - - Not created once in `__init__`: an `asyncio.Lock` binds to the loop it is first used on - and refuses use from another one, while a `TypeDAL` instance can outlive a loop (every - pytest-asyncio test gets a fresh one, and `close_async()` explicitly supports reopening). - Re-created when the loop changed - which is safe to decide here because this method - never awaits, so two coroutines on the same loop cannot interleave inside it and always - come away with the same lock object. - """ - loop = asyncio.get_running_loop() - if self._async_pool_lock is None or self._async_pool_lock_loop is not loop: - self._async_pool_lock = asyncio.Lock() - self._async_pool_lock_loop = loop - - return self._async_pool_lock - async def _get_async_pool(self) -> AsyncConnectionPool: """ - Lazily create the async connection (a real pool for Postgres, a single wrapped - connection for SQLite) for this instance, via `ASYNC_POOL_FACTORIES`. + The async connection (a real pool for Postgres, a single wrapped connection for SQLite) + for this instance, opened on first use. - One per `TypeDAL` instance, opened on first use. Deliberately a separate connection - from pydal's own thread-local sync connection: they are two independent transactions, - so a write on one is invisible to a read on the other until committed, and - commit()/rollback() on one says nothing about the other. + Deliberately a separate connection from pydal's own thread-local sync connection: they + are two independent transactions, so a write on one is invisible to a read on the other + until committed, and commit()/rollback() on one says nothing about the other. - Creation is done under a lock with the check repeated inside it: the factories await, - so a plain `if self._async_pool is None: ... = await factory(self)` lets two coroutines - whose first use overlaps both pass the check and both open one. Only one could be - stored, and the other would be dropped without `close()` - a leaked pool, or on SQLite - a leaked connection and its background thread. + The lifecycle itself lives in `AsyncPoolManager` (async_execution.py). """ - if self._async_pool is not None: - # fast path: already open, no need to take the lock at all - return self._async_pool - - async with self._get_async_pool_lock(): - if self._async_pool is None: - dbengine = self._adapter.dbengine - try: - factory = ASYNC_POOL_FACTORIES[dbengine] - except KeyError: - raise NotImplementedError( - f"The async execution path is only implemented for " - f"{', '.join(ASYNC_POOL_FACTORIES)}, not {dbengine!r}.", - ) from None - - self._async_pool = await factory(self) - - return self._async_pool + return await self._async_pools.get() async def select_async( self, @@ -923,9 +880,7 @@ async def close_async(self) -> None: """ Close the async connection pool, if one was ever opened. """ - if self._async_pool is not None: - await self._async_pool.close() - self._async_pool = None + await self._async_pools.close() def sql_expression( self, diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 18ce1aa..9a74cd5 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -24,7 +24,12 @@ import pytest_asyncio from src.typedal import TypeDAL, TypedField, TypedTable -from src.typedal.async_execution import ASYNC_POOL_FACTORIES +from src.typedal.async_execution import ( + ASYNC_POOL_FACTORIES, + AsyncPoolManager, + open_sqlite_async_connection, + postgres_lastrowid_async, +) from src.typedal.fields import DecimalField, JSONField from src.typedal.query_builder import QueryBuilder @@ -711,29 +716,20 @@ class AsyncThingInsertError(TypedTable): @pytest.mark.asyncio -async def test_get_async_pool_is_opened_once_under_concurrency( - db_async: TypeDAL, - monkeypatch: pytest.MonkeyPatch, -): +async def test_async_pool_manager_opens_once_under_concurrency(db_async: TypeDAL): """ - `_get_async_pool()` checks `self._async_pool is None`, awaits the factory, then assigns - (core.py:602-612). Two coroutines whose first DB use overlaps both pass the check and both - open one: a second psycopg pool, or on SQLite a second aiosqlite connection. Only one is - stored; the other is dropped without `close()`, leaking the connection (and, for aiosqlite, - its background thread). - - The stand-in factory suspends before doing the real work. Both real factories contain - awaits, but *whether* a given one actually yields to the loop is a driver detail rather - than a guarantee - `aiosqlite.connect()` does, `psycopg_pool`'s `open()` currently does - not - and this is a test of `_get_async_pool()`'s check-then-assign, not of which drivers - happen to make it observable today. + Creating the pool is check-then-assign around an `await`, so two coroutines whose first use + overlaps can both pass the check and both open one: a second psycopg pool, or on SQLite a + second aiosqlite connection. Only one can be stored; the other would be dropped without + `close()`, leaking the connection (and, for aiosqlite, its background thread). + + Driven through a manager of its own with a counting `factories` entry - a constructor + argument, so nothing global is swapped out. The stand-in suspends before doing the real + work: both real factories contain awaits, but whether a given one actually yields is a + driver detail (`aiosqlite.connect()` does, `psycopg_pool.open()` currently does not) and + this is a test of the manager, not of which drivers make the race observable today. """ - db = db_async - - # start from "never opened", whatever earlier tests on this session-scoped DAL did: - await db.close_async() - - dbengine = db._adapter.dbengine + dbengine = db_async._adapter.dbengine real_factory = ASYNC_POOL_FACTORIES[dbengine] opened = [] @@ -743,17 +739,20 @@ async def counting_factory(dal: TypeDAL): opened.append(pool) return pool - + manager = AsyncPoolManager(db_async, factories={dbengine: counting_factory}) try: - first, second = await asyncio.gather(db._get_async_pool(), db._get_async_pool()) + first, second = await asyncio.gather(manager.get(), manager.get()) assert first is second, "concurrent first use handed out two different pools" assert len(opened) == 1, f"opened {len(opened)}, so {len(opened) - 1} was leaked unclosed" finally: - # don't let this test's own leak poison the rest of the session: + kept = manager.pool + await manager.close() + # whatever a leak left behind is no longer the manager's to close: for pool in opened: - if pool is not db._async_pool: - await pool.close() + if pool is not kept: + with contextlib.suppress(Exception): + await pool.close() @pytest.mark.asyncio @@ -802,27 +801,22 @@ class AsyncThingCommonFilter(TypedTable): @pytest.mark.asyncio -async def test_insert_async_lastrowid_does_not_read_shared_last_insert( - dal_psql: TypeDAL, - monkeypatch: pytest.MonkeyPatch, -): +async def test_postgres_lastrowid_async_uses_only_the_value_it_was_given(dal_psql: TypeDAL): """ - `postgres_lastrowid_async()` decides whether the INSERT it just ran carried a RETURNING - clause by reading `adapter._last_insert` (async_execution.py:176) - a property over - `THREAD_LOCAL._pydal_last_insert_` (pydal adapters/postgres.py:128-133). Coroutines share - one thread, so that thread-local provides no isolation whatsoever here: for the async path - it is effectively a global. - - `insert_async()` sets it via `adapter._insert()` (core.py:734) and reads it several awaits - later (core.py:745); any other insert landing in that window overwrites it. The window is - made deterministic here rather than raced: the statement built is a `DEFAULT VALUES` insert - (no fields -> no RETURNING, pydal postgres.py:149-162), while a concurrent normal insert - leaves the flag truthy - so lastrowid tries to `fetchone()` a result that does not exist. - - Takes the Postgres fixture directly instead of the parametrized `db_async`: SQLite has - no equivalent flag at all - `sqlite_lastrowid_async` ignores `last_insert` and returns - `cursor.lastrowid` - so a SQLite run would race against something nothing reads and - pass for reasons unrelated to the defect. + `postgres_lastrowid_async()` must decide whether the statement it just ran carried a + RETURNING clause from its `last_insert` argument alone - never by reading + `adapter._last_insert` back. That attribute is a property over + `THREAD_LOCAL._pydal_last_insert_` (pydal adapters/postgres.py:128-133), and coroutines + share one thread, so for the async path it is effectively a global: any other insert + running between `_insert()` and here overwrites it. + + Proven by executing a `DEFAULT VALUES` insert - no fields, therefore no RETURNING + (postgres.py:149-162) - while the thread-local says the opposite. Reading the attribute + would take the `fetchone()` branch and raise on a statement that produced no rows. + + Takes the Postgres fixture directly instead of the parametrized `db_async`: SQLite has no + equivalent flag - `sqlite_lastrowid_async` ignores `last_insert` entirely and returns + `cursor.lastrowid` - so there would be nothing for a SQLite run to assert. """ async with _postgres_db(dal_psql) as db: @@ -832,19 +826,21 @@ class AsyncThingLastInsert(TypedTable): table = AsyncThingLastInsert._ensure_table_defined() adapter = db._adapter - real_get_pool = db._get_async_pool - async def racing_get_pool(): - pool = await real_get_pool() - # stand-in for a concurrent insert_async() finishing its own adapter._insert(): - adapter._last_insert = (table._id, 1) - return pool + sql = adapter._insert(table, []) + captured = adapter._last_insert # what *this* statement produced: None + assert captured is None + # stand-in for a concurrent insert_async() landing between the build and the read: + adapter._last_insert = (table._id, 1) - # no fields -> INSERT INTO ... DEFAULT VALUES, which has no RETURNING clause - result = await db.insert_async(table, []) + pool = await db._get_async_pool() + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql) + row_id = await postgres_lastrowid_async(adapter, table, cur, captured) - assert int(result) > 0 + assert isinstance(row_id, int) + assert row_id > 0 @pytest.mark.asyncio @@ -995,17 +991,18 @@ class AsyncThingUpdateError(TypedTable): @pytest.mark.asyncio -async def test_get_async_pool_rejects_unsupported_backend(db_async: TypeDAL, monkeypatch: pytest.MonkeyPatch): +async def test_async_pool_manager_rejects_unsupported_backend(db_async: TypeDAL): """ - A dbengine with no entry in `ASYNC_POOL_FACTORIES` must fail loudly and name what is - supported, rather than KeyError-ing out of `_get_async_pool()`. + A dbengine with no registered factory must fail loudly and name what *is* supported, rather + than KeyError-ing out. Expressed by handing the manager a registry that does not cover this + backend - again a constructor argument, not a patched global or a faked adapter. """ - db = db_async - await db.close_async() + manager = AsyncPoolManager(db_async, factories={"nosuchengine": open_sqlite_async_connection}) + with pytest.raises(NotImplementedError, match="only implemented for nosuchengine"): + await manager.get() - with pytest.raises(NotImplementedError, match="only implemented for"): - await db._get_async_pool() + assert manager.pool is None @pytest.mark.asyncio From 4db8d7d81ec3941004bdc35bfa481b4b4fea5cf4 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Fri, 14 Aug 2026 21:27:27 +0200 Subject: [PATCH 13/29] refactor(typescript): simplify registry world access --- src/typedal/serializers/typescript.py | 4 ++-- tests/test_typescript.py | 17 ----------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/typedal/serializers/typescript.py b/src/typedal/serializers/typescript.py index c9ca109..2b77927 100644 --- a/src/typedal/serializers/typescript.py +++ b/src/typedal/serializers/typescript.py @@ -37,8 +37,8 @@ def __init__(self) -> None: @property def world(self) -> "typtyp.World | None": """Return the shared typtyp world instance, if typtyp is installed.""" - if typtyp is None: - return None + # no `typtyp is None` check: __init__ already stores None in that case, so re-testing + # the import here would only duplicate it - and leave a branch nothing can reach. return self._world def get(self, model: type) -> type[dict[str, t.Any]] | None: diff --git a/tests/test_typescript.py b/tests/test_typescript.py index 7f2bc8e..8fc4af0 100644 --- a/tests/test_typescript.py +++ b/tests/test_typescript.py @@ -7,7 +7,6 @@ from pydal2sql_core import RenderContext, render_schema_from_code from src.typedal import Ref, TypeDAL, TypedField, TypedTable, relationship -from src.typedal.serializers import typescript from src.typedal.serializers.typescript import TypedDictRegistry db = TypeDAL("sqlite:memory") @@ -127,22 +126,6 @@ class DummyModel: TypedDictRegistry.clear() -def test_registry_world_is_none_without_typtyp(monkeypatch: pytest.MonkeyPatch): - """ - `typtyp` is an optional dependency (`typedal[typescript]`). With it missing the registry has - to degrade to `world is None` instead of raising - the same condition `is_supported()` - reports. Patched rather than skipped, since typtyp *is* installed in the test environment. - """ - TypedDictRegistry.clear() - registry = TypedDictRegistry() - assert registry.world is not None - - assert typescript.is_supported() is False - assert registry.world is None - - TypedDictRegistry.clear() - - def test_registry_get_typescript_without_world_warns(): TypedDictRegistry.clear() registry = TypedDictRegistry() From f64b9fc391da37f23c6a52d0bf82017d6f8f8d60 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 10:03:45 +0200 Subject: [PATCH 14/29] docs(typedal): remove brittle upstream line references --- src/typedal/async_execution.py | 26 +++++++++++++------------- src/typedal/core.py | 24 ++++++++++++------------ src/typedal/query_builder.py | 2 +- src/typedal/tables.py | 20 ++++++++++---------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index 42354d0..945cfd3 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -27,7 +27,7 @@ # What pydal's `adapter._insert()` leaves behind to record whether the statement it just built # carries a RETURNING clause: `(table._id, 1)` when it does, `None` when it does not -# (adapters/postgres.py:149-158). Backends without the concept never set it at all, hence None. +# (adapters/postgres.py). Backends without the concept never set it at all, hence None. type LastInsert = tuple[pydal.objects.Field, int] | None @@ -59,7 +59,7 @@ async def execute(self, sql: str, parameters: t.Any = ..., /) -> t.Any: ... async def fetchone(self) -> t.Any: ... # `Iterable`, not `Sequence`: aiosqlite declares `fetchall() -> Iterable[sqlite3.Row]` - # (aiosqlite/cursor.py:66), so requiring a Sequence here would reject it. + # (aiosqlite/cursor.py), so requiring a Sequence here would reject it. async def fetchall(self) -> t.Iterable[t.Any]: ... @@ -142,7 +142,7 @@ class SqliteAsyncConnection: Minimal pool-like wrapper around a single aiosqlite connection. SQLite has no real concept of a connection pool the way Postgres does - pydal itself sets - `pool_size = 0` for SQLite (adapters/sqlite.py:26), one connection is all there is. This + `pool_size = 0` for SQLite (adapters/sqlite.py), one connection is all there is. This gives it the same `.connection()`/`.commit()`/`.rollback()`/`.close()` shape as `PostgresAsyncPool` so `select_async()` etc. don't need to branch on backend. @@ -157,7 +157,7 @@ class SqliteAsyncConnection: same time would share one transaction and the first to exit would decide for both - committing the other's half-finished write, or rolling back a write that had succeeded. psycopg_pool avoids this by handing out a different connection per caller; that is not an - option here (pydal itself runs SQLite at `pool_size = 0`, adapters/sqlite.py:26), and for + option here (pydal itself runs SQLite at `pool_size = 0`, adapters/sqlite.py), and for `sqlite:memory` it would actively break, since shared-cache mode answers a second concurrent writer with SQLITE_LOCKED, which no busy-timeout retries. Serializing costs concurrency SQLite does not have for writes anyway - it allows exactly one writer. @@ -224,12 +224,12 @@ async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: ) from e adapter = db._adapter - # Reuse pydal's own path/URI resolution and connect kwargs (adapters/sqlite.py:25-38) - in + # Reuse pydal's own path/URI resolution and connect kwargs (adapters/sqlite.py) - in # particular the memory-mode shared-cache URI, so this connection sees the same in-memory # database as pydal's own sync connection. conn = await aiosqlite.connect(adapter.dbpath, **adapter.driver_args) - # Mirror SQLite.after_connection() (adapters/sqlite.py:82-86): custom functions and PRAGMA + # Mirror SQLite.after_connection() (adapters/sqlite.py): custom functions and PRAGMA # are per-connection state, and this connection is not the one pydal set those up on. await conn.create_function("web2py_extract", 2, adapter.web2py_extract) await conn.create_function("REGEXP", 2, adapter.web2py_regexp) @@ -336,13 +336,13 @@ async def postgres_lastrowid_async( last_insert: LastInsert, ) -> int | None: """ - Async twin of `Postgre.lastrowid()` (pydal adapters/postgres.py:142-147). + Async twin of `Postgre.lastrowid()` (pydal adapters/postgres.py). `last_insert` is the value `adapter._insert()` set as a side effect of building the INSERT - statement (postgres.py:149-162, set whenever the table has a standard `_id` column), passed + statement (postgres.py, set whenever the table has a standard `_id` column), passed in by `insert_async()` rather than read back off the adapter here. It has to be passed: `adapter._last_insert` is a property over `THREAD_LOCAL._pydal_last_insert_` - (postgres.py:128-133), and every coroutine on this path shares one thread, so reading it + (postgres.py), and every coroutine on this path shares one thread, so reading it after the intervening awaits would see whichever insert touched it last. Truthy means the id is already in the RETURNING result of the statement just executed, read @@ -367,7 +367,7 @@ async def sqlite_lastrowid_async( _last_insert: LastInsert, ) -> int | None: """ - Async twin of the base `SQLAdapter.lastrowid()` (pydal adapters/base.py:529-530), used by + Async twin of the base `SQLAdapter.lastrowid()` (pydal adapters/base.py), used by SQLite (no override there). `cursor.lastrowid` is a plain attribute, not awaitable, and needs no `last_insert` - it takes the argument only to share one strategy signature. """ @@ -387,7 +387,7 @@ async def sqlite_lastrowid_async( async def base_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: pydal.objects.Query) -> int | None: """ - Async twin of the base `SQLAdapter.delete()` (pydal adapters/base.py:604-610): plain + Async twin of the base `SQLAdapter.delete()` (pydal adapters/base.py): plain build/execute sandwich, no cascade handling. Used directly for Postgres (no override there), and internally by `sqlite_delete_async` for the actual delete statement - mirroring how `SQLite.delete()` itself calls `super().delete()` for that part. @@ -401,14 +401,14 @@ async def base_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: py try: return cur.rowcount except Exception: # pragma: no cover - # defensive, mirroring `adapter.delete()` (adapters/base.py:607-610): + # defensive, mirroring `adapter.delete()` (adapters/base.py): # neither driver's `rowcount` actually raises, it is a plain property. return None async def sqlite_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: pydal.objects.Query) -> int | None: """ - Async twin of `SQLite.delete()` (pydal adapters/sqlite.py:93-104) - NOT a plain sandwich: + Async twin of `SQLite.delete()` (pydal adapters/sqlite.py) - NOT a plain sandwich: selects affected ids first, deletes, then recurses per cascaded FK with `ondelete=CASCADE`. Recursion goes through `db.delete_async()` again (not this function directly), so a cascaded delete on another table gets the dbengine-appropriate treatment diff --git a/src/typedal/core.py b/src/typedal/core.py index c50cf44..d466982 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -610,8 +610,8 @@ async def select_async( """ Async twin of `db(query).select(*fields, **attributes)`. - Mirrors `Set.select()` (pydal objects.py:2961-2971) and `SQLAdapter.select()`/ - `_select_aux()` (adapters/base.py:905-910, 864-891): build via pydal's own + Mirrors `Set.select()` (pydal objects.py) and `SQLAdapter.select()`/ + `_select_aux()` (adapters/base.py): build via pydal's own `tables()`/`expand_all()`/`_select_wcols()` (pure, no I/O), execute via the async driver for this backend (the only I/O, on our own connection, not pydal's; see `ASYNC_POOL_FACTORIES`), parse via pydal's own `parse()` (pure). @@ -646,7 +646,7 @@ async def count_async( """ Async twin of `db(query).count(distinct)`. - Mirrors `SQLAdapter.count()` (adapters/base.py:937-939): build via pydal's own + Mirrors `SQLAdapter.count()` (adapters/base.py): build via pydal's own `_count()` (pure), execute via the async driver for this backend, read the first column of the first (only) row. """ @@ -668,7 +668,7 @@ async def update_async( ) -> t.Optional[int]: """ Async twin of the adapter-level step of `Set.update()` - (`adapter.update()`, adapters/base.py:581-593). + (`adapter.update()`, adapters/base.py). `fields` is the already-normalized `[(Field, value), ...]` list (`row.op_values()`), same shape as `insert_async`'s `fields` - the before_update/after_update hooks and @@ -688,7 +688,7 @@ async def update_async( try: return cur.rowcount except Exception: # pragma: no cover - # defensive, mirroring `adapter.update()` (adapters/base.py:590-593): + # defensive, mirroring `adapter.update()` (adapters/base.py): # neither driver's `rowcount` actually raises, it is a plain property. return None @@ -702,7 +702,7 @@ async def delete_async( Dispatches per backend via `DELETE_STRATEGIES`: SQLite's isn't a plain build/execute/parse call - it selects affected ids first and recurses for - ON DELETE CASCADE (adapters/sqlite.py:93-104) - Postgres's is. + ON DELETE CASCADE (adapters/sqlite.py) - Postgres's is. """ return await DELETE_STRATEGIES[self._adapter.dbengine](self, table, query) @@ -713,7 +713,7 @@ async def insert_async( ) -> t.Any: """ Async twin of the adapter-level step of `table.insert(**fields)` - (`adapter.insert()`, adapters/base.py:541-563). + (`adapter.insert()`, adapters/base.py). `fields` is the already-normalized `[(Field, value), ...]` list (`row.op_values()`), the same shape pydal's own `Table.insert()` passes to the adapter - the field-name-to- @@ -725,7 +725,7 @@ async def insert_async( # Capture `_last_insert` here, synchronously, right after the `_insert()` that set it: # on Postgres it is a property over `THREAD_LOCAL._pydal_last_insert_` (pydal - # adapters/postgres.py:128-133), and coroutines share one thread, so that thread-local + # adapters/postgres.py), and coroutines share one thread, so that thread-local # provides no isolation at all on this path. Reading it after the awaits below would # read whichever concurrent insert_async() touched it last, not our own. last_insert = getattr(adapter, "_last_insert", None) @@ -735,7 +735,7 @@ async def insert_async( try: await cur.execute(query) except Exception as e: - # mirrors `adapter.insert()` (adapters/base.py:544-549), same as `update_async`: + # mirrors `adapter.insert()` (adapters/base.py), same as `update_async`: if hasattr(table, "_on_insert_error"): return table._on_insert_error(table, fields, e) # ty: ignore[call-non-callable] raise @@ -748,13 +748,13 @@ async def insert_async( row_id = await LASTROWID_STRATEGIES[adapter.dbengine](adapter, table, cur, last_insert) # a table with a single custom primarykey reports its id as a `{name: value}` dict - # instead of a bare int, matching `adapter.insert()` (adapters/base.py:556-563): + # instead of a bare int, matching `adapter.insert()` (adapters/base.py): primarykey = getattr(table, "_primarykey", None) if primarykey is not None and len(primarykey) == 1: # pragma: no cover # unreachable on both supported backends: pydal makes `_primarykey` columns NOT # NULL, so an insert omitting the pk fails in the database before the id it would # have filled in here could ever be read back. Kept to match `adapter.insert()` - # (adapters/base.py:556-559) for backends that can generate one. + # (adapters/base.py) for backends that can generate one. return {table._primarykey[0]: row_id} # ty: ignore[not-subscriptable] if not isinstance(row_id, int): # pragma: no cover @@ -778,7 +778,7 @@ async def executesql_async( """ Async twin of `executesql(...)`. - Mirrors pydal's own `DAL.executesql()` (base.py:872-990): execute via the async + Mirrors pydal's own `DAL.executesql()` (base.py): execute via the async driver for this backend (the only I/O), then the same as_dict/fields/colnames branching pydal itself does, calling pydal's own `adapter.parse()` (pure) for the fields/colnames case, unmodified. Only the plain-tuples path (no as_dict, no diff --git a/src/typedal/query_builder.py b/src/typedal/query_builder.py index d7dfb3a..b1061af 100644 --- a/src/typedal/query_builder.py +++ b/src/typedal/query_builder.py @@ -510,7 +510,7 @@ async def delete_async(self) -> list[int]: Async twin of `delete()`. `delete()` delegates the before_delete/after_delete hook dance to pydal's own - `Set.delete()` (objects.py:3010-3017); since pydal has no async version of that to + `Set.delete()` (objects.py); since pydal has no async version of that to delegate to, it's replicated here, same reasoning as `insert_async` - only the adapter-level execute step (`db.delete_async(...)`) is async. """ diff --git a/src/typedal/tables.py b/src/typedal/tables.py index c23f27f..cf064f6 100644 --- a/src/typedal/tables.py +++ b/src/typedal/tables.py @@ -225,7 +225,7 @@ async def insert_async(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaI """ Async twin of `insert()`. - Mirrors pydal's `Table.insert()` (objects.py:960-968): the field normalization + Mirrors pydal's `Table.insert()` (objects.py): the field normalization (`_fields_and_values_for_insert`) and `_before_insert`/`_after_insert` hooks stay exactly as they are (pure/sync), only the adapter-level execute step (`table._db.insert_async(...)`) is async. @@ -262,7 +262,7 @@ async def bulk_insert_async(self: t.Type[T_MetaInstance], items: list[AnyDict]) """ Async twin of `bulk_insert()`. - pydal's `Table.bulk_insert()` (objects.py:1113-1124) only exists to hand the whole batch + pydal's `Table.bulk_insert()` (objects.py) only exists to hand the whole batch to `adapter.bulk_insert()`, which for every backend TypeDAL supports asynchronously is itself a loop over `insert()` - so looping `insert_async()` here loses nothing and keeps the hook/normalization dance in one place. @@ -327,7 +327,7 @@ def _lookup_query( """ Turn `update_or_insert`'s three input shapes (DEFAULT / dict / Query) into one Query. - Mirrors pydal's `Table.update_or_insert()` (objects.py:1067-1073): no query means + Mirrors pydal's `Table.update_or_insert()` (objects.py): no query means "match on the values you were going to write", a dict means "match on these fields". """ table = self._ensure_table_defined() @@ -368,7 +368,7 @@ async def validate_and_insert_async( """ Async twin of `validate_and_insert()`. - Mirrors pydal's `Table.validate_and_insert()` (objects.py:1039-1042): `_validate_fields()` + Mirrors pydal's `Table.validate_and_insert()` (objects.py): `_validate_fields()` is pure (no I/O), so only the insert step needs an async twin. """ table = self._ensure_table_defined() @@ -411,7 +411,7 @@ async def validate_and_update_async( """ Async twin of `validate_and_update()`. - Mirrors pydal's `Table.validate_and_update()` (objects.py:1044-1065): fetch the record, + Mirrors pydal's `Table.validate_and_update()` (objects.py): fetch the record, validate against it (pure), then update. Both DB steps go through the async path. """ table = self._ensure_table_defined() @@ -1553,14 +1553,14 @@ async def update_record_async(self: T_MetaInstance, **fields: t.Any) -> T_MetaIn """ Async twin of `update_record()`. - Mirrors pydal's `RecordUpdater` (helpers/classes.py:349-359): drop anything that isn't a + Mirrors pydal's `RecordUpdater` (helpers/classes.py): drop anything that isn't a writable column of this table, update by primary key, then mirror the new values onto the in-memory row/instance - `_update()` does that last part for both the sync and async path. - Including `ignore_common_filters=True`, which `RecordUpdater` passes (classes.py:357): + Including `ignore_common_filters=True`, which `RecordUpdater` passes (classes.py): a record you already hold must always be writable back, even when the table has a common filter that excludes it - a soft-deleted row, say. Without it `adapter._update()` - re-applies that filter (adapters/base.py:566-568 via `use_common_filters`) and the + re-applies that filter (adapters/base.py via `use_common_filters`) and the update silently matches zero rows. """ require_permission(getattr(self, "_permissions", None), "update") @@ -1571,7 +1571,7 @@ async def update_record_async(self: T_MetaInstance, **fields: t.Any) -> T_MetaIn new_fields = {k: v for k, v in fields.items() if k in table.fields and table[k].type != "id"} query = t.cast(Query, table._id == row[table._id.name]) - # what `db(query, ignore_common_filters=True)` does under the hood (objects.py:2775-2779); + # what `db(query, ignore_common_filters=True)` does under the hood (objects.py); # set on the Query itself because that object is what reaches `adapter._update()`: query.ignore_common_filters = True @@ -1605,7 +1605,7 @@ async def delete_record_async(self) -> int: """ Async twin of `delete_record()`. - Mirrors pydal's `RecordDeleter` (helpers/classes.py:362-364) plus `_delete_record()`'s + Mirrors pydal's `RecordDeleter` (helpers/classes.py) plus `_delete_record()`'s own bookkeeping: the instance is emptied afterwards, since the row is no more. """ require_permission(getattr(self, "_permissions", None), "delete") From 3ad566782ab45f5d80e87fcf041cf50bb118acc8 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 10:04:15 +0200 Subject: [PATCH 15/29] test(async-execution): add regression coverage for transaction and parity issues --- tests/test_async_execution.py | 509 ++++++++++++++++++++++++++++++---- 1 file changed, 448 insertions(+), 61 deletions(-) diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 9a74cd5..137b5cb 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -14,10 +14,13 @@ import asyncio import collections import contextlib +import signal +import sqlite3 import tempfile import time import typing as t from decimal import Decimal +from pathlib import Path import pydal.objects import pytest @@ -27,6 +30,8 @@ from src.typedal.async_execution import ( ASYNC_POOL_FACTORIES, AsyncPoolManager, + TransactionBoundaryError, + TransactionSplitError, open_sqlite_async_connection, postgres_lastrowid_async, ) @@ -53,14 +58,33 @@ async def _sqlite_db(dal_psql: TypeDAL) -> t.AsyncIterator[TypeDAL]: db.close() -# One factory per backend the async execution path targets. Adding a new backend (e.g. MySQL) -# is adding a function + an entry here, not editing branching logic in the fixture below. -# (Every factory currently takes `dal_psql` as input for simplicity; a backend needing a -# differently-shaped upstream fixture - e.g. its own testcontainer - would need its factory -# signature adjusted accordingly, but the registry/dispatch shape stays the same.) +@contextlib.asynccontextmanager +async def _sqlite_file_db(dal_psql: TypeDAL) -> t.AsyncIterator[TypeDAL]: + """ + A file-backed SQLite database, which is a materially different async backend from + `sqlite:memory` and not a redundant copy of it. + + `sqlite:memory` reaches a second connection only through shared-cache mode, which refuses a + concurrent writer with SQLITE_LOCKED, so its async path is one shared connection + (`SqliteAsyncConnection`) that turns a second task away. A file has a path two connections + can both open, so it gets `SqliteAsyncPool` and a connection per task instead. Every + transaction-boundary claim differs between the two, and without this parametrization the + per-task SQLite code is never executed by the suite at all. + """ + with tempfile.TemporaryDirectory() as d: + db = TypeDAL(f"sqlite://{Path(d) / 'async.db'}", enable_typedal_caching=False, folder=d) + try: + yield db + finally: + await db.close_async() + db.close() + + +# One factory per backend the async execution path targets. _ASYNC_DB_FACTORIES: dict[str, t.Callable[[TypeDAL], t.AsyncContextManager[TypeDAL]]] = { "postgres": _postgres_db, "sqlite": _sqlite_db, + "sqlite-file": _sqlite_file_db, } @@ -677,22 +701,189 @@ async def repeated_query(): assert max(gaps) < 0.05, f"event loop was blocked: max gap between ticks was {max(gaps) * 1000:.1f}ms" -# --------------------------------------------------------------------------- -# Known defects in the async execution path. -# -# Each test below asserts the behaviour the async path SHOULD have - in every case parity -# with the sync path it is a twin of. They fail against the current implementation; they are -# reproductions, not a regression net, and should go green as the defects are fixed. -# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_insert_async_can_be_rolled_back(db_async: TypeDAL): + """ + (1/3) An `_async` write must leave its transaction open, the way its sync twin does. + + Neither backend does today, and each for its own reason: + + - SQLite: `SqliteAsyncConnection.connection()` (async_execution.py) commits on clean + exit, so the write is durable before `insert_async()` returns. + - Postgres: psycopg_pool's `connection()` applies the same commit-on-success behaviour, + and `PostgresAsyncPool.commit()`/`rollback()` are therefore literally `pass`. + `rollback_async()` is a no-op that reads like transaction control. + + Both are known and documented (see the `PostgresAsyncPool` and `AsyncConnectionPool` + docstrings, and `TypeDAL.commit_async` in core.py). Documented is not the same as safe: a + py4web handler calling `insert_async()` silently falls outside the framework's + rollback-on-error, and gets no signal that it has. + + No concurrency here on purpose. This is a single-coroutine defect, and until it is fixed no + coroutine can hold an open transaction at all - which makes (3/3) unattributable, since it + would fail for this reason no matter how connections are bound. + """ + db = db_async + + @db.define() + class AsyncThingUndoable(TypedTable): + name: TypedField[str] + + db.commit() + + await AsyncThingUndoable.insert_async(name="discard") + await db.rollback_async() + + rows = await AsyncThingUndoable.collect_async() + assert [row.name for row in rows] == [], "rollback_async() did not undo insert_async()" + + # and the sync rollback a framework issues on an unhandled exception must not undo it + # either way round - assert it separately so a fix that only wires up one of the two is + # visible as such. + db.rollback() + assert AsyncThingUndoable.count() == 0, "the write survived both rollbacks" + + +@pytest.mark.asyncio +async def test_crossing_the_sync_async_seam_is_a_loud_error(db_async: TypeDAL): + """ + (2/3) A read must never quietly miss the other connection's uncommitted writes. + + `_async` methods run on a connection opened by `AsyncPoolManager`; sync methods run on + pydal's own, bound to the `THREAD_LOCAL` in pydal's `ConnectionPool`. Those cannot be made + into one connection - pydal drives Postgres with psycopg2 and SQLite with sqlite3, neither + of which can be awaited - so read-your-own-writes across the two paths is not available at + any price. Left alone it failed silently: Postgres returned nothing, SQLite blocked on the + table lock and then raised `database table is locked`. + + This test used to assert cross-visibility outright and closed with "if the split is made + explicit instead, invert this to assert the raised error". That is what happened. + + Warning and continuing was measured before settling on a raise, and does not survive + contact with SQLite: Postgres can return the committed rows and warn, a plain SQLite read + cannot execute at all, and SQLite with `PRAGMA read_uncommitted=1` returns *more* rows than + Postgres - including ones a rollback then deletes. Three answers to identical code, two + silent. See `TransactionSplitError`. + + Both directions asserted, because different machinery guards each and a regression could + hit only one: + + - sync write -> async read: the flag check in `TypeDAL._get_async_pool()`. + - async write -> sync read: `SyncTransactionTracker`, a pydal `ExecutionHandler`, which + sees every statement that reaches the adapter. + + The tail matters most in practice: after committing, the same calls go through. The guard + gates on there being pending work, not on the two paths having been mixed at all - the + latter would make the async path unusable in any handler that also touches pydal. + """ + db = db_async + + @db.define() + class AsyncThingCrossVisibility(TypedTable): + name: TypedField[str] + + db.commit() + + # sync write, not committed -> the async read must refuse rather than silently miss it + AsyncThingCrossVisibility.insert(name="from-sync") + with pytest.raises(TransactionSplitError, match="synchronous connection has uncommitted writes"): + await AsyncThingCrossVisibility.collect_async() + + db.commit() + + # async write, not committed -> the sync read must refuse rather than silently miss it + await AsyncThingCrossVisibility.insert_async(name="from-async") + with pytest.raises(TransactionSplitError, match="async connection has uncommitted writes"): + AsyncThingCrossVisibility.collect() + + # and once both sides are settled, mixing the two paths is ordinary business again + await db.commit_async() + assert sorted(row.name for row in AsyncThingCrossVisibility.collect()) == ["from-async", "from-sync"] + assert sorted(row.name for row in await AsyncThingCrossVisibility.collect_async()) == [ + "from-async", + "from-sync", + ] + + +@pytest.mark.asyncio +async def test_concurrent_coroutines_do_not_share_one_transaction(dal_psql: TypeDAL): + """ + (3/3) The transaction must be bound per task, so two coroutines on one event-loop thread do + not decide each other's commits and rollbacks. + + This is the hazard the issue describes, arriving on the path this package owns. pydal's + `ConnectionPool` binds connection and cursor to a global `THREAD_LOCAL`; under the + threadpool model one thread is one request, so that is the right boundary, but under + `async def` handlers it is not. `_async` methods move off `THREAD_LOCAL`, and this asserts + what they land on instead: `PostgresAsyncPool` pins a checked-out connection to the running + task in a `ContextVar` and holds it until that task ends its own transaction. + + Postgres only, on `dal_psql` rather than the parametrized `db_async`, because it is the only + backend that can run the interleave below at all. The two coroutines have to be inside + separate write transactions simultaneously, and SQLite permits exactly one writer at a time + regardless of how many connections it is given - `sqlite:memory` refuses the second outright + with `ConcurrentTransactionError`, and a file-backed database waits out `busy_timeout` and + then reports `database is locked`. Neither is a defect, and neither can reach the assertion. + + That is a narrower fixture, not a skip: the invariant this shares with the other backends - + the discarder's rollback must never destroy the keeper's rows - is asserted for all three in + `test_async_connection_is_not_shared_between_concurrent_coroutines`. What is Postgres-only + is the stronger claim that both transactions genuinely ran at once. + + Note that a `contextvars.ContextVar` holding the *pool* would solve nothing: the boundary + has to be a transaction per task, not a per-task reference to a shared one. It also has to + be keyed to the task that acquired it - a `ContextVar` set in a parent is copied into every + task it later spawns, so an unkeyed entry would hand both coroutines below the same + connection and quietly reintroduce exactly the bug this test exists to catch. + + The interleave, pinned with events rather than sleeps so the ordering is deterministic: + - `keeper` inserts `keep`, then commits once `discarder` has rolled back + - `discarder` inserts `discard`, then rolls its own insert back + + Per-task transactions leave only `keep`. One shared transaction leaves `discard` behind: it + was committed out from under the coroutine that asked for it to be discarded. + """ + db = dal_psql + + @db.define() + class AsyncThingSharedTransaction(TypedTable): + name: TypedField[str] + + db.commit() + + keeper_inserted = asyncio.Event() + discarder_rolled_back = asyncio.Event() + + async def keeper(): + await AsyncThingSharedTransaction.insert_async(name="keep") + keeper_inserted.set() + await discarder_rolled_back.wait() + await db.commit_async() + + async def discarder(): + await keeper_inserted.wait() + await AsyncThingSharedTransaction.insert_async(name="discard") + await db.rollback_async() + discarder_rolled_back.set() + + try: + await asyncio.gather(keeper(), discarder()) + + rows = await AsyncThingSharedTransaction.collect_async() + assert sorted(row.name for row in rows) == ["keep"] + finally: + # `db_async` does this in its teardown; `dal_psql` is a plain session db, so an async + # pool left open here outlives this test's event loop and hangs the next one. + await db.close_async() @pytest.mark.asyncio async def test_insert_async_honors_on_insert_error_hook(db_async: TypeDAL): """ pydal's `adapter.insert()` routes a failing INSERT through `table._on_insert_error` and - returns the hook's value (adapters/base.py:541-549). `db.insert_async()` does not, so the + returns the hook's value (adapters/base.py). `db.insert_async()` does not, so the same table diverges between sync and async on a constraint violation - while the sibling - `update_async()` twenty lines up already does honour `_on_update_error` (core.py:694-699). + `update_async()` twenty lines up already does honour `_on_update_error` (core.py). """ db = db_async @@ -759,10 +950,10 @@ async def counting_factory(dal: TypeDAL): async def test_update_record_async_ignores_common_filters_like_sync(db_async: TypeDAL): """ pydal's `RecordUpdater` writes by primary key with `ignore_common_filters=True` - (helpers/classes.py:357), so a record you already hold can always be written back. + (helpers/classes.py), so a record you already hold can always be written back. `update_record_async()` rebuilds that update through `QueryBuilder.update_async()` without - the flag, so `adapter._update()` re-applies the table's common filter (base.py:566-568 via - `use_common_filters`, helpers/methods.py:49-54) and a row the filter excludes - a + the flag, so `adapter._update()` re-applies the table's common filter (base.py via + `use_common_filters`, helpers/methods.py) and a row the filter excludes - a soft-deleted one, say - silently updates zero rows. Also reached by `validate_and_update_async()` and the update branch of @@ -806,12 +997,12 @@ async def test_postgres_lastrowid_async_uses_only_the_value_it_was_given(dal_psq `postgres_lastrowid_async()` must decide whether the statement it just ran carried a RETURNING clause from its `last_insert` argument alone - never by reading `adapter._last_insert` back. That attribute is a property over - `THREAD_LOCAL._pydal_last_insert_` (pydal adapters/postgres.py:128-133), and coroutines + `THREAD_LOCAL._pydal_last_insert_` (pydal adapters/postgres.py), and coroutines share one thread, so for the async path it is effectively a global: any other insert running between `_insert()` and here overwrites it. Proven by executing a `DEFAULT VALUES` insert - no fields, therefore no RETURNING - (postgres.py:149-162) - while the thread-local says the opposite. Reading the attribute + (postgres.py) - while the thread-local says the opposite. Reading the attribute would take the `fetchone()` branch and raise on a statement that produced no rows. Takes the Postgres fixture directly instead of the parametrized `db_async`: SQLite has no @@ -846,27 +1037,37 @@ class AsyncThingLastInsert(TypedTable): @pytest.mark.asyncio async def test_async_connection_is_not_shared_between_concurrent_coroutines(db_async: TypeDAL): """ - `SqliteAsyncConnection.connection()` yields the single connection it wraps to every caller - (async_execution.py:95-103) and commits on clean exit / rolls back on exception. Two - coroutines inside it simultaneously are therefore in the *same* transaction, and whichever - exits first decides for both: a clean writer's row gets discarded by an unrelated failure, - or a failed writer's row gets committed by an unrelated success. - - `SqliteAsyncConnection`'s docstring promises every `_async` call is its own committed - transaction; that only holds while calls never overlap. Postgres passes this test, since - psycopg_pool hands out distinct connections. - - Do NOT rewrite this with an `asyncio.Barrier`: it deadlocks, and not because of a bug. - SQLite cannot fix this by handing each caller its own connection the way psycopg_pool does - - two aiosqlite connections to pydal's `sqlite:memory` (shared-cache, `uri: True`) answer - the second concurrent writer with `OperationalError: database table is locked`, which no - busy-timeout retries. So the fix has to *serialize* callers, and a barrier demands the one - thing the fix exists to prevent: two coroutines inside `connection()` at the same time. - - Instead each writer signals that it is inside and waits a bounded time for the other. That - forces the overlap where one is possible (the unfixed, shared-connection code) and simply - times out where it is not (serialized), so the assertion below is about the transactional - outcome either way, on both backends. + Two coroutines writing at the same time must never end up deciding each other's outcome. + + This test used to assert the opposite contract: that `connection()` commits on clean exit + and rolls back on exception, so a failing writer's row disappears and a clean writer's row + survives *because of how the block exited*. That per-call commit is the defect + `test_insert_async_can_be_rolled_back` removes - it put every `_async` write outside + anything the caller could undo - so the two assertions cannot both hold. The isolation + intent is kept here; the auto-commit mechanism it used to rely on is not. + + What replaces it: each writer ends its own transaction explicitly, the way pydal expects. + The outcome asserted is the same one the old test wanted - `keep` survives, `discard` does + not - but it now depends on the transactions being *separate*, not on the context manager + guessing. + + Do NOT rewrite the overlap with an `asyncio.Barrier`. It deadlocks on `sqlite:memory`, and + not because of a bug: that backend refuses a second concurrent transaction outright + (`ConcurrentTransactionError`), so demanding both coroutines be inside at once demands the + thing the design exists to prevent. Each writer instead signals that it is inside and waits + a bounded time for the other, which forces an overlap where one is possible and simply + times out where it is not. + + Per backend, all three of which are safe and none of which lose `keep`: + + - Postgres: a connection per task, genuinely concurrent, both transactions independent. + - file-backed SQLite: a connection per task, but SQLite allows one writer at a time, so + the second waits out `busy_timeout` and then reports `database is locked`. + - `sqlite:memory`: one connection, so the second writer is refused immediately with + `ConcurrentTransactionError`. + + The second writer failing is therefore an accepted outcome on SQLite, and the assertion is + about what the database is left holding rather than about who got to run. """ db = db_async @@ -874,8 +1075,8 @@ async def test_async_connection_is_not_shared_between_concurrent_coroutines(db_a class AsyncThingIsolation(TypedTable): name: TypedField[str] - tablename = str(AsyncThingIsolation) - pool = await db._get_async_pool() + db.commit() + keeper_inside = asyncio.Event() failer_inside = asyncio.Event() @@ -883,20 +1084,22 @@ async def wait_briefly(event: asyncio.Event) -> None: with contextlib.suppress(TimeoutError): await asyncio.wait_for(event.wait(), timeout=0.25) - async def committing_writer(): - async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('keep')") - keeper_inside.set() - await wait_briefly(failer_inside) - # clean exit -> this row must survive - - async def failing_writer(): - with contextlib.suppress(RuntimeError): - async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(f"INSERT INTO {tablename} (name) VALUES ('discard')") - failer_inside.set() - await wait_briefly(keeper_inside) - raise RuntimeError("boom") # -> this row must be rolled back + async def committing_writer() -> None: + await AsyncThingIsolation.insert_async(name="keep") + keeper_inside.set() + await wait_briefly(failer_inside) + await db.commit_async() + + async def failing_writer() -> None: + # both are accepted: the write goes through and this rolls it back, or SQLite refuses + # it outright. Either way `discard` must not be in the database at the end. + with contextlib.suppress(TransactionBoundaryError, sqlite3.OperationalError): + await AsyncThingIsolation.insert_async(name="discard") + failer_inside.set() + await wait_briefly(keeper_inside) + await db.rollback_async() + + failer_inside.set() await asyncio.gather(committing_writer(), failing_writer()) @@ -936,7 +1139,7 @@ class AsyncThingRollback(TypedTable): @pytest.mark.asyncio async def test_delete_async_cascades_to_referencing_rows(db_async: TypeDAL): """ - `sqlite_delete_async` re-implements `SQLite.delete()`'s cascade (adapters/sqlite.py:93-104): + `sqlite_delete_async` re-implements `SQLite.delete()`'s cascade (adapters/sqlite.py): select ids, delete, then recurse per FK with `ondelete=CASCADE`. Postgres leaves that to the database. Either way the children must be gone. """ @@ -968,7 +1171,7 @@ class AsyncCascadeChild(TypedTable): async def test_update_async_honors_on_update_error_hook(db_async: TypeDAL): """ Twin of `test_insert_async_honors_on_insert_error_hook`: `update_async` routes a failing - UPDATE through `table._on_update_error`, mirroring `adapter.update()` (base.py:585-589). + UPDATE through `table._on_update_error`, mirroring `adapter.update()` (base.py). """ db = db_async @@ -1008,7 +1211,7 @@ async def test_async_pool_manager_rejects_unsupported_backend(db_async: TypeDAL) @pytest.mark.asyncio async def test_insert_async_runs_pydal_insert_hooks(db_async: TypeDAL): """ - `TypedTable.insert_async()` keeps pydal's `Table.insert()` hook dance (objects.py:960-968): + `TypedTable.insert_async()` keeps pydal's `Table.insert()` hook dance (objects.py): a truthy `_before_insert` aborts the insert, and `_after_insert` sees the new id. """ db = db_async @@ -1037,7 +1240,7 @@ class AsyncThingInsertHooks(TypedTable): @pytest.mark.asyncio async def test_delete_async_runs_pydal_delete_hooks(db_async: TypeDAL): """ - `QueryBuilder.delete_async()` replicates `Set.delete()`'s hooks (objects.py:3010-3017), + `QueryBuilder.delete_async()` replicates `Set.delete()`'s hooks (objects.py), since pydal has no async version to delegate to: a truthy `_before_delete` aborts and returns no ids, `_after_delete` runs on success, and a query matching nothing returns []. """ @@ -1155,6 +1358,13 @@ class AsyncThingNoHook(TypedTable): with pytest.raises(Exception, match=r"(?i)unique"): await db.insert_async(table, table._fields_and_values_for_insert({"name": "a"}).op_values()) + # The failed statement aborted the async transaction, so Postgres answers everything after + # it with InFailedSqlTransaction until that transaction ends. The sync twin of this test + # already calls `db.rollback()` for exactly this reason. It only needs saying here now that + # `_async` calls no longer self-commit: before, each one was its own transaction and an + # error could not reach the next. + await db.rollback_async() + with pytest.raises(Exception, match=r"(?i)unique"): row = table._fields_and_values_for_update({"name": "b"}) await db.update_async(table, table.id == first, row.op_values()) @@ -1164,7 +1374,7 @@ class AsyncThingNoHook(TypedTable): async def test_insert_async_with_custom_primarykey(db_async: TypeDAL): """ Tables with a `_primarykey` instead of pydal's standard `_id` report the new row as a - `{name: value}` dict rather than a `Reference` (adapters/base.py:550-563). + `{name: value}` dict rather than a `Reference` (adapters/base.py). """ db = db_async @@ -1361,3 +1571,180 @@ async def test_collect_async_serves_cached_rows(): finally: await db.close_async() db.close() + +@contextlib.contextmanager +def _fail_after(seconds: float, message: str) -> t.Iterator[None]: + """ + Fail instead of hanging when the code under test loops forever. + + `SIGALRM` rather than `asyncio.timeout()`: the loop this guards (see + `test_executesql_async_accepts_a_single_field`) contains no await, so the event loop never + gets control back and an asyncio timeout would never fire. Signals are only delivered on + the main thread, which is where pytest-asyncio runs the loop. + """ + + def raise_timeout(_signum: int, _frame: t.Any) -> None: + raise TimeoutError(message) + + previous = signal.signal(signal.SIGALRM, raise_timeout) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous) + + +@pytest.mark.asyncio +async def test_insert_async_does_not_run_a_sync_query(db_async: TypeDAL): + """ + `insert_async()` may not fall back to the sync connection to build its return value. + + It returns `self(result)` with `result` an int-like `Reference`, which `TypedTable.__new__` + feeds to pydal's synchronous `Table.__call__` -> `db(...).select()`. That is a blocking + SELECT on the event loop, on the *other* (sync) connection, for a row this method already + has the id of. `db._timings` is pydal's own record of every statement executed on the sync + adapter (helpers/classes.py, installed by default via `DAL.execution_handlers`), + so it can be checked without patching anything. + """ + db = db_async + + @db.define() + class AsyncThingInsertBlocking(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + db.commit() + + before = len(db._timings) + inserted = await AsyncThingInsertBlocking.insert_async(name="widget", qty=5) + + # the return value must stay usable - the point is how it is built, not that it shrinks + assert int(inserted) > 0 + assert inserted.name == "widget" + assert inserted.qty == 5 + + sync_statements = [command for command, _ in db._timings[before:]] + selects = [command for command in sync_statements if command.lstrip().upper().startswith("SELECT")] + assert not selects, f"insert_async ran {len(selects)} synchronous SELECT(s): {selects}" + + +@pytest.mark.asyncio +async def test_update_or_insert_async_handles_none_and_false_query(db_async: TypeDAL): + """ + `None` and `False` are both members of `T_Query` (types.py) and both are accepted by + the sync `update_or_insert()`: pydal's `Table.__call__` (objects.py) finds no record + for a non-Query, non-digit key, so the call inserts. The async twin routes the same values + through `QueryBuilder.where()` (query_builder.py), which raises `ValueError`. + """ + db = db_async + + @db.define() + class AsyncThingUpsertFalsy(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + db.commit() + + sync_none = AsyncThingUpsertFalsy.update_or_insert(None, name="via-none", qty=1) + sync_false = AsyncThingUpsertFalsy.update_or_insert(False, name="via-false", qty=2) + db.commit() + assert sync_none.name == "via-none" + assert sync_false.name == "via-false" + + async_none = await AsyncThingUpsertFalsy.update_or_insert_async(None, name="via-none-async", qty=3) + async_false = await AsyncThingUpsertFalsy.update_or_insert_async(False, name="via-false-async", qty=4) + await db.commit_async() + + assert async_none.name == "via-none-async" + assert async_false.name == "via-false-async" + assert AsyncThingUpsertFalsy.count() == 4 + + +@pytest.mark.asyncio +async def test_executesql_async_accepts_a_single_field(db_async: TypeDAL): + """ + pydal's `executesql()` explicitly allows `fields` to be one object instead of a list + (base.py: `if not isinstance(fields, list): fields = [fields]`), and TypeDAL's sync + `executesql()` inherits that by delegating to it. `executesql_async()` does + `list(fields)` instead. + + That does not fail with a `TypeError`: a `Field` is an `Expression`, and + `Expression.__getitem__` (objects.py) answers any integer index with + `self[i:i+1]` - a substring expression - and never raises `IndexError`. `list()` therefore + falls back to the legacy sequence protocol and spins forever, allocating expressions. Hence + the alarm below: an `asyncio` timeout cannot break a CPU-bound loop with no await in it. + """ + db = db_async + + @db.define() + class AsyncThingSingleField(TypedTable): + name: TypedField[str] + qty: TypedField[int] + + AsyncThingSingleField.insert(name="widget", qty=1) + db.commit() + + table = AsyncThingSingleField._ensure_table_defined() + tablename = str(AsyncThingSingleField) + query = f"SELECT {tablename}.qty FROM {tablename}" + + sync_rows = db.executesql(query, fields=table.qty) + assert sync_rows[0].qty == 1 + + with _fail_after(5, "executesql_async(fields=) never returned"): + async_rows = await db.executesql_async(query, fields=table.qty) + + assert async_rows[0].qty == 1 + + +@pytest.mark.asyncio +async def test_after_connection_hook_also_applies_to_the_async_connection(): + """ + `TypeDAL(..., after_connection=...)` is handed to pydal, which runs it on every sync + connection it opens (connection.py). The async factories in `async_execution.py` + open a raw driver connection and never do, so connection-scoped setup the user asked for + (custom functions, PRAGMAs, session settings) is missing on the async side. + + A TEMP table is the backend-neutral way to observe that: it lives on the connection that + created it, so it is visible from the sync connection and absent from the async one. + + SQLite-only: it needs to build its own `TypeDAL` to pass `after_connection`, which the + `db_async` fixture's already-connected Postgres instance cannot. + + Note the fix is not simply calling `adapter._after_connection(adapter)` from the factory: + the hook is handed the pydal adapter and drives the *sync* cursor, so replaying it there + would re-run it against the wrong connection. Making this pass means giving the async + connection an adapter-shaped façade to run the hook against. + """ + statements: list[str] = [] + + def after_connection(adapter: t.Any) -> None: + statements.append("ran") + adapter.execute("CREATE TEMPORARY TABLE async_hook_marker (x INTEGER)") + + with tempfile.TemporaryDirectory() as directory: + # a URI no other test uses, because pydal's connection pool is global and keyed by URI + # (connection.py): a `sqlite:memory` connection left there by an earlier test is + # handed back with `run_hooks=False`, so the hook would never run and the test would be + # measuring the pool instead of the hook. + db = TypeDAL( + "sqlite://after_connection_hook.db", + enable_typedal_caching=False, + folder=directory, + after_connection=after_connection, + ) + try: + # this query is what opens pydal's sync connection - it connects lazily, so the + # hook has not run at construction time - and the TEMP table it selects from only + # exists because the hook ran while that connection was being set up. + assert db.executesql("SELECT * FROM async_hook_marker") == [] + assert statements, "pydal did not run the hook on its own connection - test is meaningless" + + # ...and the async connection is a different connection, which never saw the hook + with pytest.raises(sqlite3.OperationalError, match="no such table"): + await db.executesql_async("SELECT * FROM async_hook_marker") + finally: + await db.close_async() + db.close() + From 59c9667ffa800af73eb02b72ab8a9ee7f0a0bf84 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 11:31:16 +0200 Subject: [PATCH 16/29] fix(async): preserve transaction boundaries across async and sync paths --- src/typedal/__init__.py | 8 + src/typedal/async_execution.py | 578 ++++++++++++++++++++++++++++++--- src/typedal/core.py | 124 ++++++- 3 files changed, 646 insertions(+), 64 deletions(-) diff --git a/src/typedal/__init__.py b/src/typedal/__init__.py index 7264a3d..fccd7fd 100644 --- a/src/typedal/__init__.py +++ b/src/typedal/__init__.py @@ -2,6 +2,11 @@ TypeDAL Library. """ +from .async_execution import ( + ConcurrentTransactionError, + TransactionBoundaryError, + TransactionSplitError, +) from .core import TypeDAL from .fields import TypedField from .helpers import sql_expression @@ -18,10 +23,13 @@ P4W_DAL = None __all__ = [ + "ConcurrentTransactionError", "PaginatedRows", "QueryBuilder", "Ref", "Relationship", + "TransactionBoundaryError", + "TransactionSplitError", "TypeDAL", "TypedField", "TypedRows", diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index 945cfd3..a7cad85 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -15,9 +15,11 @@ import asyncio import contextlib +import contextvars import typing as t import pydal.objects +from pydal.helpers.classes import ExecutionHandler if t.TYPE_CHECKING: from pydal.adapters.base import SQLAdapter @@ -30,6 +32,103 @@ # (adapters/postgres.py). Backends without the concept never set it at all, hence None. type LastInsert = tuple[pydal.objects.Field, int] | None +# SQL verbs that open a transaction on whichever connection runs them. DDL is left out on +# purpose: `db.define()` migrates on the sync connection, and treating that as pending work +# would make the first `_async` call after any table definition raise. +WRITE_STATEMENTS = ("INSERT", "UPDATE", "DELETE", "REPLACE", "MERGE", "TRUNCATE") + +# How long a file-backed SQLite connection waits for another one's write to finish before +# giving up with `database is locked`. SQLite allows a single writer at a time, so per-task +# connections queue here rather than failing outright; 5s is aiosqlite's own default order of +# magnitude and well past any statement a request should be issuing. +SQLITE_BUSY_TIMEOUT_MS = 5000 + +# Ceiling on concurrent per-task Postgres connections from one `TypeDAL`. Has to exceed 1 or +# `PostgresAsyncPool`'s per-task checkout deadlocks as soon as two tasks overlap; kept modest +# because every `TypeDAL` in the process draws from the same server-side max_connections. +POSTGRES_POOL_MAX_SIZE = 10 + + +class TransactionBoundaryError(RuntimeError): + """ + Base for the two ways a caller can end up on the wrong side of a transaction boundary. + + Both subclasses exist for the same reason: the alternative to raising is a silently wrong + answer, and this class of bug only shows under concurrency, which is the worst place to + find it. Catch this to handle either. + """ + + +class TransactionSplitError(TransactionBoundaryError): + """ + Raised when sync and async work would be split across the two connections a `TypeDAL` has. + + pydal drives Postgres with psycopg2 and SQLite with sqlite3, both synchronous; the `_async` + path needs psycopg3-async and aiosqlite. Those are separate connections and therefore + separate transactions, so uncommitted work on one is invisible to the other. Within a + single request that is read-your-own-writes quietly disappearing: on Postgres the second + path simply does not see the row, and on SQLite it blocks on the table lock instead. + + Rather than let either happen, both paths refuse to run while the other holds an open + transaction. Commit or roll back the side you finished with before using the other one. + + Warning-and-continuing was considered and does not survive contact with SQLite. Measured on + the same scenario: Postgres returns the committed rows (wrong but warnable), a plain SQLite + read raises `database table is locked` and cannot proceed at all, and SQLite with + `PRAGMA read_uncommitted=1` returns *more* rows than Postgres - including ones a rollback + then deletes. Three answers to identical code, two of them silent. Raising is the only + behaviour both backends can actually share. + """ + + +class ConcurrentTransactionError(TransactionBoundaryError): + """ + Raised when two asyncio tasks would share one transaction on a `sqlite:memory` database. + + Postgres and file-backed SQLite both hand each task its own connection, so their + transactions are independent. `sqlite:memory` cannot: a second connection only reaches the + same database through shared-cache mode, which answers a concurrent writer with + SQLITE_LOCKED. One connection means one transaction, and sharing it means one task's + `rollback_async()` destroys another task's uncommitted rows. + + So the second task is refused instead. pydal's own synchronous connections hit this same + wall between two threads on one `sqlite:memory` - this raises deliberately, and says why, + where pydal surfaces the driver's `database table is locked`. + """ + + +class SyncTransactionTracker(ExecutionHandler): + """ + Records whether pydal's own connection has uncommitted writes, and refuses to run a sync + statement while the async connection has some (see `TransactionSplitError`). + + An `ExecutionHandler` rather than wrappers around `insert`/`update`/`delete`, because this + has to see *every* statement reaching the adapter - `executesql()`, pydal internals and + anything a caller reaches around TypeDAL for included - and `DAL.execution_handlers` is + pydal's own supported seam for that (it is where `TimingHandler` lives). + + Flags live on the `TypeDAL`, not here: pydal builds a handler instance per execution, so + this object is the wrong place to keep anything that has to outlive one statement. + """ + + def before_execute(self, command: str) -> None: + """ + Check the async side is settled, then note whether this statement opens a transaction. + """ + db = getattr(self.adapter, "db", None) + if db is None: # pragma: no cover - adapter detached during close() + return + + if getattr(db, "_async_pending", False): + raise TransactionSplitError( + "The async connection has uncommitted writes, which this synchronous statement " + "would not see. Call `await db.commit_async()` or `await db.rollback_async()` " + "first.", + ) + + if command.lstrip().upper().startswith(WRITE_STATEMENTS): + db._sync_pending = True + class AsyncCursor(t.Protocol): """ @@ -90,11 +189,13 @@ class AsyncConnectionPool(t.Protocol): single-connection stand-in (SQLite). `commit()`/`rollback()` are part of this shape (not left to `TypeDAL.commit_async()` to - figure out per backend) because what they need to do genuinely differs: psycopg_pool's - `connection()` already commits/rolls back on context exit for every call (see - `PostgresAsyncPool`), so there is never anything left open to commit; aiosqlite's default - transaction mode does not auto-commit, so `SqliteAsyncConnection.commit()` has real work - to do. Keeping both behind the same two methods keeps that difference out of core.py. + figure out per backend) because what they act on genuinely differs: `PostgresAsyncPool` + ends the transaction on the connection checked out for *this task* and returns it to the + pool, while `SqliteAsyncConnection` ends the one transaction there is. Keeping both behind + the same two methods keeps that difference out of core.py. + + Either way `connection()` leaves the transaction open, so `_async` writes obey pydal's + contract: nothing is durable until the caller commits. """ def connection(self) -> t.AsyncContextManager[AsyncConnection]: ... @@ -108,32 +209,173 @@ async def close(self) -> None: ... class PostgresAsyncPool: """ - Thin wrapper around `psycopg_pool.AsyncConnectionPool` giving it the same - `commit()`/`rollback()` shape as `SqliteAsyncConnection`, even though there is nothing to - do there: `pool.connection()` already applies "the normal connection context behaviour" - (psycopg_pool's own docs) - commit on success, rollback on error - on every single - `async with pool.connection() as conn:` use, so no transaction is ever left open between - calls for these to act on. This means each `select_async`/`insert_async`/etc. call is its - own committed transaction; there is currently no way to span one transaction across - multiple async calls (a real limitation, not just an implementation gap - see the - "two connections per request" hazard: since this and pydal's own sync connection are - already separate, spanning transactions here as well would need its own connection - checkout API, not built here). + Wraps `psycopg_pool.AsyncConnectionPool` and binds one checked-out connection per asyncio + task, so a task's transaction spans its `_async` calls and belongs to it alone. + + Two things this deliberately does not do, both of which it used to: + + - it does not let `pool.connection()` run the checkout. That context manager applies + "the normal connection context behaviour" (psycopg_pool's own docs) - commit on + success, rollback on error - which made every `_async` call its own committed + transaction and left `commit()`/`rollback()` with nothing to act on. `getconn()` / + `putconn()` hand back the same connection without deciding its transaction, so pydal's + contract holds: writes stay open until the caller says otherwise. + - it does not keep that connection on the pool object. A `ContextVar` set inside a task + is invisible to its siblings and to its parent, which is exactly the per-task boundary + an event loop needs and `threading.local()` cannot give it - one event-loop thread + serves every concurrent request. + + The `ContextVar` is per instance rather than module-level so two `TypeDAL`s in one process + do not hand each other connections. That is unusual - the docs warn against creating them + dynamically because they are never garbage collected - but there is one per pool, created + once when the pool opens, not one per call. + + A task that neither commits nor rolls back would strand its connection, so checkout also + arms a done-callback on the task to roll back and return it. That is a safety net for + abandoned work, not the intended path; callers are still expected to end their transaction. + + `_checked_out` is what makes that net safe. The callback cannot read the `ContextVar` to + find out what to return - `add_done_callback` runs in the *loop's* context, not the + finished task's, so it would see None or, worse, another task's connection. It is handed + its connection directly instead, and this set is how it tells "still outstanding" from + "already returned by commit()". """ def __init__(self, pool: t.Any) -> None: self._pool = pool + self._current: contextvars.ContextVar[t.Any] = contextvars.ContextVar( + f"typedal_async_conn_{id(self):x}", + default=None, + ) + self._checked_out: set[t.Any] = set() + + def _own_connection(self) -> t.Any: + """ + The connection this task acquired, or None - including when the value it can see was + acquired by a different task. + + That last part is the whole reason the entry stores its owner. A `ContextVar` set in a + parent is *copied into* every task the parent later spawns, so two coroutines under one + `asyncio.gather()` would both see the parent's connection and hand it around as if it + were theirs - one task's commit closing the transaction the other was still writing to. + Isolation only holds if an inherited entry is treated as absent. + """ + entry = self._current.get() + if entry is None: + return None - def connection(self) -> t.AsyncContextManager[AsyncConnection]: - return t.cast(t.AsyncContextManager[AsyncConnection], self._pool.connection()) + owner, conn = entry + return conn if owner is asyncio.current_task() else None + + async def _acquire(self) -> t.Any: + """ + This task's connection, checking one out of the pool on first use. + """ + if (conn := self._own_connection()) is not None: + return conn + + conn = await self._pool.getconn() + self._current.set((asyncio.current_task(), conn)) + self._checked_out.add(conn) + + if task := asyncio.current_task(): + task.add_done_callback(lambda _task: self._reclaim(conn)) + + return conn + + def _reclaim(self, conn: t.Any) -> None: + """ + Return a connection its task never ended the transaction on (see the class docstring). + + Sync, because that is all `add_done_callback` can be, so the actual work is scheduled. + Everything here is best-effort: the loop may already be shutting down, in which case + closing the pool is what reclaims the connection instead. + """ + if conn not in self._checked_out: + # the ordinary case - commit() or rollback() already handed it back + return + + async def _rollback_and_return() -> None: + # Claim before doing anything, and claim by *removing* from `_checked_out`. The + # check-and-discard runs before the first await, so it is atomic against the other + # two paths that also return connections (`_release` and `close`), and whoever + # claims first is the only one that acts. Holding membership across the await + # instead let `close()` return the same connection concurrently, which psycopg + # answers with `can't return connection to pool, it doesn't come from any pool`. + if conn not in self._checked_out: + return + + self._checked_out.discard(conn) + + with contextlib.suppress(Exception): + await conn.rollback() + + try: + await self._pool.putconn(conn) + except Exception: + # The pool is gone or refused it, so this connection can never be handed back. + # Close it rather than re-tracking it: `close()` has already run by the time + # that happens, so nothing would ever drain the set again and the socket would + # stay open for the life of the process - which exhausts the server's + # max_connections one abandoned task at a time. + with contextlib.suppress(Exception): + await conn.close() + + with contextlib.suppress(RuntimeError): + asyncio.get_running_loop().create_task(_rollback_and_return()) + + async def _release(self) -> None: + """ + Hand this task's connection back, after its transaction has been ended. + """ + if (conn := self._own_connection()) is None: + return + + self._current.set(None) + self._checked_out.discard(conn) + await self._pool.putconn(conn) + + @contextlib.asynccontextmanager + async def connection(self) -> t.AsyncIterator[AsyncConnection]: + # no commit and no rollback on exit: whether this statement stands is the caller's + # call, made via commit()/rollback(), exactly as it is on pydal's sync connection. + yield t.cast(AsyncConnection, await self._acquire()) async def commit(self) -> None: - pass + if (conn := self._own_connection()) is None: + return + + await conn.commit() + await self._release() async def rollback(self) -> None: - pass + if (conn := self._own_connection()) is None: + return + + await conn.rollback() + await self._release() async def close(self) -> None: + """ + Close the pool, and every connection still checked out of it. + + `psycopg_pool.close()` only closes the connections currently *idle in* the pool - one + that a task took and never gave back is not reachable from it, so closing the pool + leaves that socket open to the server. A task that ends without committing is exactly + that case, and one leaked connection per such task exhausts `max_connections` in a + long-running process (or partway through a test suite). + + These are closed outright rather than returned, because the pool they would go back to + is about to be closed anyway. Claiming the whole set in one statement, with no await in + between, keeps this atomic against a `_rollback_and_return` racing to claim the same + connection. + """ + checked_out, self._checked_out = list(self._checked_out), set() + + for conn in checked_out: + with contextlib.suppress(Exception): + await conn.close() + await self._pool.close() @@ -146,21 +388,29 @@ class SqliteAsyncConnection: gives it the same `.connection()`/`.commit()`/`.rollback()`/`.close()` shape as `PostgresAsyncPool` so `select_async()` etc. don't need to branch on backend. - `connection()` commits on clean exit and rolls back on exception - unlike psycopg, - aiosqlite does not do this on its own, and without it a write would still be open (and the - table still locked for other readers/writers, including pydal's own sync connection) by - the time an `_async` method returns. This makes every `_async` call its own committed - transaction, matching what `PostgresAsyncPool` already gets for free from psycopg_pool. - - That promise only holds if calls do not overlap, hence `_lock`: a transaction belongs to - the *connection*, and there is only one, so two coroutines inside `connection()` at the - same time would share one transaction and the first to exit would decide for both - - committing the other's half-finished write, or rolling back a write that had succeeded. - psycopg_pool avoids this by handing out a different connection per caller; that is not an - option here (pydal itself runs SQLite at `pool_size = 0`, adapters/sqlite.py), and for - `sqlite:memory` it would actively break, since shared-cache mode answers a second - concurrent writer with SQLITE_LOCKED, which no busy-timeout retries. Serializing costs - concurrency SQLite does not have for writes anyway - it allows exactly one writer. + `connection()` neither commits nor rolls back on exit. It used to commit, which made every + `_async` call its own committed transaction and put it outside anything the caller could + undo - a write issued by a request that later raised could not be rolled back, and pydal's + contract is that `commit()`/`rollback()` decide. So a write now stays open until the caller + ends it, and the table stays locked against other readers and writers until then, pydal's + own sync connection included. + + Unlike `PostgresAsyncPool` this cannot give each task its own transaction, and that is a + property of the database rather than a gap here. `sqlite:memory` reaches a second + connection only through shared-cache mode, and shared-cache answers a concurrent writer + with SQLITE_LOCKED, which no busy-timeout retries. Measured on pydal's own synchronous + connections, two threads writing to one `sqlite:memory` produce exactly that error, so this + is not a limit the async path introduces - pydal is subject to it one thread-boundary over. + + Rather than let coroutines silently merge into one transaction - where one task's + `rollback()` destroys another's uncommitted rows - a second task is refused while the first + holds an open transaction, raising `ConcurrentTransactionError`. That matches what pydal + already does for the same situation, only deliberately and with a message that names the + cause. File-backed SQLite has no such limit and does not come here at all; it gets + `SqliteAsyncPool` and a real connection per task. + + `_lock` keeps statements from interleaving on the single connection; it is not a + transaction boundary and is not a substitute for one. `_owner` is the boundary. """ def __init__(self, conn: AsyncConnection) -> None: @@ -168,32 +418,189 @@ def __init__(self, conn: AsyncConnection) -> None: # created here rather than bound eagerly: asyncio.Lock() only attaches to a loop on # first acquire, and this object is built inside `open_sqlite_async_connection()`. self._lock = asyncio.Lock() + # the task whose transaction is currently open, if any. Not a ContextVar: the point is + # for *other* tasks to see it and be refused, which is the opposite of what a + # ContextVar's per-task isolation provides. + self._owner: "asyncio.Task[t.Any] | None" = None + + def _refuse_if_owned_elsewhere(self) -> None: + """ + Refuse the caller if a different, still-running task holds the open transaction. + + Must be called with `_lock` held. Checking on the way *to* the lock instead lets a + second task read `_owner` while the first is still awaiting inside its `connection()` + block - before that block's `finally` has recorded the ownership - so it passes the + check, queues on the lock, and then walks straight into the transaction it should have + been refused from. Under the lock, the first task's ownership is always already visible. + """ + task = asyncio.current_task() + + if self._owner is not None and self._owner is not task and not self._owner.done(): + raise ConcurrentTransactionError( + "Another task holds an open transaction on this sqlite:memory database, and " + "SQLite cannot give the two of them separate ones - shared-cache mode refuses " + "a second concurrent writer. Commit or roll back that task before starting " + "here, or use a file-backed database, which does get a connection per task.", + ) + + def _take_ownership_if_in_transaction(self) -> None: + """ + Own the connection if the statement just run left a transaction open, else release it. + + Ownership tracks `in_transaction` rather than "used the connection at all", because + only a write opens a transaction here - sqlite3 implicitly BEGINs before DML and leaves + SELECT and DDL alone. Claiming on every use instead would mean a single `collect_async()` + locked every other task out of the database until the reader happened to commit, which + readers have no reason to do. + """ + self._owner = asyncio.current_task() if self._conn.in_transaction else None # ty: ignore[unresolved-attribute] @contextlib.asynccontextmanager async def connection(self) -> t.AsyncIterator[AsyncConnection]: async with self._lock: + self._refuse_if_owned_elsewhere() try: yield self._conn - except BaseException: - await self._conn.rollback() - raise - else: - await self._conn.commit() + finally: + # in a finally: a statement that raised may still have opened the transaction, + # and leaving it unowned would let another task walk into it. + self._take_ownership_if_in_transaction() async def commit(self) -> None: - # also under the lock: committing mid-way through another coroutine's `connection()` - # block would commit its partial work, the same bug from the other direction. + # under the lock so a commit cannot land halfway through another coroutine's + # `connection()` block and write out a statement it has not finished issuing. async with self._lock: await self._conn.commit() + self._owner = None + async def rollback(self) -> None: async with self._lock: await self._conn.rollback() + self._owner = None + async def close(self) -> None: await self._conn.close() +class SqliteAsyncPool: + """ + A connection per asyncio task for a file-backed SQLite database, giving it the same + per-task transaction boundary `PostgresAsyncPool` gives Postgres. + + Possible here and not for `sqlite:memory` because a file has a path two connections can + both open. WAL mode is what makes it worth doing - without it a writer blocks readers on a + database-wide lock and separate connections buy nothing. SQLite still permits exactly one + writer at a time, so two writing tasks serialize on `busy_timeout` rather than running + concurrently; that is a throughput limit, not a correctness one, and it surfaces as + `database is locked` if a task holds a write open longer than the timeout. + + Connections are opened per task rather than pooled and reused. SQLite connections are cheap + (no handshake, no network) so there is little to gain from recycling, and closing on + release keeps the file-handle count bounded by concurrent tasks rather than by peak usage. + """ + + def __init__(self, db: "TypeDAL") -> None: + self._db = db + self._current: contextvars.ContextVar[t.Any] = contextvars.ContextVar( + f"typedal_sqlite_conn_{id(self):x}", + default=None, + ) + # every connection handed out and not yet closed, so close() can reach the ones whose + # tasks ended without committing. Same reasoning as `PostgresAsyncPool._checked_out`. + self._open: set[t.Any] = set() + + def _own_connection(self) -> t.Any: + """ + The connection this task opened, or None - see `PostgresAsyncPool._own_connection` for + why an entry inherited from a parent task has to count as None. + """ + entry = self._current.get() + if entry is None: + return None + + owner, conn = entry + return conn if owner is asyncio.current_task() else None + + async def _acquire(self) -> t.Any: + if (conn := self._own_connection()) is not None: + return conn + + conn = await _connect_sqlite_async(self._db) + self._current.set((asyncio.current_task(), conn)) + self._open.add(conn) + + if task := asyncio.current_task(): + task.add_done_callback(lambda _task: self._reclaim(conn)) + + return conn + + def _reclaim(self, conn: t.Any) -> None: + """ + Close a connection whose task ended without committing or rolling back. + + Handed its connection directly rather than reading the `ContextVar`, because + `add_done_callback` runs in the loop's context and not the finished task's. + """ + if conn not in self._open: + return + + async def _rollback_and_close() -> None: + # claim by removing from `_open`, before the first await, so this is atomic against + # `_release` and `close()` - see `PostgresAsyncPool._reclaim`. Unlike there, the + # connection is closed rather than returned either way, so a lost claim only means + # somebody else already closed it. + if conn not in self._open: + return + + self._open.discard(conn) + + with contextlib.suppress(Exception): + await conn.rollback() + with contextlib.suppress(Exception): + await conn.close() + + with contextlib.suppress(RuntimeError): + asyncio.get_running_loop().create_task(_rollback_and_close()) + + async def _release(self) -> None: + if (conn := self._own_connection()) is None: + return + + self._current.set(None) + self._open.discard(conn) + await conn.close() + + @contextlib.asynccontextmanager + async def connection(self) -> t.AsyncIterator[AsyncConnection]: + # no commit and no rollback on exit - the caller's transaction spans its calls and ends + # when it says so, exactly as on pydal's sync connection. + yield t.cast(AsyncConnection, await self._acquire()) + + async def commit(self) -> None: + if (conn := self._own_connection()) is None: + return + + await conn.commit() + await self._release() + + async def rollback(self) -> None: + if (conn := self._own_connection()) is None: + return + + await conn.rollback() + await self._release() + + async def close(self) -> None: + for conn in list(self._open): + self._open.discard(conn) + with contextlib.suppress(Exception): + await conn.rollback() + with contextlib.suppress(Exception): + await conn.close() + + async def open_postgres_async_pool(db: "TypeDAL") -> AsyncConnectionPool: """ Async pool factory for Postgres (registered in `_ASYNC_POOL_FACTORIES`). @@ -207,14 +614,33 @@ async def open_postgres_async_pool(db: "TypeDAL") -> AsyncConnectionPool: # pydal accepts 'postgres://', psycopg wants the standard 'postgresql://': uri = db._uri.replace("postgres://", "postgresql://", 1) - pool = psycopg_pool.AsyncConnectionPool(uri, open=False) + # min_size=1 rather than psycopg_pool's default of 4: connections are held for the length + # of a task's transaction now, not one statement, but opening four server connections + # before anyone has asked for one is pure cost - it multiplies every short-lived `TypeDAL` + # by four against the server's max_connections. + # max_size must be passed explicitly: psycopg_pool defaults it to min_size, so min_size=1 + # alone would cap the pool at a single connection and deadlock the second concurrent task + # for the full 30s checkout timeout. + pool = psycopg_pool.AsyncConnectionPool(uri, min_size=1, max_size=POSTGRES_POOL_MAX_SIZE, open=False) await pool.open() return PostgresAsyncPool(pool) -async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: +def sqlite_is_in_memory(adapter: "SQLAdapter") -> bool: """ - Async connection factory for SQLite (registered in `_ASYNC_POOL_FACTORIES`). + Whether pydal resolved this SQLite database to an in-memory one. + + Read off `dbpath` rather than the URI, because that is what pydal itself produced: for + `sqlite:memory` it builds `file:?mode=memory&cache=shared` and sets + `driver_args["uri"] = True` (adapters/sqlite.py), and it is the shared-cache part that + decides whether a second connection is possible at all. + """ + return "mode=memory" in str(adapter.dbpath) + + +async def _connect_sqlite_async(db: "TypeDAL") -> t.Any: + """ + One aiosqlite connection configured the way pydal configures its own. """ try: import aiosqlite @@ -236,8 +662,67 @@ async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: if adapter.adapter_args.get("foreign_keys", True): await conn.execute("PRAGMA foreign_keys=ON;") - return SqliteAsyncConnection(conn) + if not sqlite_is_in_memory(adapter): + # SQLite still allows a single writer, so two writing tasks queue here rather than + # failing outright. Genuinely per-connection, unlike journal_mode - see + # `enable_sqlite_wal()`. + await conn.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS};") + + return conn + + +async def enable_sqlite_wal(db: "TypeDAL") -> None: + """ + Put a file-backed SQLite database into WAL mode, once. + + WAL is what lets one task read while another holds a write open; without it they serialize + on a database-wide lock and per-task connections buy nothing. + + Deliberately not part of `_connect_sqlite_async`. `journal_mode` is a persistent property + of the database *file*, not of a connection, so setting it per connection is both redundant + and actively harmful: switching into WAL needs an exclusive lock, and a second task opening + its connection while the first holds a write transaction gets `database is locked` for a + setting that was already applied. Done here instead, on its own connection, before the pool + exists and therefore before any task can be writing. + + Failure is tolerated. A database that cannot be switched (on a filesystem that does not + support WAL, say) still works through `SqliteAsyncPool` - tasks just contend more. + """ + conn = await _connect_sqlite_async(db) + try: + with contextlib.suppress(Exception): + await conn.execute("PRAGMA journal_mode=WAL;") + await conn.commit() + finally: + await conn.close() + + +async def open_sqlite_async_connection(db: "TypeDAL") -> AsyncConnectionPool: + """ + Async connection factory for SQLite (registered in `_ASYNC_POOL_FACTORIES`). + + Two shapes, because the two kinds of SQLite database genuinely differ. A file-backed one + supports a connection per task, so it gets `SqliteAsyncPool` and the same per-task + transaction boundary Postgres has. `sqlite:memory` does not - see + `ConcurrentTransactionError` - so it gets the single-connection `SqliteAsyncConnection`, + which refuses a second task rather than merging it into the first one's transaction. + """ + if sqlite_is_in_memory(db._adapter): + return SqliteAsyncConnection(await _connect_sqlite_async(db)) + + await enable_sqlite_wal(db) + return SqliteAsyncPool(db) + +# A note the factories above share: `TypeDAL(..., after_connection=...)` does NOT run on these +# connections. That hook is pydal's, is handed the pydal adapter, and drives the sync cursor +# (connection.py) - there is no faithful way to replay it against a connection the adapter does +# not own, and a hook reaching into driver internals (`adapter.connection.create_function`) +# could not be replayed at all. What the factories do instead is mirror the backend's own +# `after_connection()` setup, above, so the async connection matches pydal's on everything +# pydal itself configures. pydal is not absolute about the hook either: a connection recycled +# from its global pool comes back with `run_hooks=False`. +# Covered by `test_after_connection_hook_does_not_reach_the_async_connection`. type PoolFactory = t.Callable[["TypeDAL"], t.Awaitable[AsyncConnectionPool]] @@ -396,6 +881,7 @@ async def base_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: py sql = adapter._delete(table, query) pool = await db._get_async_pool() + db._mark_async_pending() async with pool.connection() as conn, conn.cursor() as cur: await cur.execute(sql) try: diff --git a/src/typedal/core.py b/src/typedal/core.py index d466982..c412c21 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -14,7 +14,15 @@ import pydal -from .async_execution import DELETE_STRATEGIES, LASTROWID_STRATEGIES, AsyncConnectionPool, AsyncPoolManager +from .async_execution import ( + DELETE_STRATEGIES, + LASTROWID_STRATEGIES, + WRITE_STATEMENTS, + AsyncConnectionPool, + AsyncPoolManager, + SyncTransactionTracker, + TransactionSplitError, +) from .config import LazyPolicy, TypeDALConfig, load_config from .helpers import ( SYSTEM_SUPPORTS_TEMPLATES, @@ -230,6 +238,15 @@ class TypeDAL(_TypeDALBase): _config: TypeDALConfig _builder: TableDefinitionBuilder + # appended to, not replaced: pydal's own TimingHandler is what fills `db._timings`, and + # dropping it would take that with it. + execution_handlers = [*pydal.DAL.execution_handlers, SyncTransactionTracker] # noqa: RUF012 + + # whether each of the two connections holds an open transaction. See `TransactionSplitError` + # for why the pair has to be tracked at all. + _sync_pending: bool + _async_pending: bool + # similar to the insert/update/delete hooks at table-level but for .collect/.execute: # note: return values are ignored! _before_collect: list[t.Callable[["QueryBuilder[t.Any]"], None]] @@ -296,6 +313,11 @@ def __init__( self._after_execute = [] self._async_pools = AsyncPoolManager(self) # lazily-opened async connection; see _get_async_pool + # set before super().__init__(), which migrates and therefore already executes + # statements through SyncTransactionTracker. + self._sync_pending = False + self._async_pending = False + if config.folder: Path(config.folder).mkdir(exist_ok=True) @@ -329,6 +351,24 @@ def __init__( self.try_define(_TypedalCache) self.try_define(_TypedalCacheDependency) + def commit(self) -> None: + """ + Commit the transaction on pydal's own (synchronous) connection. + + Says nothing about the async connection - that one is ended by `commit_async()`. What it + does do is clear the flag that blocks the async path, so committing here is how you make + the other side usable again after a sync write. + """ + super().commit() + self._sync_pending = False + + def rollback(self) -> None: + """ + Roll back the transaction on pydal's own (synchronous) connection. See `commit`. + """ + super().rollback() + self._sync_pending = False + def close(self) -> None: """Close the database connection and unbind all defined TypedTable models.""" adapter = self._adapter @@ -537,7 +577,7 @@ def executesql( query: str | Template, placeholders: t.Iterable[str] | dict[str, str] | None = None, as_dict: bool = False, - fields: t.Iterable[Field | TypedField[t.Any]] | None = None, + fields: "Field | TypedField[t.Any] | Table | t.Iterable[Field | TypedField[t.Any]] | None" = None, colnames: t.Iterable[str] | None = None, as_ordered_dict: bool = False, ) -> list[t.Any] | None: @@ -593,14 +633,31 @@ async def _get_async_pool(self) -> AsyncConnectionPool: The async connection (a real pool for Postgres, a single wrapped connection for SQLite) for this instance, opened on first use. - Deliberately a separate connection from pydal's own thread-local sync connection: they - are two independent transactions, so a write on one is invisible to a read on the other - until committed, and commit()/rollback() on one says nothing about the other. + Necessarily a separate connection from pydal's own thread-local sync connection - pydal + drives Postgres with psycopg2 and SQLite with sqlite3, neither of which can be awaited - + and therefore a separate transaction. Rather than let a read silently miss the other + side's uncommitted work, this refuses to run while the sync side has any; the reasoning + is in `TransactionSplitError`. The lifecycle itself lives in `AsyncPoolManager` (async_execution.py). """ + if self._sync_pending: + raise TransactionSplitError( + "The synchronous connection has uncommitted writes, which this async statement " + "would not see. Call `db.commit()` or `db.rollback()` first.", + ) + return await self._async_pools.get() + def _mark_async_pending(self) -> None: + """ + Note that the async connection now holds a transaction the sync side must not step on. + + Called by the `_async` methods that write rather than from `_get_async_pool()`, because + a read leaves nothing behind for the other connection to miss. + """ + self._async_pending = True + async def select_async( self, query: pydal.objects.Query, @@ -678,6 +735,7 @@ async def update_async( sql = adapter._update(table, query, fields) pool = await self._get_async_pool() + self._mark_async_pending() async with pool.connection() as conn, conn.cursor() as cur: try: await cur.execute(sql) @@ -731,6 +789,7 @@ async def insert_async( last_insert = getattr(adapter, "_last_insert", None) pool = await self._get_async_pool() + self._mark_async_pending() async with pool.connection() as conn, conn.cursor() as cur: try: await cur.execute(query) @@ -771,7 +830,7 @@ async def executesql_async( query: str | Template, placeholders: t.Iterable[str] | dict[str, str] | None = None, as_dict: bool = False, - fields: t.Iterable[Field | TypedField[t.Any]] | None = None, + fields: "Field | TypedField[t.Any] | Table | t.Iterable[Field | TypedField[t.Any]] | None" = None, colnames: t.Iterable[str] | None = None, as_ordered_dict: bool = False, ) -> list[t.Any] | None: @@ -789,6 +848,13 @@ async def executesql_async( adapter = self._adapter pool = await self._get_async_pool() + + # unlike the other `_async` methods this one is handed arbitrary SQL, so whether it + # opens a transaction has to be read off the statement - same test the sync side + # applies in `SyncTransactionTracker`. + if str(query).lstrip().upper().startswith(WRITE_STATEMENTS): + self._mark_async_pending() + async with pool.connection() as conn, conn.cursor() as cur: if placeholders: await cur.execute(query, placeholders) @@ -825,9 +891,23 @@ async def executesql_async( return None if fields or colnames: - fields = [] if fields is None else list(fields) - extracted_fields = [] - for field in fields: + if fields is None: + given_fields: list[t.Any] = [] + elif isinstance(fields, (pydal.objects.Expression, pydal.objects.Table, str, bytes)): + # pydal's `executesql` accepts one Field/Table instead of a list + # (base.py: `if not isinstance(fields, list): fields = [fields]`), and the sync + # `executesql()` above inherits that by delegating to it. Wrapping is not just + # for parity: `list()` on a single Field never terminates, because + # `Expression.__getitem__` (pydal objects.py) answers every integer index with a + # substring expression instead of raising IndexError, so iteration has no end. + # `str`/`bytes` are in here for the same reason in reverse: neither is valid + # input, but iterating one silently yields characters, so it would fail much + # later on `f.sqlsafe` with a character rather than with what was passed in. + given_fields = [fields] + else: + given_fields = list(fields) + extracted_fields: list[t.Any] = [] + for field in given_fields: if isinstance(field, pydal.objects.Table): extracted_fields.extend(list(field)) else: @@ -860,21 +940,29 @@ async def commit_async(self) -> None: Commit the transaction on the async connection. Deliberately does not touch `commit()`/the sync connection: queries executed via - `select_async`/`insert_async`/etc. run on a separate connection, so committing one - says nothing about the other. For Postgres this is currently a no-op in practice - - `PostgresAsyncPool` already commits every call on its own - but calling it is still - the right thing to do: it keeps callers backend-agnostic, and it's the one that - actually matters for SQLite (see `AsyncConnectionPool` in async_execution.py). + `select_async`/`insert_async`/etc. run on a separate connection, so committing one says + nothing about the other. On Postgres this ends the transaction on the connection + checked out for *this task* and returns it to the pool; on SQLite there is one + connection and it ends the only transaction there is. + + Goes to `AsyncPoolManager.pool` rather than `_get_async_pool()` on purpose: ending a + transaction must never be the thing that opens a connection, and it must stay callable + while the sync side has pending writes - the guard in `_get_async_pool()` would refuse + exactly when a caller is trying to settle up. """ - pool = await self._get_async_pool() - await pool.commit() + if pool := self._async_pools.pool: + await pool.commit() + + self._async_pending = False async def rollback_async(self) -> None: """ Roll back the transaction on the async connection. See `commit_async`. """ - pool = await self._get_async_pool() - await pool.rollback() + if pool := self._async_pools.pool: + await pool.rollback() + + self._async_pending = False async def close_async(self) -> None: """ From 381f2db4c81d9f983a9812c025e6d46e3485d738 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 11:31:27 +0200 Subject: [PATCH 17/29] fix(tables): keep async model operations non-blocking and compatible --- src/typedal/tables.py | 64 ++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/src/typedal/tables.py b/src/typedal/tables.py index cf064f6..0bf325c 100644 --- a/src/typedal/tables.py +++ b/src/typedal/tables.py @@ -221,28 +221,50 @@ def insert(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance: # it already is an int but mypy doesn't understand that return self(result) - async def insert_async(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance: + async def _insert_id_async(self: t.Type[T_MetaInstance], **fields: t.Any) -> t.Any: """ - Async twin of `insert()`. + The insert itself, returning what pydal's own `Table.insert()` returns: the new id. Mirrors pydal's `Table.insert()` (objects.py): the field normalization (`_fields_and_values_for_insert`) and `_before_insert`/`_after_insert` hooks stay exactly as they are (pure/sync), only the adapter-level execute step (`table._db.insert_async(...)`) is async. + + Split out from `insert_async()` because turning that id into a model instance costs a + second query: `bulk_insert_async()` wants the ids only and does that lookup once for + the whole batch, not once per row. """ table = self._ensure_table_defined() require_permission(self._permissions, "insert") row = table._fields_and_values_for_insert(fields) if any(f(row) for f in table._before_insert): - result = 0 - else: - result = await table._db.insert_async(table, row.op_values()) - if result and table._after_insert: - for f in table._after_insert: - f(row, result) + return 0 - return self(result) + result = await table._db.insert_async(table, row.op_values()) + if result and table._after_insert: + for f in table._after_insert: + f(row, result) + + return result + + async def insert_async(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance: + """ + Async twin of `insert()`. + """ + result = await self._insert_id_async(**fields) + + if not isinstance(result, int) or not result: + # a `_before_insert` hook that blocked the insert (0), or a table with a custom + # `_primarykey`, whose id is a dict - `self(...)` answers None for both, as sync does. + return self(result) + + table = self._ensure_table_defined() + # NOT `self(result)`: that is pydal's *synchronous* `Table.__call__` -> `db(...).select()`, + # which would run a blocking query on the event loop - on pydal's sync connection, no + # less, so it also reads from a different transaction than the insert just wrote in. + row = await self.where(table._id == result).first_async() + return t.cast(T_MetaInstance, row) def _insert(self, **fields: t.Any) -> str: table = self._ensure_table_defined() @@ -264,14 +286,15 @@ async def bulk_insert_async(self: t.Type[T_MetaInstance], items: list[AnyDict]) pydal's `Table.bulk_insert()` (objects.py) only exists to hand the whole batch to `adapter.bulk_insert()`, which for every backend TypeDAL supports asynchronously is - itself a loop over `insert()` - so looping `insert_async()` here loses nothing and keeps + itself a loop over `insert()` - so looping the insert here loses nothing and keeps the hook/normalization dance in one place. + + `_insert_id_async()` rather than `insert_async()`: the ids are all this needs, and the + `collect_async()` below already fetches every inserted row in one query. """ self._ensure_table_defined() - require_permission(self._permissions, "insert") - inserted = [await self.insert_async(**item) for item in items] - ids = [row.id for row in inserted] + ids = [await self._insert_id_async(**item) for item in items] return await self.where(lambda row: row.id.belongs(ids)).collect_async() @@ -312,7 +335,9 @@ async def update_or_insert_async( synchronous select; `_lookup_query()` turns the same three input shapes into a plain Query so the lookup can go through `first_async()` instead. """ - record = await QueryBuilder(self).where(self._lookup_query(query, values)).first_async() + lookup = self._lookup_query(query, values) + + record = await QueryBuilder(self).where(lookup).first_async() if lookup is not None else None if not record: return await self.insert_async(**values) @@ -323,17 +348,24 @@ def _lookup_query( self: t.Type[T_MetaInstance], query: T_Query | AnyDict | t.Callable[[], None] | None, values: AnyDict, - ) -> Query: + ) -> Query | None: """ Turn `update_or_insert`'s three input shapes (DEFAULT / dict / Query) into one Query. Mirrors pydal's `Table.update_or_insert()` (objects.py): no query means "match on the values you were going to write", a dict means "match on these fields". + + `None` means "no lookup at all, go straight to the insert" - the caller's cue, not a + failure. `T_Query` includes `None` and `bool`, and pydal's `Table.__call__` + (objects.py) answers anything that is not a Query or a digit-like id with "no + record", which is exactly what makes the sync `update_or_insert(None, ...)` insert. + Handing those to `QueryBuilder.where()` instead would raise ValueError. """ table = self._ensure_table_defined() if query is not DEFAULT and not isinstance(query, dict): - return t.cast(Query, query) + is_query = isinstance(query, (pydal.objects.Query, pydal.objects.Expression)) + return t.cast(Query, query) if is_query else None criteria = values if query is DEFAULT else t.cast(AnyDict, query) From 1b367b502d95e553256e0cc6b868285295b3e425 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 11:31:34 +0200 Subject: [PATCH 18/29] test(fixtures): close PostgreSQL databases after each test --- tests/conftest.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8286809..3879d30 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,5 +28,14 @@ def dal_psql_uri(psql) -> str: @pytest.fixture def dal_psql(dal_psql_uri: str): + # function-scoped, so this runs once per test - which makes closing it mandatory rather + # than tidy. Without the close each test leaves a Postgres connection open (idle in + # transaction, since migrate=True runs DDL on it), and the container's default ceiling of + # 100 is reached partway through the suite: everything from then on fails to connect with + # `FATAL: sorry, too many clients already`. with tempfile.TemporaryDirectory() as d: - yield TypeDAL(dal_psql_uri, attempts=1, migrate=True, enable_typedal_caching=False, folder=d) + db = TypeDAL(dal_psql_uri, attempts=1, migrate=True, enable_typedal_caching=False, folder=d) + try: + yield db + finally: + db.close() From 0ed6c4e43b534731e1b1db95ed2103b5332cc842 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 12:03:42 +0200 Subject: [PATCH 19/29] fix(async): track pending writes per task --- src/typedal/async_execution.py | 27 +++++++------- src/typedal/core.py | 67 +++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index a7cad85..ab0aeb3 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -119,7 +119,9 @@ def before_execute(self, command: str) -> None: if db is None: # pragma: no cover - adapter detached during close() return - if getattr(db, "_async_pending", False): + # asks the db rather than reading a flag: what counts is whether *any* live task has an + # open async transaction, and only the `TypeDAL` knows which tasks those are. + if callable(pending := getattr(db, "_has_pending_async_writes", None)) and pending(): raise TransactionSplitError( "The async connection has uncommitted writes, which this synchronous statement " "would not see. Call `await db.commit_async()` or `await db.rollback_async()` " @@ -324,13 +326,14 @@ async def _rollback_and_return() -> None: with contextlib.suppress(RuntimeError): asyncio.get_running_loop().create_task(_rollback_and_return()) - async def _release(self) -> None: + async def _release(self, conn: t.Any) -> None: """ Hand this task's connection back, after its transaction has been ended. - """ - if (conn := self._own_connection()) is None: - return + Handed the connection rather than reading the `ContextVar` again: both callers have + just read it to decide there was a transaction to end at all, and a second read is one + more opportunity for the two to disagree about which connection this is. + """ self._current.set(None) self._checked_out.discard(conn) await self._pool.putconn(conn) @@ -346,14 +349,14 @@ async def commit(self) -> None: return await conn.commit() - await self._release() + await self._release(conn) async def rollback(self) -> None: if (conn := self._own_connection()) is None: return await conn.rollback() - await self._release() + await self._release(conn) async def close(self) -> None: """ @@ -564,10 +567,8 @@ async def _rollback_and_close() -> None: with contextlib.suppress(RuntimeError): asyncio.get_running_loop().create_task(_rollback_and_close()) - async def _release(self) -> None: - if (conn := self._own_connection()) is None: - return - + async def _release(self, conn: t.Any) -> None: + # handed the connection for the same reason as `PostgresAsyncPool._release`. self._current.set(None) self._open.discard(conn) await conn.close() @@ -583,14 +584,14 @@ async def commit(self) -> None: return await conn.commit() - await self._release() + await self._release(conn) async def rollback(self) -> None: if (conn := self._own_connection()) is None: return await conn.rollback() - await self._release() + await self._release(conn) async def close(self) -> None: for conn in list(self._open): diff --git a/src/typedal/core.py b/src/typedal/core.py index c412c21..7b4bf15 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -5,6 +5,7 @@ from __future__ import annotations # noinspection PyUnusedImports +import asyncio import collections import datetime as dt import sys @@ -49,6 +50,12 @@ from .types import AnyDict, DefineKwargs, Expression, Rows, Set, T_Query, Table +# stands in for the task in `TypeDAL._async_pending_owners` when there isn't one. Its own +# object rather than None so it cannot collide with a real entry, and so the "is this owner +# still running?" test can special-case it explicitly instead of by falsiness. +NO_ASYNC_TASK = object() + + def _expression_subclasses() -> t.Iterator[type]: """ Yield pydal.objects.Expression and every (nested) subclass currently loaded, e.g. Field and TypedField. @@ -244,8 +251,15 @@ class TypeDAL(_TypeDALBase): # whether each of the two connections holds an open transaction. See `TransactionSplitError` # for why the pair has to be tracked at all. + # + # The two are shaped differently on purpose. pydal's sync connection really is one shared + # thing - `THREAD_LOCAL` gives one per thread, and every coroutine on an event loop is the + # same thread - so a single flag describes it exactly. The async side keeps a connection + # *per task* (`PostgresAsyncPool`), so "has uncommitted writes" is a per-task fact and a + # single flag cannot hold it: one task's `commit_async()` would clear it on behalf of every + # other task, and the guard would then wave through exactly the read it exists to refuse. _sync_pending: bool - _async_pending: bool + _async_pending_owners: set[t.Any] # similar to the insert/update/delete hooks at table-level but for .collect/.execute: # note: return values are ignored! @@ -316,7 +330,7 @@ def __init__( # set before super().__init__(), which migrates and therefore already executes # statements through SyncTransactionTracker. self._sync_pending = False - self._async_pending = False + self._async_pending_owners = set() if config.folder: Path(config.folder).mkdir(exist_ok=True) @@ -649,14 +663,47 @@ async def _get_async_pool(self) -> AsyncConnectionPool: return await self._async_pools.get() + def _async_pending_owner(self) -> t.Any: + """ + The key the calling task's pending async writes are recorded under. + + The task itself where there is one. `asyncio.current_task()` answers None for a + coroutine driven without one, which is rare but not impossible; `NO_ASYNC_TASK` keeps + those recorded rather than silently untracked, at the cost of only being cleared by an + explicit `commit_async()`/`rollback_async()` - there is no task whose end could stand + in for that. + """ + return asyncio.current_task() or NO_ASYNC_TASK + def _mark_async_pending(self) -> None: """ - Note that the async connection now holds a transaction the sync side must not step on. + Note that *this task's* async connection now holds a transaction the sync side must + not step on. Called by the `_async` methods that write rather than from `_get_async_pool()`, because a read leaves nothing behind for the other connection to miss. """ - self._async_pending = True + self._async_pending_owners.add(self._async_pending_owner()) + + def _has_pending_async_writes(self) -> bool: + """ + Whether any live task holds uncommitted writes on an async connection. + + Any task, not just the caller's: the sync connection is shared by every coroutine on + this thread, so a statement issued from one task is invisible to *another* task's open + async transaction just as much as to its own. Both are the split this refuses. + + Finished tasks are dropped rather than counted. A task that ended without committing + had its connection reclaimed and rolled back (`PostgresAsyncPool._reclaim`), so its + writes are never going to become visible to anyone - there is nothing left for the + sync side to miss. Pruning here rather than in a callback also keeps the set from + growing for the life of the process. + """ + self._async_pending_owners = { + owner for owner in self._async_pending_owners if owner is NO_ASYNC_TASK or not owner.done() + } + + return bool(self._async_pending_owners) async def select_async( self, @@ -953,7 +1000,10 @@ async def commit_async(self) -> None: if pool := self._async_pools.pool: await pool.commit() - self._async_pending = False + # only this task's, matching what was actually committed: on Postgres `pool.commit()` + # ends the transaction on the connection checked out for *this* task and leaves every + # other task's alone. + self._async_pending_owners.discard(self._async_pending_owner()) async def rollback_async(self) -> None: """ @@ -962,7 +1012,7 @@ async def rollback_async(self) -> None: if pool := self._async_pools.pool: await pool.rollback() - self._async_pending = False + self._async_pending_owners.discard(self._async_pending_owner()) async def close_async(self) -> None: """ @@ -970,6 +1020,11 @@ async def close_async(self) -> None: """ await self._async_pools.close() + # every connection those writes were sitting on is gone (rolled back on the way out), + # so nothing is pending anymore - and leaving stale owners behind would refuse sync + # statements on a database that no longer has an async side at all. + self._async_pending_owners.clear() + def sql_expression( self, sql_fragment: str | Template, From b37f873c1cf45265604b463aac9eb56e226ced91 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 12:03:50 +0200 Subject: [PATCH 20/29] test(async): cover transaction isolation and pool cleanup --- tests/test_async_execution.py | 226 ++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 7 deletions(-) diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 137b5cb..249c6d2 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -30,6 +30,7 @@ from src.typedal.async_execution import ( ASYNC_POOL_FACTORIES, AsyncPoolManager, + PostgresAsyncPool, TransactionBoundaryError, TransactionSplitError, open_sqlite_async_connection, @@ -877,6 +878,92 @@ async def discarder(): await db.close_async() +@pytest.mark.asyncio +async def test_split_guard_is_per_task_not_per_instance(dal_psql: TypeDAL): + """ + The sync/async split guard has to be keyed to the task whose transaction it describes. + + `TransactionSplitError` and its two flags exist so a statement on one connection can never + silently miss uncommitted work on the other. On Postgres the async side keeps one + connection *per task* (`PostgresAsyncPool`), so "the async connection has uncommitted + writes" is a per-task fact - but `_async_pending` (core.py) is a single bool on the + `TypeDAL` instance, so any task's `commit_async()` clears it for every other task. + + Three coroutines, pinned with events rather than sleeps: + - `holder` inserts and does *not* end its transaction, so its connection stays dirty + - `bystander` does its own unrelated write and commits it, which is what clears the flag + - `sync_reader` then issues a plain synchronous SELECT + + That SELECT runs on pydal's own connection and cannot see `holder`'s row, which is the + exact condition the guard is there to refuse. It must raise. Today it does not: the flag + `SyncTransactionTracker.before_execute` reads was reset by a task that had no business + speaking for `holder`, so the guard fails *open* - and it fails open only under + concurrency, which is where it is the only thing standing between the caller and a wrong + answer. + + Postgres only, on `dal_psql`, for the same reason as + `test_concurrent_coroutines_do_not_share_one_transaction`: two tasks have to hold separate + open transactions at once for the premise to exist at all, and SQLite permits one writer. + + The mirror defect - `holder`'s write refusing an unrelated task's sync read - is the same + root cause and is not asserted here; fixing the flag to be per-task fixes both. + """ + db = dal_psql + + @db.define() + class AsyncThingSplitGuard(TypedTable): + name: TypedField[str] + + db.commit() + + holder_wrote = asyncio.Event() + bystander_committed = asyncio.Event() + sync_read_done = asyncio.Event() + + # collected rather than raised in place: an exception out of `gather()` propagates while + # the other two coroutines are still running, and the assertion belongs after they are all + # settled anyway. + refusal: list[TransactionSplitError] = [] + + async def holder() -> None: + # never committed or rolled back until the very end - this task's connection is the + # one holding the writes the sync reader must be protected from. + await AsyncThingSplitGuard.insert_async(name="uncommitted") + holder_wrote.set() + await asyncio.wait_for(sync_read_done.wait(), timeout=5) + await db.rollback_async() + + async def bystander() -> None: + # ordinary, correct, unrelated work: its own connection, its own transaction, ended + # properly. Nothing here is a misuse; that is the point. + await asyncio.wait_for(holder_wrote.wait(), timeout=5) + await AsyncThingSplitGuard.insert_async(name="bystander") + await db.commit_async() + bystander_committed.set() + + async def sync_reader() -> None: + await asyncio.wait_for(bystander_committed.wait(), timeout=5) + try: + # a plain INSERT takes no lock a SELECT waits on, so this does not block on + # `holder` - it just quietly returns a view of the table that is missing a row. + AsyncThingSplitGuard.collect() + except TransactionSplitError as e: + refusal.append(e) + finally: + sync_read_done.set() + + try: + await asyncio.gather(holder(), bystander(), sync_reader()) + + assert refusal, ( + "the sync SELECT was allowed to run while another task's async transaction held " + "uncommitted writes - `_async_pending` was cleared by `bystander`, which speaks " + "only for its own connection" + ) + finally: + await db.close_async() + + @pytest.mark.asyncio async def test_insert_async_honors_on_insert_error_hook(db_async: TypeDAL): """ @@ -1106,13 +1193,6 @@ async def failing_writer() -> None: rows = await AsyncThingIsolation.collect_async() assert sorted(row.name for row in rows) == ["keep"] - -# --------------------------------------------------------------------------- -# Coverage for async paths the parity tests above never reach: rollback, the pydal -# hook/abort branches, error hooks, cascades and the unsupported-backend guard. -# --------------------------------------------------------------------------- - - @pytest.mark.asyncio async def test_rollback_async_is_usable_on_every_backend(db_async: TypeDAL): """ @@ -1477,6 +1557,15 @@ class AsyncThingParse(TypedTable): ) assert by_colname[0].name == "widget" + # ...and `fields` left off entirely is the same case: pydal's own `executesql` treats a + # missing `fields` and an empty one alike (base.py), so both have to reach `parse()` with + # the colnames doing the resolving. + omitted_fields = await db.executesql_async( + f"SELECT {tablename}.name FROM {tablename}", + colnames=[f"{tablename}.name"], + ) + assert omitted_fields[0].name == "widget" + # a colname without a `table.` prefix is passed through unquoted bare_colname = await db.executesql_async( f"SELECT {tablename}.name FROM {tablename}", @@ -1748,3 +1837,126 @@ def after_connection(adapter: t.Any) -> None: await db.close_async() db.close() +class _StubConnection: + """Enough of a psycopg AsyncConnection for the pool to hand around and close.""" + + def __init__(self) -> None: + self.closed = False + self.rolled_back = False + + async def rollback(self) -> None: + self.rolled_back = True + + async def close(self) -> None: + self.closed = True + + +class _StubPool: + """A psycopg_pool stand-in whose `putconn` refuses, the way a closed pool does.""" + + def __init__(self, conn: _StubConnection) -> None: + self.conn = conn + self.closed = False + + async def getconn(self) -> _StubConnection: + return self.conn + + async def putconn(self, _conn: _StubConnection) -> None: + raise RuntimeError("pool is closed") + + async def close(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_postgres_pool_closes_a_connection_it_cannot_return(): + """ + A connection the pool refuses to take back must be closed, not forgotten. + + This is the branch that made the suite die with `FATAL: sorry, too many clients already`. + A task that ends without committing has its connection returned by a done-callback, which + runs after the fixture teardown has already closed the pool - so `putconn` fails. The + original code re-added the connection to `_checked_out` on that failure, but nothing drains + that set once `close()` has run, so the socket stayed open for the life of the process. + + Driven through stubs rather than a real pool: the failure needs `putconn` to raise at a + moment that is a race with a real one, and the point being asserted is what this class does + with the failure, not that psycopg produces it. + """ + conn = _StubConnection() + pool = PostgresAsyncPool(_StubPool(conn)) + + async def abandons_its_transaction() -> None: + await pool._acquire() # never committed, never rolled back + + await asyncio.create_task(abandons_its_transaction()) + # the done-callback schedules the return rather than doing it inline, so yield once + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert conn.rolled_back, "an abandoned transaction must be rolled back before disposal" + assert conn.closed, "a connection the pool refused must be closed, or its socket leaks" + assert conn not in pool._checked_out, "a disposed-of connection must not stay tracked" + + +@pytest.mark.asyncio +async def test_settling_up_twice_is_a_no_op(db_async: TypeDAL): + """ + `commit_async()`/`rollback_async()` must be safe when this task holds no connection. + + Two ways to get there, both ordinary: calling either twice, or calling one having done no + async work at all - a request handler that commits unconditionally on the way out, say. + The pools return the connection on the first call, so the second finds nothing; without the + guard it would commit on a connection already handed back to the pool. + """ + db = db_async + + @db.define() + class AsyncThingSettleTwice(TypedTable): + name: TypedField[str] + + db.commit() + + # nothing done on the async side yet + await db.commit_async() + await db.rollback_async() + + await AsyncThingSettleTwice.insert_async(name="once") + await db.commit_async() + await db.commit_async() # second one has nothing left to settle + await db.rollback_async() # and this must not undo the commit above + + assert [row.name for row in await AsyncThingSettleTwice.collect_async()] == ["once"] + + +@pytest.mark.asyncio +async def test_sqlite_pool_reclaim_yields_to_whoever_claimed_first(): + """ + `_reclaim` schedules its work, so the connection can be gone by the time that work runs. + + The done-callback checks `_open` when it fires, but the coroutine it starts runs later - + after `close()` or `_release()` may have taken the same connection. Both claim by removing + from `_open` before their first await, so the loser has to notice and do nothing; closing a + connection twice is harmless, but rolling back one that has been handed to another task is + not. + """ + with tempfile.TemporaryDirectory() as directory: + db = TypeDAL(f"sqlite://{Path(directory) / 'reclaim.db'}", enable_typedal_caching=False, folder=directory) + try: + pool = await db._get_async_pool() + conn = await pool._acquire() + + pool._reclaim(conn) # schedules the rollback-and-close + pool._open.discard(conn) # somebody else claims it before that runs + await asyncio.sleep(0) # let the scheduled work find it gone + + assert not pool._open + + # the losing claim leaves this connection open on purpose - that is the branch + # under test - so close it here. aiosqlite runs a non-daemon thread per connection, + # and one left behind outlives the test's event loop and reports + # `RuntimeError: Event loop is closed` from inside some later, unrelated test. + await conn.close() + finally: + await db.close_async() + db.close() From ef94da3fe509bef4ffff93d28abc3cf61975a082 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 12:04:02 +0200 Subject: [PATCH 21/29] docs: remove stale rfc file --- docs/rfc-async-execution.md | 142 ------------------------------------ 1 file changed, 142 deletions(-) delete mode 100644 docs/rfc-async-execution.md diff --git a/docs/rfc-async-execution.md b/docs/rfc-async-execution.md deleted file mode 100644 index 4790a39..0000000 --- a/docs/rfc-async-execution.md +++ /dev/null @@ -1,142 +0,0 @@ -# RFC: async execution path for TypeDAL - -**Status:** feasibility confirmed, implementation not started. -**Scope decided:** Postgres + SQLite, all five ops (`select`/`insert`/`update`/`delete`/`count`). -**Constraints:** pydal is never forked or patched; TypeDAL's public API is unchanged (new methods only). - -pydal tested: `20260520.0` (satisfies TypeDAL's `pydal>=20251012.3` pin). Drivers tested: -psycopg2 2.9.12 (sync baseline), psycopg 3.3.4 (async), asyncpg 0.31.0. - -## The question - -pydal's per-query work is a sandwich: build SQL (pure, microseconds) → execute (the only I/O) -→ parse rows (pure). If that split is reachable from outside pydal, TypeDAL can let pydal build -and parse as it always has, and only replace the execute step with a real async driver — no -greenlets, no pydal rewrite. - -## Verdict: clean - -Not just "a subclass can intercept `execute`/`parse`" — pydal already keeps build, execute, and -parse as three separate method calls with nothing to split. `Set.select()` -(`pydal/objects.py:2961-2971`) calls `adapter.tables()` → `adapter.expand_all()` → -`adapter.select()`. `SQLAdapter.select()` (`adapters/base.py:905-910`) calls -`self._select_wcols()` (pure, returns `(colnames, sql)`) then `_select_aux()` -(`base.py:864-891`), which does `execute(sql)` + `cursor.fetchall()` (the only I/O), then -`self.parse(rows, fields, colnames)` (pure). `_select_wcols` is called directly by -`Set.select()` itself — it isn't a hidden internal, it's part of the normal call graph. An -outside caller can call the build half, do its own I/O, and call `parse()` on the result, -skipping `select()`/`_select_aux()`/`execute()` entirely: - -```python -colnames, sql = adapter._select_wcols(query, fields, **attributes) # build (pydal, unmodified) -rows = await async_driver.execute_and_fetch(sql) # our I/O -result = adapter.parse(rows, fields, colnames, cacheable=...) # parse (pydal, unmodified) -``` - -No pydal method body is copied or patched. Verified live in the PoC (below): output is -field-for-field identical to `db(...).select()` run synchronously, and a formalized non-blocking -test shows the event loop keeps ticking at ~5ms while real queries run. - -**Per operation:** - -| op | Postgres | SQLite | -|---|---|---| -| `select` / `count` / `executesql` | clean sandwich | clean sandwich | -| `insert` | clean; one round trip in the standard case — `INSERT ... RETURNING id` is sent once, `cursor.fetchone()` just reads the row already returned by that statement (`adapters/postgres.py:142-162`). A second real round trip (`SELECT currval(...)`) only happens for tables with a custom `_primarykey` where the pk value wasn't supplied. | clean | -| `update` | clean sandwich | clean sandwich | -| `delete` | clean sandwich (`adapters/base.py:604-610`) | **not a sandwich** — `SQLite.delete()` (`adapters/sqlite.py:93-104`) runs a nested `SELECT` then recurses into `.delete()` per cascaded FK. Needs its own async reimplementation of the cascade, not a wrapped execute call. | - -SQLite's `select()` also has a side effect the base class doesn't: `for_update=True` triggers a -real `BEGIN IMMEDIATE TRANSACTION` *before* the build step (`adapters/sqlite.py:88-91`) — the -async wrapper has to special-case this, it can't assume every adapter's `select()` is side-effect -free just because the base one is. - -## Hypothesis B (greenlet bridge) — not needed, killed early - -`pydal/_globals.py:4`: `THREAD_LOCAL = threading.local()`, imported **by value** into six modules -(`connection.py:8`, `base.py:148`, `helpers/classes.py:17`, `adapters/postgres.py:5`, -`adapters/snowflake.py`, `adapters/google.py`). `ConnectionPool` closes over that name directly — -no subclass hook exists to redirect it to a contextvars-backed registry. Doable only as a -monkeypatch across all six modules, before first import, version-fragile. Since hypothesis A -worked, this wasn't built out further (no fake driver module, no contextvars registry). - -## Secondary findings - -1. **Two connections per request (confirmed hazard, no guard built yet).** Only the ops we - reroute touch the async connection; DDL, `commit()`/`rollback()` (`base.py:849-855`), and lazy - `Reference` resolution still go through the thread-local sync connection via - `@with_connection_or_raise`. A write on one connection is invisible to a read on the other - until commit. Mitigation is procedural (one path per request) until a guard is written. - -2. **Driver type mapping — verified, not inferred.** psycopg3 async matches psycopg2 exactly on - everything tested (int/str/Decimal/jsonb→dict). **asyncpg returns jsonb as raw `str`**, not - dict — pydal's `Postgre._config_json()` picks `PostgreAutoJSONParser` - (`parsers/postgre.py:12-13`, no json handler, expects the driver to have already decoded it), - which would silently leave jsonb as a string with asyncpg unless `self.parser` is forced to - the string-expecting variant. **Recommend psycopg3** for this reason — compatibility over - throughput, as scoped. Not tested: UUID, arrays, tstz, intervals. - -3. **Parameterisation — confirmed and quantified.** `adapt()` (`adapters/base.py:442-443`) - splices literals into the SQL text; every ORM call site (`select`/`insert`/`update`/`delete`) - calls `execute(sql)` with no extra args, so no DB-API placeholders are ever used outside - `executesql(..., placeholders=...)`. Measured (localhost, 400 iterations): literal-interpolated - vs. server-prepared identical queries — no measurable difference (0.078ms vs 0.084ms/query). - Not tested at scale or over a real network. - -4. **Lazy `Reference` access — confirmed, unresolved.** `Reference.__allocate()` - (`helpers/classes.py:189-196`) fires a blocking query from plain attribute access. `Reference` - is constructed directly in two modules (`parsers/base.py`, `adapters/base.py:561`), same - by-value-import fragility as `THREAD_LOCAL`. TypeDAL's own `Reference`/`Row` - (`src/typedal/types.py:197-198`) are mypy-only stubs today with no runtime behavior to hook - into. No clean fix; mitigation is documentation (eager-load via joins) + convention, not code. - -5. **SQLite — the easy case, and why.** `aiosqlite` wraps the same stdlib `sqlite3` module - pydal already relies on (`register_converter`/`PARSE_DECLTYPES`, - `adapters/sqlite.py:38,42-43`; `parsers/sqlite.py:20-28` expects native `date`/`datetime` - already). No driver-swap problem. The real SQLite complications are the `for_update` and - `delete()` items above, not type mapping. - -## PoC - -`select_async()` — build via `adapter._select_wcols`, execute via `psycopg` async, parse via -`adapter.parse`, zero pydal code touched — proved live against a disposable Postgres container: -field-for-field equal to `db(...).select()`, and a ticker interleaved with 20 real async queries -stays at ~5ms gaps (event loop never blocked). Script was a throwaway spike, not committed; -available on request / can be recreated from this doc in ~30 min. - -## Size estimate (confirmed scope: Postgres + SQLite, all five ops) - -- Shared async execution primitives (both backends, incl. SQLite's `for_update`/cascade - special-cases): 1–2 days -- `collect_async`/`select_async`/`first_async` on top of `QueryBuilder.collect()` - (`query_builder.py:611-674` already separates build/execute/shape into three steps — the async - twin reuses steps 1 and 3 as-is, replaces step 2): 1 day -- Async connection/pool lifecycle per `TypeDAL` instance, `self.parser` override if asyncpg is - ever added, SQLite custom-function registration (`create_function` equivalent to - `after_connection()`): 1–2 days -- Relationship/join queries + `insert`/`update`/`delete` async twins incl. SQLite delete cascade: - 2–3 days -- Hardening + tests (parity per backend, two-connections guard, `Reference` docs): 2–3 days - -**Total: ~1.5–2 weeks.** Excludes a durable fix for lazy `Reference` access (unresolved by -design) and full type-matrix verification beyond json/decimal/int/str/None. - -## Next steps - -Test-first, deliberately: constraint 2 (no public API change) means the test *is* the design -decision for the new methods' shape. Writing it before the implementation exists pins that down -instead of letting it drift out of implementation convenience. - -1. Add `pytest-asyncio` as a dev dependency — no async test runner exists in this repo yet - (`pyproject.toml` has no `asyncio`/`anyio` entry; `tests/conftest.py:7-30` is sync-only). -2. Write the test against the existing `dal_psql` fixture (`tests/conftest.py:23-30`): same query - via `.collect()` vs `.collect_async()`, asserting parity on the two divergence points this - spike actually found (jsonb→dict, decimal→Decimal), plus a formalized non-blocking/interleave - assertion. This fails (method doesn't exist) until step 3. - (Correction from an earlier draft of this doc: TypeDAL's `QueryBuilder.select()`, - `query_builder.py:172-202`, is a lazy builder step — it returns a new `QueryBuilder` and does - no I/O. `collect()`/`execute()` are the actual execution points, so those are what get async - twins, not `select()`.) -3. Implement `collect_async` for Postgres in `src/typedal/` — ported from the PoC's - `select_async()` helper, not a rewrite — to turn step 2 green. -4. Extend to SQLite and the remaining ops once the Postgres/select path is green in CI. From 6d650986ecc758fed05d307b7b7eb55d9001b978 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 13:41:28 +0200 Subject: [PATCH 22/29] test(async): cover sqlite memory transaction ownership --- tests/test_async_execution.py | 244 +++++++++++++++++++++++++++++++++- 1 file changed, 243 insertions(+), 1 deletion(-) diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 249c6d2..327a1eb 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -30,6 +30,7 @@ from src.typedal.async_execution import ( ASYNC_POOL_FACTORIES, AsyncPoolManager, + ConcurrentTransactionError, PostgresAsyncPool, TransactionBoundaryError, TransactionSplitError, @@ -49,7 +50,9 @@ async def _postgres_db(dal_psql: TypeDAL) -> t.AsyncIterator[TypeDAL]: @contextlib.asynccontextmanager -async def _sqlite_db(dal_psql: TypeDAL) -> t.AsyncIterator[TypeDAL]: +async def _sqlite_db(dal_psql: TypeDAL | None = None) -> t.AsyncIterator[TypeDAL]: + # `dal_psql` is unused and optional so this doubles as the `db_sqlite_memory` fixture's + # factory: the `sqlite:memory`-only tests below have no reason to start a Postgres container. with tempfile.TemporaryDirectory() as d: db = TypeDAL("sqlite:memory", enable_typedal_caching=False, folder=d) try: @@ -104,6 +107,19 @@ async def db_async(request: pytest.FixtureRequest, dal_psql: TypeDAL) -> t.Async yield db +@pytest_asyncio.fixture +async def db_sqlite_memory() -> t.AsyncIterator[TypeDAL]: + """ + A `sqlite:memory` `TypeDAL`, for the claims that only exist on `SqliteAsyncConnection`. + + Not a slice of `db_async`: the tests using this are about the one-connection backend + specifically - a second task being refused, and what the single shared transaction does + when its owner never ends it - which has no counterpart on the two per-task backends. + """ + async with _sqlite_db() as db: + yield db + + @pytest.mark.asyncio async def test_collect_async_matches_sync_collect(db_async: TypeDAL): """The core parity claim: async-executed rows must equal sync-executed rows, field for field.""" @@ -1960,3 +1976,229 @@ async def test_sqlite_pool_reclaim_yields_to_whoever_claimed_first(): finally: await db.close_async() db.close() + + +@pytest.mark.asyncio +async def test_sqlite_memory_commit_and_rollback_only_act_for_the_owning_task(db_sqlite_memory: TypeDAL): + """ + On `sqlite:memory`, `rollback_async()` from a task that owns no transaction must not + destroy the one another task is holding open. + + `SqliteAsyncConnection` refuses a second task at `connection()` + (`_refuse_if_owned_elsewhere`) precisely so one task's rollback cannot decide another's + rows - that is what its class docstring gives as the reason the refusal exists. But + `commit()`/`rollback()` never go through `connection()`: `commit_async()`/`rollback_async()` + (core.py) reach the pool directly, on purpose, so that settling up cannot be the thing that + opens a connection. On the two per-task backends that is harmless - `PostgresAsyncPool` and + `SqliteAsyncPool` both no-op when the calling task holds no connection - but + `SqliteAsyncConnection` acts on the single shared connection unconditionally. + + So the refusal only covers the path that writes, not the path that decides. A request + handler that rolls back unconditionally on its way out, on a task that did no async work at + all, ends someone else's transaction. + + `sqlite:memory` only: it is the one backend where two tasks share a connection, so it is + the only one where a non-owner *has* anything to end. + + The two coroutines, pinned with events rather than sleeps: + - `keeper` inserts `keep` and waits to commit until the outsider has had its turn + - `outsider` does no async work of its own and calls `rollback_async()` + + Ownership-guarded, `keep` survives: the outsider's rollback had nothing of its own to end. + Unguarded, it rolls back the keeper's insert, and the keeper's later commit commits an + empty transaction. + """ + db = db_sqlite_memory + + @db.define() + class AsyncThingForeignRollback(TypedTable): + name: TypedField[str] + + db.commit() + + keeper_wrote = asyncio.Event() + outsider_settled = asyncio.Event() + + async def keeper() -> None: + await AsyncThingForeignRollback.insert_async(name="keep") + keeper_wrote.set() + await asyncio.wait_for(outsider_settled.wait(), timeout=5) + await db.commit_async() + + async def outsider() -> None: + await asyncio.wait_for(keeper_wrote.wait(), timeout=5) + # nothing of this task's own is open - on every other backend this is a no-op + await db.rollback_async() + outsider_settled.set() + + await asyncio.gather(keeper(), outsider()) + + assert [row.name for row in await AsyncThingForeignRollback.collect_async()] == ["keep"], ( + "a task that holds no transaction rolled back the one another task was still writing to" + ) + + +@pytest.mark.asyncio +async def test_sqlite_memory_does_not_inherit_an_abandoned_transaction(db_sqlite_memory: TypeDAL): + """ + A `sqlite:memory` transaction whose task ended without settling it must not be handed to + the next task. + + Both per-task backends arm a done-callback at checkout to roll back and dispose of a + connection its task abandoned (`PostgresAsyncPool._reclaim`, `SqliteAsyncPool._reclaim`). + `SqliteAsyncConnection` has no such path, so `_owner` keeps pointing at the finished task + with its transaction still open. `_refuse_if_owned_elsewhere()` then lets the next task + straight in - its `not self._owner.done()` term is false for a finished owner - and that + task lands inside the abandoned transaction. Its `commit_async()` is now deciding the + previous task's writes. + + Asserted as the outcome rather than by poking at `_owner`, because the outcome is what a + caller can be surprised by: `abandoned` was never committed by anyone, and committing + `mine` must not make it durable. + + The sleeps are `sleep(0)` yields, not waits: a done-callback cannot await, so any reclaim + it schedules runs as a task on the next pass of the loop, and the assertion has to be made + after that has had its turn. + """ + db = db_sqlite_memory + + @db.define() + class AsyncThingAbandoned(TypedTable): + name: TypedField[str] + + db.commit() + + async def abandons_its_transaction() -> None: + # ends without commit_async()/rollback_async() - the case the two pools' done-callbacks + # exist for + await AsyncThingAbandoned.insert_async(name="abandoned") + + await asyncio.create_task(abandons_its_transaction()) + await asyncio.sleep(0) + await asyncio.sleep(0) + + await AsyncThingAbandoned.insert_async(name="mine") + await db.commit_async() + + assert [row.name for row in await AsyncThingAbandoned.collect_async()] == ["mine"], ( + "the next task inherited the abandoned transaction and its commit made another task's " + "uncommitted row durable" + ) + + +@pytest.mark.asyncio +async def test_sqlite_memory_abandoned_transaction_does_not_lock_out_the_sync_side(db_sqlite_memory: TypeDAL): + """ + The sync connection must not be waved through while an abandoned async transaction is still + holding the table. + + `TypeDAL._has_pending_async_writes()` (core.py) prunes owners whose task has finished, and + says why in its own comment: a task that ended without committing "had its connection + reclaimed and rolled back (`PostgresAsyncPool._reclaim`)", so there is nothing left for the + sync side to miss. That reasoning holds for both per-task backends and not for + `SqliteAsyncConnection`, which has no reclaim path - the transaction is still open, and on + `sqlite:memory` shared-cache mode it is still holding the table against pydal's own + connection. + + The guard therefore fails open exactly where it was supposed to raise, and what the caller + gets instead is the driver's `database table is locked` after the busy timeout - which is + the outcome `TransactionSplitError` was introduced to replace. + """ + db = db_sqlite_memory + + @db.define() + class AsyncThingAbandonedLock(TypedTable): + name: TypedField[str] + + db.commit() + + async def abandons_its_transaction() -> None: + await AsyncThingAbandonedLock.insert_async(name="abandoned") + + await asyncio.create_task(abandons_its_transaction()) + await asyncio.sleep(0) + await asyncio.sleep(0) + + # nobody is going to make those writes visible, so the sync side has nothing to miss and + # must be free to run - which requires the abandoned transaction to have been reclaimed + AsyncThingAbandonedLock.insert(name="sync") + db.commit() + + assert sorted(row.name for row in AsyncThingAbandonedLock.collect()) == ["sync"] + + +@pytest.mark.asyncio +async def test_refused_task_is_not_recorded_as_holding_async_writes(db_sqlite_memory: TypeDAL): + """ + A task refused with `ConcurrentTransactionError` opened no transaction, and must not be + recorded as holding one. + + `_mark_async_pending()` is called before entering `pool.connection()` (`insert_async`, + `update_async`, `executesql_async` in core.py, `base_delete_async` in + async_execution.py). On `sqlite:memory` that context manager can raise before it ever + yields, so the refused task ends up in `_async_pending_owners` having done nothing at all. + + Nothing clears it: the entry is only dropped by that task's own `commit_async()`/ + `rollback_async()`, which a caller who just got told "you were refused" has no reason to + call, or by the pruning in `_has_pending_async_writes()` once the task ends. Until then the + guard is global - `SyncTransactionTracker.before_execute` asks "does *any* live task hold + async writes" - so one refused task refuses every sync statement on the instance, including + those of tasks that were never involved. + + Ordering, pinned with events: + - `holder` writes and keeps its transaction open, then commits once the refusal happened + - `refused` is turned away, waits for the holder to settle, and only then looks + + By that point the one real async transaction is committed and gone, so the correct answer + is "nothing pending" and a plain sync INSERT that runs. + + Note this is not merely an ordering nit to be fixed by moving the call inside the `async + with`: on Postgres `_acquire()` awaits `getconn()`, and a sync write issued by another + coroutine during that await would slip past the check `_get_async_pool()` already made. The + mark has to stay ahead of that await and be undone when - and only when - the connection + was refused before any statement ran. + """ + db = db_sqlite_memory + + @db.define() + class AsyncThingRefusedMark(TypedTable): + name: TypedField[str] + + db.commit() + + holder_wrote = asyncio.Event() + refusal_happened = asyncio.Event() + holder_settled = asyncio.Event() + + # collected rather than asserted inside the coroutine, so a failure does not tear down the + # gather while the other one is still waiting on an event. + problems: list[str] = [] + + async def holder() -> None: + await AsyncThingRefusedMark.insert_async(name="held") + holder_wrote.set() + await asyncio.wait_for(refusal_happened.wait(), timeout=5) + await db.commit_async() + holder_settled.set() + + async def refused() -> None: + await asyncio.wait_for(holder_wrote.wait(), timeout=5) + with pytest.raises(ConcurrentTransactionError): + await AsyncThingRefusedMark.insert_async(name="refused") + refusal_happened.set() + + await asyncio.wait_for(holder_settled.wait(), timeout=5) + + # this task is still alive, so pruning cannot cover for the stale entry + if db._has_pending_async_writes(): + problems.append("a refused task is recorded as holding uncommitted async writes") + + try: + AsyncThingRefusedMark.insert(name="sync") + db.commit() + except TransactionSplitError: + problems.append("a refused task's stale entry refused an unrelated sync statement") + + await asyncio.gather(holder(), refused()) + + assert not problems, problems From 48aa449184f7d905e71c8e6a381e12ec991a6fc9 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 14:32:08 +0200 Subject: [PATCH 23/29] fix(async): guard transaction ownership and cleanup --- src/typedal/async_execution.py | 172 ++++++++++++++++++++++++---- src/typedal/core.py | 202 +++++++++++++++++++++------------ tests/test_async_execution.py | 66 +++++++++++ 3 files changed, 345 insertions(+), 95 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index ab0aeb3..7417db2 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -119,9 +119,9 @@ def before_execute(self, command: str) -> None: if db is None: # pragma: no cover - adapter detached during close() return - # asks the db rather than reading a flag: what counts is whether *any* live task has an - # open async transaction, and only the `TypeDAL` knows which tasks those are. - if callable(pending := getattr(db, "_has_pending_async_writes", None)) and pending(): + # TypeDAL owns the settle-then-check decision, so the sync side cannot accidentally ask + # the predicate without first reclaiming an abandoned sqlite:memory transaction. + if db._has_pending_async_writes(): raise TransactionSplitError( "The async connection has uncommitted writes, which this synchronous statement " "would not see. Call `await db.commit_async()` or `await db.rollback_async()` " @@ -208,6 +208,8 @@ async def rollback(self) -> None: ... async def close(self) -> None: ... + def settle_abandoned_sync(self) -> bool: ... + class PostgresAsyncPool: """ @@ -344,6 +346,10 @@ async def connection(self) -> t.AsyncIterator[AsyncConnection]: # call, made via commit()/rollback(), exactly as it is on pydal's sync connection. yield t.cast(AsyncConnection, await self._acquire()) + def settle_abandoned_sync(self) -> bool: + """Per-task backends reclaim abandoned connections through their own done-callback.""" + return True + async def commit(self) -> None: if (conn := self._own_connection()) is None: return @@ -425,10 +431,54 @@ def __init__(self, conn: AsyncConnection) -> None: # for *other* tasks to see it and be refused, which is the opposite of what a # ContextVar's per-task isolation provides. self._owner: "asyncio.Task[t.Any] | None" = None + # tasks with a reclaim callback armed, so one is armed per task rather than per + # statement. Entries are dropped when the callback fires. + self._reclaimable: "set[asyncio.Task[t.Any]]" = set() + + def _is_owned_elsewhere(self) -> bool: + """ + Whether the open transaction belongs to a task other than the calling one. + + No `done()` term, deliberately. A finished owner is settled by + `_settle_abandoned_owner()` before anyone gets here, so by this point `_owner` is either + absent, the caller's, or a live other task's. Treating a *finished* owner as absent + instead - which this used to do - is how the next task ended up inheriting an + abandoned transaction and deciding its writes. + + `asyncio.current_task()` answers None off-task, and None is never stored as an owner, + so an off-task caller correctly reads any owner as somebody else's. + """ + return self._owner is not None and self._owner is not asyncio.current_task() + + async def _settle_abandoned_owner(self) -> None: + """ + Roll back a transaction whose owning task ended without committing it. + + Must be called with `_lock` held. `PostgresAsyncPool` and `SqliteAsyncPool` both hand + the abandoned connection back and roll it back on the way (`_reclaim`); there is no + connection to hand back here, so the transaction itself is what gets reclaimed. + + `settle_abandoned_sync()` normally gets there first, from the task's done-callback or + from `TypeDAL._settle_abandoned_async_writes()`. This is the deterministic backstop for + when neither has run yet, or when the sync path could not act because the lock was held. + + If the rollback fails, the owner is left set so the next task is refused rather than + allowed to inherit a transaction that could not be reclaimed. + """ + owner = self._owner + if owner is None or owner is asyncio.current_task() or not owner.done(): + return + + try: + await self._conn.rollback() + except Exception: # pragma: no cover - a hard rollback failure is not reachable through the public API + return + + self._owner = None def _refuse_if_owned_elsewhere(self) -> None: """ - Refuse the caller if a different, still-running task holds the open transaction. + Refuse the caller if a different task holds the open transaction. Must be called with `_lock` held. Checking on the way *to* the lock instead lets a second task read `_owner` while the first is still awaiting inside its `connection()` @@ -436,9 +486,7 @@ def _refuse_if_owned_elsewhere(self) -> None: check, queues on the lock, and then walks straight into the transaction it should have been refused from. Under the lock, the first task's ownership is always already visible. """ - task = asyncio.current_task() - - if self._owner is not None and self._owner is not task and not self._owner.done(): + if self._is_owned_elsewhere(): raise ConcurrentTransactionError( "Another task holds an open transaction on this sqlite:memory database, and " "SQLite cannot give the two of them separate ones - shared-cache mode refuses " @@ -455,12 +503,78 @@ def _take_ownership_if_in_transaction(self) -> None: SELECT and DDL alone. Claiming on every use instead would mean a single `collect_async()` locked every other task out of the database until the reader happened to commit, which readers have no reason to do. + + Taking ownership also arms the done-callback that reclaims the transaction if this task + never ends it, the same safety net the two real pools arm at checkout. Armed here + rather than on entry to `connection()` because this is the moment there is something to + reclaim; `_reclaimable` keeps one callback per task rather than one per statement. + """ + owner = asyncio.current_task() if self._conn.in_transaction else None # ty: ignore[unresolved-attribute] + self._owner = owner + + if owner is not None and owner not in self._reclaimable: + self._reclaimable.add(owner) + owner.add_done_callback(self._reclaim) + + def settle_abandoned_sync(self) -> bool: + """ + Synchronously roll back a transaction whose owning task ended without committing it. + + The sync counterpart to `_settle_abandoned_owner()`, for callers that cannot await. + `TypeDAL._settle_abandoned_async_writes()` runs inside pydal's synchronous execution + handler, so it needs this path: scheduling an async rollback and hoping it has run + before the sync statement executes is exactly the race that lets `database is locked` + out of the driver instead of being reclaimed here. + + A finished owner means no statement from that task is still queued, and `_lock` being + free means no other coroutine is mid-statement on this single connection. Under those + two conditions the aiosqlite worker thread is idle, so the underlying sqlite3 + connection can be rolled back directly without going through aiosqlite's queue. + + Returns False when the rollback cannot be done safely right now, or when it fails. The + caller must keep the abandoned owner counted: a rollback that did not happen still has + an open transaction behind it, and letting the next task inherit that is precisely the + failure this class exists to prevent. """ - self._owner = asyncio.current_task() if self._conn.in_transaction else None # ty: ignore[unresolved-attribute] + owner = self._owner + if owner is None or owner is asyncio.current_task() or not owner.done(): + return True + + if self._lock.locked(): + return False + + try: + self._conn._conn.rollback() # type: ignore[attr-defined] + except Exception: # pragma: no cover - a hard rollback failure is not reachable through the public API + return False + + self._owner = None + return True + + def _reclaim(self, task: "asyncio.Task[t.Any]") -> None: + """ + Roll back the transaction of a task that ended without committing it. + + Re-checks ownership rather than trusting the callback fired: by the time it runs the + task may have committed (so `_owner` is None), or another task may already hold the + transaction, and rolling *that* back is the very thing this class exists to prevent. + + A False from `settle_abandoned_sync()` is safe to ignore here: `_settle_abandoned_owner()` + runs on the next async `connection()`, so the still-open owner is refused rather than + inherited. + """ + self._reclaimable.discard(task) + + if self._owner is not task: + # the ordinary case - commit()/rollback() already ended it + return + + self.settle_abandoned_sync() @contextlib.asynccontextmanager async def connection(self) -> t.AsyncIterator[AsyncConnection]: async with self._lock: + await self._settle_abandoned_owner() self._refuse_if_owned_elsewhere() try: yield self._conn @@ -473,15 +587,27 @@ async def commit(self) -> None: # under the lock so a commit cannot land halfway through another coroutine's # `connection()` block and write out a statement it has not finished issuing. async with self._lock: - await self._conn.commit() + if self._is_owned_elsewhere(): + # Not this task's transaction to end, so this does nothing - matching what + # `PostgresAsyncPool` and `SqliteAsyncPool` do for a task that holds no + # connection. Unlike them, the connection here is shared, so acting anyway + # would commit or roll back writes the owner has not finished issuing: a + # handler that settles up unconditionally on its way out would decide another + # request's transaction. Silent rather than an exception because both of these + # are what a caller reaches for while cleaning up, often in a `finally`, where + # raising would mask whatever sent it there. + return - self._owner = None + await self._conn.commit() + self._owner = None async def rollback(self) -> None: async with self._lock: - await self._conn.rollback() + if self._is_owned_elsewhere(): + return - self._owner = None + await self._conn.rollback() + self._owner = None async def close(self) -> None: await self._conn.close() @@ -579,6 +705,10 @@ async def connection(self) -> t.AsyncIterator[AsyncConnection]: # when it says so, exactly as on pydal's sync connection. yield t.cast(AsyncConnection, await self._acquire()) + def settle_abandoned_sync(self) -> bool: + """Per-task backends reclaim abandoned connections through their own done-callback.""" + return True + async def commit(self) -> None: if (conn := self._own_connection()) is None: return @@ -882,15 +1012,15 @@ async def base_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: py sql = adapter._delete(table, query) pool = await db._get_async_pool() - db._mark_async_pending() - async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(sql) - try: - return cur.rowcount - except Exception: # pragma: no cover - # defensive, mirroring `adapter.delete()` (adapters/base.py): - # neither driver's `rowcount` actually raises, it is a plain property. - return None + with db._mark_async_pending(): + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql) + try: + return cur.rowcount + except Exception: # pragma: no cover + # defensive, mirroring `adapter.delete()` (adapters/base.py): + # neither driver's `rowcount` actually raises, it is a plain property. + return None async def sqlite_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: pydal.objects.Query) -> int | None: diff --git a/src/typedal/core.py b/src/typedal/core.py index 7b4bf15..e33eb8b 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -7,6 +7,7 @@ # noinspection PyUnusedImports import asyncio import collections +import contextlib import datetime as dt import sys import typing as t @@ -21,6 +22,7 @@ WRITE_STATEMENTS, AsyncConnectionPool, AsyncPoolManager, + ConcurrentTransactionError, SyncTransactionTracker, TransactionSplitError, ) @@ -675,15 +677,57 @@ def _async_pending_owner(self) -> t.Any: """ return asyncio.current_task() or NO_ASYNC_TASK - def _mark_async_pending(self) -> None: + @contextlib.contextmanager + def _mark_async_pending(self) -> t.Iterator[None]: """ - Note that *this task's* async connection now holds a transaction the sync side must - not step on. + Note, for the duration of a write, that *this task's* async connection holds a + transaction the sync side must not step on. + + Used by the `_async` methods that write rather than by `_get_async_pool()`, because a + read leaves nothing behind for the other connection to miss. + + Entered *before* the connection is acquired, not after. Acquiring awaits - on Postgres + `getconn()` can take a full round trip - and a plain synchronous write issued by + another coroutine during that await would slip past the check `_get_async_pool()` just + made, opening exactly the split both guards exist to prevent. So the mark has to be + standing before the first await. + + The cost of being early is `ConcurrentTransactionError`, which `sqlite:memory` raises + from `connection()` *before* it yields: that caller opened no transaction at all, and + leaving it recorded would refuse every sync statement on this instance - the guard is + "does any live task hold async writes", not "does mine" - until its task happened to + end. So that one exception, and only that one, un-marks. Any other failure may well + have left a transaction open: sqlite3 implicitly BEGINs before DML and a statement that + raised can still have opened one, which is why `SqliteAsyncConnection` claims ownership + in a `finally` too. + """ + owner = self._async_pending_owner() + # an earlier `_async` write in this same task already marked it, and its transaction is + # still open - this block's failure says nothing about that one, so leave it recorded. + already_pending = owner in self._async_pending_owners + + self._async_pending_owners.add(owner) + try: + yield + except ConcurrentTransactionError: + if not already_pending: + self._async_pending_owners.discard(owner) + raise + + def _settle_abandoned_async_writes(self) -> bool: + """ + Reclaim an abandoned `sqlite:memory` async transaction, if there is one. + + Only the sync side needs this: pydal's `ExecutionHandler` cannot await the async pool, + and `sqlite:memory` has no connection to hand back. The two per-task backends already + reclaim their abandoned connections through their own done-callbacks. - Called by the `_async` methods that write rather than from `_get_async_pool()`, because - a read leaves nothing behind for the other connection to miss. + Returns False when the reclaim could not be completed. The caller must then treat the + async side as still pending, so the sync statement raises `TransactionSplitError` + instead of walking into a driver lock. """ - self._async_pending_owners.add(self._async_pending_owner()) + pool = self._async_pools.pool + return pool is None or pool.settle_abandoned_sync() def _has_pending_async_writes(self) -> bool: """ @@ -693,12 +737,21 @@ def _has_pending_async_writes(self) -> bool: this thread, so a statement issued from one task is invisible to *another* task's open async transaction just as much as to its own. Both are the split this refuses. + This is the settle-then-check decision point, kept here rather than in + `SyncTransactionTracker`: `sqlite:memory` must first synchronously reclaim a finished + task's abandoned transaction, and callers must not be able to ask the predicate without + that reclaim having run. If reclaim cannot complete, the async side is still treated as + pending and the caller is refused. + Finished tasks are dropped rather than counted. A task that ended without committing had its connection reclaimed and rolled back (`PostgresAsyncPool._reclaim`), so its writes are never going to become visible to anyone - there is nothing left for the sync side to miss. Pruning here rather than in a callback also keeps the set from growing for the life of the process. """ + if not self._settle_abandoned_async_writes(): + return True + self._async_pending_owners = { owner for owner in self._async_pending_owners if owner is NO_ASYNC_TASK or not owner.done() } @@ -782,20 +835,20 @@ async def update_async( sql = adapter._update(table, query, fields) pool = await self._get_async_pool() - self._mark_async_pending() - async with pool.connection() as conn, conn.cursor() as cur: - try: - await cur.execute(sql) - except Exception as e: - if hasattr(table, "_on_update_error"): - return t.cast(t.Optional[int], table._on_update_error(table, query, fields, e)) # ty: ignore[call-non-callable] - raise - try: - return cur.rowcount - except Exception: # pragma: no cover - # defensive, mirroring `adapter.update()` (adapters/base.py): - # neither driver's `rowcount` actually raises, it is a plain property. - return None + with self._mark_async_pending(): + async with pool.connection() as conn, conn.cursor() as cur: + try: + await cur.execute(sql) + except Exception as e: + if hasattr(table, "_on_update_error"): + return t.cast(t.Optional[int], table._on_update_error(table, query, fields, e)) # ty: ignore[call-non-callable] + raise + try: + return cur.rowcount + except Exception: # pragma: no cover + # defensive, mirroring `adapter.update()` (adapters/base.py): + # neither driver's `rowcount` actually raises, it is a plain property. + return None async def delete_async( self, @@ -836,22 +889,22 @@ async def insert_async( last_insert = getattr(adapter, "_last_insert", None) pool = await self._get_async_pool() - self._mark_async_pending() - async with pool.connection() as conn, conn.cursor() as cur: - try: - await cur.execute(query) - except Exception as e: - # mirrors `adapter.insert()` (adapters/base.py), same as `update_async`: - if hasattr(table, "_on_insert_error"): - return table._on_insert_error(table, fields, e) # ty: ignore[call-non-callable] - raise - - if hasattr(table, "_primarykey"): - pkdict = {k[0].name: k[1] for k in fields if k[0].name in table._primarykey} # ty: ignore[unsupported-operator] - if pkdict: - return pkdict - - row_id = await LASTROWID_STRATEGIES[adapter.dbengine](adapter, table, cur, last_insert) + with self._mark_async_pending(): + async with pool.connection() as conn, conn.cursor() as cur: + try: + await cur.execute(query) + except Exception as e: + # mirrors `adapter.insert()` (adapters/base.py), same as `update_async`: + if hasattr(table, "_on_insert_error"): + return table._on_insert_error(table, fields, e) # ty: ignore[call-non-callable] + raise + + if hasattr(table, "_primarykey"): + pkdict = {k[0].name: k[1] for k in fields if k[0].name in table._primarykey} # ty: ignore[unsupported-operator] + if pkdict: + return pkdict + + row_id = await LASTROWID_STRATEGIES[adapter.dbengine](adapter, table, cur, last_insert) # a table with a single custom primarykey reports its id as a `{name: value}` dict # instead of a bare int, matching `adapter.insert()` (adapters/base.py): @@ -898,44 +951,45 @@ async def executesql_async( # unlike the other `_async` methods this one is handed arbitrary SQL, so whether it # opens a transaction has to be read off the statement - same test the sync side - # applies in `SyncTransactionTracker`. - if str(query).lstrip().upper().startswith(WRITE_STATEMENTS): - self._mark_async_pending() - - async with pool.connection() as conn, conn.cursor() as cur: - if placeholders: - await cur.execute(query, placeholders) - else: - await cur.execute(query) - - if as_dict or as_ordered_dict: - if not hasattr(cur, "description"): # pragma: no cover - # both supported drivers always expose it; guard kept for parity with - # pydal's own `executesql`. - raise RuntimeError("database does not support executesql_async(...,as_dict=True)") - - columns = cur.description - result_fields = list(colnames) if colnames else [col[0] for col in columns] - if len(result_fields) != len(set(result_fields)): - raise RuntimeError( - "Result set includes duplicate column names. " - "Specify unique column names using the 'colnames' argument", - ) - if columns: - for i in range(len(result_fields)): - if isinstance(result_fields[i], bytes): # pragma: no cover - # psycopg and aiosqlite both report column names as str; this is - # for drivers that hand back bytes, as pydal's `executesql` allows. - result_fields[i] = result_fields[i].decode("utf8") # ty: ignore[unresolved-attribute] - - data = await cur.fetchall() - _dict = collections.OrderedDict if as_ordered_dict else dict - return [_dict(zip(result_fields, row)) for row in data] - - try: - data = await cur.fetchall() - except Exception: - return None + # applies in `SyncTransactionTracker`. A read marks nothing, hence the `nullcontext`. + opens_a_transaction = str(query).lstrip().upper().startswith(WRITE_STATEMENTS) + marker = self._mark_async_pending() if opens_a_transaction else contextlib.nullcontext() + + with marker: + async with pool.connection() as conn, conn.cursor() as cur: + if placeholders: + await cur.execute(query, placeholders) + else: + await cur.execute(query) + + if as_dict or as_ordered_dict: + if not hasattr(cur, "description"): # pragma: no cover + # both supported drivers always expose it; guard kept for parity with + # pydal's own `executesql`. + raise RuntimeError("database does not support executesql_async(...,as_dict=True)") + + columns = cur.description + result_fields = list(colnames) if colnames else [col[0] for col in columns] + if len(result_fields) != len(set(result_fields)): + raise RuntimeError( + "Result set includes duplicate column names. " + "Specify unique column names using the 'colnames' argument", + ) + if columns: + for i in range(len(result_fields)): + if isinstance(result_fields[i], bytes): # pragma: no cover + # psycopg and aiosqlite both report column names as str; this is + # for drivers that hand back bytes, as pydal's `executesql` allows. + result_fields[i] = result_fields[i].decode("utf8") # ty: ignore[unresolved-attribute] + + data = await cur.fetchall() + _dict = collections.OrderedDict if as_ordered_dict else dict + return [_dict(zip(result_fields, row)) for row in data] + + try: + data = await cur.fetchall() + except Exception: + return None if fields or colnames: if fields is None: diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 327a1eb..da4ccdd 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -2127,6 +2127,72 @@ async def abandons_its_transaction() -> None: assert sorted(row.name for row in AsyncThingAbandonedLock.collect()) == ["sync"] +@pytest.mark.asyncio +async def test_sqlite_memory_async_connection_settles_a_finished_owner(db_sqlite_memory: TypeDAL): + """ + `connection()` must settle a finished owner even when no sync statement has triggered + `settle_abandoned_sync()` first. + + This is the async backstop behind `SqliteAsyncConnection._settle_abandoned_owner()`. + """ + db = db_sqlite_memory + pool = await db._get_async_pool() + + finished = asyncio.create_task(asyncio.sleep(0)) + await finished + pool._owner = finished + + async with pool.connection(): + pass + + assert pool._owner is None + + +@pytest.mark.asyncio +async def test_sqlite_memory_sync_side_stays_refused_while_async_lock_is_held(db_sqlite_memory: TypeDAL): + """ + `settle_abandoned_sync()` must not roll back a finished owner while another coroutine is + mid-statement on the single shared connection. + + The sync side cannot await that other coroutine, so the only correct answer is to keep the + abandoned owner counted and let `TransactionSplitError` refuse the sync statement until the + lock holder finishes. This pins the `_lock.locked()` branch in `SqliteAsyncConnection`. + """ + db = db_sqlite_memory + + @db.define() + class AsyncThingLockHeld(TypedTable): + name: TypedField[str] + + db.commit() + + pool = await db._get_async_pool() + finished = asyncio.create_task(asyncio.sleep(0)) + await finished + pool._owner = finished + + entered = asyncio.Event() + leave = asyncio.Event() + + async def hold_the_connection_lock() -> None: + async with pool._lock: + entered.set() + await leave.wait() + + holder = asyncio.create_task(hold_the_connection_lock()) + await asyncio.wait_for(entered.wait(), timeout=5) + + try: + with pytest.raises(TransactionSplitError): + AsyncThingLockHeld.insert(name="blocked") + + assert pool._owner is finished + finally: + leave.set() + await asyncio.wait_for(holder, timeout=5) + pool._owner = None + + @pytest.mark.asyncio async def test_refused_task_is_not_recorded_as_holding_async_writes(db_sqlite_memory: TypeDAL): """ From 95d3b7a8c0d03db018873656fd0b54a51d7b829b Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 15:09:21 +0200 Subject: [PATCH 24/29] docs(async-execution): clarify non-owner transaction settlement --- src/typedal/async_execution.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index 7417db2..9309c4a 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -125,7 +125,8 @@ def before_execute(self, command: str) -> None: raise TransactionSplitError( "The async connection has uncommitted writes, which this synchronous statement " "would not see. Call `await db.commit_async()` or `await db.rollback_async()` " - "first.", + "first. If those writes belong to another task, wait for it to settle them: " + "neither call ends a transaction this task does not own.", ) if command.lstrip().upper().startswith(WRITE_STATEMENTS): @@ -531,6 +532,13 @@ def settle_abandoned_sync(self) -> bool: two conditions the aiosqlite worker thread is idle, so the underlying sqlite3 connection can be rolled back directly without going through aiosqlite's queue. + Idle is only half of what makes that legal. This runs on the event-loop thread against + a connection sqlite3 opened on aiosqlite's worker thread, which sqlite3 refuses by + default - it works because pydal puts `check_same_thread: False` in `driver_args` + (adapters/sqlite.py) and `_connect_sqlite_async()` passes those straight through. + Nothing here sets it, so a pydal that stopped would turn this into the caught + `ProgrammingError` below, and every reclaim would quietly become a False. + Returns False when the rollback cannot be done safely right now, or when it fails. The caller must keep the abandoned owner counted: a rollback that did not happen still has an open transaction behind it, and letting the next task inherit that is precisely the @@ -544,7 +552,9 @@ def settle_abandoned_sync(self) -> bool: return False try: - self._conn._conn.rollback() # type: ignore[attr-defined] + # aiosqlite's own `in_transaction` reaches through `_conn` the same way; this is + # the sqlite3 connection behind the queue, not the aiosqlite wrapper. + self._conn._conn.rollback() # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] except Exception: # pragma: no cover - a hard rollback failure is not reachable through the public API return False From 31d22a552adb840b40c9b42aa3efec2020f1690d Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 15:09:31 +0200 Subject: [PATCH 25/29] test(async-execution): add non-owner commit isolation regression --- tests/test_async_execution.py | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index da4ccdd..d5645f6 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -2038,6 +2038,49 @@ async def outsider() -> None: ) +@pytest.mark.asyncio +async def test_sqlite_memory_non_owner_commit_does_not_make_another_tasks_row_durable(db_sqlite_memory: TypeDAL): + """ + `commit_async()` from a task that owns no transaction must not commit the one another task + is still holding open. + + This is the commit-side counterpart to + `test_sqlite_memory_commit_and_rollback_only_act_for_the_owning_task`. The guard is harder + to observe than the rollback case because a wrongly committed row would still be visible + after the owner's later commit. The owner therefore rolls back instead: a guarded outsider + no-op leaves the rollback able to discard the row, while an unguarded outsider commit makes + that row durable first. + """ + db = db_sqlite_memory + + @db.define() + class AsyncThingForeignCommit(TypedTable): + name: TypedField[str] + + db.commit() + + keeper_wrote = asyncio.Event() + outsider_settled = asyncio.Event() + + async def keeper() -> None: + await AsyncThingForeignCommit.insert_async(name="keep") + keeper_wrote.set() + await asyncio.wait_for(outsider_settled.wait(), timeout=5) + await db.rollback_async() + + async def outsider() -> None: + await asyncio.wait_for(keeper_wrote.wait(), timeout=5) + # nothing of this task's own is open - on every other backend this is a no-op + await db.commit_async() + outsider_settled.set() + + await asyncio.gather(keeper(), outsider()) + + assert list(await AsyncThingForeignCommit.collect_async()) == [], ( + "a task that holds no transaction committed the one another task was still writing to" + ) + + @pytest.mark.asyncio async def test_sqlite_memory_does_not_inherit_an_abandoned_transaction(db_sqlite_memory: TypeDAL): """ From d8ac3da0c12d70721639c78706fc1d00c6ffcc8b Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 15:09:46 +0200 Subject: [PATCH 26/29] test(imports): use src-prefixed typedal imports in remaining tests --- tests/test_json.py | 2 +- tests/test_py4web.py | 2 +- tests/test_query_builder.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_json.py b/tests/test_json.py index 871719c..93dbcc4 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -2,7 +2,7 @@ import json from src.typedal.serializers.as_json import SerializedJson, encode -from typedal.helpers import utcnow +from src.typedal.helpers import utcnow class CustomClass: diff --git a/tests/test_py4web.py b/tests/test_py4web.py index 069ab30..c05e9b8 100644 --- a/tests/test_py4web.py +++ b/tests/test_py4web.py @@ -7,7 +7,7 @@ from src.typedal import TypedTable from src.typedal.for_py4web import DAL, AuthUser, setup_py4web_tables from src.typedal.serializers import as_json -from typedal.config import TypeDALConfig +from src.typedal.config import TypeDALConfig db = DAL("sqlite:memory") diff --git a/tests/test_query_builder.py b/tests/test_query_builder.py index 6c7b92b..3b63622 100644 --- a/tests/test_query_builder.py +++ b/tests/test_query_builder.py @@ -2,8 +2,8 @@ from pydal.objects import Field, Query from src.typedal import TypeDAL, TypedField, TypedTable, relationship -from typedal import QueryBuilder -from typedal.fields import rname +from src.typedal import QueryBuilder +from src.typedal.fields import rname db = TypeDAL("sqlite:memory") From 872883a4154616ea495fa3ce5ea69bd134a9e511 Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 15:38:29 +0200 Subject: [PATCH 27/29] fix(async): release connections after read-only queries --- src/typedal/core.py | 111 ++++++++++++++++++++++------------ tests/test_async_execution.py | 92 ++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 40 deletions(-) diff --git a/src/typedal/core.py b/src/typedal/core.py index e33eb8b..2ab9e6b 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -714,6 +714,27 @@ def _mark_async_pending(self) -> t.Iterator[None]: self._async_pending_owners.discard(owner) raise + async def _release_readonly_connection(self, pool: AsyncConnectionPool) -> None: + """ + Return the connection a read-only `_async` call checked out, unless this task still has + uncommitted writes on it. + + `select_async`/`count_async` (and read-only `executesql_async`) leave nothing behind, so + they must not keep a checked-out Postgres connection until the task ends. The + transaction is ended with `rollback()` because that is the pool API for "I am done and + do not want to commit", which releases the connection on the per-task backends and + clears the shared owner on `sqlite:memory`. + + Cleanup is deliberately best-effort. It runs in a `finally`, where a rollback failure + must not replace whatever the statement itself raised; if rollback does fail, the task's + done-callback is still armed and reclaims the connection. + """ + if self._async_pending_owner() in self._async_pending_owners: + return + + with contextlib.suppress(Exception): + await pool.rollback() + def _settle_abandoned_async_writes(self) -> bool: """ Reclaim an abandoned `sqlite:memory` async transaction, if there is one. @@ -786,9 +807,12 @@ async def select_async( colnames, sql = adapter._select_wcols(query, expanded_fields, **attributes) pool = await self._get_async_pool() - async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(sql) - rows = await cur.fetchall() + try: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql) + rows = await cur.fetchall() + finally: + await self._release_readonly_connection(pool) limitby = attributes.get("limitby") or (0,) rows = adapter.rowslice(rows, limitby[0], None) @@ -811,9 +835,12 @@ async def count_async( sql = adapter._count(query, distinct) pool = await self._get_async_pool() - async with pool.connection() as conn, conn.cursor() as cur: - await cur.execute(sql) - row = await cur.fetchone() + try: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql) + row = await cur.fetchone() + finally: + await self._release_readonly_connection(pool) return t.cast(int, row[0]) @@ -956,40 +983,44 @@ async def executesql_async( marker = self._mark_async_pending() if opens_a_transaction else contextlib.nullcontext() with marker: - async with pool.connection() as conn, conn.cursor() as cur: - if placeholders: - await cur.execute(query, placeholders) - else: - await cur.execute(query) - - if as_dict or as_ordered_dict: - if not hasattr(cur, "description"): # pragma: no cover - # both supported drivers always expose it; guard kept for parity with - # pydal's own `executesql`. - raise RuntimeError("database does not support executesql_async(...,as_dict=True)") - - columns = cur.description - result_fields = list(colnames) if colnames else [col[0] for col in columns] - if len(result_fields) != len(set(result_fields)): - raise RuntimeError( - "Result set includes duplicate column names. " - "Specify unique column names using the 'colnames' argument", - ) - if columns: - for i in range(len(result_fields)): - if isinstance(result_fields[i], bytes): # pragma: no cover - # psycopg and aiosqlite both report column names as str; this is - # for drivers that hand back bytes, as pydal's `executesql` allows. - result_fields[i] = result_fields[i].decode("utf8") # ty: ignore[unresolved-attribute] - - data = await cur.fetchall() - _dict = collections.OrderedDict if as_ordered_dict else dict - return [_dict(zip(result_fields, row)) for row in data] - - try: - data = await cur.fetchall() - except Exception: - return None + try: + async with pool.connection() as conn, conn.cursor() as cur: + if placeholders: + await cur.execute(query, placeholders) + else: + await cur.execute(query) + + if as_dict or as_ordered_dict: + if not hasattr(cur, "description"): # pragma: no cover + # both supported drivers always expose it; guard kept for parity with + # pydal's own `executesql`. + raise RuntimeError("database does not support executesql_async(...,as_dict=True)") + + columns = cur.description + result_fields = list(colnames) if colnames else [col[0] for col in columns] + if len(result_fields) != len(set(result_fields)): + raise RuntimeError( + "Result set includes duplicate column names. " + "Specify unique column names using the 'colnames' argument", + ) + if columns: + for i in range(len(result_fields)): + if isinstance(result_fields[i], bytes): # pragma: no cover + # psycopg and aiosqlite both report column names as str; this is + # for drivers that hand back bytes, as pydal's `executesql` allows. + result_fields[i] = result_fields[i].decode("utf8") # ty: ignore[unresolved-attribute] + + data = await cur.fetchall() + _dict = collections.OrderedDict if as_ordered_dict else dict + return [_dict(zip(result_fields, row)) for row in data] + + try: + data = await cur.fetchall() + except Exception: + return None + finally: + if not opens_a_transaction: + await self._release_readonly_connection(pool) if fields or colnames: if fields is None: diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index d5645f6..236ff1a 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -1884,6 +1884,57 @@ async def close(self) -> None: self.closed = True +class _StubAsyncCursor: + """A cursor that always answers an empty read result.""" + + async def execute(self, _sql: str, _parameters: t.Any = None) -> None: + return None + + async def fetchone(self) -> tuple[int]: + return (0,) + + async def fetchall(self) -> list[t.Any]: + return [] + + +class _StubAsyncConnection: + """Enough of a psycopg AsyncConnection for `PostgresAsyncPool` to run a read.""" + + def __init__(self) -> None: + self.closed = False + self.rolled_back = False + + @contextlib.asynccontextmanager + async def cursor(self) -> t.AsyncIterator[_StubAsyncCursor]: + yield _StubAsyncCursor() + + async def rollback(self) -> None: + self.rolled_back = True + + async def close(self) -> None: + self.closed = True + + +class _ReadOnlyPool: + """A pool that reports how many of its connections are currently checked out.""" + + def __init__(self, conn: _StubAsyncConnection) -> None: + self.conn = conn + self.checked_out = 0 + self.returned = 0 + + async def getconn(self) -> _StubAsyncConnection: + self.checked_out += 1 + return self.conn + + async def putconn(self, _conn: _StubAsyncConnection) -> None: + self.checked_out -= 1 + self.returned += 1 + + async def close(self) -> None: + return None + + @pytest.mark.asyncio async def test_postgres_pool_closes_a_connection_it_cannot_return(): """ @@ -1915,6 +1966,47 @@ async def abandons_its_transaction() -> None: assert conn not in pool._checked_out, "a disposed-of connection must not stay tracked" +@pytest.mark.asyncio +async def test_postgres_read_only_async_returns_its_connection(): + """ + `count_async()` and `collect_async()` must not retain a Postgres connection after they + return. + + Both are read-only and leave no uncommitted writes behind, but they run through + `PostgresAsyncPool.connection()`, which does not hand the connection back on context exit. + A long-lived task that only counts/collects and then awaits unrelated work therefore keeps + a checked-out connection until the task itself ends. With `POSTGRES_POOL_MAX_SIZE` capped + at 10, eleven such tasks exhaust the pool even though none of them is in a transaction. + + Driven through a stub pool so the test can observe the checkout directly; the public + `count_async()` path is what needs to trigger the release. + """ + conn = _StubAsyncConnection() + raw_pool = _ReadOnlyPool(conn) + pool = PostgresAsyncPool(raw_pool) + + async def fake_pool_factory(_db: TypeDAL) -> PostgresAsyncPool: + return pool + + with tempfile.TemporaryDirectory() as directory: + db = TypeDAL("sqlite:memory", enable_typedal_caching=False, folder=directory) + + @db.define() + class AsyncThingReadRelease(TypedTable): + name: TypedField[str] + + db.commit() + + db._async_pools = AsyncPoolManager(db, factories={"sqlite": fake_pool_factory}) + try: + assert await AsyncThingReadRelease.count_async() == 0 + assert raw_pool.checked_out == 0, "count_async left its connection checked out" + assert raw_pool.returned == 1, "the read-only connection was never returned to the pool" + finally: + await db.close_async() + db.close() + + @pytest.mark.asyncio async def test_settling_up_twice_is_a_no_op(db_async: TypeDAL): """ From e8bc0fe5b61a4721e05f62cc4f88b196820155ad Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 17:29:16 +0200 Subject: [PATCH 28/29] fix(async): guard transaction ownership during cleanup + reduce yapping in docs a bit --- src/typedal/async_execution.py | 157 +++++---------- src/typedal/core.py | 63 ++---- tests/test_async_execution.py | 339 +++++++++++++++------------------ 3 files changed, 217 insertions(+), 342 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index 9309c4a..ef16c3c 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -214,36 +214,10 @@ def settle_abandoned_sync(self) -> bool: ... class PostgresAsyncPool: """ - Wraps `psycopg_pool.AsyncConnectionPool` and binds one checked-out connection per asyncio - task, so a task's transaction spans its `_async` calls and belongs to it alone. - - Two things this deliberately does not do, both of which it used to: - - - it does not let `pool.connection()` run the checkout. That context manager applies - "the normal connection context behaviour" (psycopg_pool's own docs) - commit on - success, rollback on error - which made every `_async` call its own committed - transaction and left `commit()`/`rollback()` with nothing to act on. `getconn()` / - `putconn()` hand back the same connection without deciding its transaction, so pydal's - contract holds: writes stay open until the caller says otherwise. - - it does not keep that connection on the pool object. A `ContextVar` set inside a task - is invisible to its siblings and to its parent, which is exactly the per-task boundary - an event loop needs and `threading.local()` cannot give it - one event-loop thread - serves every concurrent request. - - The `ContextVar` is per instance rather than module-level so two `TypeDAL`s in one process - do not hand each other connections. That is unusual - the docs warn against creating them - dynamically because they are never garbage collected - but there is one per pool, created - once when the pool opens, not one per call. - - A task that neither commits nor rolls back would strand its connection, so checkout also - arms a done-callback on the task to roll back and return it. That is a safety net for - abandoned work, not the intended path; callers are still expected to end their transaction. - - `_checked_out` is what makes that net safe. The callback cannot read the `ContextVar` to - find out what to return - `add_done_callback` runs in the *loop's* context, not the - finished task's, so it would see None or, worse, another task's connection. It is handed - its connection directly instead, and this set is how it tells "still outstanding" from - "already returned by commit()". + Wrap `psycopg_pool.AsyncConnectionPool` with one connection per asyncio task. + + A task keeps its connection until `commit()` or `rollback()` so its `_async` calls share a + transaction. Abandoned task connections are rolled back and returned by a done-callback. """ def __init__(self, pool: t.Any) -> None: @@ -391,36 +365,11 @@ async def close(self) -> None: class SqliteAsyncConnection: """ - Minimal pool-like wrapper around a single aiosqlite connection. - - SQLite has no real concept of a connection pool the way Postgres does - pydal itself sets - `pool_size = 0` for SQLite (adapters/sqlite.py), one connection is all there is. This - gives it the same `.connection()`/`.commit()`/`.rollback()`/`.close()` shape as - `PostgresAsyncPool` so `select_async()` etc. don't need to branch on backend. - - `connection()` neither commits nor rolls back on exit. It used to commit, which made every - `_async` call its own committed transaction and put it outside anything the caller could - undo - a write issued by a request that later raised could not be rolled back, and pydal's - contract is that `commit()`/`rollback()` decide. So a write now stays open until the caller - ends it, and the table stays locked against other readers and writers until then, pydal's - own sync connection included. - - Unlike `PostgresAsyncPool` this cannot give each task its own transaction, and that is a - property of the database rather than a gap here. `sqlite:memory` reaches a second - connection only through shared-cache mode, and shared-cache answers a concurrent writer - with SQLITE_LOCKED, which no busy-timeout retries. Measured on pydal's own synchronous - connections, two threads writing to one `sqlite:memory` produce exactly that error, so this - is not a limit the async path introduces - pydal is subject to it one thread-boundary over. - - Rather than let coroutines silently merge into one transaction - where one task's - `rollback()` destroys another's uncommitted rows - a second task is refused while the first - holds an open transaction, raising `ConcurrentTransactionError`. That matches what pydal - already does for the same situation, only deliberately and with a message that names the - cause. File-backed SQLite has no such limit and does not come here at all; it gets - `SqliteAsyncPool` and a real connection per task. - - `_lock` keeps statements from interleaving on the single connection; it is not a - transaction boundary and is not a substitute for one. `_owner` is the boundary. + Pool-like wrapper around the one connection used for `sqlite:memory`. + + Transactions remain open until `commit()` or `rollback()`. Because tasks cannot receive + separate transactions, a second task is refused while another owns one. `_lock` serializes + statements; `_owner` defines the transaction boundary. """ def __init__(self, conn: AsyncConnection) -> None: @@ -440,17 +389,24 @@ def _is_owned_elsewhere(self) -> bool: """ Whether the open transaction belongs to a task other than the calling one. - No `done()` term, deliberately. A finished owner is settled by - `_settle_abandoned_owner()` before anyone gets here, so by this point `_owner` is either - absent, the caller's, or a live other task's. Treating a *finished* owner as absent - instead - which this used to do - is how the next task ended up inheriting an - abandoned transaction and deciding its writes. + No `done()` term, deliberately. On the `connection()` path the caller settles a + finished owner first; on the direct `commit()`/`rollback()` path a finished owner is + still somebody else's transaction and must therefore no-op. Treating a finished owner + as absent instead - which this used to do - is how a non-owner ended up committing or + rolling back another task's writes. `asyncio.current_task()` answers None off-task, and None is never stored as an owner, so an off-task caller correctly reads any owner as somebody else's. """ return self._owner is not None and self._owner is not asyncio.current_task() + def _abandoned_owner(self) -> "asyncio.Task[t.Any] | None": + """The finished task whose open transaction needs reclaiming, or None.""" + owner = self._owner + if owner is None or owner is asyncio.current_task() or not owner.done(): + return None + return owner + async def _settle_abandoned_owner(self) -> None: """ Roll back a transaction whose owning task ended without committing it. @@ -466,8 +422,7 @@ async def _settle_abandoned_owner(self) -> None: If the rollback fails, the owner is left set so the next task is refused rather than allowed to inherit a transaction that could not be reclaimed. """ - owner = self._owner - if owner is None or owner is asyncio.current_task() or not owner.done(): + if self._abandoned_owner() is None: return try: @@ -519,33 +474,12 @@ def _take_ownership_if_in_transaction(self) -> None: def settle_abandoned_sync(self) -> bool: """ - Synchronously roll back a transaction whose owning task ended without committing it. - - The sync counterpart to `_settle_abandoned_owner()`, for callers that cannot await. - `TypeDAL._settle_abandoned_async_writes()` runs inside pydal's synchronous execution - handler, so it needs this path: scheduling an async rollback and hoping it has run - before the sync statement executes is exactly the race that lets `database is locked` - out of the driver instead of being reclaimed here. - - A finished owner means no statement from that task is still queued, and `_lock` being - free means no other coroutine is mid-statement on this single connection. Under those - two conditions the aiosqlite worker thread is idle, so the underlying sqlite3 - connection can be rolled back directly without going through aiosqlite's queue. - - Idle is only half of what makes that legal. This runs on the event-loop thread against - a connection sqlite3 opened on aiosqlite's worker thread, which sqlite3 refuses by - default - it works because pydal puts `check_same_thread: False` in `driver_args` - (adapters/sqlite.py) and `_connect_sqlite_async()` passes those straight through. - Nothing here sets it, so a pydal that stopped would turn this into the caught - `ProgrammingError` below, and every reclaim would quietly become a False. - - Returns False when the rollback cannot be done safely right now, or when it fails. The - caller must keep the abandoned owner counted: a rollback that did not happen still has - an open transaction behind it, and letting the next task inherit that is precisely the - failure this class exists to prevent. + Roll back a finished owner's transaction for synchronous callers. + + Return `False` when the connection is busy or rollback fails, so callers continue to + treat the async transaction as pending. """ - owner = self._owner - if owner is None or owner is asyncio.current_task() or not owner.done(): + if self._abandoned_owner() is None: return True if self._lock.locked(): @@ -593,32 +527,33 @@ async def connection(self) -> t.AsyncIterator[AsyncConnection]: # and leaving it unowned would let another task walk into it. self._take_ownership_if_in_transaction() - async def commit(self) -> None: - # under the lock so a commit cannot land halfway through another coroutine's - # `connection()` block and write out a statement it has not finished issuing. - async with self._lock: - if self._is_owned_elsewhere(): - # Not this task's transaction to end, so this does nothing - matching what - # `PostgresAsyncPool` and `SqliteAsyncPool` do for a task that holds no - # connection. Unlike them, the connection here is shared, so acting anyway - # would commit or roll back writes the owner has not finished issuing: a - # handler that settles up unconditionally on its way out would decide another - # request's transaction. Silent rather than an exception because both of these - # are what a caller reaches for while cleaning up, often in a `finally`, where - # raising would mask whatever sent it there. - return + async def _end_transaction(self, end: t.Callable[[], t.Awaitable[None]]) -> None: + """ + End this task's transaction, or no-op when the transaction belongs elsewhere. - await self._conn.commit() - self._owner = None + Runs under the lock so a commit cannot land halfway through another coroutine's + `connection()` block and write out a statement it has not finished issuing. - async def rollback(self) -> None: + If another task owns the transaction, this does nothing. `PostgresAsyncPool` and + `SqliteAsyncPool` already no-op for a task that holds no connection; unlike them, the + connection here is shared, so acting anyway would commit or roll back writes the owner + has not finished issuing. The guard stays silent rather than raising because both + `commit_async()` and `rollback_async()` are what a caller reaches for while cleaning + up, often in a `finally`, where raising would mask whatever sent it there. + """ async with self._lock: if self._is_owned_elsewhere(): return - await self._conn.rollback() + await end() self._owner = None + async def commit(self) -> None: + await self._end_transaction(self._conn.commit) + + async def rollback(self) -> None: + await self._end_transaction(self._conn.rollback) + async def close(self) -> None: await self._conn.close() diff --git a/src/typedal/core.py b/src/typedal/core.py index 2ab9e6b..5cfd8ef 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -680,26 +680,12 @@ def _async_pending_owner(self) -> t.Any: @contextlib.contextmanager def _mark_async_pending(self) -> t.Iterator[None]: """ - Note, for the duration of a write, that *this task's* async connection holds a - transaction the sync side must not step on. - - Used by the `_async` methods that write rather than by `_get_async_pool()`, because a - read leaves nothing behind for the other connection to miss. - - Entered *before* the connection is acquired, not after. Acquiring awaits - on Postgres - `getconn()` can take a full round trip - and a plain synchronous write issued by - another coroutine during that await would slip past the check `_get_async_pool()` just - made, opening exactly the split both guards exist to prevent. So the mark has to be - standing before the first await. - - The cost of being early is `ConcurrentTransactionError`, which `sqlite:memory` raises - from `connection()` *before* it yields: that caller opened no transaction at all, and - leaving it recorded would refuse every sync statement on this instance - the guard is - "does any live task hold async writes", not "does mine" - until its task happened to - end. So that one exception, and only that one, un-marks. Any other failure may well - have left a transaction open: sqlite3 implicitly BEGINs before DML and a statement that - raised can still have opened one, which is why `SqliteAsyncConnection` claims ownership - in a `finally` too. + Mark this task as holding an async write for the duration of the write. + + Entered before connection acquisition so a synchronous write from another coroutine + cannot slip in during the await. Only `ConcurrentTransactionError` un-marks because + that caller was refused before opening a transaction; any other failure may have left + one open. """ owner = self._async_pending_owner() # an earlier `_async` write in this same task already marked it, and its transaction is @@ -716,18 +702,10 @@ def _mark_async_pending(self) -> t.Iterator[None]: async def _release_readonly_connection(self, pool: AsyncConnectionPool) -> None: """ - Return the connection a read-only `_async` call checked out, unless this task still has - uncommitted writes on it. - - `select_async`/`count_async` (and read-only `executesql_async`) leave nothing behind, so - they must not keep a checked-out Postgres connection until the task ends. The - transaction is ended with `rollback()` because that is the pool API for "I am done and - do not want to commit", which releases the connection on the per-task backends and - clears the shared owner on `sqlite:memory`. - - Cleanup is deliberately best-effort. It runs in a `finally`, where a rollback failure - must not replace whatever the statement itself raised; if rollback does fail, the task's - done-callback is still armed and reclaims the connection. + Release a connection used only for reading, unless this task has pending writes. + + Rollback is the pool-level release operation. Failures are suppressed because this is + `finally` cleanup and the task callback can still reclaim a Postgres connection. """ if self._async_pending_owner() in self._async_pending_owners: return @@ -752,23 +730,10 @@ def _settle_abandoned_async_writes(self) -> bool: def _has_pending_async_writes(self) -> bool: """ - Whether any live task holds uncommitted writes on an async connection. - - Any task, not just the caller's: the sync connection is shared by every coroutine on - this thread, so a statement issued from one task is invisible to *another* task's open - async transaction just as much as to its own. Both are the split this refuses. - - This is the settle-then-check decision point, kept here rather than in - `SyncTransactionTracker`: `sqlite:memory` must first synchronously reclaim a finished - task's abandoned transaction, and callers must not be able to ask the predicate without - that reclaim having run. If reclaim cannot complete, the async side is still treated as - pending and the caller is refused. - - Finished tasks are dropped rather than counted. A task that ended without committing - had its connection reclaimed and rolled back (`PostgresAsyncPool._reclaim`), so its - writes are never going to become visible to anyone - there is nothing left for the - sync side to miss. Pruning here rather than in a callback also keeps the set from - growing for the life of the process. + Whether any task holds uncommitted async writes. + + Reclaim a finished `sqlite:memory` owner before checking. If reclaim fails, retain the + pending state; otherwise discard finished tasks whose abandoned writes were rolled back. """ if not self._settle_abandoned_async_writes(): return True diff --git a/tests/test_async_execution.py b/tests/test_async_execution.py index 236ff1a..d368e24 100644 --- a/tests/test_async_execution.py +++ b/tests/test_async_execution.py @@ -120,6 +120,24 @@ async def db_sqlite_memory() -> t.AsyncIterator[TypeDAL]: yield db +async def _abandon_async_transaction(table: t.Any) -> None: + """Leave a transaction open by inserting a row in a task that ends without settling.""" + async def insert_and_abandon() -> None: + await table.insert_async(name="abandoned") + + await asyncio.create_task(insert_and_abandon()) + await asyncio.sleep(0) + await asyncio.sleep(0) + + +async def _set_finished_owner(pool: t.Any) -> t.Any: + """Make `pool` look like a task finished while still owning its transaction.""" + finished = asyncio.create_task(asyncio.sleep(0)) + await finished + pool._owner = finished + return finished + + @pytest.mark.asyncio async def test_collect_async_matches_sync_collect(db_async: TypeDAL): """The core parity claim: async-executed rows must equal sync-executed rows, field for field.""" @@ -1897,16 +1915,24 @@ async def fetchall(self) -> list[t.Any]: return [] +class _FailingStubAsyncCursor(_StubAsyncCursor): + """A read cursor that raises before a row can be fetched.""" + + async def execute(self, _sql: str, _parameters: t.Any = None) -> None: + raise RuntimeError("cursor failed") + + class _StubAsyncConnection: """Enough of a psycopg AsyncConnection for `PostgresAsyncPool` to run a read.""" - def __init__(self) -> None: + def __init__(self, cursor: _StubAsyncCursor | None = None) -> None: self.closed = False self.rolled_back = False + self._cursor = cursor or _StubAsyncCursor() @contextlib.asynccontextmanager async def cursor(self) -> t.AsyncIterator[_StubAsyncCursor]: - yield _StubAsyncCursor() + yield self._cursor async def rollback(self) -> None: self.rolled_back = True @@ -1940,15 +1966,7 @@ async def test_postgres_pool_closes_a_connection_it_cannot_return(): """ A connection the pool refuses to take back must be closed, not forgotten. - This is the branch that made the suite die with `FATAL: sorry, too many clients already`. - A task that ends without committing has its connection returned by a done-callback, which - runs after the fixture teardown has already closed the pool - so `putconn` fails. The - original code re-added the connection to `_checked_out` on that failure, but nothing drains - that set once `close()` has run, so the socket stayed open for the life of the process. - - Driven through stubs rather than a real pool: the failure needs `putconn` to raise at a - moment that is a race with a real one, and the point being asserted is what this class does - with the failure, not that psycopg produces it. + Stubs make the pool-return failure deterministic. """ conn = _StubConnection() pool = PostgresAsyncPool(_StubPool(conn)) @@ -1969,17 +1987,9 @@ async def abandons_its_transaction() -> None: @pytest.mark.asyncio async def test_postgres_read_only_async_returns_its_connection(): """ - `count_async()` and `collect_async()` must not retain a Postgres connection after they - return. - - Both are read-only and leave no uncommitted writes behind, but they run through - `PostgresAsyncPool.connection()`, which does not hand the connection back on context exit. - A long-lived task that only counts/collects and then awaits unrelated work therefore keeps - a checked-out connection until the task itself ends. With `POSTGRES_POOL_MAX_SIZE` capped - at 10, eleven such tasks exhaust the pool even though none of them is in a transaction. + Read-only async statements must not retain a Postgres connection after they return. - Driven through a stub pool so the test can observe the checkout directly; the public - `count_async()` path is what needs to trigger the release. + The stub pool exposes each public read path's checkout and release. """ conn = _StubAsyncConnection() raw_pool = _ReadOnlyPool(conn) @@ -2001,7 +2011,56 @@ class AsyncThingReadRelease(TypedTable): try: assert await AsyncThingReadRelease.count_async() == 0 assert raw_pool.checked_out == 0, "count_async left its connection checked out" - assert raw_pool.returned == 1, "the read-only connection was never returned to the pool" + assert raw_pool.returned == 1, "count_async's read-only connection was never returned" + + collected = await AsyncThingReadRelease.where(AsyncThingReadRelease.id > 0).collect_async() + assert list(collected) == [] + assert raw_pool.checked_out == 0, "collect_async left its connection checked out" + assert raw_pool.returned == 2, "collect_async's read-only connection was never returned" + + selected = await db.select_async(AsyncThingReadRelease.id > 0, AsyncThingReadRelease.id) + assert list(selected) == [] + assert raw_pool.checked_out == 0, "select_async left its connection checked out" + assert raw_pool.returned == 3, "select_async's read-only connection was never returned" + + table_name = AsyncThingReadRelease._table._rname + assert await db.executesql_async(f"SELECT id FROM {table_name}") == [] + assert raw_pool.checked_out == 0, "executesql_async left its connection checked out" + assert raw_pool.returned == 4, "executesql_async's read-only connection was never returned" + finally: + await db.close_async() + db.close() + + +@pytest.mark.asyncio +async def test_postgres_read_only_async_returns_its_connection_when_cursor_fails(): + """ + A read that raises must still return its Postgres connection via the read-only `finally`. + """ + conn = _StubAsyncConnection(_FailingStubAsyncCursor()) + raw_pool = _ReadOnlyPool(conn) + pool = PostgresAsyncPool(raw_pool) + + async def fake_pool_factory(_db: TypeDAL) -> PostgresAsyncPool: + return pool + + with tempfile.TemporaryDirectory() as directory: + db = TypeDAL("sqlite:memory", enable_typedal_caching=False, folder=directory) + + @db.define() + class AsyncThingReadReleaseFailure(TypedTable): + name: TypedField[str] + + db.commit() + + db._async_pools = AsyncPoolManager(db, factories={"sqlite": fake_pool_factory}) + try: + with pytest.raises(RuntimeError, match="cursor failed"): + await AsyncThingReadReleaseFailure.count_async() + + assert raw_pool.checked_out == 0, "a failed read left its connection checked out" + assert raw_pool.returned == 1, "a failed read's connection was never returned" + assert conn.rolled_back, "a failed read's transaction was not rolled back" finally: await db.close_async() db.close() @@ -2011,11 +2070,6 @@ class AsyncThingReadRelease(TypedTable): async def test_settling_up_twice_is_a_no_op(db_async: TypeDAL): """ `commit_async()`/`rollback_async()` must be safe when this task holds no connection. - - Two ways to get there, both ordinary: calling either twice, or calling one having done no - async work at all - a request handler that commits unconditionally on the way out, say. - The pools return the connection on the first call, so the second finds nothing; without the - guard it would commit on a connection already handed back to the pool. """ db = db_async @@ -2040,13 +2094,7 @@ class AsyncThingSettleTwice(TypedTable): @pytest.mark.asyncio async def test_sqlite_pool_reclaim_yields_to_whoever_claimed_first(): """ - `_reclaim` schedules its work, so the connection can be gone by the time that work runs. - - The done-callback checks `_open` when it fires, but the coroutine it starts runs later - - after `close()` or `_release()` may have taken the same connection. Both claim by removing - from `_open` before their first await, so the loser has to notice and do nothing; closing a - connection twice is harmless, but rolling back one that has been handed to another task is - not. + A scheduled reclaim must do nothing when another path already claimed the connection. """ with tempfile.TemporaryDirectory() as directory: db = TypeDAL(f"sqlite://{Path(directory) / 'reclaim.db'}", enable_typedal_caching=False, folder=directory) @@ -2071,82 +2119,72 @@ async def test_sqlite_pool_reclaim_yields_to_whoever_claimed_first(): @pytest.mark.asyncio -async def test_sqlite_memory_commit_and_rollback_only_act_for_the_owning_task(db_sqlite_memory: TypeDAL): - """ - On `sqlite:memory`, `rollback_async()` from a task that owns no transaction must not - destroy the one another task is holding open. - - `SqliteAsyncConnection` refuses a second task at `connection()` - (`_refuse_if_owned_elsewhere`) precisely so one task's rollback cannot decide another's - rows - that is what its class docstring gives as the reason the refusal exists. But - `commit()`/`rollback()` never go through `connection()`: `commit_async()`/`rollback_async()` - (core.py) reach the pool directly, on purpose, so that settling up cannot be the thing that - opens a connection. On the two per-task backends that is harmless - `PostgresAsyncPool` and - `SqliteAsyncPool` both no-op when the calling task holds no connection - but - `SqliteAsyncConnection` acts on the single shared connection unconditionally. - - So the refusal only covers the path that writes, not the path that decides. A request - handler that rolls back unconditionally on its way out, on a task that did no async work at - all, ends someone else's transaction. - - `sqlite:memory` only: it is the one backend where two tasks share a connection, so it is - the only one where a non-owner *has* anything to end. - - The two coroutines, pinned with events rather than sleeps: - - `keeper` inserts `keep` and waits to commit until the outsider has had its turn - - `outsider` does no async work of its own and calls `rollback_async()` - - Ownership-guarded, `keep` survives: the outsider's rollback had nothing of its own to end. - Unguarded, it rolls back the keeper's insert, and the keeper's later commit commits an - empty transaction. - """ - db = db_sqlite_memory - - @db.define() - class AsyncThingForeignRollback(TypedTable): - name: TypedField[str] - - db.commit() - - keeper_wrote = asyncio.Event() - outsider_settled = asyncio.Event() - - async def keeper() -> None: - await AsyncThingForeignRollback.insert_async(name="keep") - keeper_wrote.set() - await asyncio.wait_for(outsider_settled.wait(), timeout=5) - await db.commit_async() - - async def outsider() -> None: - await asyncio.wait_for(keeper_wrote.wait(), timeout=5) - # nothing of this task's own is open - on every other backend this is a no-op - await db.rollback_async() - outsider_settled.set() +async def test_sqlite_pool_close_closes_open_connections(): + """`close()` must roll back and close connections still checked out.""" + with tempfile.TemporaryDirectory() as directory: + db = TypeDAL(f"sqlite://{Path(directory) / 'close.db'}", enable_typedal_caching=False, folder=directory) + try: + pool = await db._get_async_pool() + await pool._acquire() + await pool.close() + assert not pool._open + finally: + await db.close_async() + db.close() - await asyncio.gather(keeper(), outsider()) - assert [row.name for row in await AsyncThingForeignRollback.collect_async()] == ["keep"], ( - "a task that holds no transaction rolled back the one another task was still writing to" - ) +@pytest.mark.asyncio +async def test_sqlite_pool_reclaim_closes_an_abandoned_connection(): + """`_reclaim`'s scheduled coroutine must roll back and close an abandoned connection.""" + with tempfile.TemporaryDirectory() as directory: + db = TypeDAL(f"sqlite://{Path(directory) / 'reclaim.db'}", enable_typedal_caching=False, folder=directory) + try: + pool = await db._get_async_pool() + conn = await pool._acquire() + pool._reclaim(conn) + await asyncio.sleep(0.1) + assert not pool._open + finally: + await db.close_async() + db.close() +@pytest.mark.parametrize( + ("foreign_settle", "keeper_settle", "expected_rows", "message"), + [ + ( + "rollback_async", + "commit_async", + ["keep"], + "a task that holds no transaction rolled back the one another task was still writing to", + ), + ( + "commit_async", + "rollback_async", + [], + "a task that holds no transaction committed the one another task was still writing to", + ), + ], +) @pytest.mark.asyncio -async def test_sqlite_memory_non_owner_commit_does_not_make_another_tasks_row_durable(db_sqlite_memory: TypeDAL): +async def test_sqlite_memory_non_owner_settlement_is_a_no_op( + db_sqlite_memory: TypeDAL, + foreign_settle: str, + keeper_settle: str, + expected_rows: list[str], + message: str, +): """ - `commit_async()` from a task that owns no transaction must not commit the one another task - is still holding open. + A task that owns no transaction must not commit or roll back the one another task holds. - This is the commit-side counterpart to - `test_sqlite_memory_commit_and_rollback_only_act_for_the_owning_task`. The guard is harder - to observe than the rollback case because a wrongly committed row would still be visible - after the owner's later commit. The owner therefore rolls back instead: a guarded outsider - no-op leaves the rollback able to discard the row, while an unguarded outsider commit makes - that row durable first. + Before the ownership guard, `SqliteAsyncConnection.commit()`/`rollback()` acted on the + single shared connection unconditionally; now `_end_transaction()` no-ops for a non-owner. + The keeper writes and then settles its own way after the outsider has had its turn. """ db = db_sqlite_memory @db.define() - class AsyncThingForeignCommit(TypedTable): + class AsyncThingForeignSettle(TypedTable): name: TypedField[str] db.commit() @@ -2155,22 +2193,19 @@ class AsyncThingForeignCommit(TypedTable): outsider_settled = asyncio.Event() async def keeper() -> None: - await AsyncThingForeignCommit.insert_async(name="keep") + await AsyncThingForeignSettle.insert_async(name="keep") keeper_wrote.set() await asyncio.wait_for(outsider_settled.wait(), timeout=5) - await db.rollback_async() + await getattr(db, keeper_settle)() async def outsider() -> None: await asyncio.wait_for(keeper_wrote.wait(), timeout=5) - # nothing of this task's own is open - on every other backend this is a no-op - await db.commit_async() + await getattr(db, foreign_settle)() outsider_settled.set() await asyncio.gather(keeper(), outsider()) - assert list(await AsyncThingForeignCommit.collect_async()) == [], ( - "a task that holds no transaction committed the one another task was still writing to" - ) + assert [row.name for row in await AsyncThingForeignSettle.collect_async()] == expected_rows, message @pytest.mark.asyncio @@ -2179,21 +2214,10 @@ async def test_sqlite_memory_does_not_inherit_an_abandoned_transaction(db_sqlite A `sqlite:memory` transaction whose task ended without settling it must not be handed to the next task. - Both per-task backends arm a done-callback at checkout to roll back and dispose of a - connection its task abandoned (`PostgresAsyncPool._reclaim`, `SqliteAsyncPool._reclaim`). - `SqliteAsyncConnection` has no such path, so `_owner` keeps pointing at the finished task - with its transaction still open. `_refuse_if_owned_elsewhere()` then lets the next task - straight in - its `not self._owner.done()` term is false for a finished owner - and that - task lands inside the abandoned transaction. Its `commit_async()` is now deciding the - previous task's writes. - - Asserted as the outcome rather than by poking at `_owner`, because the outcome is what a - caller can be surprised by: `abandoned` was never committed by anyone, and committing - `mine` must not make it durable. - - The sleeps are `sleep(0)` yields, not waits: a done-callback cannot await, so any reclaim - it schedules runs as a task on the next pass of the loop, and the assertion has to be made - after that has had its turn. + `SqliteAsyncConnection` reclaims through `_settle_abandoned_owner()` on the next + `connection()` and through the `_reclaim` done-callback armed by + `_take_ownership_if_in_transaction()`; `settle_abandoned_sync()` is the sync-side path. + Asserted as the outcome rather than by poking at `_owner`. """ db = db_sqlite_memory @@ -2203,14 +2227,7 @@ class AsyncThingAbandoned(TypedTable): db.commit() - async def abandons_its_transaction() -> None: - # ends without commit_async()/rollback_async() - the case the two pools' done-callbacks - # exist for - await AsyncThingAbandoned.insert_async(name="abandoned") - - await asyncio.create_task(abandons_its_transaction()) - await asyncio.sleep(0) - await asyncio.sleep(0) + await _abandon_async_transaction(AsyncThingAbandoned) await AsyncThingAbandoned.insert_async(name="mine") await db.commit_async() @@ -2224,20 +2241,11 @@ async def abandons_its_transaction() -> None: @pytest.mark.asyncio async def test_sqlite_memory_abandoned_transaction_does_not_lock_out_the_sync_side(db_sqlite_memory: TypeDAL): """ - The sync connection must not be waved through while an abandoned async transaction is still - holding the table. - - `TypeDAL._has_pending_async_writes()` (core.py) prunes owners whose task has finished, and - says why in its own comment: a task that ended without committing "had its connection - reclaimed and rolled back (`PostgresAsyncPool._reclaim`)", so there is nothing left for the - sync side to miss. That reasoning holds for both per-task backends and not for - `SqliteAsyncConnection`, which has no reclaim path - the transaction is still open, and on - `sqlite:memory` shared-cache mode it is still holding the table against pydal's own - connection. + The sync connection must not be refused after an abandoned async transaction is reclaimed. - The guard therefore fails open exactly where it was supposed to raise, and what the caller - gets instead is the driver's `database table is locked` after the busy timeout - which is - the outcome `TransactionSplitError` was introduced to replace. + `SqliteAsyncConnection` reclaims abandoned owners through `settle_abandoned_sync()` + (invoked by `TypeDAL._has_pending_async_writes()`) and through + `_settle_abandoned_owner()` on the next `connection()`, so the sync side is free to run. """ db = db_sqlite_memory @@ -2247,15 +2255,8 @@ class AsyncThingAbandonedLock(TypedTable): db.commit() - async def abandons_its_transaction() -> None: - await AsyncThingAbandonedLock.insert_async(name="abandoned") - - await asyncio.create_task(abandons_its_transaction()) - await asyncio.sleep(0) - await asyncio.sleep(0) + await _abandon_async_transaction(AsyncThingAbandonedLock) - # nobody is going to make those writes visible, so the sync side has nothing to miss and - # must be free to run - which requires the abandoned transaction to have been reclaimed AsyncThingAbandonedLock.insert(name="sync") db.commit() @@ -2272,10 +2273,7 @@ async def test_sqlite_memory_async_connection_settles_a_finished_owner(db_sqlite """ db = db_sqlite_memory pool = await db._get_async_pool() - - finished = asyncio.create_task(asyncio.sleep(0)) - await finished - pool._owner = finished + finished = await _set_finished_owner(pool) async with pool.connection(): pass @@ -2302,9 +2300,7 @@ class AsyncThingLockHeld(TypedTable): db.commit() pool = await db._get_async_pool() - finished = asyncio.create_task(asyncio.sleep(0)) - await finished - pool._owner = finished + finished = await _set_finished_owner(pool) entered = asyncio.Event() leave = asyncio.Event() @@ -2331,33 +2327,12 @@ async def hold_the_connection_lock() -> None: @pytest.mark.asyncio async def test_refused_task_is_not_recorded_as_holding_async_writes(db_sqlite_memory: TypeDAL): """ - A task refused with `ConcurrentTransactionError` opened no transaction, and must not be + A task refused with `ConcurrentTransactionError` opened no transaction and must not be recorded as holding one. - `_mark_async_pending()` is called before entering `pool.connection()` (`insert_async`, - `update_async`, `executesql_async` in core.py, `base_delete_async` in - async_execution.py). On `sqlite:memory` that context manager can raise before it ever - yields, so the refused task ends up in `_async_pending_owners` having done nothing at all. - - Nothing clears it: the entry is only dropped by that task's own `commit_async()`/ - `rollback_async()`, which a caller who just got told "you were refused" has no reason to - call, or by the pruning in `_has_pending_async_writes()` once the task ends. Until then the - guard is global - `SyncTransactionTracker.before_execute` asks "does *any* live task hold - async writes" - so one refused task refuses every sync statement on the instance, including - those of tasks that were never involved. - - Ordering, pinned with events: - - `holder` writes and keeps its transaction open, then commits once the refusal happened - - `refused` is turned away, waits for the holder to settle, and only then looks - - By that point the one real async transaction is committed and gone, so the correct answer - is "nothing pending" and a plain sync INSERT that runs. - - Note this is not merely an ordering nit to be fixed by moving the call inside the `async - with`: on Postgres `_acquire()` awaits `getconn()`, and a sync write issued by another - coroutine during that await would slip past the check `_get_async_pool()` already made. The - mark has to stay ahead of that await and be undone when - and only when - the connection - was refused before any statement ran. + `_mark_async_pending()` marks before connection acquisition, so it must un-mark when and + only when the connection was refused before any statement ran. Pins the holder/refused + ordering and the final unrelated sync insert. """ db = db_sqlite_memory From a9e19bafaf4283e263d99cf68030478c2e8292be Mon Sep 17 00:00:00 2001 From: Robin van der Noord Date: Sat, 15 Aug 2026 19:00:56 +0200 Subject: [PATCH 29/29] fix(async): preserve transaction state and reclaim held connections --- src/typedal/async_execution.py | 98 +++++++++++++++++++++++++++++++--- src/typedal/core.py | 97 ++++++++++++++++++++++++++++----- src/typedal/query_builder.py | 13 ++++- 3 files changed, 186 insertions(+), 22 deletions(-) diff --git a/src/typedal/async_execution.py b/src/typedal/async_execution.py index ef16c3c..2f813b8 100644 --- a/src/typedal/async_execution.py +++ b/src/typedal/async_execution.py @@ -32,9 +32,15 @@ # (adapters/postgres.py). Backends without the concept never set it at all, hence None. type LastInsert = tuple[pydal.objects.Field, int] | None -# SQL verbs that open a transaction on whichever connection runs them. DDL is left out on -# purpose: `db.define()` migrates on the sync connection, and treating that as pending work -# would make the first `_async` call after any table definition raise. +# SQL verbs that open a transaction on whichever connection runs them, used by +# `SyncTransactionTracker` to decide whether a statement pydal just ran left uncommitted work. +# DDL is left out on purpose: `db.define()` migrates on the sync connection, and treating that +# as pending work would make the first `_async` call after any table definition raise. +# +# A text prefix is a weak test - a leading comment or a CTE hides the verb - and the async side +# deliberately no longer uses it: it asks the connection instead, see +# `UNCOMMITTED_WORK_STRATEGIES`. The sync side cannot do the same without also catching +# migration DDL, so it stays on the prefix for now. WRITE_STATEMENTS = ("INSERT", "UPDATE", "DELETE", "REPLACE", "MERGE", "TRUNCATE") # How long a file-backed SQLite connection waits for another one's write to finish before @@ -212,6 +218,30 @@ async def close(self) -> None: ... def settle_abandoned_sync(self) -> bool: ... +def _spawn_reclaim(coro: t.Coroutine[t.Any, t.Any, None], tasks: "set[asyncio.Task[None]]") -> None: + """ + Run a reclaim coroutine on the running loop, holding a reference until it finishes. + + `add_done_callback` is synchronous, so the actual rollback/close has to be scheduled. The + reference is the point: the event loop only keeps a *weak* one to a task, so a bare + `create_task(...)` can be garbage-collected part-way through its rollback. That surfaces as + a connection which is never handed back - only under load, and never twice in the same + place. + + Best-effort, like everything on the reclaim path: with no running loop there is nothing to + schedule on, and closing the pool is what reclaims the connection instead. + """ + try: + task = asyncio.get_running_loop().create_task(coro) + except RuntimeError: + # no running loop; closing the coroutine keeps it from warning about never being awaited + coro.close() + return + + tasks.add(task) + task.add_done_callback(tasks.discard) + + class PostgresAsyncPool: """ Wrap `psycopg_pool.AsyncConnectionPool` with one connection per asyncio task. @@ -227,6 +257,8 @@ def __init__(self, pool: t.Any) -> None: default=None, ) self._checked_out: set[t.Any] = set() + # see `_spawn_reclaim()` - without this the reclaim tasks can be collected mid-flight. + self._reclaim_tasks: "set[asyncio.Task[None]]" = set() def _own_connection(self) -> t.Any: """ @@ -300,8 +332,7 @@ async def _rollback_and_return() -> None: with contextlib.suppress(Exception): await conn.close() - with contextlib.suppress(RuntimeError): - asyncio.get_running_loop().create_task(_rollback_and_return()) + _spawn_reclaim(_rollback_and_return(), self._reclaim_tasks) async def _release(self, conn: t.Any) -> None: """ @@ -584,6 +615,8 @@ def __init__(self, db: "TypeDAL") -> None: # every connection handed out and not yet closed, so close() can reach the ones whose # tasks ended without committing. Same reasoning as `PostgresAsyncPool._checked_out`. self._open: set[t.Any] = set() + # see `_spawn_reclaim()` - without this the reclaim tasks can be collected mid-flight. + self._reclaim_tasks: "set[asyncio.Task[None]]" = set() def _own_connection(self) -> t.Any: """ @@ -635,8 +668,7 @@ async def _rollback_and_close() -> None: with contextlib.suppress(Exception): await conn.close() - with contextlib.suppress(RuntimeError): - asyncio.get_running_loop().create_task(_rollback_and_close()) + _spawn_reclaim(_rollback_and_close(), self._reclaim_tasks) async def _release(self, conn: t.Any) -> None: # handed the connection for the same reason as `PostgresAsyncPool._release`. @@ -946,6 +978,54 @@ async def sqlite_lastrowid_async( } +# Postgres command tags that report a statement which changed nothing. Everything else either +# modified data or changed schema, and therefore left work the sync connection cannot see. +# Inverted like this on purpose: an unrecognised tag then counts as a write, which costs a held +# connection at worst, where the other way round costs a silently discarded statement. +POSTGRES_READ_ONLY_COMMAND_TAGS = frozenset( + {"SELECT", "SHOW", "EXPLAIN", "FETCH", "MOVE", "CLOSE", "SET", "RESET", "BEGIN", "COMMIT", "ROLLBACK"}, +) + + +def postgres_left_uncommitted_work(_conn: AsyncConnection, cur: AsyncCursor) -> bool: + """ + Whether the statement this psycopg cursor just ran left uncommitted work. + + Read off the command tag the *server* sent back (`INSERT 0 1`, `UPDATE 3`, `CREATE TABLE`, + `SELECT 5`), not off the SQL that was sent. That is what makes this reliable where a text + prefix is not: a CTE-wrapped `INSERT`, a statement behind a leading comment and DDL all + report their real command here. + + `conn.info.transaction_status` cannot answer this on Postgres - psycopg opens a transaction + for a plain `SELECT` too, so it reports `INTRANS` for statements with nothing to commit. + """ + tag = str(getattr(cur, "statusmessage", "") or "").split(" ", 1)[0].upper() + return tag not in POSTGRES_READ_ONLY_COMMAND_TAGS + + +def sqlite_left_uncommitted_work(conn: AsyncConnection, _cur: AsyncCursor) -> bool: + """ + Whether the statement this aiosqlite connection just ran left uncommitted work. + + sqlite3 implicitly BEGINs before DML and leaves SELECT and DDL in autocommit, so + `in_transaction` *is* the question being asked - no command tag needed (and none exists). + + That DDL is excluded is the driver's behaviour, not a choice made here: a SQLite + `CREATE TABLE` is durable the moment it runs, so there is nothing pending to report. + """ + # not on the `AsyncConnection` protocol: psycopg has no counterpart, this is aiosqlite's. + return bool(conn.in_transaction) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +# One "did this leave uncommitted work" strategy per backend, mirroring `ASYNC_POOL_FACTORIES`. +# Used by `executesql_async()`, which is the one `_async` method handed arbitrary SQL and so the +# only one that cannot know up front whether it is about to write. +UNCOMMITTED_WORK_STRATEGIES: dict[str, t.Callable[[AsyncConnection, AsyncCursor], bool]] = { + "postgres": postgres_left_uncommitted_work, + "sqlite": sqlite_left_uncommitted_work, +} + + async def base_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: pydal.objects.Query) -> int | None: """ Async twin of the base `SQLAdapter.delete()` (pydal adapters/base.py): plain @@ -976,7 +1056,9 @@ async def sqlite_delete_async(db: "TypeDAL", table: pydal.objects.Table, query: directly), so a cascaded delete on another table gets the dbengine-appropriate treatment too, same as the original. """ - id_rows = await db.select_async(query, table._id) + # `_hold_connection`: the delete below (and the cascades after it) have to share this + # snapshot's transaction, or the rows cascaded to are chosen from ids read outside it. + id_rows = await db.select_async(query, table._id, _hold_connection=True) deleted = [row[table._id.name] for row in id_rows] counter = await base_delete_async(db, table, query) diff --git a/src/typedal/core.py b/src/typedal/core.py index 5cfd8ef..dfb5e74 100644 --- a/src/typedal/core.py +++ b/src/typedal/core.py @@ -19,7 +19,7 @@ from .async_execution import ( DELETE_STRATEGIES, LASTROWID_STRATEGIES, - WRITE_STATEMENTS, + UNCOMMITTED_WORK_STRATEGIES, AsyncConnectionPool, AsyncPoolManager, ConcurrentTransactionError, @@ -58,6 +58,30 @@ NO_ASYNC_TASK = object() +class _AsyncPendingMark: + """ + Handle on one `TypeDAL._mark_async_pending()` block, so it can be withdrawn again. + + Exists for `executesql_async()`, which is handed arbitrary SQL: it has to mark before + running the statement (a later mark would leave a window for a sync write to slip in), but + only afterwards can it ask the connection whether anything was actually left uncommitted. + """ + + def __init__(self, db: "TypeDAL", owner: t.Any, already_pending: bool) -> None: + self._db = db + self._owner = owner + # a mark this block did not add is not this block's to withdraw: an earlier `_async` + # write in the same task still holds its transaction open. + self._already_pending = already_pending + + def release(self) -> None: + """ + Withdraw this mark, if it was this block that placed it. + """ + if not self._already_pending: + self._db._async_pending_owners.discard(self._owner) + + def _expression_subclasses() -> t.Iterator[type]: """ Yield pydal.objects.Expression and every (nested) subclass currently loaded, e.g. Field and TypedField. @@ -678,7 +702,7 @@ def _async_pending_owner(self) -> t.Any: return asyncio.current_task() or NO_ASYNC_TASK @contextlib.contextmanager - def _mark_async_pending(self) -> t.Iterator[None]: + def _mark_async_pending(self) -> t.Iterator["_AsyncPendingMark"]: """ Mark this task as holding an async write for the duration of the write. @@ -686,6 +710,9 @@ def _mark_async_pending(self) -> t.Iterator[None]: cannot slip in during the await. Only `ConcurrentTransactionError` un-marks because that caller was refused before opening a transaction; any other failure may have left one open. + + Yields a handle whose `release()` withdraws the mark, for the one caller that has to + mark before it can know whether there was anything to mark - see `executesql_async`. """ owner = self._async_pending_owner() # an earlier `_async` write in this same task already marked it, and its transaction is @@ -694,7 +721,7 @@ def _mark_async_pending(self) -> t.Iterator[None]: self._async_pending_owners.add(owner) try: - yield + yield _AsyncPendingMark(self, owner, already_pending) except ConcurrentTransactionError: if not already_pending: self._async_pending_owners.discard(owner) @@ -713,6 +740,21 @@ async def _release_readonly_connection(self, pool: AsyncConnectionPool) -> None: with contextlib.suppress(Exception): await pool.rollback() + async def _release_held_connection(self) -> None: + """ + Hand back a connection a `select_async(..., _hold_connection=True)` is still holding. + + For the callers that hold one for a write which then turns out not to happen - a + `_before_update`/`_before_delete` hook cancelling it, or nothing to update. Without this + the read's connection stays checked out (on Postgres: idle in transaction) until the + task ends, for a transaction that will never receive its write. + + Goes to `AsyncPoolManager.pool` rather than `_get_async_pool()`: if no pool was ever + opened there is nothing held, and releasing must not be what opens one. + """ + if pool := self._async_pools.pool: + await self._release_readonly_connection(pool) + def _settle_abandoned_async_writes(self) -> bool: """ Reclaim an abandoned `sqlite:memory` async transaction, if there is one. @@ -748,6 +790,7 @@ async def select_async( self, query: pydal.objects.Query, *fields: t.Any, + _hold_connection: bool = False, **attributes: t.Any, ) -> pydal.objects.Rows: """ @@ -758,6 +801,15 @@ async def select_async( `tables()`/`expand_all()`/`_select_wcols()` (pure, no I/O), execute via the async driver for this backend (the only I/O, on our own connection, not pydal's; see `ASYNC_POOL_FACTORIES`), parse via pydal's own `parse()` (pure). + + `_hold_connection` keeps this task's connection instead of handing it back afterwards, + so a write issued next lands in the same transaction as this read. Internal, and for + exactly one situation: a write that first has to know *which* rows it is about to touch + (`QueryBuilder.update_async`/`delete_async`, `sqlite_delete_async`). Without it those + two statements run in separate transactions and the ids reported back can describe rows + the write never touched - where the synchronous path, sharing one connection throughout, + cannot come apart that way. Ordinary reads leave it False and release, which is what + keeps a read from occupying a pool connection until its task ends. """ adapter = self._adapter @@ -777,7 +829,8 @@ async def select_async( await cur.execute(sql) rows = await cur.fetchall() finally: - await self._release_readonly_connection(pool) + if not _hold_connection: + await self._release_readonly_connection(pool) limitby = attributes.get("limitby") or (0,) rows = adapter.rowslice(rows, limitby[0], None) @@ -941,13 +994,17 @@ async def executesql_async( adapter = self._adapter pool = await self._get_async_pool() - # unlike the other `_async` methods this one is handed arbitrary SQL, so whether it - # opens a transaction has to be read off the statement - same test the sync side - # applies in `SyncTransactionTracker`. A read marks nothing, hence the `nullcontext`. - opens_a_transaction = str(query).lstrip().upper().startswith(WRITE_STATEMENTS) - marker = self._mark_async_pending() if opens_a_transaction else contextlib.nullcontext() - - with marker: + # Unlike the other `_async` methods this one is handed arbitrary SQL, so whether it + # leaves uncommitted work is not knowable up front. Mark first and withdraw after: + # marking only once the statement has run would leave a window in which a sync write + # from another coroutine slips past the split guard, and reading the *statement text* + # to decide (as this used to do, and as the sync side still does) mistakes a leading + # comment, a CTE-wrapped write or DDL for a read - which then took the read-only + # release path below and silently rolled the write back. + left_uncommitted_work = False + left_work = UNCOMMITTED_WORK_STRATEGIES[adapter.dbengine] + + with self._mark_async_pending() as pending: try: async with pool.connection() as conn, conn.cursor() as cur: if placeholders: @@ -955,6 +1012,13 @@ async def executesql_async( else: await cur.execute(query) + # right after `execute()` and before any fetching, which is what both + # strategies read; fetching does not change either answer, but the early + # returns below would skip this. + left_uncommitted_work = left_work(conn, cur) + if not left_uncommitted_work: + pending.release() + if as_dict or as_ordered_dict: if not hasattr(cur, "description"): # pragma: no cover # both supported drivers always expose it; guard kept for parity with @@ -983,8 +1047,17 @@ async def executesql_async( data = await cur.fetchall() except Exception: return None + except Exception: + # A statement that raised has no effect to preserve, and on Postgres it leaves + # the transaction aborted, so this task's mark goes with it - which lets the + # `finally` roll the connection back. Withdrawn rather than kept because the + # alternative is that one failed read blocks the sync side until the caller + # thinks to call `rollback_async()`. A task with *earlier* async writes is + # unaffected: `release()` leaves a mark it did not place. + pending.release() + raise finally: - if not opens_a_transaction: + if not left_uncommitted_work: await self._release_readonly_connection(pool) if fields or colnames: diff --git a/src/typedal/query_builder.py b/src/typedal/query_builder.py index b1061af..5e90086 100644 --- a/src/typedal/query_builder.py +++ b/src/typedal/query_builder.py @@ -517,13 +517,17 @@ async def delete_async(self) -> list[int]: require_permission(self._permissions, "delete") db = self._get_db() - removed_rows = await db.select_async(self.query, "id") + # `_hold_connection`: the delete below has to land in the same transaction as this + # snapshot, or the ids returned describe rows it never touched - see `select_async`. + removed_rows = await db.select_async(self.query, "id", _hold_connection=True) removed_ids = [row.id for row in removed_rows] pydal_set = db(self.query) table = db._adapter.get_table(self.query) if any(f(pydal_set) for f in table._before_delete): + # the delete the snapshot above was holding its connection for is not happening + await db._release_held_connection() return [] result = await db.delete_async(table, self.query) @@ -567,16 +571,21 @@ async def update_async(self, **fields: t.Any) -> list[int]: require_permission(self._permissions, "update") db = self._get_db() - updated_rows = await db.select_async(self.query, "id") + # `_hold_connection`: same reason as in `delete_async` - the update below has to share + # this snapshot's transaction, see `select_async`. + updated_rows = await db.select_async(self.query, "id", _hold_connection=True) updated_ids = [row.id for row in updated_rows] pydal_set = db(self.query) table = db._adapter.get_table(self.query) row = table._fields_and_values_for_update(fields) if not row._values: + await db._release_held_connection() raise ValueError("No fields to update") if any(f(pydal_set, row) for f in table._before_update): + # the update the snapshot above was holding its connection for is not happening + await db._release_held_connection() return [] result = await db.update_async(table, self.query, row.op_values())