sustained 2.3.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.3.0 → sustained-2.4.0}/CHANGELOG.md +14 -0
- {sustained-2.3.0 → sustained-2.4.0}/PKG-INFO +1 -1
- {sustained-2.3.0 → sustained-2.4.0}/docs/schema.md +21 -4
- {sustained-2.3.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.3.0 → sustained-2.4.0}/src/sustained/compilers/base.py +67 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/duckdb.py +27 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/mssql.py +44 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/postgres.py +27 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/migrations.py +61 -4
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/model.py +33 -2
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/schema.py +27 -0
- 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.3.0/src/sustained/autogenerate.py +0 -321
- sustained-2.3.0/tests/test_autogenerate.py +0 -204
- {sustained-2.3.0 → sustained-2.4.0}/.gitignore +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/.pre-commit-config.yaml +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/DEVELOPERS.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/LICENSE +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/README.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/deploy.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/CNAME +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/_config.yml +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/_layouts/default.html +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/assets/css/style.scss +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/executing.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/filtering.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/grouping.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/index.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/models.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/queries.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/docs/relations.md +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/__init__.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/aio.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/__init__.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/__init__.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/having_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/having_builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/join_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/join_builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/where_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/where_builder.pyi +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/__init__.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/presto.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/dialects.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/exceptions.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/execution.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/expressions.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/functions.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/pool.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/py.typed +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/rendering.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/src/sustained/types.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/__init__.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_analyst_sql.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_async.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_builder_ergonomics.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_builder_robustness.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_dialect.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_dialect_behaviors.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_dialect_functions.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_dml.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_duckdb_dialect.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_etl_statements.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_execution.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_expressions.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_functions.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_having_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_join_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_lambda_join_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_migrations.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_model.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_model_features.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_mssql_compiler.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_order_by_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_parameterization.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_pool.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_postgres_compiler.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_predicates.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_query_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_raw_bindings.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_result_formats.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_schema_ddl.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_select_clause_builder.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_transactions.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_upsert.py +0 -0
- {sustained-2.3.0 → sustained-2.4.0}/tests/test_where_builder.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 2.3.0 (2026-08-14)
|
|
4
18
|
|
|
5
19
|
### Added
|
|
@@ -61,11 +61,13 @@ print(migration.down) # ['ALTER TABLE users DROP COLUMN bio']
|
|
|
61
61
|
Autogeneration refuses to guess about anything that loses data or fails on populated tables:
|
|
62
62
|
|
|
63
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
|
|
65
|
-
- **
|
|
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
66
|
- The migration tracking table is excluded from diffing, and `exclude_tables` protects any other tables Sustained does not manage.
|
|
67
67
|
|
|
68
|
-
|
|
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.
|
|
69
71
|
|
|
70
72
|
## Typed Columns
|
|
71
73
|
|
|
@@ -85,7 +87,18 @@ class User(Model):
|
|
|
85
87
|
}
|
|
86
88
|
```
|
|
87
89
|
|
|
88
|
-
Definitions support composite primary keys (mark several columns `primary_key=True`), `unique`, literal or raw `Expression` defaults,
|
|
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`.
|
|
89
102
|
|
|
90
103
|
## Generating and Running DDL Directly
|
|
91
104
|
|
|
@@ -121,3 +134,7 @@ migrator.up() # apply all pending
|
|
|
121
134
|
migrator.up(target='create_users') # stop after a target
|
|
122
135
|
migrator.status() # [(id, applied), ...]
|
|
123
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 []
|