sustained 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {sustained-2.1.0 → sustained-2.2.0}/CHANGELOG.md +10 -0
  2. {sustained-2.1.0 → sustained-2.2.0}/PKG-INFO +2 -2
  3. {sustained-2.1.0 → sustained-2.2.0}/README.md +1 -1
  4. {sustained-2.1.0 → sustained-2.2.0}/docs/executing.md +41 -0
  5. {sustained-2.1.0 → sustained-2.2.0}/docs/index.md +1 -0
  6. sustained-2.2.0/docs/schema.md +64 -0
  7. {sustained-2.1.0 → sustained-2.2.0}/pyproject.toml +1 -1
  8. sustained-2.2.0/src/sustained/aio.py +356 -0
  9. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builder.py +100 -61
  10. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builder.pyi +10 -0
  11. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/base.py +37 -0
  12. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/duckdb.py +8 -0
  13. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/mssql.py +13 -0
  14. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/postgres.py +5 -0
  15. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/presto.py +5 -0
  16. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/execution.py +81 -0
  17. sustained-2.2.0/src/sustained/migrations.py +181 -0
  18. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/model.py +96 -0
  19. sustained-2.2.0/src/sustained/pool.py +112 -0
  20. sustained-2.2.0/src/sustained/schema.py +190 -0
  21. sustained-2.2.0/tests/test_async.py +155 -0
  22. sustained-2.2.0/tests/test_migrations.py +132 -0
  23. sustained-2.2.0/tests/test_pool.py +148 -0
  24. sustained-2.2.0/tests/test_schema_ddl.py +168 -0
  25. {sustained-2.1.0 → sustained-2.2.0}/.gitignore +0 -0
  26. {sustained-2.1.0 → sustained-2.2.0}/.pre-commit-config.yaml +0 -0
  27. {sustained-2.1.0 → sustained-2.2.0}/DEVELOPERS.md +0 -0
  28. {sustained-2.1.0 → sustained-2.2.0}/LICENSE +0 -0
  29. {sustained-2.1.0 → sustained-2.2.0}/deploy.py +0 -0
  30. {sustained-2.1.0 → sustained-2.2.0}/docs/CNAME +0 -0
  31. {sustained-2.1.0 → sustained-2.2.0}/docs/_config.yml +0 -0
  32. {sustained-2.1.0 → sustained-2.2.0}/docs/_layouts/default.html +0 -0
  33. {sustained-2.1.0 → sustained-2.2.0}/docs/assets/css/style.scss +0 -0
  34. {sustained-2.1.0 → sustained-2.2.0}/docs/filtering.md +0 -0
  35. {sustained-2.1.0 → sustained-2.2.0}/docs/grouping.md +0 -0
  36. {sustained-2.1.0 → sustained-2.2.0}/docs/models.md +0 -0
  37. {sustained-2.1.0 → sustained-2.2.0}/docs/queries.md +0 -0
  38. {sustained-2.1.0 → sustained-2.2.0}/docs/relations.md +0 -0
  39. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/__init__.py +0 -0
  40. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/__init__.py +0 -0
  41. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/__init__.pyi +0 -0
  42. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
  43. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
  44. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/group_by_builder.py +0 -0
  45. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/group_by_builder.pyi +0 -0
  46. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/having_builder.py +0 -0
  47. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/having_builder.pyi +0 -0
  48. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/join_builder.py +0 -0
  49. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/join_builder.pyi +0 -0
  50. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.py +0 -0
  51. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.pyi +0 -0
  52. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.py +0 -0
  53. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
  54. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/where_builder.py +0 -0
  55. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/where_builder.pyi +0 -0
  56. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/__init__.py +0 -0
  57. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/dialects.py +0 -0
  58. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/exceptions.py +0 -0
  59. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/expressions.py +0 -0
  60. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/functions.py +0 -0
  61. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/py.typed +0 -0
  62. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/rendering.py +0 -0
  63. {sustained-2.1.0 → sustained-2.2.0}/src/sustained/types.py +0 -0
  64. {sustained-2.1.0 → sustained-2.2.0}/tests/__init__.py +0 -0
  65. {sustained-2.1.0 → sustained-2.2.0}/tests/test_analyst_sql.py +0 -0
  66. {sustained-2.1.0 → sustained-2.2.0}/tests/test_builder_ergonomics.py +0 -0
  67. {sustained-2.1.0 → sustained-2.2.0}/tests/test_builder_robustness.py +0 -0
  68. {sustained-2.1.0 → sustained-2.2.0}/tests/test_dialect.py +0 -0
  69. {sustained-2.1.0 → sustained-2.2.0}/tests/test_dialect_behaviors.py +0 -0
  70. {sustained-2.1.0 → sustained-2.2.0}/tests/test_dialect_functions.py +0 -0
  71. {sustained-2.1.0 → sustained-2.2.0}/tests/test_dml.py +0 -0
  72. {sustained-2.1.0 → sustained-2.2.0}/tests/test_duckdb_dialect.py +0 -0
  73. {sustained-2.1.0 → sustained-2.2.0}/tests/test_etl_statements.py +0 -0
  74. {sustained-2.1.0 → sustained-2.2.0}/tests/test_execution.py +0 -0
  75. {sustained-2.1.0 → sustained-2.2.0}/tests/test_expressions.py +0 -0
  76. {sustained-2.1.0 → sustained-2.2.0}/tests/test_functions.py +0 -0
  77. {sustained-2.1.0 → sustained-2.2.0}/tests/test_having_builder.py +0 -0
  78. {sustained-2.1.0 → sustained-2.2.0}/tests/test_join_builder.py +0 -0
  79. {sustained-2.1.0 → sustained-2.2.0}/tests/test_lambda_join_builder.py +0 -0
  80. {sustained-2.1.0 → sustained-2.2.0}/tests/test_model.py +0 -0
  81. {sustained-2.1.0 → sustained-2.2.0}/tests/test_model_features.py +0 -0
  82. {sustained-2.1.0 → sustained-2.2.0}/tests/test_mssql_compiler.py +0 -0
  83. {sustained-2.1.0 → sustained-2.2.0}/tests/test_order_by_builder.py +0 -0
  84. {sustained-2.1.0 → sustained-2.2.0}/tests/test_parameterization.py +0 -0
  85. {sustained-2.1.0 → sustained-2.2.0}/tests/test_postgres_compiler.py +0 -0
  86. {sustained-2.1.0 → sustained-2.2.0}/tests/test_predicates.py +0 -0
  87. {sustained-2.1.0 → sustained-2.2.0}/tests/test_query_builder.py +0 -0
  88. {sustained-2.1.0 → sustained-2.2.0}/tests/test_raw_bindings.py +0 -0
  89. {sustained-2.1.0 → sustained-2.2.0}/tests/test_result_formats.py +0 -0
  90. {sustained-2.1.0 → sustained-2.2.0}/tests/test_select_clause_builder.py +0 -0
  91. {sustained-2.1.0 → sustained-2.2.0}/tests/test_transactions.py +0 -0
  92. {sustained-2.1.0 → sustained-2.2.0}/tests/test_upsert.py +0 -0
  93. {sustained-2.1.0 → sustained-2.2.0}/tests/test_where_builder.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 (2026-08-14)
