sustained 2.2.0__tar.gz → 2.4.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 (99) hide show
  1. {sustained-2.2.0 → sustained-2.4.0}/CHANGELOG.md +23 -0
  2. {sustained-2.2.0 → sustained-2.4.0}/PKG-INFO +2 -2
  3. {sustained-2.2.0 → sustained-2.4.0}/README.md +1 -1
  4. {sustained-2.2.0 → sustained-2.4.0}/docs/index.md +1 -1
  5. sustained-2.4.0/docs/schema.md +140 -0
  6. {sustained-2.2.0 → sustained-2.4.0}/pyproject.toml +1 -1
  7. sustained-2.4.0/src/sustained/aio_migrations.py +154 -0
  8. sustained-2.4.0/src/sustained/autogenerate.py +859 -0
  9. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/base.py +76 -0
  10. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/duckdb.py +27 -0
  11. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/mssql.py +48 -0
  12. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/postgres.py +27 -0
  13. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/migrations.py +106 -3
  14. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/model.py +33 -2
  15. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/schema.py +56 -19
  16. sustained-2.4.0/tests/test_async_migrations.py +88 -0
  17. sustained-2.4.0/tests/test_autogenerate.py +555 -0
  18. sustained-2.4.0/tests/test_dialect_ddl.py +108 -0
  19. sustained-2.2.0/docs/schema.md +0 -64
  20. {sustained-2.2.0 → sustained-2.4.0}/.gitignore +0 -0
  21. {sustained-2.2.0 → sustained-2.4.0}/.pre-commit-config.yaml +0 -0
  22. {sustained-2.2.0 → sustained-2.4.0}/DEVELOPERS.md +0 -0
  23. {sustained-2.2.0 → sustained-2.4.0}/LICENSE +0 -0
  24. {sustained-2.2.0 → sustained-2.4.0}/deploy.py +0 -0
  25. {sustained-2.2.0 → sustained-2.4.0}/docs/CNAME +0 -0
  26. {sustained-2.2.0 → sustained-2.4.0}/docs/_config.yml +0 -0
  27. {sustained-2.2.0 → sustained-2.4.0}/docs/_layouts/default.html +0 -0
  28. {sustained-2.2.0 → sustained-2.4.0}/docs/assets/css/style.scss +0 -0
  29. {sustained-2.2.0 → sustained-2.4.0}/docs/executing.md +0 -0
  30. {sustained-2.2.0 → sustained-2.4.0}/docs/filtering.md +0 -0
  31. {sustained-2.2.0 → sustained-2.4.0}/docs/grouping.md +0 -0
  32. {sustained-2.2.0 → sustained-2.4.0}/docs/models.md +0 -0
  33. {sustained-2.2.0 → sustained-2.4.0}/docs/queries.md +0 -0
  34. {sustained-2.2.0 → sustained-2.4.0}/docs/relations.md +0 -0
  35. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/__init__.py +0 -0
  36. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/aio.py +0 -0
  37. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builder.py +0 -0
  38. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builder.pyi +0 -0
  39. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/__init__.py +0 -0
  40. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/__init__.pyi +0 -0
  41. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
  42. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
  43. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.py +0 -0
  44. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.pyi +0 -0
  45. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/having_builder.py +0 -0
  46. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/having_builder.pyi +0 -0
  47. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/join_builder.py +0 -0
  48. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/join_builder.pyi +0 -0
  49. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.py +0 -0
  50. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.pyi +0 -0
  51. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.py +0 -0
  52. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
  53. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/where_builder.py +0 -0
  54. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/where_builder.pyi +0 -0
  55. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/__init__.py +0 -0
  56. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/presto.py +0 -0
  57. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/dialects.py +0 -0
  58. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/exceptions.py +0 -0
  59. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/execution.py +0 -0
  60. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/expressions.py +0 -0
  61. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/functions.py +0 -0
  62. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/pool.py +0 -0
  63. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/py.typed +0 -0
  64. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/rendering.py +0 -0
  65. {sustained-2.2.0 → sustained-2.4.0}/src/sustained/types.py +0 -0
  66. {sustained-2.2.0 → sustained-2.4.0}/tests/__init__.py +0 -0
  67. {sustained-2.2.0 → sustained-2.4.0}/tests/test_analyst_sql.py +0 -0
  68. {sustained-2.2.0 → sustained-2.4.0}/tests/test_async.py +0 -0
  69. {sustained-2.2.0 → sustained-2.4.0}/tests/test_builder_ergonomics.py +0 -0
  70. {sustained-2.2.0 → sustained-2.4.0}/tests/test_builder_robustness.py +0 -0
  71. {sustained-2.2.0 → sustained-2.4.0}/tests/test_dialect.py +0 -0
  72. {sustained-2.2.0 → sustained-2.4.0}/tests/test_dialect_behaviors.py +0 -0
  73. {sustained-2.2.0 → sustained-2.4.0}/tests/test_dialect_functions.py +0 -0
  74. {sustained-2.2.0 → sustained-2.4.0}/tests/test_dml.py +0 -0
  75. {sustained-2.2.0 → sustained-2.4.0}/tests/test_duckdb_dialect.py +0 -0
  76. {sustained-2.2.0 → sustained-2.4.0}/tests/test_etl_statements.py +0 -0
  77. {sustained-2.2.0 → sustained-2.4.0}/tests/test_execution.py +0 -0
  78. {sustained-2.2.0 → sustained-2.4.0}/tests/test_expressions.py +0 -0
  79. {sustained-2.2.0 → sustained-2.4.0}/tests/test_functions.py +0 -0
  80. {sustained-2.2.0 → sustained-2.4.0}/tests/test_having_builder.py +0 -0
  81. {sustained-2.2.0 → sustained-2.4.0}/tests/test_join_builder.py +0 -0
  82. {sustained-2.2.0 → sustained-2.4.0}/tests/test_lambda_join_builder.py +0 -0
  83. {sustained-2.2.0 → sustained-2.4.0}/tests/test_migrations.py +0 -0
  84. {sustained-2.2.0 → sustained-2.4.0}/tests/test_model.py +0 -0
  85. {sustained-2.2.0 → sustained-2.4.0}/tests/test_model_features.py +0 -0
  86. {sustained-2.2.0 → sustained-2.4.0}/tests/test_mssql_compiler.py +0 -0
  87. {sustained-2.2.0 → sustained-2.4.0}/tests/test_order_by_builder.py +0 -0
  88. {sustained-2.2.0 → sustained-2.4.0}/tests/test_parameterization.py +0 -0
  89. {sustained-2.2.0 → sustained-2.4.0}/tests/test_pool.py +0 -0
  90. {sustained-2.2.0 → sustained-2.4.0}/tests/test_postgres_compiler.py +0 -0
  91. {sustained-2.2.0 → sustained-2.4.0}/tests/test_predicates.py +0 -0
  92. {sustained-2.2.0 → sustained-2.4.0}/tests/test_query_builder.py +0 -0
  93. {sustained-2.2.0 → sustained-2.4.0}/tests/test_raw_bindings.py +0 -0
  94. {sustained-2.2.0 → sustained-2.4.0}/tests/test_result_formats.py +0 -0
  95. {sustained-2.2.0 → sustained-2.4.0}/tests/test_schema_ddl.py +0 -0
  96. {sustained-2.2.0 → sustained-2.4.0}/tests/test_select_clause_builder.py +0 -0
  97. {sustained-2.2.0 → sustained-2.4.0}/tests/test_transactions.py +0 -0
  98. {sustained-2.2.0 → sustained-2.4.0}/tests/test_upsert.py +0 -0
  99. {sustained-2.2.0 → sustained-2.4.0}/tests/test_where_builder.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0 (2026-08-14)
