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.
- {sustained-2.2.0 → sustained-2.4.0}/CHANGELOG.md +23 -0
- {sustained-2.2.0 → sustained-2.4.0}/PKG-INFO +2 -2
- {sustained-2.2.0 → sustained-2.4.0}/README.md +1 -1
- {sustained-2.2.0 → sustained-2.4.0}/docs/index.md +1 -1
- sustained-2.4.0/docs/schema.md +140 -0
- {sustained-2.2.0 → sustained-2.4.0}/pyproject.toml +1 -1
- sustained-2.4.0/src/sustained/aio_migrations.py +154 -0
- sustained-2.4.0/src/sustained/autogenerate.py +859 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/base.py +76 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/duckdb.py +27 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/mssql.py +48 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/postgres.py +27 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/migrations.py +106 -3
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/model.py +33 -2
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/schema.py +56 -19
- sustained-2.4.0/tests/test_async_migrations.py +88 -0
- sustained-2.4.0/tests/test_autogenerate.py +555 -0
- sustained-2.4.0/tests/test_dialect_ddl.py +108 -0
- sustained-2.2.0/docs/schema.md +0 -64
- {sustained-2.2.0 → sustained-2.4.0}/.gitignore +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/.pre-commit-config.yaml +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/DEVELOPERS.md +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/LICENSE +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/deploy.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/CNAME +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/_config.yml +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/_layouts/default.html +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/assets/css/style.scss +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/executing.md +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/filtering.md +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/grouping.md +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/models.md +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/queries.md +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/docs/relations.md +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/aio.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/__init__.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/having_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/having_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/join_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/join_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/where_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/builders/where_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/compilers/presto.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/dialects.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/exceptions.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/execution.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/expressions.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/functions.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/pool.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/py.typed +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/rendering.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/src/sustained/types.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_analyst_sql.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_async.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_builder_ergonomics.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_builder_robustness.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_dialect.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_dialect_behaviors.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_dialect_functions.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_dml.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_duckdb_dialect.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_etl_statements.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_execution.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_expressions.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_functions.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_having_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_join_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_lambda_join_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_migrations.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_model.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_model_features.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_mssql_compiler.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_order_by_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_parameterization.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_pool.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_postgres_compiler.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_predicates.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_query_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_raw_bindings.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_result_formats.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_schema_ddl.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_select_clause_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_transactions.py +0 -0
- {sustained-2.2.0 → sustained-2.4.0}/tests/test_upsert.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -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 []
|