sustained 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {sustained-2.2.0 → sustained-2.3.0}/CHANGELOG.md +9 -0
  2. {sustained-2.2.0 → sustained-2.3.0}/PKG-INFO +2 -2
  3. {sustained-2.2.0 → sustained-2.3.0}/README.md +1 -1
  4. {sustained-2.2.0 → sustained-2.3.0}/docs/index.md +1 -1
  5. sustained-2.3.0/docs/schema.md +123 -0
  6. {sustained-2.2.0 → sustained-2.3.0}/pyproject.toml +1 -1
  7. sustained-2.3.0/src/sustained/autogenerate.py +321 -0
  8. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/base.py +9 -0
  9. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/mssql.py +4 -0
  10. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/migrations.py +46 -0
  11. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/schema.py +29 -19
  12. sustained-2.3.0/tests/test_autogenerate.py +204 -0
  13. sustained-2.2.0/docs/schema.md +0 -64
  14. {sustained-2.2.0 → sustained-2.3.0}/.gitignore +0 -0
  15. {sustained-2.2.0 → sustained-2.3.0}/.pre-commit-config.yaml +0 -0
  16. {sustained-2.2.0 → sustained-2.3.0}/DEVELOPERS.md +0 -0
  17. {sustained-2.2.0 → sustained-2.3.0}/LICENSE +0 -0
  18. {sustained-2.2.0 → sustained-2.3.0}/deploy.py +0 -0
  19. {sustained-2.2.0 → sustained-2.3.0}/docs/CNAME +0 -0
  20. {sustained-2.2.0 → sustained-2.3.0}/docs/_config.yml +0 -0
  21. {sustained-2.2.0 → sustained-2.3.0}/docs/_layouts/default.html +0 -0
  22. {sustained-2.2.0 → sustained-2.3.0}/docs/assets/css/style.scss +0 -0
  23. {sustained-2.2.0 → sustained-2.3.0}/docs/executing.md +0 -0
  24. {sustained-2.2.0 → sustained-2.3.0}/docs/filtering.md +0 -0
  25. {sustained-2.2.0 → sustained-2.3.0}/docs/grouping.md +0 -0
  26. {sustained-2.2.0 → sustained-2.3.0}/docs/models.md +0 -0
  27. {sustained-2.2.0 → sustained-2.3.0}/docs/queries.md +0 -0
  28. {sustained-2.2.0 → sustained-2.3.0}/docs/relations.md +0 -0
  29. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/__init__.py +0 -0
  30. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/aio.py +0 -0
  31. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builder.py +0 -0
  32. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builder.pyi +0 -0
  33. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/__init__.py +0 -0
  34. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/__init__.pyi +0 -0
  35. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
  36. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
  37. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/group_by_builder.py +0 -0
  38. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/group_by_builder.pyi +0 -0
  39. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/having_builder.py +0 -0
  40. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/having_builder.pyi +0 -0
  41. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/join_builder.py +0 -0
  42. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/join_builder.pyi +0 -0
  43. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/order_by_builder.py +0 -0
  44. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/order_by_builder.pyi +0 -0
  45. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/select_clause_builder.py +0 -0
  46. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
  47. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/where_builder.py +0 -0
  48. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/where_builder.pyi +0 -0
  49. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/__init__.py +0 -0
  50. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/duckdb.py +0 -0
  51. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/postgres.py +0 -0
  52. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/presto.py +0 -0
  53. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/dialects.py +0 -0
  54. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/exceptions.py +0 -0
  55. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/execution.py +0 -0
  56. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/expressions.py +0 -0
  57. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/functions.py +0 -0
  58. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/model.py +0 -0
  59. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/pool.py +0 -0
  60. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/py.typed +0 -0
  61. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/rendering.py +0 -0
  62. {sustained-2.2.0 → sustained-2.3.0}/src/sustained/types.py +0 -0
  63. {sustained-2.2.0 → sustained-2.3.0}/tests/__init__.py +0 -0
  64. {sustained-2.2.0 → sustained-2.3.0}/tests/test_analyst_sql.py +0 -0
  65. {sustained-2.2.0 → sustained-2.3.0}/tests/test_async.py +0 -0
  66. {sustained-2.2.0 → sustained-2.3.0}/tests/test_builder_ergonomics.py +0 -0
  67. {sustained-2.2.0 → sustained-2.3.0}/tests/test_builder_robustness.py +0 -0
  68. {sustained-2.2.0 → sustained-2.3.0}/tests/test_dialect.py +0 -0
  69. {sustained-2.2.0 → sustained-2.3.0}/tests/test_dialect_behaviors.py +0 -0
  70. {sustained-2.2.0 → sustained-2.3.0}/tests/test_dialect_functions.py +0 -0
  71. {sustained-2.2.0 → sustained-2.3.0}/tests/test_dml.py +0 -0
  72. {sustained-2.2.0 → sustained-2.3.0}/tests/test_duckdb_dialect.py +0 -0
  73. {sustained-2.2.0 → sustained-2.3.0}/tests/test_etl_statements.py +0 -0
  74. {sustained-2.2.0 → sustained-2.3.0}/tests/test_execution.py +0 -0
  75. {sustained-2.2.0 → sustained-2.3.0}/tests/test_expressions.py +0 -0
  76. {sustained-2.2.0 → sustained-2.3.0}/tests/test_functions.py +0 -0
  77. {sustained-2.2.0 → sustained-2.3.0}/tests/test_having_builder.py +0 -0
  78. {sustained-2.2.0 → sustained-2.3.0}/tests/test_join_builder.py +0 -0
  79. {sustained-2.2.0 → sustained-2.3.0}/tests/test_lambda_join_builder.py +0 -0
  80. {sustained-2.2.0 → sustained-2.3.0}/tests/test_migrations.py +0 -0
  81. {sustained-2.2.0 → sustained-2.3.0}/tests/test_model.py +0 -0
  82. {sustained-2.2.0 → sustained-2.3.0}/tests/test_model_features.py +0 -0
  83. {sustained-2.2.0 → sustained-2.3.0}/tests/test_mssql_compiler.py +0 -0
  84. {sustained-2.2.0 → sustained-2.3.0}/tests/test_order_by_builder.py +0 -0
  85. {sustained-2.2.0 → sustained-2.3.0}/tests/test_parameterization.py +0 -0
  86. {sustained-2.2.0 → sustained-2.3.0}/tests/test_pool.py +0 -0
  87. {sustained-2.2.0 → sustained-2.3.0}/tests/test_postgres_compiler.py +0 -0
  88. {sustained-2.2.0 → sustained-2.3.0}/tests/test_predicates.py +0 -0
  89. {sustained-2.2.0 → sustained-2.3.0}/tests/test_query_builder.py +0 -0
  90. {sustained-2.2.0 → sustained-2.3.0}/tests/test_raw_bindings.py +0 -0
  91. {sustained-2.2.0 → sustained-2.3.0}/tests/test_result_formats.py +0 -0
  92. {sustained-2.2.0 → sustained-2.3.0}/tests/test_schema_ddl.py +0 -0
  93. {sustained-2.2.0 → sustained-2.3.0}/tests/test_select_clause_builder.py +0 -0
  94. {sustained-2.2.0 → sustained-2.3.0}/tests/test_transactions.py +0 -0
  95. {sustained-2.2.0 → sustained-2.3.0}/tests/test_upsert.py +0 -0
  96. {sustained-2.2.0 → sustained-2.3.0}/tests/test_where_builder.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.0 (2026-08-14)
