pg-schema-diff 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: pg-schema-diff
3
+ Version: 0.2.0
4
+ Summary: CLI tool to detect schema drift between PostgreSQL databases and generate safe migration SQL
5
+ Author-email: Asad Shah <asadshah7950@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Asadshah7950/schemadrift
8
+ Project-URL: Repository, https://github.com/Asadshah7950/schemadrift
9
+ Project-URL: Bug Tracker, https://github.com/Asadshah7950/schemadrift/issues
10
+ Keywords: postgresql,schema,migration,diff,database,devops
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Database
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: psycopg2-binary>=2.9.9
26
+ Requires-Dist: click>=8.1.7
27
+ Requires-Dist: rich>=13.7.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
30
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
31
+ Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
32
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
33
+ Requires-Dist: mypy>=1.10.0; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # schemadrift
37
+
38
+ > **Detect schema drift between PostgreSQL databases and generate safe, ordered migration SQL — from the command line.**
39
+
40
+ [![CI](https://github.com/Asadshah7950/schemadrift/actions/workflows/ci.yml/badge.svg)](https://github.com/Asadshah7950/schemadrift/actions/workflows/ci.yml)
41
+ [![PyPI version](https://img.shields.io/pypi/v/schemadrift.svg)](https://pypi.org/project/schemadrift/)
42
+ [![Python versions](https://img.shields.io/pypi/pyversions/schemadrift.svg)](https://pypi.org/project/schemadrift/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
44
+
45
+ ---
46
+
47
+ ## Features
48
+
49
+ - 🔍 **Schema inspection** — Introspects live PostgreSQL databases via `psycopg2` (tables, columns, indexes, foreign keys, enums)
50
+ - 🔄 **Drift detection** — Pure-Python diff engine with zero database dependency for the comparison step
51
+ - 📝 **Safe SQL generation** — Produces `BEGIN`/`COMMIT`-wrapped migration scripts in the correct dependency order (drop FKs first, create tables before adding columns, etc.)
52
+ - 🖥️ **Rich CLI** — Beautiful terminal output powered by [Rich](https://github.com/Textualize/rich)
53
+ - 📦 **Multiple output formats** — SQL, JSON, or human-readable summary
54
+ - ✅ **95%+ unit test coverage** — All core logic tested without a live database
55
+
56
+ ---
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ pip install schemadrift
62
+ ```
63
+
64
+ Or install from source:
65
+
66
+ ```bash
67
+ git clone https://github.com/Asadshah7950/schemadrift.git
68
+ cd schemadrift
69
+ pip install -e '.[dev]'
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Quick Start
75
+
76
+ ### Python API
77
+
78
+ ```python
79
+ from schemadrift.inspector import SchemaInspector
80
+ from schemadrift.differ import SchemaDiffer
81
+ from schemadrift.generator import MigrationGenerator
82
+
83
+ # Introspect both databases
84
+ source = SchemaInspector("postgres://user:pass@source-host/mydb").snapshot()
85
+ target = SchemaInspector("postgres://user:pass@target-host/mydb").snapshot()
86
+
87
+ # Compute the diff
88
+ diff = SchemaDiffer(source, target).diff()
89
+
90
+ # Generate migration SQL
91
+ sql = MigrationGenerator(diff).generate()
92
+ print(sql)
93
+ ```
94
+
95
+ ### Output example
96
+
97
+ ```sql
98
+ BEGIN;
99
+
100
+ -- Drop foreign key: fk_orders_user
101
+ ALTER TABLE "orders" DROP CONSTRAINT "fk_orders_user";
102
+
103
+ -- Add table: payments
104
+ CREATE TABLE "payments" (
105
+ "id" integer NOT NULL,
106
+ "amount" numeric NOT NULL,
107
+ PRIMARY KEY ("id")
108
+ );
109
+
110
+ -- Add column: users.phone
111
+ ALTER TABLE "users" ADD COLUMN "phone" text;
112
+
113
+ -- Add foreign key: fk_orders_user
114
+ ALTER TABLE "orders" ADD CONSTRAINT "fk_orders_user"
115
+ FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE NO ACTION;
116
+
117
+ COMMIT;
118
+ ```
119
+
120
+ ---
121
+
122
+ ## CLI Usage
123
+
124
+ ### `diff` — Compare two schemas
125
+
126
+ ```bash
127
+ # Print migration SQL to stdout
128
+ schemadrift diff \
129
+ --source "postgres://user:pass@source-host/db" \
130
+ --target "postgres://user:pass@target-host/db"
131
+
132
+ # Save to a file
133
+ schemadrift diff \
134
+ --source "postgres://user:pass@source-host/db" \
135
+ --target "postgres://user:pass@target-host/db" \
136
+ --output migration.sql
137
+
138
+ # JSON output
139
+ schemadrift diff \
140
+ --source "postgres://user:pass@source-host/db" \
141
+ --target "postgres://user:pass@target-host/db" \
142
+ --format json
143
+
144
+ # Human-readable summary
145
+ schemadrift diff \
146
+ --source "postgres://user:pass@source-host/db" \
147
+ --target "postgres://user:pass@target-host/db" \
148
+ --format summary
149
+
150
+ # GitHub Actions / PR Markdown report
151
+ schemadrift diff \
152
+ --source "postgres://user:pass@source-host/db" \
153
+ --target "postgres://user:pass@target-host/db" \
154
+ --format markdown >> $GITHUB_STEP_SUMMARY
155
+
156
+ # CI/CD Gate: Fail pipeline (exit code 1) if schema drift is detected
157
+ schemadrift diff \
158
+ --source "postgres://user:pass@source-host/db" \
159
+ --target "postgres://user:pass@target-host/db" \
160
+ --fail-on-drift
161
+
162
+ # Rollback / down migration (revert target back to source)
163
+ schemadrift diff \
164
+ --source "postgres://user:pass@source-host/db" \
165
+ --target "postgres://user:pass@target-host/db" \
166
+ --direction down \
167
+ --output rollback.sql
168
+
169
+ # Non-transactional execution (omit BEGIN / COMMIT)
170
+ schemadrift diff \
171
+ --source "postgres://user:pass@source-host/db" \
172
+ --target "postgres://user:pass@target-host/db" \
173
+ --no-transaction
174
+
175
+ # Zero-downtime index management (CREATE / DROP INDEX CONCURRENTLY)
176
+ schemadrift diff \
177
+ --source "postgres://user:pass@source-host/db" \
178
+ --target "postgres://user:pass@target-host/db" \
179
+ --concurrently
180
+ ```
181
+
182
+
183
+ ### `inspect` — Print a schema overview
184
+
185
+ ```bash
186
+ schemadrift inspect --dsn "postgres://user:pass@host/db"
187
+ ```
188
+
189
+ Output:
190
+
191
+ ```
192
+ Schema Summary
193
+ ┌──────────────┬─────────┬─────────┐
194
+ │ Table │ Columns │ Indexes │
195
+ ├──────────────┼─────────┼─────────┤
196
+ │ orders │ 6 │ 3 │
197
+ │ payments │ 4 │ 1 │
198
+ │ users │ 8 │ 4 │
199
+ └──────────────┴─────────┴─────────┘
200
+ Foreign keys: 2
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Architecture
206
+
207
+ | Module | Description |
208
+ |---|---|
209
+ | [`schemadrift/models.py`](schemadrift/models.py) | Dataclasses for all schema objects (`ColumnDef`, `TableDef`, `IndexDef`, `ForeignKeyDef`, `SchemaSnapshot`, `DiffResult`) |
210
+ | [`schemadrift/inspector.py`](schemadrift/inspector.py) | `SchemaInspector` — connects to PostgreSQL and builds a `SchemaSnapshot` using `information_schema` and `pg_catalog` queries |
211
+ | [`schemadrift/differ.py`](schemadrift/differ.py) | `SchemaDiffer` — pure-Python comparison engine; no DB connection required |
212
+ | [`schemadrift/generator.py`](schemadrift/generator.py) | `MigrationGenerator` — converts a `DiffResult` into safe, ordered SQL wrapped in a transaction |
213
+ | [`schemadrift/cli.py`](schemadrift/cli.py) | Click CLI exposing `diff` and `inspect` commands with Rich terminal output |
214
+
215
+ ---
216
+
217
+ ## Contributing
218
+
219
+ Contributions, bug reports, and feature requests are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup instructions.
220
+
221
+ ---
222
+
223
+ ## License
224
+
225
+ [MIT](LICENSE) © 2024 Asad Shah
@@ -0,0 +1,12 @@
1
+ pg_schema_diff-0.2.0.dist-info/licenses/LICENSE,sha256=tIyCf7UMktYxgZqh7Q7VBj9fFU95gw67a2ny_VqjLUA,1066
2
+ schemadrift/__init__.py,sha256=ZI1H1EWBzMmmpiAkQF_GTmDbwlON9IuKK7ABSY_Urz8,133
3
+ schemadrift/cli.py,sha256=cvbsfvZSprDnf2hJ7bsn_4KOtOxcLqlypHoIXroF8GQ,9363
4
+ schemadrift/differ.py,sha256=lmuThsvRD1pWtD3dV9_L-86kP2gBFy4OqJVpkjV21lI,4538
5
+ schemadrift/generator.py,sha256=ovB9snXUNlFCMhv02xeBlGSqhjITBwEJfkeGxLzQRS8,7171
6
+ schemadrift/inspector.py,sha256=0K7zZ5xrrJBQHBN-rypBSTgsPnTGmDOzzxWGa6z5CHU,6186
7
+ schemadrift/models.py,sha256=TFZ7JlR_NH8_8rhTosnJaoWarupLKjyk9tGPML51DKA,2937
8
+ pg_schema_diff-0.2.0.dist-info/METADATA,sha256=vnTAKpiADOFz7QzxSvrobg-15MKs02naOKv7Ce8McYI,7705
9
+ pg_schema_diff-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ pg_schema_diff-0.2.0.dist-info/entry_points.txt,sha256=ae4gX3EasGh9bXv9vaZg013LO9IXSrJNW5c1dzOsCJs,53
11
+ pg_schema_diff-0.2.0.dist-info/top_level.txt,sha256=ZxF_yEm1D9MfmfoTtQP3c_8EYvoU79KGDLUb4jXxlI8,12
12
+ pg_schema_diff-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ schemadrift = schemadrift.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Asad Shah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ schemadrift
@@ -0,0 +1,4 @@
1
+ """schemadrift: PostgreSQL schema drift detector and migration SQL generator."""
2
+
3
+ __version__ = "0.2.0"
4
+ __author__ = "Asad Shah"
schemadrift/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """CLI entry point for pg-schema-diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from schemadrift.differ import SchemaDiffer
13
+ from schemadrift.generator import MigrationGenerator
14
+ from schemadrift.inspector import SchemaInspector
15
+ from schemadrift.models import DiffResult
16
+
17
+ console = Console()
18
+
19
+
20
+ @click.group()
21
+
22
+ @click.version_option()
23
+ def main() -> None:
24
+ """pg-schema-diff: Detect schema drift between PostgreSQL databases."""
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # diff command
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @main.command()
33
+ @click.option("--source", required=True, help="Source database DSN (e.g. postgres://user:pass@host/db).")
34
+ @click.option("--target", required=True, help="Target database DSN.")
35
+ @click.option("--output", "-o", default=None, help="Write migration SQL to this file path.")
36
+ @click.option(
37
+ "--format",
38
+ "fmt",
39
+ default="sql",
40
+ type=click.Choice(["sql", "json", "summary", "markdown"]),
41
+ show_default=True,
42
+ help="Output format.",
43
+ )
44
+ @click.option(
45
+ "--direction",
46
+ "-d",
47
+ type=click.Choice(["up", "down"]),
48
+ default="up",
49
+ show_default=True,
50
+ help="Migration direction (up=forward source->target, down=rollback target->source).",
51
+ )
52
+ @click.option(
53
+ "--transaction/--no-transaction",
54
+ default=True,
55
+ show_default=True,
56
+ help="Wrap migration SQL in a BEGIN/COMMIT transaction block.",
57
+ )
58
+ @click.option(
59
+ "--concurrently",
60
+ is_flag=True,
61
+ default=False,
62
+ help="Create/drop indexes concurrently (disables transaction blocks).",
63
+ )
64
+ @click.option(
65
+ "--fail-on-drift",
66
+ is_flag=True,
67
+ default=False,
68
+ help="Exit with status code 1 if schema drift is detected (useful for CI/CD checks).",
69
+ )
70
+ def diff(
71
+ source: str,
72
+ target: str,
73
+ output: str | None,
74
+ fmt: str,
75
+ direction: str,
76
+ transaction: bool,
77
+ concurrently: bool,
78
+ fail_on_drift: bool,
79
+ ) -> None:
80
+ """Compare SOURCE and TARGET schemas and generate a migration script."""
81
+ if output or fmt == "summary":
82
+ console.print("[bold blue]Inspecting source schema…[/bold blue]")
83
+ try:
84
+ src_snapshot = SchemaInspector(source).snapshot()
85
+ except Exception as exc: # noqa: BLE001
86
+ click.echo(f"Error connecting to source database: {exc}", err=True)
87
+ sys.exit(1)
88
+
89
+ if output or fmt == "summary":
90
+ console.print("[bold blue]Inspecting target schema…[/bold blue]")
91
+ try:
92
+ tgt_snapshot = SchemaInspector(target).snapshot()
93
+ except Exception as exc: # noqa: BLE001
94
+ click.echo(f"Error connecting to target database: {exc}", err=True)
95
+ sys.exit(1)
96
+
97
+ if direction == "down":
98
+ diff_result = SchemaDiffer(tgt_snapshot, src_snapshot).diff()
99
+ else:
100
+ diff_result = SchemaDiffer(src_snapshot, tgt_snapshot).diff()
101
+
102
+ if fmt == "sql":
103
+ use_transaction = False if concurrently else transaction
104
+ content = MigrationGenerator(
105
+ diff_result,
106
+ transaction=use_transaction,
107
+ concurrent_indexes=concurrently,
108
+ ).generate()
109
+ elif fmt == "json":
110
+ content = _diff_to_json(diff_result)
111
+ elif fmt == "markdown":
112
+ content = _diff_to_markdown(diff_result)
113
+ else: # summary
114
+ content = _diff_summary(diff_result)
115
+
116
+ if output:
117
+ with open(output, "w", encoding="utf-8") as fh:
118
+ fh.write(content)
119
+ console.print(f"[green]Migration written to {output}[/green]")
120
+ else:
121
+ click.echo(content)
122
+
123
+ if fail_on_drift and diff_result.has_drift:
124
+ sys.exit(1)
125
+
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # inspect command
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ @main.command()
134
+ @click.option("--dsn", required=True, help="Database DSN to inspect.")
135
+ def inspect(dsn: str) -> None:
136
+ """Inspect a PostgreSQL schema and print a summary table."""
137
+ try:
138
+ snapshot = SchemaInspector(dsn).snapshot()
139
+ except Exception as exc: # noqa: BLE001
140
+ click.echo(f"Error connecting to database: {exc}", err=True)
141
+ sys.exit(1)
142
+
143
+ table = Table(title="Schema Summary", show_header=True, header_style="bold magenta")
144
+ table.add_column("Table", style="cyan", no_wrap=True)
145
+ table.add_column("Columns", justify="right")
146
+ table.add_column("Indexes", justify="right")
147
+
148
+ for tbl_name, tbl in sorted(snapshot.tables.items()):
149
+ table.add_row(tbl_name, str(len(tbl.columns)), str(len(tbl.indexes)))
150
+
151
+ console.print(table)
152
+
153
+ if snapshot.enums:
154
+ console.print(f"\n[bold]Enums ({len(snapshot.enums)}):[/bold] " + ", ".join(snapshot.enums))
155
+
156
+ if snapshot.foreign_keys:
157
+ console.print(f"[bold]Foreign keys:[/bold] {len(snapshot.foreign_keys)}")
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Formatters
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def _diff_to_json(diff_result: DiffResult) -> str:
166
+ """Serialize the diff result to a JSON string."""
167
+ data = {
168
+ "tables_added": [t.name for t in diff_result.tables_added],
169
+ "tables_dropped": list(diff_result.tables_dropped),
170
+ "columns_added": [
171
+ {"table": t, "column": c.name, "type": c.data_type}
172
+ for t, c in diff_result.columns_added
173
+ ],
174
+ "columns_dropped": [
175
+ {"table": t, "column": c} for t, c in diff_result.columns_dropped
176
+ ],
177
+ "columns_altered": [
178
+ {"table": t, "column": s.name, "from_type": s.data_type, "to_type": g.data_type}
179
+ for t, s, g in diff_result.columns_altered
180
+ ],
181
+ "indexes_added": [i.name for i in diff_result.indexes_added],
182
+ "indexes_dropped": [i.name for i in diff_result.indexes_dropped],
183
+ "fks_added": [fk.name for fk in diff_result.fks_added],
184
+ "fks_dropped": [fk.name for fk in diff_result.fks_dropped],
185
+ }
186
+ return json.dumps(data, indent=2)
187
+
188
+
189
+ def _diff_summary(diff_result: DiffResult) -> str:
190
+ """Return a human-readable plain-text summary of the diff."""
191
+ lines = ["Schema Diff Summary", "=" * 40]
192
+ lines.append(f"Tables added: {len(diff_result.tables_added)}")
193
+ lines.append(f"Tables dropped: {len(diff_result.tables_dropped)}")
194
+ lines.append(f"Columns added: {len(diff_result.columns_added)}")
195
+ lines.append(f"Columns dropped: {len(diff_result.columns_dropped)}")
196
+ lines.append(f"Columns altered: {len(diff_result.columns_altered)}")
197
+ lines.append(f"Indexes added: {len(diff_result.indexes_added)}")
198
+ lines.append(f"Indexes dropped: {len(diff_result.indexes_dropped)}")
199
+ lines.append(f"FKs added: {len(diff_result.fks_added)}")
200
+ lines.append(f"FKs dropped: {len(diff_result.fks_dropped)}")
201
+ return "\n".join(lines)
202
+
203
+
204
+ def _md_row(label: str, badge: str, items: list[str]) -> str:
205
+ details = ", ".join(items)
206
+ return f"| **{label}** | {badge} | `{len(items)}` | {details} |"
207
+
208
+
209
+
210
+ def _diff_to_markdown(diff_result: DiffResult) -> str:
211
+ """Serialize the diff result to a GitHub-flavored Markdown report."""
212
+ if not diff_result.has_drift:
213
+ return "### 🟢 Schema Diff: No Changes Detected\n\nDatabase schemas are in sync."
214
+
215
+ lines = [
216
+ "### 🔍 PostgreSQL Schema Drift Report\n",
217
+ "| Component | Status | Count | Details |",
218
+ "| :--- | :--- | :---: | :--- |",
219
+ ]
220
+ if diff_result.tables_added:
221
+ items = [f"`{t.name}`" for t in diff_result.tables_added]
222
+ lines.append(_md_row("Tables Added", "🟢 Added", items))
223
+ if diff_result.tables_dropped:
224
+ items = [f"`{t}`" for t in diff_result.tables_dropped]
225
+ lines.append(_md_row("Tables Dropped", "🔴 Dropped", items))
226
+ if diff_result.columns_added:
227
+ items = [f"`{t}.{c.name}`" for t, c in diff_result.columns_added]
228
+ lines.append(_md_row("Columns Added", "🟢 Added", items))
229
+ if diff_result.columns_dropped:
230
+ items = [f"`{t}.{c}`" for t, c in diff_result.columns_dropped]
231
+ lines.append(_md_row("Columns Dropped", "🔴 Dropped", items))
232
+ if diff_result.columns_altered:
233
+ items = [f"`{t}.{s.name}`" for t, s, _ in diff_result.columns_altered]
234
+ lines.append(_md_row("Columns Altered", "🟡 Altered", items))
235
+ if diff_result.indexes_added:
236
+ items = [f"`{i.name}`" for i in diff_result.indexes_added]
237
+ lines.append(_md_row("Indexes Added", "🟢 Added", items))
238
+ if diff_result.indexes_dropped:
239
+ items = [f"`{i.name}`" for i in diff_result.indexes_dropped]
240
+ lines.append(_md_row("Indexes Dropped", "🔴 Dropped", items))
241
+ if diff_result.fks_added:
242
+ items = [f"`{fk.name}`" for fk in diff_result.fks_added]
243
+ lines.append(_md_row("Foreign Keys Added", "🟢 Added", items))
244
+ if diff_result.fks_dropped:
245
+ items = [f"`{fk.name}`" for fk in diff_result.fks_dropped]
246
+ lines.append(_md_row("Foreign Keys Dropped", "🔴 Dropped", items))
247
+
248
+ return "\n".join(lines)
249
+
250
+
schemadrift/differ.py ADDED
@@ -0,0 +1,130 @@
1
+ """Schema differ: compares two SchemaSnapshot objects and produces a DiffResult."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from schemadrift.models import (
6
+ ColumnDef,
7
+ DiffResult,
8
+ SchemaSnapshot,
9
+ TableDef,
10
+ )
11
+
12
+
13
+ class SchemaDiffer:
14
+ """Compares a source and target SchemaSnapshot and returns a DiffResult."""
15
+
16
+ def __init__(self, source: SchemaSnapshot, target: SchemaSnapshot) -> None:
17
+ self.source = source
18
+ self.target = target
19
+
20
+ def diff(self) -> DiffResult:
21
+ """Compare source vs target and return all detected differences."""
22
+ result = DiffResult()
23
+
24
+ self._diff_tables(result)
25
+ self._diff_foreign_keys(result)
26
+ self._diff_enums(result)
27
+
28
+ return result
29
+
30
+ def _diff_tables(self, result: DiffResult) -> None:
31
+ src_tables = self.source.tables
32
+ tgt_tables = self.target.tables
33
+
34
+ src_names = set(src_tables.keys())
35
+ tgt_names = set(tgt_tables.keys())
36
+
37
+ # Tables in target that don't exist in source → added
38
+ for name in tgt_names - src_names:
39
+ result.tables_added.append(tgt_tables[name])
40
+
41
+ # Tables in source that don't exist in target → dropped
42
+ for name in src_names - tgt_names:
43
+ result.tables_dropped.append(name)
44
+
45
+ # Tables in both → compare columns and indexes
46
+ for name in src_names & tgt_names:
47
+ self._diff_columns(result, name, src_tables[name], tgt_tables[name])
48
+ self._diff_indexes(result, src_tables[name], tgt_tables[name])
49
+
50
+ def _diff_columns(
51
+ self,
52
+ result: DiffResult,
53
+ table_name: str,
54
+ src_table: TableDef,
55
+ tgt_table: TableDef,
56
+ ) -> None:
57
+ src_cols = {c.name: c for c in src_table.columns}
58
+ tgt_cols = {c.name: c for c in tgt_table.columns}
59
+
60
+ src_col_names = set(src_cols.keys())
61
+ tgt_col_names = set(tgt_cols.keys())
62
+
63
+ # Columns in target not in source → added
64
+ for col_name in tgt_col_names - src_col_names:
65
+ result.columns_added.append((table_name, tgt_cols[col_name]))
66
+
67
+ # Columns in source not in target → dropped
68
+ for col_name in src_col_names - tgt_col_names:
69
+ result.columns_dropped.append((table_name, col_name))
70
+
71
+ # Columns in both → check for alterations
72
+ for col_name in src_col_names & tgt_col_names:
73
+ src_col = src_cols[col_name]
74
+ tgt_col = tgt_cols[col_name]
75
+ if self._column_changed(src_col, tgt_col):
76
+ result.columns_altered.append((table_name, src_col, tgt_col))
77
+
78
+ @staticmethod
79
+ def _column_changed(src: ColumnDef, tgt: ColumnDef) -> bool:
80
+ """Return True if anything meaningful changed on a column."""
81
+ return (
82
+ src.data_type != tgt.data_type
83
+ or src.nullable != tgt.nullable
84
+ or src.default != tgt.default
85
+ )
86
+
87
+ def _diff_indexes(
88
+ self,
89
+ result: DiffResult,
90
+ src_table: TableDef,
91
+ tgt_table: TableDef,
92
+ ) -> None:
93
+ src_idx = {i.name: i for i in src_table.indexes}
94
+ tgt_idx = {i.name: i for i in tgt_table.indexes}
95
+
96
+ src_names = set(src_idx.keys())
97
+ tgt_names = set(tgt_idx.keys())
98
+
99
+ for name in tgt_names - src_names:
100
+ result.indexes_added.append(tgt_idx[name])
101
+
102
+ for name in src_names - tgt_names:
103
+ result.indexes_dropped.append(src_idx[name])
104
+
105
+ def _diff_foreign_keys(self, result: DiffResult) -> None:
106
+ src_fks = {fk.name: fk for fk in self.source.foreign_keys}
107
+ tgt_fks = {fk.name: fk for fk in self.target.foreign_keys}
108
+
109
+ src_names = set(src_fks.keys())
110
+ tgt_names = set(tgt_fks.keys())
111
+
112
+ for name in tgt_names - src_names:
113
+ result.fks_added.append(tgt_fks[name])
114
+
115
+ for name in src_names - tgt_names:
116
+ result.fks_dropped.append(src_fks[name])
117
+
118
+ def _diff_enums(self, result: DiffResult) -> None:
119
+ src_enums = self.source.enums
120
+ tgt_enums = self.target.enums
121
+
122
+ src_names = set(src_enums.keys())
123
+ tgt_names = set(tgt_enums.keys())
124
+
125
+ for name in tgt_names - src_names:
126
+ result.enums_added.append((name, tgt_enums[name]))
127
+
128
+ for name in src_names & tgt_names:
129
+ if src_enums[name] != tgt_enums[name]:
130
+ result.enums_altered.append((name, src_enums[name], tgt_enums[name]))
@@ -0,0 +1,191 @@
1
+ """Migration SQL generator: turns a DiffResult into a safe, ordered SQL script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from schemadrift.models import ColumnDef, DiffResult, TableDef
6
+
7
+
8
+ class MigrationGenerator:
9
+ """Generates an ordered SQL migration script from a DiffResult."""
10
+
11
+ def __init__(
12
+ self,
13
+ diff: DiffResult,
14
+ transaction: bool | None = None,
15
+ concurrent_indexes: bool = False,
16
+ ) -> None:
17
+ self.diff = diff
18
+ self.concurrent_indexes = concurrent_indexes
19
+ if transaction is None:
20
+ self.transaction = not concurrent_indexes
21
+ else:
22
+ self.transaction = transaction
23
+
24
+ def generate(self) -> str:
25
+ """
26
+ Return a full SQL migration script.
27
+
28
+ Order of operations (safe for FK/index dependencies):
29
+ 1. DROP foreign keys
30
+ 2. DROP indexes
31
+ 3. ALTER / DROP columns
32
+ 4. DROP tables
33
+ 5. CREATE tables
34
+ 6. ADD columns
35
+ 7. CREATE indexes
36
+ 8. ADD foreign keys
37
+ """
38
+ if self.diff.is_empty():
39
+ if self.transaction:
40
+ return "BEGIN;\n-- No schema differences detected.\nCOMMIT;\n"
41
+ return "-- No schema differences detected.\n"
42
+
43
+ statements: list[str] = []
44
+
45
+ # 1. DROP foreign keys
46
+ for fk in self.diff.fks_dropped:
47
+ statements.append(
48
+ f"-- Drop foreign key: {fk.name}\n"
49
+ f"ALTER TABLE {_q(fk.table)} DROP CONSTRAINT {_q(fk.name)};"
50
+ )
51
+
52
+ # 2. DROP indexes
53
+ for idx in self.diff.indexes_dropped:
54
+ concurrent_kw = "CONCURRENTLY " if self.concurrent_indexes else ""
55
+ statements.append(
56
+ f"-- Drop index: {idx.name}\n"
57
+ f"DROP INDEX {concurrent_kw}IF EXISTS {_q(idx.name)};"
58
+ )
59
+
60
+ # 3. ALTER columns (type changes), DROP columns
61
+ for table_name, src_col, tgt_col in self.diff.columns_altered:
62
+ statements.extend(_alter_column_statements(table_name, src_col, tgt_col))
63
+
64
+ for table_name, col_name in self.diff.columns_dropped:
65
+ statements.append(
66
+ f"-- Drop column: {table_name}.{col_name}\n"
67
+ f"ALTER TABLE {_q(table_name)} DROP COLUMN IF EXISTS {_q(col_name)};"
68
+ )
69
+
70
+ # 4. DROP tables
71
+ for table_name in self.diff.tables_dropped:
72
+ statements.append(
73
+ f"-- Drop table: {table_name}\nDROP TABLE IF EXISTS {_q(table_name)};"
74
+ )
75
+
76
+ # 5. CREATE tables
77
+ for table in self.diff.tables_added:
78
+ statements.append(_create_table_statement(table))
79
+
80
+ # 6. ADD columns
81
+ for table_name, col in self.diff.columns_added:
82
+ statements.append(
83
+ f"-- Add column: {table_name}.{col.name}\n"
84
+ f"ALTER TABLE {_q(table_name)} ADD COLUMN {_column_def_sql(col)};"
85
+ )
86
+
87
+ # 7. CREATE indexes
88
+ for idx in self.diff.indexes_added:
89
+ unique_kw = "UNIQUE " if idx.unique else ""
90
+ concurrent_kw = "CONCURRENTLY " if self.concurrent_indexes else ""
91
+ cols = ", ".join(_q(c) for c in idx.columns)
92
+ statements.append(
93
+ f"-- Add index: {idx.name}\n"
94
+ f"CREATE {unique_kw}INDEX {concurrent_kw}{_q(idx.name)} ON {_q(idx.table)} "
95
+ f"USING {idx.method} ({cols});"
96
+ )
97
+
98
+ # 8. ADD foreign keys
99
+ for fk in self.diff.fks_added:
100
+ src_cols = ", ".join(_q(c) for c in fk.columns)
101
+ ref_cols = ", ".join(_q(c) for c in fk.ref_columns)
102
+ statements.append(
103
+ f"-- Add foreign key: {fk.name}\n"
104
+ f"ALTER TABLE {_q(fk.table)} ADD CONSTRAINT {_q(fk.name)} "
105
+ f"FOREIGN KEY ({src_cols}) REFERENCES {_q(fk.ref_table)} ({ref_cols}) "
106
+ f"ON DELETE {fk.on_delete};"
107
+ )
108
+
109
+ # 9. Enum additions (informational – add values)
110
+ for enum_name, labels in self.diff.enums_added:
111
+ for label in labels:
112
+ statements.append(
113
+ f"-- Add enum value to {enum_name}\n"
114
+ f"ALTER TYPE {_q(enum_name)} ADD VALUE IF NOT EXISTS '{label}';"
115
+ )
116
+
117
+ body = "\n\n".join(statements)
118
+ if self.transaction:
119
+ return f"BEGIN;\n\n{body}\n\nCOMMIT;\n"
120
+ return f"{body}\n"
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _q(name: str) -> str:
129
+ """Quote a PostgreSQL identifier."""
130
+ return f'"{name}"'
131
+
132
+
133
+ def _column_def_sql(col: ColumnDef) -> str:
134
+ """Return the SQL fragment for a column definition (name type [NOT NULL] [DEFAULT ...])."""
135
+ parts = [_q(col.name), col.data_type]
136
+ if not col.nullable:
137
+ parts.append("NOT NULL")
138
+ if col.default is not None:
139
+ parts.append(f"DEFAULT {col.default}")
140
+ return " ".join(parts)
141
+
142
+
143
+ def _create_table_statement(table: TableDef) -> str:
144
+ """Return a CREATE TABLE SQL statement for the given TableDef."""
145
+ col_lines = []
146
+ pk_cols = [c.name for c in table.columns if c.is_primary_key]
147
+
148
+ for col in table.columns:
149
+ col_lines.append(f" {_column_def_sql(col)}")
150
+
151
+ if pk_cols:
152
+ pk_list = ", ".join(_q(c) for c in pk_cols)
153
+ col_lines.append(f" PRIMARY KEY ({pk_list})")
154
+
155
+ cols_sql = ",\n".join(col_lines)
156
+ return f"-- Add table: {table.name}\nCREATE TABLE {_q(table.name)} (\n{cols_sql}\n);"
157
+
158
+
159
+ def _alter_column_statements(table_name: str, src: ColumnDef, tgt: ColumnDef) -> list[str]:
160
+ """Return ALTER TABLE statements needed to transition src column to tgt column."""
161
+ stmts = []
162
+ if src.data_type != tgt.data_type:
163
+ stmts.append(
164
+ f"-- Alter column type: {table_name}.{tgt.name}\n"
165
+ f"ALTER TABLE {_q(table_name)} ALTER COLUMN {_q(tgt.name)} "
166
+ f"TYPE {tgt.data_type};"
167
+ )
168
+ if src.nullable != tgt.nullable:
169
+ if tgt.nullable:
170
+ stmts.append(
171
+ f"-- Allow NULL: {table_name}.{tgt.name}\n"
172
+ f"ALTER TABLE {_q(table_name)} ALTER COLUMN {_q(tgt.name)} DROP NOT NULL;"
173
+ )
174
+ else:
175
+ stmts.append(
176
+ f"-- Set NOT NULL: {table_name}.{tgt.name}\n"
177
+ f"ALTER TABLE {_q(table_name)} ALTER COLUMN {_q(tgt.name)} SET NOT NULL;"
178
+ )
179
+ if src.default != tgt.default:
180
+ if tgt.default is None:
181
+ stmts.append(
182
+ f"-- Drop default: {table_name}.{tgt.name}\n"
183
+ f"ALTER TABLE {_q(table_name)} ALTER COLUMN {_q(tgt.name)} DROP DEFAULT;"
184
+ )
185
+ else:
186
+ stmts.append(
187
+ f"-- Set default: {table_name}.{tgt.name}\n"
188
+ f"ALTER TABLE {_q(table_name)} ALTER COLUMN {_q(tgt.name)} "
189
+ f"SET DEFAULT {tgt.default};"
190
+ )
191
+ return stmts
@@ -0,0 +1,185 @@
1
+ """Schema inspector: connects to PostgreSQL and produces a SchemaSnapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import psycopg2
8
+ import psycopg2.extras
9
+
10
+ from schemadrift.models import (
11
+ ColumnDef,
12
+ ForeignKeyDef,
13
+ IndexDef,
14
+ SchemaSnapshot,
15
+ TableDef,
16
+ )
17
+
18
+ _SQL_TABLES = """
19
+ SELECT table_name
20
+ FROM information_schema.tables
21
+ WHERE table_schema = 'public'
22
+ AND table_type = 'BASE TABLE'
23
+ ORDER BY table_name;
24
+ """
25
+
26
+ _SQL_COLUMNS = """
27
+ SELECT column_name, data_type, is_nullable, column_default
28
+ FROM information_schema.columns
29
+ WHERE table_schema = 'public'
30
+ AND table_name = %s
31
+ ORDER BY ordinal_position;
32
+ """
33
+
34
+ _SQL_PRIMARY_KEYS = """
35
+ SELECT kcu.column_name
36
+ FROM information_schema.table_constraints tc
37
+ JOIN information_schema.key_column_usage kcu
38
+ ON tc.constraint_name = kcu.constraint_name
39
+ AND tc.table_schema = kcu.table_schema
40
+ WHERE tc.constraint_type = 'PRIMARY KEY'
41
+ AND tc.table_schema = 'public'
42
+ AND tc.table_name = %s;
43
+ """
44
+
45
+ _SQL_INDEXES = """
46
+ SELECT indexname, indexdef
47
+ FROM pg_indexes
48
+ WHERE schemaname = 'public'
49
+ AND tablename = %s;
50
+ """
51
+
52
+ _SQL_FOREIGN_KEYS = """
53
+ SELECT
54
+ rc.constraint_name,
55
+ kcu.table_name,
56
+ kcu.column_name,
57
+ ccu.table_name AS ref_table,
58
+ ccu.column_name AS ref_column,
59
+ rc.delete_rule
60
+ FROM information_schema.referential_constraints rc
61
+ JOIN information_schema.key_column_usage kcu
62
+ ON rc.constraint_name = kcu.constraint_name
63
+ AND rc.constraint_schema = kcu.constraint_schema
64
+ JOIN information_schema.constraint_column_usage ccu
65
+ ON rc.unique_constraint_name = ccu.constraint_name
66
+ AND rc.unique_constraint_schema = ccu.constraint_schema
67
+ WHERE rc.constraint_schema = 'public'
68
+ ORDER BY rc.constraint_name, kcu.ordinal_position;
69
+ """
70
+
71
+ _SQL_ENUMS = """
72
+ SELECT t.typname, e.enumlabel
73
+ FROM pg_type t
74
+ JOIN pg_enum e ON t.oid = e.enumtypid
75
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
76
+ WHERE n.nspname = 'public'
77
+ ORDER BY t.typname, e.enumsortorder;
78
+ """
79
+
80
+
81
+ def _parse_index_columns(indexdef: str) -> list[str]:
82
+ """Extract column names from an index definition string."""
83
+ match = re.search(r"\((.+)\)$", indexdef)
84
+ if not match:
85
+ return []
86
+ raw = match.group(1)
87
+ return [col.strip().split()[0] for col in raw.split(",")]
88
+
89
+
90
+ class SchemaInspector:
91
+ """Inspects a live PostgreSQL schema and returns a SchemaSnapshot."""
92
+
93
+ def __init__(self, dsn: str) -> None:
94
+ """Store DSN; no connection is made until snapshot() is called."""
95
+ self.dsn = dsn
96
+
97
+ def snapshot(self) -> SchemaSnapshot:
98
+ """Connect to the database and return a full SchemaSnapshot."""
99
+ conn = psycopg2.connect(self.dsn)
100
+ try:
101
+ return self._build_snapshot(conn)
102
+ finally:
103
+ conn.close()
104
+
105
+ def _build_snapshot(self, conn: psycopg2.connection) -> SchemaSnapshot:
106
+ with conn.cursor() as cur:
107
+ tables = self._fetch_tables(cur)
108
+ foreign_keys = self._fetch_foreign_keys(cur)
109
+ enums = self._fetch_enums(cur)
110
+ return SchemaSnapshot(tables=tables, foreign_keys=foreign_keys, enums=enums)
111
+
112
+ def _fetch_tables(self, cur: psycopg2.cursor) -> dict[str, TableDef]:
113
+ cur.execute(_SQL_TABLES)
114
+ table_names = [row[0] for row in cur.fetchall()]
115
+ tables: dict[str, TableDef] = {}
116
+ for name in table_names:
117
+ columns = self._fetch_columns(cur, name)
118
+ indexes = self._fetch_indexes(cur, name)
119
+ tables[name] = TableDef(name=name, columns=columns, indexes=indexes)
120
+ return tables
121
+
122
+ def _fetch_columns(self, cur: psycopg2.cursor, table: str) -> list[ColumnDef]:
123
+ cur.execute(_SQL_COLUMNS, (table,))
124
+ rows = cur.fetchall()
125
+
126
+ cur.execute(_SQL_PRIMARY_KEYS, (table,))
127
+ pk_cols = {row[0] for row in cur.fetchall()}
128
+
129
+ columns = []
130
+ for col_name, data_type, is_nullable, col_default in rows:
131
+ columns.append(
132
+ ColumnDef(
133
+ name=col_name,
134
+ data_type=data_type,
135
+ nullable=(is_nullable == "YES"),
136
+ default=col_default,
137
+ is_primary_key=(col_name in pk_cols),
138
+ )
139
+ )
140
+ return columns
141
+
142
+ def _fetch_indexes(self, cur: psycopg2.cursor, table: str) -> list[IndexDef]:
143
+ cur.execute(_SQL_INDEXES, (table,))
144
+ indexes = []
145
+ for indexname, indexdef in cur.fetchall():
146
+ unique = "UNIQUE" in indexdef.upper()
147
+ method_match = re.search(r"USING\s+(\w+)", indexdef, re.IGNORECASE)
148
+ method = method_match.group(1).lower() if method_match else "btree"
149
+ columns = _parse_index_columns(indexdef)
150
+ indexes.append(
151
+ IndexDef(
152
+ name=indexname,
153
+ table=table,
154
+ columns=columns,
155
+ unique=unique,
156
+ method=method,
157
+ )
158
+ )
159
+ return indexes
160
+
161
+ def _fetch_foreign_keys(self, cur: psycopg2.cursor) -> list[ForeignKeyDef]:
162
+ cur.execute(_SQL_FOREIGN_KEYS)
163
+ rows = cur.fetchall()
164
+
165
+ fks: dict[str, ForeignKeyDef] = {}
166
+ for constraint_name, table, column, ref_table, ref_col, delete_rule in rows:
167
+ if constraint_name not in fks:
168
+ fks[constraint_name] = ForeignKeyDef(
169
+ name=constraint_name,
170
+ table=table,
171
+ columns=[],
172
+ ref_table=ref_table,
173
+ ref_columns=[],
174
+ on_delete=delete_rule,
175
+ )
176
+ fks[constraint_name].columns.append(column)
177
+ fks[constraint_name].ref_columns.append(ref_col)
178
+ return list(fks.values())
179
+
180
+ def _fetch_enums(self, cur: psycopg2.cursor) -> dict[str, list[str]]:
181
+ cur.execute(_SQL_ENUMS)
182
+ enums: dict[str, list[str]] = {}
183
+ for typname, enumlabel in cur.fetchall():
184
+ enums.setdefault(typname, []).append(enumlabel)
185
+ return enums
schemadrift/models.py ADDED
@@ -0,0 +1,96 @@
1
+ """Data models for pg-schema-diff schema objects and diff results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class ColumnDef:
10
+ """Represents a single column in a PostgreSQL table."""
11
+
12
+ name: str
13
+ data_type: str
14
+ nullable: bool = True
15
+ default: str | None = None
16
+ is_primary_key: bool = False
17
+
18
+
19
+ @dataclass
20
+ class IndexDef:
21
+ """Represents a PostgreSQL index on a table."""
22
+
23
+ name: str
24
+ table: str
25
+ columns: list[str]
26
+ unique: bool = False
27
+ method: str = "btree"
28
+
29
+
30
+ @dataclass
31
+ class TableDef:
32
+ """Represents a PostgreSQL table with its columns and indexes."""
33
+
34
+ name: str
35
+ columns: list[ColumnDef] = field(default_factory=list)
36
+ indexes: list[IndexDef] = field(default_factory=list)
37
+
38
+
39
+ @dataclass
40
+ class ForeignKeyDef:
41
+ """Represents a foreign key constraint."""
42
+
43
+ name: str
44
+ table: str
45
+ columns: list[str]
46
+ ref_table: str
47
+ ref_columns: list[str]
48
+ on_delete: str = "NO ACTION"
49
+
50
+
51
+ @dataclass
52
+ class SchemaSnapshot:
53
+ """A complete snapshot of a PostgreSQL schema."""
54
+
55
+ tables: dict[str, TableDef] = field(default_factory=dict)
56
+ foreign_keys: list[ForeignKeyDef] = field(default_factory=list)
57
+ enums: dict[str, list[str]] = field(default_factory=dict)
58
+
59
+
60
+ @dataclass
61
+ class DiffResult:
62
+ """The result of comparing two SchemaSnapshot objects."""
63
+
64
+ tables_added: list[TableDef] = field(default_factory=list)
65
+ tables_dropped: list[str] = field(default_factory=list)
66
+ columns_added: list[tuple[str, ColumnDef]] = field(default_factory=list)
67
+ columns_dropped: list[tuple[str, str]] = field(default_factory=list)
68
+ columns_altered: list[tuple[str, ColumnDef, ColumnDef]] = field(default_factory=list)
69
+ indexes_added: list[IndexDef] = field(default_factory=list)
70
+ indexes_dropped: list[IndexDef] = field(default_factory=list)
71
+ fks_added: list[ForeignKeyDef] = field(default_factory=list)
72
+ fks_dropped: list[ForeignKeyDef] = field(default_factory=list)
73
+ enums_added: list[tuple[str, list[str]]] = field(default_factory=list)
74
+ enums_altered: list[tuple[str, list[str], list[str]]] = field(default_factory=list)
75
+
76
+ def is_empty(self) -> bool:
77
+ """Return True if there are no detected differences."""
78
+ return (
79
+ not self.tables_added
80
+ and not self.tables_dropped
81
+ and not self.columns_added
82
+ and not self.columns_dropped
83
+ and not self.columns_altered
84
+ and not self.indexes_added
85
+ and not self.indexes_dropped
86
+ and not self.fks_added
87
+ and not self.fks_dropped
88
+ and not self.enums_added
89
+ and not self.enums_altered
90
+ )
91
+
92
+ @property
93
+ def has_drift(self) -> bool:
94
+ """Return True if schema drift is detected."""
95
+ return not self.is_empty()
96
+