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.
- {sustained-2.1.0 → sustained-2.2.0}/CHANGELOG.md +10 -0
- {sustained-2.1.0 → sustained-2.2.0}/PKG-INFO +2 -2
- {sustained-2.1.0 → sustained-2.2.0}/README.md +1 -1
- {sustained-2.1.0 → sustained-2.2.0}/docs/executing.md +41 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/index.md +1 -0
- sustained-2.2.0/docs/schema.md +64 -0
- {sustained-2.1.0 → sustained-2.2.0}/pyproject.toml +1 -1
- sustained-2.2.0/src/sustained/aio.py +356 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builder.py +100 -61
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builder.pyi +10 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/base.py +37 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/duckdb.py +8 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/mssql.py +13 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/postgres.py +5 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/presto.py +5 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/execution.py +81 -0
- sustained-2.2.0/src/sustained/migrations.py +181 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/model.py +96 -0
- sustained-2.2.0/src/sustained/pool.py +112 -0
- sustained-2.2.0/src/sustained/schema.py +190 -0
- sustained-2.2.0/tests/test_async.py +155 -0
- sustained-2.2.0/tests/test_migrations.py +132 -0
- sustained-2.2.0/tests/test_pool.py +148 -0
- sustained-2.2.0/tests/test_schema_ddl.py +168 -0
- {sustained-2.1.0 → sustained-2.2.0}/.gitignore +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/.pre-commit-config.yaml +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/DEVELOPERS.md +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/LICENSE +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/deploy.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/CNAME +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/_config.yml +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/_layouts/default.html +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/assets/css/style.scss +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/filtering.md +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/grouping.md +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/models.md +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/queries.md +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/docs/relations.md +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/__init__.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/__init__.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/__init__.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/group_by_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/group_by_builder.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/having_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/having_builder.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/join_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/join_builder.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/where_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/builders/where_builder.pyi +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/compilers/__init__.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/dialects.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/exceptions.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/expressions.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/functions.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/py.typed +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/rendering.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/src/sustained/types.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/__init__.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_analyst_sql.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_builder_ergonomics.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_builder_robustness.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_dialect.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_dialect_behaviors.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_dialect_functions.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_dml.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_duckdb_dialect.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_etl_statements.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_execution.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_expressions.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_functions.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_having_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_join_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_lambda_join_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_model.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_model_features.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_mssql_compiler.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_order_by_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_parameterization.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_postgres_compiler.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_predicates.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_query_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_raw_bindings.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_result_formats.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_select_clause_builder.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_transactions.py +0 -0
- {sustained-2.1.0 → sustained-2.2.0}/tests/test_upsert.py +0 -0
- {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.
|
|
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()`.
|
|
@@ -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)
|