4
+
5
+ ### Added
6
+
7
+ - Schema autogeneration: `diff_schema()` introspects the live database (SQLite PRAGMA on the default dialect, `information_schema.columns` elsewhere) and reports missing tables, new columns, extra objects, and changed columns with a readable `summary()`. Type comparison round-trips through each dialect's own type mapping, so tables created from models diff clean.
8
+ - `autogenerate()`: builds a `Migration` from the diff. Additive steps are reversible (CREATE/DROP TABLE, ADD/DROP COLUMN pairs). Drops require `allow_drops=True` and carry no down step; changed column types block generation unless explicitly ignored; NOT NULL adds without defaults and primary key or autoincrement adds are rejected.
9
+ - `Migrator.sync(models)`: diff, generate, register, and apply in one call, idempotent when the schema is current. `Migrator.down_to(id)` reverts newest-first until the target is the most recent applied migration.
10
+ - Compilers render `ADD COLUMN` and `DROP COLUMN` statements, with the T-SQL `ADD` spelling on MSSQL.
11
+
3
12
  ## 2.2.0 (2026-08-14)
4
13
 
5
14
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sustained
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A Python query builder inspired by Objection.js
5
5
  Project-URL: Homepage, https://github.com/wetherc/sustained
6
6
  Project-URL: Issues, https://github.com/wetherc/sustained/issues
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  A Python query builder and lightweight ORM inspired by [Objection.js](https://vincit.github.io/objection.js/).
18
18
 
19
- Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, Presto, and DuckDB dialects. It executes queries against any DB-API 2.0 connection 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,123 @@
1
+ ---
2
+ layout: default
3
+ title: Schema and Migrations
4
+ ---
5
+
6
+ Sustained manages your database schema from your models. Declare typed columns once, then let the migrator create tables, detect drift, generate migrations, apply them, and roll them back.
7
+
8
+ ## Automated Migration in One Call
9
+
10
+ `Migrator.sync()` diffs the live database against your models, generates the migration, records it, and applies it. Run it again after changing a model and only the difference is applied. Nothing to hand-write for additive changes.
11
+
12
+ ```python
13
+ from sustained import Model
14
+ from sustained.migrations import Migrator
15
+ from sustained.schema import Integer, String, Text
16
+
17
+ class User(Model):
18
+ tableName = 'users'
19
+ tableColumns = {
20
+ 'id': Integer(primary_key=True, autoincrement=True),
21
+ 'email': String(120, unique=True, nullable=False),
22
+ }
23
+
24
+ migrator = Migrator(conn, [])
25
+ migrator.sync([User]) # creates the users table
26
+
27
+ User.tableColumns['bio'] = Text()
28
+ migrator.sync([User]) # adds only the bio column
29
+ ```
30
+
31
+ ## Automated Rollback
32
+
33
+ Generated migrations are reversible: a created table carries its DROP, an added column carries its DROP COLUMN. Roll back one step, several steps, or down to a known-good migration.
34
+
35
+ ```python
36
+ migrator.down() # revert the newest applied migration
37
+ migrator.down(steps=2) # revert the two newest
38
+ migrator.down_to('auto_20260814...') # revert until this id is newest
39
+ ```
40
+
41
+ Every applied migration is recorded in a tracking table, each runs inside a transaction, and a failing step rolls itself back and leaves earlier migrations applied.
42
+
43
+ ## Inspecting Drift Before Applying
44
+
45
+ `diff_schema()` reports every difference without touching anything. `autogenerate()` builds the migration so you can review its statements before it runs.
46
+
47
+ ```python
48
+ from sustained.autogenerate import autogenerate, diff_schema
49
+
50
+ print(diff_schema(conn, [User]).summary())
51
+ # add column users.bio
52
+ # drop column users.legacy (destructive)
53
+
54
+ migration = autogenerate(conn, [User], id='add_bio')
55
+ print(migration.up) # ['ALTER TABLE users ADD COLUMN bio TEXT']
56
+ print(migration.down) # ['ALTER TABLE users DROP COLUMN bio']
57
+ ```
58
+
59
+ ## Safety Rules
60
+
61
+ Autogeneration refuses to guess about anything that loses data or fails on populated tables:
62
+
63
+ - **Drops are opt-in.** Extra tables and columns raise unless `allow_drops=True`. A migration containing drops has no down step, because the dropped data cannot come back.
64
+ - **Type changes are never auto-migrated.** A changed type or nullability is reported in the diff and blocks generation until you write that migration by hand or pass `ignore_changed_columns=True`. SQLite cannot alter a column type in place, and a silent rewrite is exactly the change a human should review.
65
+ - **Unsafe adds are rejected.** A new NOT NULL column needs a default; new primary key or autoincrement columns cannot be added with ALTER TABLE.
66
+ - The migration tracking table is excluded from diffing, and `exclude_tables` protects any other tables Sustained does not manage.
67
+
68
+ Diffing compares column presence, type, and nullability. Constraint changes (primary keys, unique indexes, foreign keys) are out of scope.
69
+
70
+ ## Typed Columns
71
+
72
+ `tableColumns` maps column names to typed definitions: `Integer`, `BigInteger`, `String(length)`, `Text`, `Boolean`, `Float`, `Numeric(precision, scale)`, `Date`, `Timestamp`, `Json`.
73
+
74
+ ```python
75
+ from sustained.schema import Boolean, Integer, String, Timestamp
76
+ from sustained.types import Expression
77
+
78
+ class User(Model):
79
+ tableName = 'users'
80
+ tableColumns = {
81
+ 'id': Integer(primary_key=True, autoincrement=True),
82
+ 'email': String(120, unique=True, nullable=False),
83
+ 'active': Boolean(default=True),
84
+ 'created_at': Timestamp(default=Expression('CURRENT_TIMESTAMP')),
85
+ }
86
+ ```
87
+
88
+ Definitions support composite primary keys (mark several columns `primary_key=True`), `unique`, literal or raw `Expression` defaults, and foreign keys through `references='table.column'`. `autoincrement` requires a single integer primary key; DuckDB and Presto raise because they have no identity columns. A model with `tableColumns` also gets strict column-name access automatically: a typo'd column raises `AttributeError`.
89
+
90
+ ## Generating and Running DDL Directly
91
+
92
+ ```python
93
+ User.create_table_sql() # dialect-specific CREATE TABLE
94
+ User.create_table(conn) # execute it
95
+ User.drop_table(conn) # DROP TABLE IF EXISTS
96
+ ```
97
+
98
+ Types map per dialect: `BIT`, `NVARCHAR`, and `DATETIME2` on MSSQL; `JSONB` and identity columns on Postgres; plain `INTEGER PRIMARY KEY` rowid behavior on the default dialect. Schema introspection reads SQLite's PRAGMA tables on the default dialect and `information_schema.columns` elsewhere.
99
+
100
+ ## Hand-Written Migrations
101
+
102
+ Anything autogeneration will not express, write explicitly. A `Migration` pairs an id with an up step and an optional down step; steps are a SQL string, a list of statements, or a callable receiving the connection. Hand-written and generated migrations share one ordered list and one tracking table.
103
+
104
+ ```python
105
+ from sustained.migrations import Migration, Migrator, create_table_migration
106
+
107
+ migrations = [
108
+ create_table_migration(User),
109
+ Migration(
110
+ 'split_name_column',
111
+ up=[
112
+ 'ALTER TABLE users ADD COLUMN first_name TEXT',
113
+ "UPDATE users SET first_name = substr(name, 1, instr(name, ' ') - 1) WHERE 1 = 1",
114
+ ],
115
+ down='ALTER TABLE users DROP COLUMN first_name',
116
+ ),
117
+ ]
118
+
119
+ migrator = Migrator(conn, migrations)
120
+ migrator.up() # apply all pending
121
+ migrator.up(target='create_users') # stop after a target
122
+ migrator.status() # [(id, applied), ...]
123
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sustained"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "A Python query builder inspired by Objection.js"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,321 @@
1
+ """
2
+ Schema autogeneration: diff the live database against model tableColumns
3
+ declarations and produce a Migration.
4
+
5
+ diff_schema() introspects the database and reports missing tables, new
6
+ columns, extra tables and columns, and changed columns. autogenerate()
7
+ turns the additive part of that diff into a Migration with matching down
8
+ steps, so the change is fully reversible: CREATE TABLE reverses with DROP
9
+ TABLE, ADD COLUMN reverses with DROP COLUMN.
10
+
11
+ Destructive changes never happen silently. Dropping extra tables and
12
+ columns requires allow_drops=True, and those steps carry no down step
13
+ because the dropped data cannot be restored. Changed column types and
14
+ nullability are reported but never migrated automatically: SQLite cannot
15
+ alter a column's type in place, and a type rewrite is exactly the change a
16
+ human should review. autogenerate() raises when the diff contains changes
17
+ it will not express, unless told to ignore them.
18
+
19
+ Only column presence, type, and nullability are compared. Constraint
20
+ changes such as primary keys, unique indexes, and foreign keys are out of
21
+ scope for diffing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Tuple, Type
28
+
29
+ from sustained.dialects import Dialects
30
+ from sustained.migrations import Migration
31
+ from sustained.schema import build_create_table_sql, render_column_sql
32
+
33
+ if TYPE_CHECKING:
34
+ from sustained.model import Model
35
+ from sustained.schema import ColumnDef
36
+
37
+
38
+ class IntrospectedColumn(NamedTuple):
39
+ """One column as reported by the database."""
40
+
41
+ raw_type: str
42
+ nullable: bool
43
+ primary_key: bool
44
+
45
+
46
+ # Engine type spellings mapped to Sustained's logical types. Both sides of
47
+ # a comparison pass through this table, so a model column compared against
48
+ # the table its own DDL created always matches.
49
+ _TYPE_SYNONYMS = {
50
+ "INT": "INTEGER",
51
+ "INT4": "INTEGER",
52
+ "INTEGER": "INTEGER",
53
+ "BIGINT": "BIGINT",
54
+ "INT8": "BIGINT",
55
+ "VARCHAR": "VARCHAR",
56
+ "CHARACTER VARYING": "VARCHAR",
57
+ "NVARCHAR": "VARCHAR",
58
+ "TEXT": "TEXT",
59
+ "BOOLEAN": "BOOLEAN",
60
+ "BOOL": "BOOLEAN",
61
+ "BIT": "BOOLEAN",
62
+ "FLOAT": "FLOAT",
63
+ "FLOAT8": "FLOAT",
64
+ "DOUBLE": "FLOAT",
65
+ "DOUBLE PRECISION": "FLOAT",
66
+ "REAL": "FLOAT",
67
+ "NUMERIC": "NUMERIC",
68
+ "DECIMAL": "NUMERIC",
69
+ "DATE": "DATE",
70
+ "TIMESTAMP": "TIMESTAMP",
71
+ "TIMESTAMP WITHOUT TIME ZONE": "TIMESTAMP",
72
+ "TIMESTAMP WITH TIME ZONE": "TIMESTAMP",
73
+ "DATETIME": "TIMESTAMP",
74
+ "DATETIME2": "TIMESTAMP",
75
+ "JSON": "JSON",
76
+ "JSONB": "JSON",
77
+ }
78
+
79
+ _TYPE_PARAMS_RE = re.compile(r"\s*\(.*\)\s*$")
80
+
81
+
82
+ def normalize_type(raw: str) -> str:
83
+ """
84
+ Reduces an engine type spelling to a logical type name, dropping length
85
+ and precision parameters. Unknown spellings return uppercased as-is.
86
+ """
87
+ base = _TYPE_PARAMS_RE.sub("", raw).strip().upper()
88
+ return _TYPE_SYNONYMS.get(base, base)
89
+
90
+
91
+ class SchemaDiff:
92
+ """The differences between declared models and the live database."""
93
+
94
+ def __init__(self) -> None:
95
+ self.missing_tables: List[Type["Model"]] = []
96
+ self.new_columns: List[Tuple[Type["Model"], str, "ColumnDef"]] = []
97
+ self.extra_tables: List[str] = []
98
+ self.extra_columns: List[Tuple[str, str]] = []
99
+ self.changed_columns: List[Tuple[str, str, str, str]] = []
100
+
101
+ def is_empty(self) -> bool:
102
+ return not (
103
+ self.missing_tables
104
+ or self.new_columns
105
+ or self.extra_tables
106
+ or self.extra_columns
107
+ or self.changed_columns
108
+ )
109
+
110
+ def summary(self) -> str:
111
+ """A human-readable description of every difference."""
112
+ lines: List[str] = []
113
+ for model in self.missing_tables:
114
+ lines.append(f"create table {model.tableName}")
115
+ for model, name, _ in self.new_columns:
116
+ lines.append(f"add column {model.tableName}.{name}")
117
+ for table in self.extra_tables:
118
+ lines.append(f"drop table {table} (destructive)")
119
+ for table, name in self.extra_columns:
120
+ lines.append(f"drop column {table}.{name} (destructive)")
121
+ for table, name, actual, expected in self.changed_columns:
122
+ lines.append(
123
+ f"change column {table}.{name}: database has {actual}, "
124
+ f"model declares {expected} (not auto-migrated)"
125
+ )
126
+ return "\n".join(lines) if lines else "schema up to date"
127
+
128
+
129
+ def introspect_schema(
130
+ connection: Any, dialect: Dialects = Dialects.DEFAULT
131
+ ) -> Dict[str, Dict[str, IntrospectedColumn]]:
132
+ """
133
+ Reads tables and columns from the database. The default dialect reads
134
+ SQLite's PRAGMA tables; every other dialect reads
135
+ information_schema.columns. Table and column names are keyed lowercase.
136
+ """
137
+ cursor = connection.cursor()
138
+ schema: Dict[str, Dict[str, IntrospectedColumn]] = {}
139
+
140
+ if dialect == Dialects.DEFAULT:
141
+ cursor.execute(
142
+ "SELECT name FROM sqlite_master WHERE type = 'table' "
143
+ "AND name NOT LIKE 'sqlite_%'"
144
+ )
145
+ tables = [row[0] for row in cursor.fetchall()]
146
+ for table in tables:
147
+ cursor.execute(f"PRAGMA table_info({table})")
148
+ columns = {}
149
+ for _, name, raw_type, notnull, _, pk in cursor.fetchall():
150
+ columns[name.lower()] = IntrospectedColumn(
151
+ raw_type=raw_type or "",
152
+ nullable=not notnull,
153
+ primary_key=bool(pk),
154
+ )
155
+ schema[table.lower()] = columns
156
+ return schema
157
+
158
+ cursor.execute(
159
+ "SELECT table_name, column_name, data_type, is_nullable "
160
+ "FROM information_schema.columns ORDER BY table_name, ordinal_position"
161
+ )
162
+ for table, name, data_type, is_nullable in cursor.fetchall():
163
+ table_columns = schema.setdefault(table.lower(), {})
164
+ table_columns[name.lower()] = IntrospectedColumn(
165
+ raw_type=data_type or "",
166
+ nullable=str(is_nullable).upper() == "YES",
167
+ primary_key=False,
168
+ )
169
+ return schema
170
+
171
+
172
+ def diff_schema(
173
+ connection: Any,
174
+ models: List[Type["Model"]],
175
+ dialect: Dialects = Dialects.DEFAULT,
176
+ exclude_tables: Tuple[str, ...] = ("sustained_migrations",),
177
+ ) -> SchemaDiff:
178
+ """
179
+ Compares the models' tableColumns declarations against the live
180
+ database and returns the differences.
181
+ """
182
+ compiler = Dialects.get_compiler(dialect)
183
+ diff = SchemaDiff()
184
+
185
+ declared: Dict[str, Type["Model"]] = {}
186
+ for model in models:
187
+ if not model.tableName or not model.tableColumns:
188
+ raise ValueError(
189
+ f"Model '{model.__name__}' needs tableName and tableColumns "
190
+ "to participate in schema diffing."
191
+ )
192
+ key = model.tableName.lower()
193
+ if key in declared:
194
+ raise ValueError(f"Two models declare the table '{model.tableName}'.")
195
+ declared[key] = model
196
+
197
+ excluded = {t.lower() for t in exclude_tables}
198
+ actual = introspect_schema(connection, dialect)
199
+
200
+ for table_key, model in declared.items():
201
+ assert model.tableColumns is not None
202
+ actual_columns = actual.get(table_key)
203
+ if actual_columns is None:
204
+ diff.missing_tables.append(model)
205
+ continue
206
+ for name, coldef in model.tableColumns.items():
207
+ actual_col = actual_columns.get(name.lower())
208
+ if actual_col is None:
209
+ diff.new_columns.append((model, name, coldef))
210
+ continue
211
+ expected_type = normalize_type(compiler.compile_column_type(coldef))
212
+ actual_type = normalize_type(actual_col.raw_type)
213
+ type_changed = expected_type != actual_type
214
+ # SQLite reports INTEGER PRIMARY KEY as nullable, so nullability
215
+ # is only compared on non-key columns.
216
+ null_changed = (
217
+ not coldef.primary_key
218
+ and not actual_col.primary_key
219
+ and actual_col.nullable != coldef.nullable
220
+ )
221
+ if type_changed or null_changed:
222
+ expected_desc = expected_type + ("" if coldef.nullable else " NOT NULL")
223
+ actual_desc = actual_type + ("" if actual_col.nullable else " NOT NULL")
224
+ diff.changed_columns.append(
225
+ (model.tableName or "", name, actual_desc, expected_desc)
226
+ )
227
+ declared_names = {c.lower() for c in model.tableColumns}
228
+ for name in actual_columns:
229
+ if name not in declared_names:
230
+ diff.extra_columns.append((model.tableName or "", name))
231
+
232
+ for table_key in actual:
233
+ if table_key not in declared and table_key not in excluded:
234
+ diff.extra_tables.append(table_key)
235
+
236
+ return diff
237
+
238
+
239
+ def autogenerate(
240
+ connection: Any,
241
+ models: List[Type["Model"]],
242
+ id: str,
243
+ dialect: Dialects = Dialects.DEFAULT,
244
+ allow_drops: bool = False,
245
+ ignore_changed_columns: bool = False,
246
+ exclude_tables: Tuple[str, ...] = ("sustained_migrations",),
247
+ ) -> Optional[Migration]:
248
+ """
249
+ Diffs the database against the models and builds a Migration for the
250
+ differences. Returns None when the schema is up to date.
251
+
252
+ Missing tables and new columns generate reversible steps. Extra tables
253
+ and columns generate drops only with allow_drops=True, and the
254
+ migration then has no down step, since dropped data cannot come back.
255
+ Changed columns raise unless ignore_changed_columns=True, because type
256
+ and nullability rewrites need a hand-written migration.
257
+ """
258
+ compiler = Dialects.get_compiler(dialect)
259
+ diff = diff_schema(connection, models, dialect, exclude_tables)
260
+
261
+ if diff.changed_columns and not ignore_changed_columns:
262
+ details = "; ".join(
263
+ f"{table}.{name}: {actual} -> {expected}"
264
+ for table, name, actual, expected in diff.changed_columns
265
+ )
266
+ raise ValueError(
267
+ "Changed columns need a hand-written migration: "
268
+ f"{details}. Pass ignore_changed_columns=True to skip them."
269
+ )
270
+ if (diff.extra_tables or diff.extra_columns) and not allow_drops:
271
+ dropped = [t for t in diff.extra_tables] + [
272
+ f"{t}.{c}" for t, c in diff.extra_columns
273
+ ]
274
+ raise ValueError(
275
+ "The database has objects the models do not declare: "
276
+ f"{', '.join(dropped)}. Pass allow_drops=True to generate the "
277
+ "drops, or add them to exclude_tables."
278
+ )
279
+
280
+ up_steps: List[str] = []
281
+ down_steps: List[str] = []
282
+ reversible = True
283
+
284
+ for model in diff.missing_tables:
285
+ assert model.tableColumns is not None
286
+ table_sql = model._qualified_table_sql()
287
+ up_steps.append(build_create_table_sql(compiler, table_sql, model.tableColumns))
288
+ down_steps.insert(0, model.drop_table_sql())
289
+
290
+ for model, name, coldef in diff.new_columns:
291
+ if coldef.primary_key or coldef.autoincrement:
292
+ raise ValueError(
293
+ f"Cannot add '{model.tableName}.{name}' with ALTER TABLE: "
294
+ "primary key and autoincrement columns need a hand-written "
295
+ "migration."
296
+ )
297
+ if not coldef.nullable and coldef.default is None:
298
+ raise ValueError(
299
+ f"Cannot add NOT NULL column '{model.tableName}.{name}' "
300
+ "without a default; existing rows would have no value."
301
+ )
302
+ table_sql = model._qualified_table_sql()
303
+ column_sql = render_column_sql(compiler, name, coldef, inline_pk=False)
304
+ up_steps.append(compiler.compile_add_column(table_sql, column_sql))
305
+ down_steps.insert(0, compiler.compile_drop_column(table_sql, name))
306
+
307
+ if allow_drops:
308
+ for table, name in diff.extra_columns:
309
+ table_sql = compiler.quote_fully_qualified_identifier(table)
310
+ up_steps.append(compiler.compile_drop_column(table_sql, name))
311
+ reversible = False
312
+ for table in diff.extra_tables:
313
+ table_sql = compiler.quote_fully_qualified_identifier(table)
314
+ up_steps.append(f"DROP TABLE {table_sql}")
315
+ reversible = False
316
+
317
+ if not up_steps:
318
+ return None
319
+ return Migration(
320
+ id=id, up=up_steps, down=down_steps if reversible and down_steps else None
321
+ )
@@ -224,6 +224,15 @@ class Compiler:
224
224
  """
225
225
  return ""
226
226
 
227
+ def compile_add_column(self, table_sql: str, column_sql: str) -> str:
228
+ """Renders an ALTER TABLE statement that adds one column."""
229
+ return f"ALTER TABLE {table_sql} ADD COLUMN {column_sql}"
230
+
231
+ def compile_drop_column(self, table_sql: str, column_name: str) -> str:
232
+ """Renders an ALTER TABLE statement that drops one column."""
233
+ quoted = self.quote_identifier(column_name)
234
+ return f"ALTER TABLE {table_sql} DROP COLUMN {quoted}"
235
+
227
236
  def compile_returning(self, columns_sql: str) -> str:
228
237
  """
229
238
  Renders a RETURNING clause for DML statements. Dialects without
@@ -74,6 +74,10 @@ class MssqlCompiler(Compiler):
74
74
  def compile_identity(self) -> str:
75
75
  return "IDENTITY(1,1)"
76
76
 
77
+ def compile_add_column(self, table_sql: str, column_sql: str) -> str:
78
+ # T-SQL spells it ADD without the COLUMN keyword.
79
+ return f"ALTER TABLE {table_sql} ADD {column_sql}"
80
+
77
81
  def compile_with_keyword(self, recursive: bool) -> str:
78
82
  # T-SQL uses plain WITH for recursive CTEs.
79
83
  return "WITH"
@@ -85,6 +85,7 @@ class Migrator:
85
85
  self._connection = connection
86
86
  self._migrations = list(migrations)
87
87
  self._table = table
88
+ self._dialect = dialect
88
89
  self._compiler = Dialects.get_compiler(dialect)
89
90
 
90
91
  def _table_sql(self) -> str:
@@ -152,6 +153,51 @@ class Migrator:
152
153
  applied_now.append(migration.id)
153
154
  return applied_now
154
155
 
156
+ def sync(
157
+ self,
158
+ models: List[Type["Model"]],
159
+ allow_drops: bool = False,
160
+ ignore_changed_columns: bool = False,
161
+ migration_id: Optional[str] = None,
162
+ ) -> List[str]:
163
+ """
164
+ Diffs the database against the models, registers the generated
165
+ migration, and applies everything pending. Returns the applied ids;
166
+ an empty list means the schema was already up to date.
167
+
168
+ Additive changes generate reversible steps, so down() rolls the
169
+ sync back. Drops require allow_drops=True and are not reversible.
170
+ """
171
+ from sustained.autogenerate import autogenerate
172
+
173
+ self._ensure_tracking_table()
174
+ generated_id = migration_id or datetime.now(timezone.utc).strftime(
175
+ "auto_%Y%m%d%H%M%S_%f"
176
+ )
177
+ migration = autogenerate(
178
+ self._connection,
179
+ models,
180
+ id=generated_id,
181
+ dialect=self._dialect,
182
+ allow_drops=allow_drops,
183
+ ignore_changed_columns=ignore_changed_columns,
184
+ exclude_tables=(self._table,),
185
+ )
186
+ if migration is not None:
187
+ self._migrations.append(migration)
188
+ return self.up()
189
+
190
+ def down_to(self, target: str) -> List[str]:
191
+ """
192
+ Reverts applied migrations newest-first until the target is the
193
+ most recent applied migration. The target itself stays applied.
194
+ """
195
+ applied = self.applied()
196
+ if target not in applied:
197
+ raise ValueError(f"Migration '{target}' is not applied.")
198
+ steps = len(applied) - applied.index(target) - 1
199
+ return self.down(steps) if steps else []
200
+
155
201
  def down(self, steps: int = 1) -> List[str]:
156
202
  """
157
203
  Reverts the most recently applied migrations, newest first. Every
@@ -119,6 +119,34 @@ def Json(**kwargs: Any) -> ColumnDef:
119
119
  return ColumnDef("JSON", **kwargs)
120
120
 
121
121
 
122
+ def render_column_sql(
123
+ compiler: "Compiler",
124
+ name: str,
125
+ col: ColumnDef,
126
+ inline_pk: bool,
127
+ ) -> str:
128
+ """Renders one column definition for CREATE TABLE or ADD COLUMN."""
129
+ parts = [compiler.quote_identifier(name), compiler.compile_column_type(col)]
130
+ if col.autoincrement:
131
+ identity = compiler.compile_identity()
132
+ if identity:
133
+ parts.append(identity)
134
+ if col.primary_key and inline_pk:
135
+ parts.append("PRIMARY KEY")
136
+ elif not col.nullable:
137
+ parts.append("NOT NULL")
138
+ if col.unique and not col.primary_key:
139
+ parts.append("UNIQUE")
140
+ if col.default is not None:
141
+ parts.append(f"DEFAULT {compiler.format_value(col.default)}")
142
+ if col.references is not None:
143
+ ref_table, ref_column = col.references.rsplit(".", 1)
144
+ quoted_table = compiler.quote_fully_qualified_identifier(ref_table)
145
+ quoted_column = compiler.quote_identifier(ref_column)
146
+ parts.append(f"REFERENCES {quoted_table} ({quoted_column})")
147
+ return " ".join(parts)
148
+
149
+
122
150
  def build_create_table_sql(
123
151
  compiler: "Compiler",
124
152
  table_sql: str,
@@ -144,25 +172,7 @@ def build_create_table_sql(
144
172
  inline_pk = len(primary_keys) == 1
145
173
 
146
174
  for name, col in columns.items():
147
- parts = [compiler.quote_identifier(name), compiler.compile_column_type(col)]
148
- if col.autoincrement:
149
- identity = compiler.compile_identity()
150
- if identity:
151
- parts.append(identity)
152
- if col.primary_key and inline_pk:
153
- parts.append("PRIMARY KEY")
154
- elif not col.nullable:
155
- parts.append("NOT NULL")
156
- if col.unique and not col.primary_key:
157
- parts.append("UNIQUE")
158
- if col.default is not None:
159
- parts.append(f"DEFAULT {compiler.format_value(col.default)}")
160
- if col.references is not None:
161
- ref_table, ref_column = col.references.rsplit(".", 1)
162
- quoted_table = compiler.quote_fully_qualified_identifier(ref_table)
163
- quoted_column = compiler.quote_identifier(ref_column)
164
- parts.append(f"REFERENCES {quoted_table} ({quoted_column})")
165
- column_parts.append(" ".join(parts))
175
+ column_parts.append(render_column_sql(compiler, name, col, inline_pk))
166
176
 
167
177
  if len(primary_keys) > 1:
168
178
  pk_sql = ", ".join(compiler.quote_identifier(c) for c in primary_keys)
@@ -0,0 +1,204 @@
1
+ """
2
+ Tests for schema diffing, migration autogeneration, and rollback of
3
+ generated migrations, against in-memory SQLite.
4
+ """
5
+
6
+ import sqlite3
7
+ import unittest
8
+
9
+ from sustained import Model, create_model
10
+ from sustained.autogenerate import (
11
+ autogenerate,
12
+ diff_schema,
13
+ introspect_schema,
14
+ normalize_type,
15
+ )
16
+ from sustained.dialects import Dialects
17
+ from sustained.migrations import Migration, Migrator
18
+ from sustained.schema import Boolean, Integer, String, Text
19
+
20
+
21
+ def make_model(name, table, columns):
22
+ model = create_model(name, table)
23
+ model.tableColumns = columns
24
+ model.columns = tuple(columns)
25
+ return model
26
+
27
+
28
+ class AutogenTestCase(unittest.TestCase):
29
+ def setUp(self):
30
+ self.conn = sqlite3.connect(":memory:")
31
+ self.User = make_model(
32
+ f"AgU_{self.id().rsplit('.', 1)[-1]}",
33
+ "ag_users",
34
+ {
35
+ "id": Integer(primary_key=True),
36
+ "email": String(120, nullable=False),
37
+ },
38
+ )
39
+
40
+ def tearDown(self):
41
+ self.conn.close()
42
+
43
+
44
+ class TestNormalizeType(unittest.TestCase):
45
+ def test_synonyms_and_parameters(self):
46
+ self.assertEqual(normalize_type("VARCHAR(120)"), "VARCHAR")
47
+ self.assertEqual(normalize_type("character varying"), "VARCHAR")
48
+ self.assertEqual(normalize_type("NVARCHAR(MAX)"), "VARCHAR")
49
+ self.assertEqual(normalize_type("DOUBLE PRECISION"), "FLOAT")
50
+ self.assertEqual(normalize_type("jsonb"), "JSON")
51
+ self.assertEqual(normalize_type("DATETIME2"), "TIMESTAMP")
52
+ self.assertEqual(normalize_type("BIT"), "BOOLEAN")
53
+
54
+ def test_unknown_passthrough(self):
55
+ self.assertEqual(normalize_type("GEOMETRY"), "GEOMETRY")
56
+
57
+
58
+ class TestDiffSchema(AutogenTestCase):
59
+ def test_missing_table_detected(self):
60
+ diff = diff_schema(self.conn, [self.User])
61
+ self.assertEqual(diff.missing_tables, [self.User])
62
+ self.assertIn("create table ag_users", diff.summary())
63
+
64
+ def test_round_trip_is_empty(self):
65
+ self.User.create_table(self.conn)
66
+ diff = diff_schema(self.conn, [self.User])
67
+ self.assertTrue(diff.is_empty())
68
+ self.assertEqual(diff.summary(), "schema up to date")
69
+
70
+ def test_new_column_detected(self):
71
+ self.User.create_table(self.conn)
72
+ self.User.tableColumns["bio"] = Text()
73
+ diff = diff_schema(self.conn, [self.User])
74
+ self.assertEqual(len(diff.new_columns), 1)
75
+ self.assertEqual(diff.new_columns[0][1], "bio")
76
+
77
+ def test_extra_objects_detected(self):
78
+ self.User.create_table(self.conn)
79
+ self.conn.execute("ALTER TABLE ag_users ADD COLUMN legacy TEXT")
80
+ self.conn.execute("CREATE TABLE orphan (id INTEGER)")
81
+ diff = diff_schema(self.conn, [self.User])
82
+ self.assertEqual(diff.extra_columns, [("ag_users", "legacy")])
83
+ self.assertEqual(diff.extra_tables, ["orphan"])
84
+
85
+ def test_changed_column_detected(self):
86
+ self.User.create_table(self.conn)
87
+ changed = make_model(
88
+ "AgChanged",
89
+ "ag_users",
90
+ {"id": Integer(primary_key=True), "email": Boolean()},
91
+ )
92
+ diff = diff_schema(self.conn, [changed])
93
+ self.assertEqual(len(diff.changed_columns), 1)
94
+ self.assertIn("not auto-migrated", diff.summary())
95
+
96
+ def test_tracking_table_excluded(self):
97
+ self.conn.execute("CREATE TABLE sustained_migrations (id TEXT)")
98
+ self.User.create_table(self.conn)
99
+ diff = diff_schema(self.conn, [self.User])
100
+ self.assertEqual(diff.extra_tables, [])
101
+
102
+ def test_duplicate_table_declarations_rejected(self):
103
+ other = make_model("AgDup", "ag_users", {"id": Integer(primary_key=True)})
104
+ with self.assertRaises(ValueError):
105
+ diff_schema(self.conn, [self.User, other])
106
+
107
+ def test_model_without_table_columns_rejected(self):
108
+ bare = create_model("AgBare", "bare_tbl")
109
+ with self.assertRaises(ValueError):
110
+ diff_schema(self.conn, [bare])
111
+
112
+
113
+ class TestAutogenerate(AutogenTestCase):
114
+ def test_up_to_date_returns_none(self):
115
+ self.User.create_table(self.conn)
116
+ self.assertIsNone(autogenerate(self.conn, [self.User], id="noop"))
117
+
118
+ def test_create_table_migration_is_reversible(self):
119
+ migration = autogenerate(self.conn, [self.User], id="m1")
120
+ self.assertEqual(migration.down, ["DROP TABLE IF EXISTS ag_users"])
121
+ self.assertIn("CREATE TABLE", migration.up[0])
122
+
123
+ def test_add_column_migration_is_reversible(self):
124
+ self.User.create_table(self.conn)
125
+ self.User.tableColumns["bio"] = Text()
126
+ migration = autogenerate(self.conn, [self.User], id="m2")
127
+ self.assertEqual(migration.up, ["ALTER TABLE ag_users ADD COLUMN bio TEXT"])
128
+ self.assertEqual(migration.down, ["ALTER TABLE ag_users DROP COLUMN bio"])
129
+
130
+ def test_drops_require_opt_in(self):
131
+ self.User.create_table(self.conn)
132
+ self.conn.execute("ALTER TABLE ag_users ADD COLUMN legacy TEXT")
133
+ with self.assertRaises(ValueError):
134
+ autogenerate(self.conn, [self.User], id="m3")
135
+ migration = autogenerate(self.conn, [self.User], id="m3", allow_drops=True)
136
+ self.assertEqual(migration.up, ["ALTER TABLE ag_users DROP COLUMN legacy"])
137
+ self.assertIsNone(migration.down)
138
+
139
+ def test_changed_columns_block_generation(self):
140
+ self.User.create_table(self.conn)
141
+ changed = make_model(
142
+ "AgBlock",
143
+ "ag_users",
144
+ {"id": Integer(primary_key=True), "email": Boolean()},
145
+ )
146
+ with self.assertRaises(ValueError):
147
+ autogenerate(self.conn, [changed], id="m4")
148
+ self.assertIsNone(
149
+ autogenerate(self.conn, [changed], id="m4", ignore_changed_columns=True)
150
+ )
151
+
152
+ def test_not_null_add_without_default_rejected(self):
153
+ self.User.create_table(self.conn)
154
+ self.User.tableColumns["req"] = String(10, nullable=False)
155
+ with self.assertRaises(ValueError):
156
+ autogenerate(self.conn, [self.User], id="m5")
157
+
158
+ def test_primary_key_add_rejected(self):
159
+ self.User.create_table(self.conn)
160
+ self.User.tableColumns["id2"] = Integer(primary_key=True)
161
+ with self.assertRaises(ValueError):
162
+ autogenerate(self.conn, [self.User], id="m6")
163
+
164
+
165
+ class TestMigratorSync(AutogenTestCase):
166
+ def test_sync_creates_and_is_idempotent(self):
167
+ migrator = Migrator(self.conn, [])
168
+ applied = migrator.sync([self.User])
169
+ self.assertEqual(len(applied), 1)
170
+ self.assertTrue(diff_schema(self.conn, [self.User]).is_empty())
171
+ self.assertEqual(migrator.sync([self.User]), [])
172
+
173
+ def test_sync_then_down_rolls_back(self):
174
+ migrator = Migrator(self.conn, [])
175
+ migrator.sync([self.User])
176
+ self.User.tableColumns["bio"] = Text()
177
+ migrator.sync([self.User])
178
+ migrator.down()
179
+ columns = introspect_schema(self.conn)["ag_users"]
180
+ self.assertNotIn("bio", columns)
181
+
182
+ def test_down_to_target(self):
183
+ migrator = Migrator(
184
+ self.conn,
185
+ [
186
+ Migration("a", up="CREATE TABLE ta (id INTEGER)", down="DROP TABLE ta"),
187
+ Migration("b", up="CREATE TABLE tb (id INTEGER)", down="DROP TABLE tb"),
188
+ Migration("c", up="CREATE TABLE tc (id INTEGER)", down="DROP TABLE tc"),
189
+ ],
190
+ )
191
+ migrator.up()
192
+ reverted = migrator.down_to("a")
193
+ self.assertEqual(reverted, ["c", "b"])
194
+ self.assertEqual(migrator.applied(), ["a"])
195
+ self.assertEqual(migrator.down_to("a"), [])
196
+
197
+ def test_down_to_unapplied_target_raises(self):
198
+ migrator = Migrator(self.conn, [])
199
+ with self.assertRaises(ValueError):
200
+ migrator.down_to("nope")
201
+
202
+
203
+ if __name__ == "__main__":
204
+ unittest.main()
@@ -1,64 +0,0 @@
1
- ---
2
- layout: default
3
- title: Schema and Migrations
4
- ---
5
-
6
- Models can declare their physical shape and generate DDL from it, and the migration runner applies ordered schema changes.
7
-
8
- ## Typed Columns
9
-
10
- Declare `tableColumns` as a dict of column name to a typed definition. The factories are `Integer`, `BigInteger`, `String(length)`, `Text`, `Boolean`, `Float`, `Numeric(precision, scale)`, `Date`, `Timestamp`, and `Json`.
11
-
12
- ```python
13
- from sustained import Model
14
- from sustained.schema import Boolean, Integer, String, Timestamp
15
- from sustained.types import Expression
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
- 'active': Boolean(default=True),
23
- 'created_at': Timestamp(default=Expression('CURRENT_TIMESTAMP')),
24
- }
25
- ```
26
-
27
- Definitions support composite primary keys (mark several columns `primary_key=True`), `unique`, literal defaults 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.
28
-
29
- A model with `tableColumns` also gets strict column-name access automatically: a typo'd column raises `AttributeError`.
30
-
31
- ## Generating and Running DDL
32
-
33
- ```python
34
- User.create_table_sql() # dialect-specific CREATE TABLE
35
- User.create_table(conn) # execute it
36
- User.drop_table(conn) # DROP TABLE IF EXISTS
37
- ```
38
-
39
- 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.
40
-
41
- ## Migrations
42
-
43
- Migrations are explicit and ordered. Each 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.
44
-
45
- ```python
46
- from sustained.migrations import Migration, Migrator, create_table_migration
47
-
48
- migrations = [
49
- create_table_migration(User),
50
- Migration(
51
- 'add_last_login',
52
- up='ALTER TABLE users ADD COLUMN last_login TEXT',
53
- down='ALTER TABLE users DROP COLUMN last_login',
54
- ),
55
- ]
56
-
57
- migrator = Migrator(conn, migrations)
58
- migrator.up() # apply all pending
59
- migrator.up(target='create_users') # stop after a target
60
- migrator.status() # [(id, applied), ...]
61
- migrator.down() # revert the newest applied migration
62
- ```
63
-
64
- Applied ids live in a tracking table that the migrator creates on first use. Each migration runs inside a transaction, so a failing step leaves the schema at the previous migration. There is no automatic diffing against the database catalog; write migrations explicitly or derive create/drop pairs from models with `create_table_migration()`.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes