sustained 2.1.0__tar.gz → 2.3.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 (95) hide show
  1. {sustained-2.1.0 → sustained-2.3.0}/CHANGELOG.md +19 -0
  2. {sustained-2.1.0 → sustained-2.3.0}/PKG-INFO +2 -2
  3. {sustained-2.1.0 → sustained-2.3.0}/README.md +1 -1
  4. {sustained-2.1.0 → sustained-2.3.0}/docs/executing.md +41 -0
  5. {sustained-2.1.0 → sustained-2.3.0}/docs/index.md +1 -0
  6. sustained-2.3.0/docs/schema.md +123 -0
  7. {sustained-2.1.0 → sustained-2.3.0}/pyproject.toml +1 -1
  8. sustained-2.3.0/src/sustained/aio.py +356 -0
  9. sustained-2.3.0/src/sustained/autogenerate.py +321 -0
  10. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builder.py +100 -61
  11. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builder.pyi +10 -0
  12. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/compilers/base.py +46 -0
  13. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/compilers/duckdb.py +8 -0
  14. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/compilers/mssql.py +17 -0
  15. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/compilers/postgres.py +5 -0
  16. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/compilers/presto.py +5 -0
  17. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/execution.py +81 -0
  18. sustained-2.3.0/src/sustained/migrations.py +227 -0
  19. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/model.py +96 -0
  20. sustained-2.3.0/src/sustained/pool.py +112 -0
  21. sustained-2.3.0/src/sustained/schema.py +200 -0
  22. sustained-2.3.0/tests/test_async.py +155 -0
  23. sustained-2.3.0/tests/test_autogenerate.py +204 -0
  24. sustained-2.3.0/tests/test_migrations.py +132 -0
  25. sustained-2.3.0/tests/test_pool.py +148 -0
  26. sustained-2.3.0/tests/test_schema_ddl.py +168 -0
  27. {sustained-2.1.0 → sustained-2.3.0}/.gitignore +0 -0
  28. {sustained-2.1.0 → sustained-2.3.0}/.pre-commit-config.yaml +0 -0
  29. {sustained-2.1.0 → sustained-2.3.0}/DEVELOPERS.md +0 -0
  30. {sustained-2.1.0 → sustained-2.3.0}/LICENSE +0 -0
  31. {sustained-2.1.0 → sustained-2.3.0}/deploy.py +0 -0
  32. {sustained-2.1.0 → sustained-2.3.0}/docs/CNAME +0 -0
  33. {sustained-2.1.0 → sustained-2.3.0}/docs/_config.yml +0 -0
  34. {sustained-2.1.0 → sustained-2.3.0}/docs/_layouts/default.html +0 -0
  35. {sustained-2.1.0 → sustained-2.3.0}/docs/assets/css/style.scss +0 -0
  36. {sustained-2.1.0 → sustained-2.3.0}/docs/filtering.md +0 -0
  37. {sustained-2.1.0 → sustained-2.3.0}/docs/grouping.md +0 -0
  38. {sustained-2.1.0 → sustained-2.3.0}/docs/models.md +0 -0
  39. {sustained-2.1.0 → sustained-2.3.0}/docs/queries.md +0 -0
  40. {sustained-2.1.0 → sustained-2.3.0}/docs/relations.md +0 -0
  41. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/__init__.py +0 -0
  42. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/__init__.py +0 -0
  43. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/__init__.pyi +0 -0
  44. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
  45. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
  46. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/group_by_builder.py +0 -0
  47. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/group_by_builder.pyi +0 -0
  48. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/having_builder.py +0 -0
  49. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/having_builder.pyi +0 -0
  50. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/join_builder.py +0 -0
  51. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/join_builder.pyi +0 -0
  52. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/order_by_builder.py +0 -0
  53. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/order_by_builder.pyi +0 -0
  54. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/select_clause_builder.py +0 -0
  55. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
  56. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/where_builder.py +0 -0
  57. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/builders/where_builder.pyi +0 -0
  58. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/compilers/__init__.py +0 -0
  59. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/dialects.py +0 -0
  60. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/exceptions.py +0 -0
  61. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/expressions.py +0 -0
  62. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/functions.py +0 -0
  63. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/py.typed +0 -0
  64. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/rendering.py +0 -0
  65. {sustained-2.1.0 → sustained-2.3.0}/src/sustained/types.py +0 -0
  66. {sustained-2.1.0 → sustained-2.3.0}/tests/__init__.py +0 -0
  67. {sustained-2.1.0 → sustained-2.3.0}/tests/test_analyst_sql.py +0 -0
  68. {sustained-2.1.0 → sustained-2.3.0}/tests/test_builder_ergonomics.py +0 -0
  69. {sustained-2.1.0 → sustained-2.3.0}/tests/test_builder_robustness.py +0 -0
  70. {sustained-2.1.0 → sustained-2.3.0}/tests/test_dialect.py +0 -0
  71. {sustained-2.1.0 → sustained-2.3.0}/tests/test_dialect_behaviors.py +0 -0
  72. {sustained-2.1.0 → sustained-2.3.0}/tests/test_dialect_functions.py +0 -0
  73. {sustained-2.1.0 → sustained-2.3.0}/tests/test_dml.py +0 -0
  74. {sustained-2.1.0 → sustained-2.3.0}/tests/test_duckdb_dialect.py +0 -0
  75. {sustained-2.1.0 → sustained-2.3.0}/tests/test_etl_statements.py +0 -0
  76. {sustained-2.1.0 → sustained-2.3.0}/tests/test_execution.py +0 -0
  77. {sustained-2.1.0 → sustained-2.3.0}/tests/test_expressions.py +0 -0
  78. {sustained-2.1.0 → sustained-2.3.0}/tests/test_functions.py +0 -0
  79. {sustained-2.1.0 → sustained-2.3.0}/tests/test_having_builder.py +0 -0
  80. {sustained-2.1.0 → sustained-2.3.0}/tests/test_join_builder.py +0 -0
  81. {sustained-2.1.0 → sustained-2.3.0}/tests/test_lambda_join_builder.py +0 -0
  82. {sustained-2.1.0 → sustained-2.3.0}/tests/test_model.py +0 -0
  83. {sustained-2.1.0 → sustained-2.3.0}/tests/test_model_features.py +0 -0
  84. {sustained-2.1.0 → sustained-2.3.0}/tests/test_mssql_compiler.py +0 -0
  85. {sustained-2.1.0 → sustained-2.3.0}/tests/test_order_by_builder.py +0 -0
  86. {sustained-2.1.0 → sustained-2.3.0}/tests/test_parameterization.py +0 -0
  87. {sustained-2.1.0 → sustained-2.3.0}/tests/test_postgres_compiler.py +0 -0
  88. {sustained-2.1.0 → sustained-2.3.0}/tests/test_predicates.py +0 -0
  89. {sustained-2.1.0 → sustained-2.3.0}/tests/test_query_builder.py +0 -0
  90. {sustained-2.1.0 → sustained-2.3.0}/tests/test_raw_bindings.py +0 -0
  91. {sustained-2.1.0 → sustained-2.3.0}/tests/test_result_formats.py +0 -0
  92. {sustained-2.1.0 → sustained-2.3.0}/tests/test_select_clause_builder.py +0 -0
  93. {sustained-2.1.0 → sustained-2.3.0}/tests/test_transactions.py +0 -0
  94. {sustained-2.1.0 → sustained-2.3.0}/tests/test_upsert.py +0 -0
  95. {sustained-2.1.0 → sustained-2.3.0}/tests/test_where_builder.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.0 (2026-08-14)