4
+
5
+ ### Added
6
+
7
+ - Constraint-aware introspection: primary keys, unique constraints, foreign keys, column defaults, and indexes are read from SQLite PRAGMA tables or information_schema, with graceful degradation and system schemas filtered.
8
+ - Type and nullability changes now generate migrations: in-place reversible ALTER COLUMN on Postgres (with `type_casts` USING hints), MSSQL, and DuckDB; automatic table rebuild with row copy on SQLite.
9
+ - Rename hints: `renames={'table.old': 'new'}` and `table_renames` produce reversible RENAME statements (sp_rename on MSSQL) instead of destructive drop-plus-add.
10
+ - Declared indexes on models via `Index`; created with the table and diffed for additions, definition changes, and opt-in drops, all reversible.
11
+ - `backfill` on ColumnDef: NOT NULL adds and tightenings emit add-nullable, UPDATE, SET NOT NULL, or fold into the SQLite rebuild.
12
+ - Length and precision changes detected when both sides report them.
13
+ - Constraint notes: PK, FK, unique, and default drift reported in the diff, never auto-migrated.
14
+ - Offline scripts: `migration_sql()` and `Migrator.script()` render the SQL a run would execute for DBA review.
15
+ - `AsyncMigrator`: the migration runner on an AsyncAdapter with transactional application and awaited callable steps.
16
+
17
+ ## 2.3.0 (2026-08-14)
18
+
19
+ ### Added
20
+
21
+ - 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.
22
+ - `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.
23
+ - `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.
24
+ - Compilers render `ADD COLUMN` and `DROP COLUMN` statements, with the T-SQL `ADD` spelling on MSSQL.
25
+
3
26
  ## 2.2.0 (2026-08-14)
