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.
Files changed (100) hide show
  1. {sustained-2.3.0 → sustained-2.4.0}/CHANGELOG.md +14 -0
  2. {sustained-2.3.0 → sustained-2.4.0}/PKG-INFO +1 -1
  3. {sustained-2.3.0 → sustained-2.4.0}/docs/schema.md +21 -4
  4. {sustained-2.3.0 → sustained-2.4.0}/pyproject.toml +1 -1
  5. sustained-2.4.0/src/sustained/aio_migrations.py +154 -0
  6. sustained-2.4.0/src/sustained/autogenerate.py +859 -0
  7. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/base.py +67 -0
  8. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/duckdb.py +27 -0
  9. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/mssql.py +44 -0
  10. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/postgres.py +27 -0
  11. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/migrations.py +61 -4
  12. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/model.py +33 -2
  13. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/schema.py +27 -0
  14. sustained-2.4.0/tests/test_async_migrations.py +88 -0
  15. sustained-2.4.0/tests/test_autogenerate.py +555 -0
  16. sustained-2.4.0/tests/test_dialect_ddl.py +108 -0
  17. sustained-2.3.0/src/sustained/autogenerate.py +0 -321
  18. sustained-2.3.0/tests/test_autogenerate.py +0 -204
  19. {sustained-2.3.0 → sustained-2.4.0}/.gitignore +0 -0
  20. {sustained-2.3.0 → sustained-2.4.0}/.pre-commit-config.yaml +0 -0
  21. {sustained-2.3.0 → sustained-2.4.0}/DEVELOPERS.md +0 -0
  22. {sustained-2.3.0 → sustained-2.4.0}/LICENSE +0 -0
  23. {sustained-2.3.0 → sustained-2.4.0}/README.md +0 -0
  24. {sustained-2.3.0 → sustained-2.4.0}/deploy.py +0 -0
  25. {sustained-2.3.0 → sustained-2.4.0}/docs/CNAME +0 -0
  26. {sustained-2.3.0 → sustained-2.4.0}/docs/_config.yml +0 -0
  27. {sustained-2.3.0 → sustained-2.4.0}/docs/_layouts/default.html +0 -0
  28. {sustained-2.3.0 → sustained-2.4.0}/docs/assets/css/style.scss +0 -0
  29. {sustained-2.3.0 → sustained-2.4.0}/docs/executing.md +0 -0
  30. {sustained-2.3.0 → sustained-2.4.0}/docs/filtering.md +0 -0
  31. {sustained-2.3.0 → sustained-2.4.0}/docs/grouping.md +0 -0
  32. {sustained-2.3.0 → sustained-2.4.0}/docs/index.md +0 -0
  33. {sustained-2.3.0 → sustained-2.4.0}/docs/models.md +0 -0
  34. {sustained-2.3.0 → sustained-2.4.0}/docs/queries.md +0 -0
  35. {sustained-2.3.0 → sustained-2.4.0}/docs/relations.md +0 -0
  36. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/__init__.py +0 -0
  37. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/aio.py +0 -0
  38. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builder.py +0 -0
  39. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builder.pyi +0 -0
  40. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/__init__.py +0 -0
  41. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/__init__.pyi +0 -0
  42. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
  43. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
  44. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.py +0 -0
  45. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/group_by_builder.pyi +0 -0
  46. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/having_builder.py +0 -0
  47. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/having_builder.pyi +0 -0
  48. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/join_builder.py +0 -0
  49. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/join_builder.pyi +0 -0
  50. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.py +0 -0
  51. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/order_by_builder.pyi +0 -0
  52. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.py +0 -0
  53. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
  54. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/where_builder.py +0 -0
  55. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/builders/where_builder.pyi +0 -0
  56. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/__init__.py +0 -0
  57. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/compilers/presto.py +0 -0
  58. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/dialects.py +0 -0
  59. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/exceptions.py +0 -0
  60. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/execution.py +0 -0
  61. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/expressions.py +0 -0
  62. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/functions.py +0 -0
  63. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/pool.py +0 -0
  64. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/py.typed +0 -0
  65. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/rendering.py +0 -0
  66. {sustained-2.3.0 → sustained-2.4.0}/src/sustained/types.py +0 -0
  67. {sustained-2.3.0 → sustained-2.4.0}/tests/__init__.py +0 -0
  68. {sustained-2.3.0 → sustained-2.4.0}/tests/test_analyst_sql.py +0 -0
  69. {sustained-2.3.0 → sustained-2.4.0}/tests/test_async.py +0 -0
  70. {sustained-2.3.0 → sustained-2.4.0}/tests/test_builder_ergonomics.py +0 -0
  71. {sustained-2.3.0 → sustained-2.4.0}/tests/test_builder_robustness.py +0 -0
  72. {sustained-2.3.0 → sustained-2.4.0}/tests/test_dialect.py +0 -0
  73. {sustained-2.3.0 → sustained-2.4.0}/tests/test_dialect_behaviors.py +0 -0
  74. {sustained-2.3.0 → sustained-2.4.0}/tests/test_dialect_functions.py +0 -0
  75. {sustained-2.3.0 → sustained-2.4.0}/tests/test_dml.py +0 -0
  76. {sustained-2.3.0 → sustained-2.4.0}/tests/test_duckdb_dialect.py +0 -0
  77. {sustained-2.3.0 → sustained-2.4.0}/tests/test_etl_statements.py +0 -0
  78. {sustained-2.3.0 → sustained-2.4.0}/tests/test_execution.py +0 -0
  79. {sustained-2.3.0 → sustained-2.4.0}/tests/test_expressions.py +0 -0
  80. {sustained-2.3.0 → sustained-2.4.0}/tests/test_functions.py +0 -0
  81. {sustained-2.3.0 → sustained-2.4.0}/tests/test_having_builder.py +0 -0
  82. {sustained-2.3.0 → sustained-2.4.0}/tests/test_join_builder.py +0 -0
  83. {sustained-2.3.0 → sustained-2.4.0}/tests/test_lambda_join_builder.py +0 -0
  84. {sustained-2.3.0 → sustained-2.4.0}/tests/test_migrations.py +0 -0
  85. {sustained-2.3.0 → sustained-2.4.0}/tests/test_model.py +0 -0
  86. {sustained-2.3.0 → sustained-2.4.0}/tests/test_model_features.py +0 -0
  87. {sustained-2.3.0 → sustained-2.4.0}/tests/test_mssql_compiler.py +0 -0
  88. {sustained-2.3.0 → sustained-2.4.0}/tests/test_order_by_builder.py +0 -0
  89. {sustained-2.3.0 → sustained-2.4.0}/tests/test_parameterization.py +0 -0
  90. {sustained-2.3.0 → sustained-2.4.0}/tests/test_pool.py +0 -0
  91. {sustained-2.3.0 → sustained-2.4.0}/tests/test_postgres_compiler.py +0 -0
  92. {sustained-2.3.0 → sustained-2.4.0}/tests/test_predicates.py +0 -0
  93. {sustained-2.3.0 → sustained-2.4.0}/tests/test_query_builder.py +0 -0
  94. {sustained-2.3.0 → sustained-2.4.0}/tests/test_raw_bindings.py +0 -0
  95. {sustained-2.3.0 → sustained-2.4.0}/tests/test_result_formats.py +0 -0
  96. {sustained-2.3.0 → sustained-2.4.0}/tests/test_schema_ddl.py +0 -0
  97. {sustained-2.3.0 → sustained-2.4.0}/tests/test_select_clause_builder.py +0 -0
  98. {sustained-2.3.0 → sustained-2.4.0}/tests/test_transactions.py +0 -0
  99. {sustained-2.3.0 → sustained-2.4.0}/tests/test_upsert.py +0 -0
  100. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sustained
3
- Version: 2.3.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
@@ -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 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.
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
- Diffing compares column presence, type, and nullability. Constraint changes (primary keys, unique indexes, foreign keys) are out of scope.
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, 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`.
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sustained"
7
- version = "2.3.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 []