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.
- {sustained-2.2.0 → sustained-2.3.0}/CHANGELOG.md +9 -0
- {sustained-2.2.0 → sustained-2.3.0}/PKG-INFO +2 -2
- {sustained-2.2.0 → sustained-2.3.0}/README.md +1 -1
- {sustained-2.2.0 → sustained-2.3.0}/docs/index.md +1 -1
- sustained-2.3.0/docs/schema.md +123 -0
- {sustained-2.2.0 → sustained-2.3.0}/pyproject.toml +1 -1
- sustained-2.3.0/src/sustained/autogenerate.py +321 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/base.py +9 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/mssql.py +4 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/migrations.py +46 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/schema.py +29 -19
- sustained-2.3.0/tests/test_autogenerate.py +204 -0
- sustained-2.2.0/docs/schema.md +0 -64
- {sustained-2.2.0 → sustained-2.3.0}/.gitignore +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/.pre-commit-config.yaml +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/DEVELOPERS.md +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/LICENSE +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/deploy.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/CNAME +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/_config.yml +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/_layouts/default.html +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/assets/css/style.scss +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/executing.md +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/filtering.md +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/grouping.md +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/models.md +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/queries.md +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/docs/relations.md +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/aio.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/__init__.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/conditional_clause_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/group_by_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/group_by_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/having_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/having_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/join_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/join_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/order_by_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/order_by_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/select_clause_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/where_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/builders/where_builder.pyi +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/duckdb.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/postgres.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/compilers/presto.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/dialects.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/exceptions.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/execution.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/expressions.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/functions.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/model.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/pool.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/py.typed +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/rendering.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/src/sustained/types.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/__init__.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_analyst_sql.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_async.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_builder_ergonomics.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_builder_robustness.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_dialect.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_dialect_behaviors.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_dialect_functions.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_dml.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_duckdb_dialect.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_etl_statements.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_execution.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_expressions.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_functions.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_having_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_join_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_lambda_join_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_migrations.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_model.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_model_features.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_mssql_compiler.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_order_by_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_parameterization.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_pool.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_postgres_compiler.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_predicates.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_query_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_raw_bindings.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_result_formats.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_schema_ddl.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_select_clause_builder.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_transactions.py +0 -0
- {sustained-2.2.0 → sustained-2.3.0}/tests/test_upsert.py +0 -0
- {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.
|
|
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
|
|
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,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
|
+
```
|
|
@@ -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
|
-
|
|
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()
|
sustained-2.2.0/docs/schema.md
DELETED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|