4
+
5
+ ### Added
6
+
7
+ - Schema autogeneration: `diff_schema()` introspects the live database (SQLite PRAGMA on the default dialect, `information_schema.columns` elsewhere) and reports missing tables, new columns, extra objects, and changed columns with a readable `summary()`. Type comparison round-trips through each dialect's own type mapping, so tables created from models diff clean.
8
+ - `autogenerate()`: builds a `Migration` from the diff. Additive steps are reversible (CREATE/DROP TABLE, ADD/DROP COLUMN pairs). Drops require `allow_drops=True` and carry no down step; changed column types block generation unless explicitly ignored; NOT NULL adds without defaults and primary key or autoincrement adds are rejected.
9
+ - `Migrator.sync(models)`: diff, generate, register, and apply in one call, idempotent when the schema is current. `Migrator.down_to(id)` reverts newest-first until the target is the most recent applied migration.
10
+ - Compilers render `ADD COLUMN` and `DROP COLUMN` statements, with the T-SQL `ADD` spelling on MSSQL.
11
+
12
+ ## 2.2.0 (2026-08-14)
13
+
14
+ ### Added
15
+
16
+ - 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.
17
+ - Model-driven DDL: `create_table_sql()`, `create_table()`, `drop_table()` with per-dialect type mapping and identity syntax. DuckDB and Presto raise for autoincrement.
18
+ - 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.
19
+ - `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.
20
+ - 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.
21
+
3
22
  ## 2.1.0 (2026-08-14)
4
23
 
5
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sustained
3
- Version: 2.1.0
3
+ Version: 2.3.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 declare typed columns and manage their own schema: `Migrator.sync(models)` diffs the live database, generates the migration, applies it, and `down()` rolls it back, with destructive changes gated behind explicit opt-ins. 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 declare typed columns and manage their own schema: `Migrator.sync(models)` diffs the live database, generates the migration, applies it, and `down()` rolls it back, with destructive changes gated behind explicit opt-ins. 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, then let the migrator diff the live database, generate migrations, apply them, and roll them back.
21
22
 
22
23
  ## API Reference
23
24
 
@@ -0,0 +1,123 @@
1
+ ---
2
+ layout: default
3
+ title: Schema and Migrations
4
+ ---
5
+
6
+ Sustained manages your database schema from your models. Declare typed columns once, then let the migrator create tables, detect drift, generate migrations, apply them, and roll them back.
7
+
8
+ ## Automated Migration in One Call
9
+
10
+ `Migrator.sync()` diffs the live database against your models, generates the migration, records it, and applies it. Run it again after changing a model and only the difference is applied. Nothing to hand-write for additive changes.
11
+
12
+ ```python
13
+ from sustained import Model
14
+ from sustained.migrations import Migrator
15
+ from sustained.schema import Integer, String, Text
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
+ }
23
+
24
+ migrator = Migrator(conn, [])
25
+ migrator.sync([User]) # creates the users table
26
+
27
+ User.tableColumns['bio'] = Text()
28
+ migrator.sync([User]) # adds only the bio column
29
+ ```
30
+
31
+ ## Automated Rollback
32
+
33
+ Generated migrations are reversible: a created table carries its DROP, an added column carries its DROP COLUMN. Roll back one step, several steps, or down to a known-good migration.
34
+
35
+ ```python
36
+ migrator.down() # revert the newest applied migration
37
+ migrator.down(steps=2) # revert the two newest
38
+ migrator.down_to('auto_20260814...') # revert until this id is newest
39
+ ```
40
+
41
+ Every applied migration is recorded in a tracking table, each runs inside a transaction, and a failing step rolls itself back and leaves earlier migrations applied.
42
+
43
+ ## Inspecting Drift Before Applying
44
+
45
+ `diff_schema()` reports every difference without touching anything. `autogenerate()` builds the migration so you can review its statements before it runs.
46
+
47
+ ```python
48
+ from sustained.autogenerate import autogenerate, diff_schema
49
+
50
+ print(diff_schema(conn, [User]).summary())
51
+ # add column users.bio
52
+ # drop column users.legacy (destructive)
53
+
54
+ migration = autogenerate(conn, [User], id='add_bio')
55
+ print(migration.up) # ['ALTER TABLE users ADD COLUMN bio TEXT']
56
+ print(migration.down) # ['ALTER TABLE users DROP COLUMN bio']
57
+ ```
58
+
59
+ ## Safety Rules
60
+
61
+ Autogeneration refuses to guess about anything that loses data or fails on populated tables:
62
+
63
+ - **Drops are opt-in.** Extra tables and columns raise unless `allow_drops=True`. A migration containing drops has no down step, because the dropped data cannot come back.
64
+ - **Type changes are never auto-migrated.** A changed type or nullability is reported in the diff and blocks generation until you write that migration by hand or pass `ignore_changed_columns=True`. SQLite cannot alter a column type in place, and a silent rewrite is exactly the change a human should review.
65
+ - **Unsafe adds are rejected.** A new NOT NULL column needs a default; new primary key or autoincrement columns cannot be added with ALTER TABLE.
66
+ - The migration tracking table is excluded from diffing, and `exclude_tables` protects any other tables Sustained does not manage.
67
+
68
+ Diffing compares column presence, type, and nullability. Constraint changes (primary keys, unique indexes, foreign keys) are out of scope.
69
+
70
+ ## Typed Columns
71
+
72
+ `tableColumns` maps column names to typed definitions: `Integer`, `BigInteger`, `String(length)`, `Text`, `Boolean`, `Float`, `Numeric(precision, scale)`, `Date`, `Timestamp`, `Json`.
73
+
74
+ ```python
75
+ from sustained.schema import Boolean, Integer, String, Timestamp
76
+ from sustained.types import Expression
77
+
78
+ class User(Model):
79
+ tableName = 'users'
80
+ tableColumns = {
81
+ 'id': Integer(primary_key=True, autoincrement=True),
82
+ 'email': String(120, unique=True, nullable=False),
83
+ 'active': Boolean(default=True),
84
+ 'created_at': Timestamp(default=Expression('CURRENT_TIMESTAMP')),
85
+ }
86
+ ```
87
+
88
+ Definitions support composite primary keys (mark several columns `primary_key=True`), `unique`, literal 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. A model with `tableColumns` also gets strict column-name access automatically: a typo'd column raises `AttributeError`.
89
+
90
+ ## Generating and Running DDL Directly
91
+
92
+ ```python
93
+ User.create_table_sql() # dialect-specific CREATE TABLE
94
+ User.create_table(conn) # execute it
95
+ User.drop_table(conn) # DROP TABLE IF EXISTS
96
+ ```
97
+
98
+ 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. Schema introspection reads SQLite's PRAGMA tables on the default dialect and `information_schema.columns` elsewhere.
99
+
100
+ ## Hand-Written Migrations
101
+
102
+ Anything autogeneration will not express, write explicitly. A `Migration` 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. Hand-written and generated migrations share one ordered list and one tracking table.
103
+
104
+ ```python
105
+ from sustained.migrations import Migration, Migrator, create_table_migration
106
+
107
+ migrations = [
108
+ create_table_migration(User),
109
+ Migration(
110
+ 'split_name_column',
111
+ up=[
112
+ 'ALTER TABLE users ADD COLUMN first_name TEXT',
113
+ "UPDATE users SET first_name = substr(name, 1, instr(name, ' ') - 1) WHERE 1 = 1",
114
+ ],
115
+ down='ALTER TABLE users DROP COLUMN first_name',
116
+ ),
117
+ ]
118
+
119
+ migrator = Migrator(conn, migrations)
120
+ migrator.up() # apply all pending
121
+ migrator.up(target='create_users') # stop after a target
122
+ migrator.status() # [(id, applied), ...]
123
+ ```
@@ -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.3.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)