4
+
5
+ ### Added
6
+
7
+ - Typed column definitions: models declare `tableColumns` with `Integer`, `BigInteger`, `String`, `Text`, `Boolean`, `Float`, `Numeric`, `Date`, `Timestamp`, and `Json`, including composite primary keys, defaults, unique constraints, foreign key references, and autoincrement. Strict column access derives automatically.
8
+ - Model-driven DDL: `create_table_sql()`, `create_table()`, `drop_table()` with per-dialect type mapping and identity syntax. DuckDB and Presto raise for autoincrement.
9
+ - Migration runner: ordered `Migration` objects with up/down steps (SQL, statement lists, or callables), a self-creating tracking table, transactional application, stop-after targets, and newest-first reverts. `create_table_migration()` derives create/drop pairs from models. No catalog diffing.
10
+ - `ConnectionPool`: thread-safe, lazy, bounded pooling for DB-API connections. `Model.bind()` and all execution entry points accept a pool; transactions pin one checked-out connection to the thread; nested blocks reuse it via savepoints.
11
+ - Async execution: `arun()`, `afirst()`, `ato_dicts()` through an adapter interface with `DbApiAsyncAdapter` (any sync driver via worker threads), `AiosqliteAdapter`, and `AsyncpgAdapter` (`%s` to `$n` conversion). `Model.bind_async()` and `async_transaction()` with ContextVar pinning. Async through-relation eager loading and nested async transactions are not supported yet.
12
+
3
13
  ## 2.1.0 (2026-08-14)