4
27
 
5
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sustained
3
- Version: 2.2.0
3
+ Version: 2.4.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 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()`.
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 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()`.
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
 
@@ -18,7 +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
+ 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.
22
22
 
23
23
  ## API Reference
24
24
 
@@ -0,0 +1,140 @@
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 and nullability changes migrate per dialect.** Postgres, MSSQL, and DuckDB alter in place with reversible down steps; Postgres casts take a hint through `type_casts={'table.col': 'col::integer'}`. SQLite rebuilds the table (create new, copy rows, replace), which is not reversible. Pass `ignore_changed_columns=True` to skip them entirely.
65
+ - **NOT NULL needs a value for existing rows.** Adding or tightening to NOT NULL requires a `default` or a `backfill` value on the ColumnDef; generation emits add-nullable, UPDATE backfill, SET NOT NULL, or folds the backfill into a SQLite rebuild. 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
+ Renames cannot be detected from the catalog, so pass hints: `sync(models, renames={'users.name': 'full_name'}, table_renames={'old': 'new'})` emits reversible RENAME statements instead of a destructive drop-plus-add.
69
+
70
+ Primary key, foreign key, column-level unique, and default differences are reported as constraint notes in the diff but never auto-migrated.
71
+
72
+ ## Typed Columns
73
+
74
+ `tableColumns` maps column names to typed definitions: `Integer`, `BigInteger`, `String(length)`, `Text`, `Boolean`, `Float`, `Numeric(precision, scale)`, `Date`, `Timestamp`, `Json`.
75
+
76
+ ```python
77
+ from sustained.schema import Boolean, Integer, String, Timestamp
78
+ from sustained.types import Expression
79
+
80
+ class User(Model):
81
+ tableName = 'users'
82
+ tableColumns = {
83
+ 'id': Integer(primary_key=True, autoincrement=True),
84
+ 'email': String(120, unique=True, nullable=False),
85
+ 'active': Boolean(default=True),
86
+ 'created_at': Timestamp(default=Expression('CURRENT_TIMESTAMP')),
87
+ }
88
+ ```
89
+
90
+ Definitions support composite primary keys (mark several columns `primary_key=True`), `unique`, literal or raw `Expression` defaults, foreign keys through `references='table.column'`, and `backfill` values for NOT NULL migrations. Models also declare named indexes, which `create_table()` and generated migrations create and keep in sync:
91
+
92
+ ```python
93
+ from sustained.schema import Index
94
+
95
+ class User(Model):
96
+ tableName = 'users'
97
+ tableColumns = {...}
98
+ indexes = [Index('ix_users_email', 'email', unique=True)]
99
+ ```
100
+
101
+ `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`.
102
+
103
+ ## Generating and Running DDL Directly
104
+
105
+ ```python
106
+ User.create_table_sql() # dialect-specific CREATE TABLE
107
+ User.create_table(conn) # execute it
108
+ User.drop_table(conn) # DROP TABLE IF EXISTS
109
+ ```
110
+
111
+ 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.
112
+
113
+ ## Hand-Written Migrations
114
+
115
+ 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.
116
+
117
+ ```python
118
+ from sustained.migrations import Migration, Migrator, create_table_migration
119
+
120
+ migrations = [
121
+ create_table_migration(User),
122
+ Migration(
123
+ 'split_name_column',
124
+ up=[
125
+ 'ALTER TABLE users ADD COLUMN first_name TEXT',
126
+ "UPDATE users SET first_name = substr(name, 1, instr(name, ' ') - 1) WHERE 1 = 1",
127
+ ],
128
+ down='ALTER TABLE users DROP COLUMN first_name',
129
+ ),
130
+ ]
131
+
132
+ migrator = Migrator(conn, migrations)
133
+ migrator.up() # apply all pending
134
+ migrator.up(target='create_users') # stop after a target
135
+ migrator.status() # [(id, applied), ...]
136
+ ```
137
+
138
+ ## Offline Review and Async
139
+
140
+ `migrator.script('up')` renders every statement a run would execute, including tracking bookkeeping, without touching the database, for review or DBA handoff; `script('down')` renders the rollback. For async services, `AsyncMigrator` in `sustained.aio_migrations` runs the same `Migration` objects on an `AsyncAdapter` with the same `up`, `down`, `down_to`, and `status` surface; callable steps receive the adapter and are awaited.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sustained"
7
- version = "2.2.0"
7
+ version = "2.4.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,154 @@
1
+ """
2
+ Async migration runner.
3
+
4
+ AsyncMigrator mirrors Migrator on an AsyncAdapter: same Migration objects,
5
+ same tracking table, same ordering rules. String and list steps execute
6
+ through the adapter; callable steps receive the adapter and are awaited
7
+ when they return a coroutine. Each migration runs inside an
8
+ async_transaction() block.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+ from datetime import datetime, timezone
15
+ from typing import Any, List, Optional, Tuple
16
+
17
+ from sustained.aio import AsyncAdapter, async_transaction
18
+ from sustained.dialects import Dialects
19
+ from sustained.migrations import Migration, MigrationStep
20
+
21
+
22
+ class AsyncMigrator:
23
+ """Applies and reverts an ordered list of migrations on an adapter."""
24
+
25
+ def __init__(
26
+ self,
27
+ adapter: AsyncAdapter,
28
+ migrations: List[Migration],
29
+ table: str = "sustained_migrations",
30
+ dialect: Dialects = Dialects.DEFAULT,
31
+ ) -> None:
32
+ ids = [m.id for m in migrations]
33
+ duplicates = {i for i in ids if ids.count(i) > 1}
34
+ if duplicates:
35
+ raise ValueError(f"Duplicate migration ids: {sorted(duplicates)}.")
36
+ self._adapter = adapter
37
+ self._migrations = list(migrations)
38
+ self._table = table
39
+ self._dialect = dialect
40
+ self._compiler = Dialects.get_compiler(dialect)
41
+
42
+ def _table_sql(self) -> str:
43
+ return self._compiler.quote_identifier(self._table)
44
+
45
+ async def _run_step(self, step: MigrationStep) -> None:
46
+ if callable(step):
47
+ result = step(self._adapter)
48
+ if inspect.isawaitable(result):
49
+ await result
50
+ return
51
+ statements = [step] if isinstance(step, str) else list(step)
52
+ for statement in statements:
53
+ await self._adapter.execute(statement, ())
54
+
55
+ async def _ensure_tracking_table(self) -> None:
56
+ from sustained.schema import String, Text, build_create_table_sql
57
+
58
+ sql = build_create_table_sql(
59
+ self._compiler,
60
+ self._table_sql(),
61
+ {
62
+ "id": String(255, primary_key=True),
63
+ "applied_at": Text(nullable=False),
64
+ },
65
+ if_not_exists=True,
66
+ )
67
+ await self._adapter.execute(sql, ())
68
+ await self._adapter.commit()
69
+
70
+ async def applied(self) -> List[str]:
71
+ """Returns the applied migration ids in application order."""
72
+ await self._ensure_tracking_table()
73
+ _, rows = await self._adapter.fetch(
74
+ f"SELECT id FROM {self._table_sql()} ORDER BY applied_at, id", ()
75
+ )
76
+ return [row[0] for row in rows]
77
+
78
+ async def pending(self) -> List[Migration]:
79
+ """Returns the registered migrations that have not been applied."""
80
+ applied = set(await self.applied())
81
+ return [m for m in self._migrations if m.id not in applied]
82
+
83
+ async def status(self) -> List[Tuple[str, bool]]:
84
+ """Returns (id, applied) pairs for every registered migration."""
85
+ applied = set(await self.applied())
86
+ return [(m.id, m.id in applied) for m in self._migrations]
87
+
88
+ async def up(self, target: Optional[str] = None) -> List[str]:
89
+ """
90
+ Applies pending migrations in order, stopping after the target id
91
+ when one is given. Returns the ids that were applied.
92
+ """
93
+ migrations = self._migrations
94
+ if target is not None:
95
+ ids = [m.id for m in migrations]
96
+ if target not in ids:
97
+ raise ValueError(f"Unknown migration target: {target!r}.")
98
+ migrations = migrations[: ids.index(target) + 1]
99
+
100
+ already_applied = set(await self.applied())
101
+ placeholder = self._compiler.placeholder()
102
+ applied_now: List[str] = []
103
+ for migration in migrations:
104
+ if migration.id in already_applied:
105
+ continue
106
+ async with async_transaction(self._adapter):
107
+ await self._run_step(migration.up)
108
+ timestamp = datetime.now(timezone.utc).isoformat()
109
+ await self._adapter.execute(
110
+ f"INSERT INTO {self._table_sql()} (id, applied_at) "
111
+ f"VALUES ({placeholder}, {placeholder})",
112
+ (migration.id, timestamp),
113
+ )
114
+ applied_now.append(migration.id)
115
+ return applied_now
116
+
117
+ async def down(self, steps: int = 1) -> List[str]:
118
+ """
119
+ Reverts the most recently applied migrations, newest first. Every
120
+ reverted migration must define a down step and be registered with
121
+ this migrator. Returns the ids that were reverted.
122
+ """
123
+ applied = await self.applied()
124
+ by_id = {m.id: m for m in self._migrations}
125
+ placeholder = self._compiler.placeholder()
126
+ reverted: List[str] = []
127
+ for migration_id in reversed(applied[-steps:] if steps else []):
128
+ migration = by_id.get(migration_id)
129
+ if migration is None:
130
+ raise ValueError(
131
+ f"Applied migration '{migration_id}' is not registered "
132
+ "with this migrator; cannot revert."
133
+ )
134
+ if migration.down is None:
135
+ raise ValueError(f"Migration '{migration_id}' has no down step.")
136
+ async with async_transaction(self._adapter):
137
+ await self._run_step(migration.down)
138
+ await self._adapter.execute(
139
+ f"DELETE FROM {self._table_sql()} WHERE id = {placeholder}",
140
+ (migration_id,),
141
+ )
142
+ reverted.append(migration_id)
143
+ return reverted
144
+
145
+ async def down_to(self, target: str) -> List[str]:
146
+ """
147
+ Reverts applied migrations newest-first until the target is the
148
+ most recent applied migration. The target itself stays applied.
149
+ """
150
+ applied = await self.applied()
151
+ if target not in applied:
152
+ raise ValueError(f"Migration '{target}' is not applied.")
153
+ steps = len(applied) - applied.index(target) - 1
154
+ return await self.down(steps) if steps else []