4
14
 
5
15
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sustained
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: A Python query builder inspired by Objection.js
5
5
  Project-URL: Homepage, https://github.com/wetherc/sustained
6
6
  Project-URL: Issues, https://github.com/wetherc/sustained/issues
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  A Python query builder and lightweight ORM inspired by [Objection.js](https://vincit.github.io/objection.js/).
18
18
 
19
- Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, Presto, and DuckDB dialects. It executes queries against any DB-API 2.0 connection with transactions, hydrates rows into model instances or DataFrames, writes data with `insert()`, `update()`, `delete()`, upserts, and `INSERT ... SELECT`, and eager loads relations. Filters compose as typed predicates: `User.query().where((User.c.age > 21) & User.c.name.like('A%'))`.
19
+ Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, Presto, and DuckDB dialects. It executes queries against any DB-API 2.0 connection or connection pool with transactions, hydrates rows into model instances or DataFrames, writes data with `insert()`, `update()`, `delete()`, upserts, and `INSERT ... SELECT`, and eager loads relations. Filters compose as typed predicates: `User.query().where((User.c.age > 21) & User.c.name.like('A%'))`. Models can declare typed columns and generate their DDL, an explicit migration runner manages schema changes, and async services run the same queries through driver adapters with `await query.arun()`.
20
20
 
21
21
  ## Installation
22
22
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  A Python query builder and lightweight ORM inspired by [Objection.js](https://vincit.github.io/objection.js/).
4
4
 
5
- Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, Presto, and DuckDB dialects. It executes queries against any DB-API 2.0 connection with transactions, hydrates rows into model instances or DataFrames, writes data with `insert()`, `update()`, `delete()`, upserts, and `INSERT ... SELECT`, and eager loads relations. Filters compose as typed predicates: `User.query().where((User.c.age > 21) & User.c.name.like('A%'))`.
5
+ Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, Presto, and DuckDB dialects. It executes queries against any DB-API 2.0 connection or connection pool with transactions, hydrates rows into model instances or DataFrames, writes data with `insert()`, `update()`, `delete()`, upserts, and `INSERT ... SELECT`, and eager loads relations. Filters compose as typed predicates: `User.query().where((User.c.age > 21) & User.c.name.like('A%'))`. Models can declare typed columns and generate their DDL, an explicit migration runner manages schema changes, and async services run the same queries through driver adapters with `await query.arun()`.
6
6
 
7
7
  ## Installation
8
8
 
@@ -137,6 +137,47 @@ Eager loading needs the join key columns in both result sets, so keep them in yo
137
137
 
138
138
  pandas and pyarrow are optional; the methods raise a clear error when the library is missing.
139
139
 
140
+ ## Connection Pooling
141
+
142
+ `ConnectionPool` creates connections lazily from a factory up to `max_size` and reuses released ones. Bind it like a connection; every statement checks a connection out for its duration.
143
+
144
+ ```python
145
+ from sustained.pool import ConnectionPool
146
+
147
+ pool = ConnectionPool(lambda: psycopg2.connect(DSN), max_size=10)
148
+ User.bind(pool)
149
+
150
+ users = User.query().where('active', '=', True).run()
151
+
152
+ with User.transaction():
153
+ # One connection is pinned to this thread for the whole block.
154
+ User.query().insert({...}).run()
155
+ Account.query().update({...}).where(...).run()
156
+ ```
157
+
158
+ An exhausted pool raises `PoolTimeout` after the configured timeout. `pool.close()` closes idle connections.
159
+
160
+ ## Async Execution
161
+
162
+ Queries run asynchronously through an adapter. `DbApiAsyncAdapter` wraps any synchronous DB-API connection in a worker thread; `AiosqliteAdapter` and `AsyncpgAdapter` wrap their native drivers. The asyncpg adapter converts `%s` placeholders to `$1..$n`.
163
+
164
+ ```python
165
+ from sustained.aio import DbApiAsyncAdapter
166
+
167
+ adapter = DbApiAsyncAdapter(sqlite3.connect('app.db', check_same_thread=False))
168
+ User.bind_async(adapter)
169
+
170
+ users = await User.query().where('active', '=', True).arun()
171
+ user = await User.query().where('id', '=', 1).afirst()
172
+ rows = await User.query().ato_dicts()
173
+
174
+ async with User.async_transaction():
175
+ await User.query().insert({...}).arun()
176
+ await Account.query().update({...}).where(...).arun()
177
+ ```
178
+
179
+ `arun()` mirrors `run()`: hydration, RETURNING rows, batched multi-row inserts, and eager loading of basic relations. Async eager loading of through relations is not supported yet, and async transactions do not nest.
180
+
140
181
  ## Statement Logging
141
182
 
142
183
  `sustained.execution.set_statement_listener(fn)` registers an observer called after every executed statement with the SQL text, the parameter tuple, and the duration in seconds. Pass `None` to remove it.
@@ -18,6 +18,7 @@ If you are new to Sustained, it's recommended to read the guides in the followin
18
18
  5. **[Filtering](./filtering):** Dive into the various `where` methods for filtering your results.
19
19
  6. **[Relations and Joins](./relations):** Learn how to define relationships between models and join them in your queries.
20
20
  7. **[Executing Queries](./executing):** Run queries against a database, hydrate results into models, write data, and eager load relations.
21
+ 8. **[Schema and Migrations](./schema):** Declare typed columns on models, generate DDL, and run ordered migrations.
21
22
 
22
23
  ## API Reference
23
24
 
@@ -0,0 +1,64 @@
1
+ ---
2
+ layout: default
3
+ title: Schema and Migrations
4
+ ---
5
+
6
+ Models can declare their physical shape and generate DDL from it, and the migration runner applies ordered schema changes.
7
+
8
+ ## Typed Columns
9
+
10
+ Declare `tableColumns` as a dict of column name to a typed definition. The factories are `Integer`, `BigInteger`, `String(length)`, `Text`, `Boolean`, `Float`, `Numeric(precision, scale)`, `Date`, `Timestamp`, and `Json`.
11
+
12
+ ```python
13
+ from sustained import Model
14
+ from sustained.schema import Boolean, Integer, String, Timestamp
15
+ from sustained.types import Expression
16
+
17
+ class User(Model):
18
+ tableName = 'users'
19
+ tableColumns = {
20
+ 'id': Integer(primary_key=True, autoincrement=True),
21
+ 'email': String(120, unique=True, nullable=False),
22
+ 'active': Boolean(default=True),
23
+ 'created_at': Timestamp(default=Expression('CURRENT_TIMESTAMP')),
24
+ }
25
+ ```
26
+
27
+ Definitions support composite primary keys (mark several columns `primary_key=True`), `unique`, literal defaults or raw `Expression` defaults, and foreign keys through `references='table.column'`. `autoincrement` requires a single integer primary key; DuckDB and Presto raise because they have no identity columns.
28
+
29
+ A model with `tableColumns` also gets strict column-name access automatically: a typo'd column raises `AttributeError`.
30
+
31
+ ## Generating and Running DDL
32
+
33
+ ```python
34
+ User.create_table_sql() # dialect-specific CREATE TABLE
35
+ User.create_table(conn) # execute it
36
+ User.drop_table(conn) # DROP TABLE IF EXISTS
37
+ ```
38
+
39
+ Types map per dialect: `BIT`, `NVARCHAR`, and `DATETIME2` on MSSQL; `JSONB` and identity columns on Postgres; plain `INTEGER PRIMARY KEY` rowid behavior on the default dialect.
40
+
41
+ ## Migrations
42
+
43
+ Migrations are explicit and ordered. Each pairs an id with an up step and an optional down step; steps are a SQL string, a list of statements, or a callable receiving the connection.
44
+
45
+ ```python
46
+ from sustained.migrations import Migration, Migrator, create_table_migration
47
+
48
+ migrations = [
49
+ create_table_migration(User),
50
+ Migration(
51
+ 'add_last_login',
52
+ up='ALTER TABLE users ADD COLUMN last_login TEXT',
53
+ down='ALTER TABLE users DROP COLUMN last_login',
54
+ ),
55
+ ]
56
+
57
+ migrator = Migrator(conn, migrations)
58
+ migrator.up() # apply all pending
59
+ migrator.up(target='create_users') # stop after a target
60
+ migrator.status() # [(id, applied), ...]
61
+ migrator.down() # revert the newest applied migration
62
+ ```
63
+
64
+ Applied ids live in a tracking table that the migrator creates on first use. Each migration runs inside a transaction, so a failing step leaves the schema at the previous migration. There is no automatic diffing against the database catalog; write migrations explicitly or derive create/drop pairs from models with `create_table_migration()`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sustained"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "A Python query builder inspired by Objection.js"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,356 @@
1
+ """
2
+ Async execution support.
3
+
4
+ Queries run asynchronously through an adapter that wraps an async database
5
+ driver. Three adapters ship with Sustained:
6
+
7
+ - DbApiAsyncAdapter wraps any synchronous DB-API 2.0 connection and runs
8
+ its calls in a worker thread. It works with every driver the sync path
9
+ supports and is the reference implementation.
10
+ - AiosqliteAdapter wraps an aiosqlite connection.
11
+ - AsyncpgAdapter wraps an asyncpg connection and converts the Postgres
12
+ compiler's %s placeholders to asyncpg's $1..$n style. A literal %s inside
13
+ raw SQL text would be converted too; avoid it in raw fragments.
14
+
15
+ Bind an adapter with Model.bind_async(adapter), then use arun(), afirst(),
16
+ and ato_dicts() on queries. async_transaction() gives atomic blocks; the
17
+ pin travels through a ContextVar, so concurrent tasks do not share it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import time
24
+ from contextlib import asynccontextmanager
25
+ from contextvars import ContextVar
26
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Tuple, Type
27
+
28
+ from sustained.execution import notify_statement
29
+ from sustained.types import RelationType
30
+
31
+ if TYPE_CHECKING:
32
+ from sustained.builder import QueryBuilder
33
+ from sustained.model import Model
34
+
35
+
36
+ class AsyncAdapter:
37
+ """
38
+ The interface async execution needs from a driver. Subclasses implement
39
+ fetch, execute, executemany, commit, and rollback.
40
+ """
41
+
42
+ async def fetch(
43
+ self, sql: str, params: Tuple[Any, ...]
44
+ ) -> Tuple[List[str], List[Any]]:
45
+ """Runs a statement and returns (column names, rows)."""
46
+ raise NotImplementedError
47
+
48
+ async def execute(self, sql: str, params: Tuple[Any, ...]) -> int:
49
+ """Runs a statement and returns the affected row count."""
50
+ raise NotImplementedError
51
+
52
+ async def executemany(self, sql: str, seq_of_params: List[Tuple[Any, ...]]) -> int:
53
+ """Runs a statement for every parameter tuple."""
54
+ raise NotImplementedError
55
+
56
+ async def commit(self) -> None:
57
+ raise NotImplementedError
58
+
59
+ async def rollback(self) -> None:
60
+ raise NotImplementedError
61
+
62
+
63
+ class DbApiAsyncAdapter(AsyncAdapter):
64
+ """
65
+ Adapts a synchronous DB-API 2.0 connection to the async interface by
66
+ running each call in a worker thread. The connection must allow use
67
+ from other threads, e.g. sqlite3.connect(..., check_same_thread=False).
68
+ """
69
+
70
+ def __init__(self, connection: Any) -> None:
71
+ self._connection = connection
72
+ # One statement at a time per connection; DB-API connections are
73
+ # not safe for concurrent use.
74
+ self._lock = asyncio.Lock()
75
+
76
+ def _fetch_sync(
77
+ self, sql: str, params: Tuple[Any, ...]
78
+ ) -> Tuple[List[str], List[Any]]:
79
+ cursor = self._connection.cursor()
80
+ cursor.execute(sql, params)
81
+ columns = [d[0] for d in cursor.description] if cursor.description else []
82
+ return columns, cursor.fetchall()
83
+
84
+ def _execute_sync(self, sql: str, params: Tuple[Any, ...]) -> int:
85
+ cursor = self._connection.cursor()
86
+ cursor.execute(sql, params)
87
+ return int(cursor.rowcount)
88
+
89
+ def _executemany_sync(self, sql: str, seq: List[Tuple[Any, ...]]) -> int:
90
+ cursor = self._connection.cursor()
91
+ cursor.executemany(sql, seq)
92
+ return int(cursor.rowcount)
93
+
94
+ async def fetch(
95
+ self, sql: str, params: Tuple[Any, ...]
96
+ ) -> Tuple[List[str], List[Any]]:
97
+ async with self._lock:
98
+ return await asyncio.to_thread(self._fetch_sync, sql, params)
99
+
100
+ async def execute(self, sql: str, params: Tuple[Any, ...]) -> int:
101
+ async with self._lock:
102
+ return await asyncio.to_thread(self._execute_sync, sql, params)
103
+
104
+ async def executemany(self, sql: str, seq_of_params: List[Tuple[Any, ...]]) -> int:
105
+ async with self._lock:
106
+ return await asyncio.to_thread(self._executemany_sync, sql, seq_of_params)
107
+
108
+ async def commit(self) -> None:
109
+ async with self._lock:
110
+ await asyncio.to_thread(self._connection.commit)
111
+
112
+ async def rollback(self) -> None:
113
+ async with self._lock:
114
+ await asyncio.to_thread(self._connection.rollback)
115
+
116
+
117
+ class AiosqliteAdapter(AsyncAdapter):
118
+ """Adapts an aiosqlite connection."""
119
+
120
+ def __init__(self, connection: Any) -> None:
121
+ self._connection = connection
122
+
123
+ async def fetch(
124
+ self, sql: str, params: Tuple[Any, ...]
125
+ ) -> Tuple[List[str], List[Any]]:
126
+ cursor = await self._connection.execute(sql, params)
127
+ columns = [d[0] for d in cursor.description] if cursor.description else []
128
+ rows = await cursor.fetchall()
129
+ return columns, list(rows)
130
+
131
+ async def execute(self, sql: str, params: Tuple[Any, ...]) -> int:
132
+ cursor = await self._connection.execute(sql, params)
133
+ return int(cursor.rowcount)
134
+
135
+ async def executemany(self, sql: str, seq_of_params: List[Tuple[Any, ...]]) -> int:
136
+ cursor = await self._connection.executemany(sql, seq_of_params)
137
+ return int(cursor.rowcount)
138
+
139
+ async def commit(self) -> None:
140
+ await self._connection.commit()
141
+
142
+ async def rollback(self) -> None:
143
+ await self._connection.rollback()
144
+
145
+
146
+ def convert_format_to_numbered(sql: str) -> str:
147
+ """Converts %s placeholders to $1..$n for asyncpg."""
148
+ pieces = sql.split("%s")
149
+ out = [pieces[0]]
150
+ for index, piece in enumerate(pieces[1:], start=1):
151
+ out.append(f"${index}")
152
+ out.append(piece)
153
+ return "".join(out)
154
+
155
+
156
+ class AsyncpgAdapter(AsyncAdapter):
157
+ """
158
+ Adapts an asyncpg connection. Statements arrive with the Postgres
159
+ compiler's %s placeholders and are converted to $1..$n.
160
+ """
161
+
162
+ def __init__(self, connection: Any) -> None:
163
+ self._connection = connection
164
+
165
+ async def fetch(
166
+ self, sql: str, params: Tuple[Any, ...]
167
+ ) -> Tuple[List[str], List[Any]]:
168
+ records = await self._connection.fetch(convert_format_to_numbered(sql), *params)
169
+ if not records:
170
+ return [], []
171
+ columns = list(records[0].keys())
172
+ return columns, [tuple(r) for r in records]
173
+
174
+ async def execute(self, sql: str, params: Tuple[Any, ...]) -> int:
175
+ status = await self._connection.execute(
176
+ convert_format_to_numbered(sql), *params
177
+ )
178
+ # asyncpg returns a status string such as 'INSERT 0 3' or 'DELETE 2'.
179
+ try:
180
+ return int(status.rsplit(" ", 1)[-1])
181
+ except (ValueError, AttributeError):
182
+ return -1
183
+
184
+ async def executemany(self, sql: str, seq_of_params: List[Tuple[Any, ...]]) -> int:
185
+ await self._connection.executemany(
186
+ convert_format_to_numbered(sql), seq_of_params
187
+ )
188
+ # asyncpg's executemany reports no row count.
189
+ return -1
190
+
191
+ async def commit(self) -> None:
192
+ # asyncpg runs in autocommit outside explicit transactions.
193
+ pass
194
+
195
+ async def rollback(self) -> None:
196
+ pass
197
+
198
+
199
+ # Adapter pinned by an open async_transaction() block. A ContextVar keeps
200
+ # the pin scoped to the current task tree.
201
+ _pinned_adapter: ContextVar[Optional[AsyncAdapter]] = ContextVar(
202
+ "sustained_pinned_adapter", default=None
203
+ )
204
+ # Adapters with an open transaction; arun() skips per-statement commits.
205
+ _active_async_transactions: Dict[int, AsyncAdapter] = {}
206
+
207
+
208
+ def resolve_adapter(
209
+ explicit: Optional[AsyncAdapter], model_class: Type["Model"]
210
+ ) -> AsyncAdapter:
211
+ """Resolves the adapter: explicit, then pinned, then the model binding."""
212
+ if explicit is not None:
213
+ return explicit
214
+ pinned = _pinned_adapter.get()
215
+ if pinned is not None:
216
+ return pinned
217
+ bound = getattr(model_class, "_async_adapter", None)
218
+ if bound is None:
219
+ raise RuntimeError(
220
+ "No async adapter. Bind one with Model.bind_async(adapter) "
221
+ "or pass it to arun()."
222
+ )
223
+ return bound # type: ignore[no-any-return]
224
+
225
+
226
+ def in_async_transaction(adapter: AsyncAdapter) -> bool:
227
+ """Reports whether the adapter has an open async_transaction() block."""
228
+ return _active_async_transactions.get(id(adapter)) is adapter
229
+
230
+
231
+ @asynccontextmanager
232
+ async def async_transaction(adapter: AsyncAdapter) -> AsyncIterator[AsyncAdapter]:
233
+ """
234
+ Runs the block atomically on the adapter: commit on success, rollback
235
+ on exception. The adapter pins to the current task context, so arun()
236
+ calls inside the block use it without passing it around. Savepoint
237
+ nesting is not supported; nested blocks raise.
238
+ """
239
+ if in_async_transaction(adapter):
240
+ raise RuntimeError(
241
+ "async_transaction() does not support nesting on one adapter."
242
+ )
243
+ key = id(adapter)
244
+ _active_async_transactions[key] = adapter
245
+ token = _pinned_adapter.set(adapter)
246
+ try:
247
+ # Explicit statements rather than adapter.commit(), because drivers
248
+ # in autocommit mode (asyncpg) treat commit() as a no-op.
249
+ await adapter.execute("BEGIN", ())
250
+ try:
251
+ yield adapter
252
+ except BaseException:
253
+ await adapter.execute("ROLLBACK", ())
254
+ raise
255
+ await adapter.execute("COMMIT", ())
256
+ finally:
257
+ _pinned_adapter.reset(token)
258
+ del _active_async_transactions[key]
259
+
260
+
261
+ async def run_async(
262
+ query: "QueryBuilder", adapter: Optional[AsyncAdapter] = None
263
+ ) -> Any:
264
+ """
265
+ Executes a built query on an async adapter. SELECT statements return
266
+ hydrated model instances with eager relations attached; writes return
267
+ the affected row count or RETURNING rows as dicts.
268
+ """
269
+ resolved = resolve_adapter(adapter, query._model_class)
270
+
271
+ use_executemany = (
272
+ query._stmt_type == "insert"
273
+ and len(query._insert_rows) > 1
274
+ and not query._returning_columns
275
+ )
276
+ started = time.perf_counter()
277
+ if query._stmt_type == "select":
278
+ sql, params = query.to_sql()
279
+ columns, rows = await resolved.fetch(sql, params)
280
+ notify_statement(sql, params, time.perf_counter() - started)
281
+ models = [query._model_class(**dict(zip(columns, row))) for row in rows]
282
+ for relation_name in query._eager_relations:
283
+ await _eager_load_async(query._model_class, resolved, models, relation_name)
284
+ return models
285
+
286
+ if use_executemany:
287
+ template = query.clone()
288
+ template._insert_rows = [query._insert_rows[0]]
289
+ sql, _ = template.to_sql()
290
+ column_names = list(query._insert_rows[0].keys())
291
+ seq = [tuple(row[c] for c in column_names) for row in query._insert_rows]
292
+ result: Any = await resolved.executemany(sql, seq)
293
+ notify_statement(sql, (), time.perf_counter() - started)
294
+ elif query._returning_columns:
295
+ sql, params = query.to_sql()
296
+ columns, rows = await resolved.fetch(sql, params)
297
+ notify_statement(sql, params, time.perf_counter() - started)
298
+ result = [dict(zip(columns, row)) for row in rows]
299
+ else:
300
+ sql, params = query.to_sql()
301
+ result = await resolved.execute(sql, params)
302
+ notify_statement(sql, params, time.perf_counter() - started)
303
+
304
+ if not in_async_transaction(resolved):
305
+ await resolved.commit()
306
+ return result
307
+
308
+
309
+ async def _eager_load_async(
310
+ model_class: Type["Model"],
311
+ adapter: AsyncAdapter,
312
+ parents: List["Model"],
313
+ relation_name: str,
314
+ ) -> None:
315
+ """Async mirror of the sync eager loader for basic relations."""
316
+ from sustained.execution import _collect_parent_keys, _split_column_ref
317
+ from sustained.model import resolve_model_reference
318
+
319
+ if not parents:
320
+ return
321
+ relation = model_class.relationMappings[relation_name]
322
+ join_info = relation["join"]
323
+ if "through" in join_info:
324
+ raise NotImplementedError(
325
+ "Async eager loading of through relations is not supported yet."
326
+ )
327
+ related_cls = resolve_model_reference(
328
+ relation["modelClass"], context_module=model_class.__module__
329
+ )
330
+ from_table, from_col = _split_column_ref(join_info["from"], relation_name)
331
+ to_table, to_col = _split_column_ref(join_info["to"], relation_name)
332
+ if from_table == model_class.tableName:
333
+ parent_col, child_col = from_col, to_col
334
+ else:
335
+ parent_col, child_col = to_col, from_col
336
+
337
+ parent_keys = _collect_parent_keys(parents, parent_col, relation_name)
338
+ unique_keys = [k for k in dict.fromkeys(parent_keys) if k is not None]
339
+ is_many = relation["relation"] == RelationType.HasManyRelation
340
+ if not unique_keys:
341
+ for parent in parents:
342
+ setattr(parent, relation_name, [] if is_many else None)
343
+ return
344
+
345
+ children = await run_async(
346
+ related_cls.query().whereIn(child_col, unique_keys), adapter
347
+ )
348
+ grouped: Dict[Any, List["Model"]] = {}
349
+ for child in children:
350
+ grouped.setdefault(child.__dict__.get(child_col), []).append(child)
351
+ for parent, key in zip(parents, parent_keys):
352
+ matches = grouped.get(key, [])
353
+ if is_many:
354
+ setattr(parent, relation_name, matches)
355
+ else:
356
+ setattr(parent, relation_name, matches[0] if matches else None)