pgdevkit 0.3.3__tar.gz → 0.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/PKG-INFO +56 -1
  2. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/README.md +54 -0
  3. pgdevkit-0.3.6/pgdevkit/cli.py +434 -0
  4. pgdevkit-0.3.6/pgdevkit/migrate.py +369 -0
  5. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pyproject.toml +2 -1
  6. pgdevkit-0.3.6/tests/test_migrate.py +134 -0
  7. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/uv.lock +15 -1
  8. pgdevkit-0.3.3/pgdevkit/cli.py +0 -226
  9. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.github/workflows/python-publish.yml +0 -0
  10. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.github/workflows/python-test.yml +0 -0
  11. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.gitignore +0 -0
  12. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.python-version +0 -0
  13. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/docs/database-layout.md +0 -0
  14. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/__init__.py +0 -0
  15. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/__init__.py +0 -0
  16. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/base.py +0 -0
  17. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/mssql.py +0 -0
  18. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/postgres.py +0 -0
  19. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/connection.py +0 -0
  20. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/__init__.py +0 -0
  21. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/complex_types.py +0 -0
  22. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/connection.py +0 -0
  23. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/crud.py +0 -0
  24. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/loader.py +0 -0
  25. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/model.py +0 -0
  26. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/mssql_crud.py +0 -0
  27. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/mssql_sql.py +0 -0
  28. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/dialect.py +0 -0
  29. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/diff.py +0 -0
  30. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/fetch_missing.py +0 -0
  31. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/introspect.py +0 -0
  32. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/lakebase.py +0 -0
  33. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/models.py +0 -0
  34. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/mssql_introspect.py +0 -0
  35. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/parser.py +0 -0
  36. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/__init__.py +0 -0
  37. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/_docker.py +0 -0
  38. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/api.py +0 -0
  39. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/config.py +0 -0
  40. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/constants.py +0 -0
  41. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/container.py +0 -0
  42. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/__init__.py +0 -0
  43. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/api.py +0 -0
  44. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/constants.py +0 -0
  45. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/container.py +0 -0
  46. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/naming.py +0 -0
  47. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/query.py +0 -0
  48. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/schema.py +0 -0
  49. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/skills/pgdevkit/SKILL.md +0 -0
  50. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  51. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/skills/pgdevkit/references/temporal-tables.md +0 -0
  52. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/__init__.py +0 -0
  53. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/conftest.py +0 -0
  54. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/__init__.py +0 -0
  55. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_complex_types.py +0 -0
  56. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_connection.py +0 -0
  57. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_crud.py +0 -0
  58. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_loader.py +0 -0
  59. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_mssql_crud_live.py +0 -0
  60. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_mssql_crud_sql.py +0 -0
  61. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/01_schema.sql +0 -0
  62. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/02_types.sql +0 -0
  63. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/03_tables.sql +0 -0
  64. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/04_views.sql +0 -0
  65. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/05_functions.sql +0 -0
  66. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/06_indexes.sql +0 -0
  67. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_cli_compare.py +0 -0
  68. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_compare.py +0 -0
  69. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_compare_mssql_live.py +0 -0
  70. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_connection.py +0 -0
  71. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_dialect.py +0 -0
  72. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_diff_mssql.py +0 -0
  73. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_fetch_missing.py +0 -0
  74. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_fetch_missing_cli.py +0 -0
  75. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_lakebase.py +0 -0
  76. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_mssql_introspect.py +0 -0
  77. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_parser_mssql.py +0 -0
  78. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/__init__.py +0 -0
  79. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/conftest.py +0 -0
  80. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  81. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  82. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  83. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  84. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  85. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  86. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  87. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  88. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  89. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  90. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  91. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  92. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  93. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  94. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  95. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  96. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  97. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_api.py +0 -0
  98. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_api_mssql.py +0 -0
  99. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_api_mssql_live.py +0 -0
  100. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_cli.py +0 -0
  101. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_config.py +0 -0
  102. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_config_mssql.py +0 -0
  103. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_constants.py +0 -0
  104. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_container.py +0 -0
  105. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_mssql_constants.py +0 -0
  106. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_naming.py +0 -0
  107. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_query.py +0 -0
  108. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_schema.py +0 -0
  109. {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.3.3
3
+ Version: 0.3.6
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -10,6 +10,7 @@ Provides-Extra: azure
10
10
  Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
11
11
  Provides-Extra: cli
12
12
  Requires-Dist: rich>=13.0.0; extra == 'cli'
13
+ Requires-Dist: tqdm>=4.66.0; extra == 'cli'
13
14
  Requires-Dist: typer>=0.26.7; extra == 'cli'
14
15
  Provides-Extra: db
15
16
  Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
@@ -145,6 +146,60 @@ bootstraps the `sa` login — additional logins are a known limitation.
145
146
  the same category as `psql` for the Postgres path) rather than a Python
146
147
  REPL.
147
148
 
149
+ ## `pgdb migrate`
150
+
151
+ Applies numbered, forward-only SQL migration files from a directory to a live
152
+ Postgres database, tracking each one in a `schema.table` (default
153
+ `public.schema_migrations`) so repeat runs only apply what's pending. Postgres only —
154
+ not available for `--dialect mssql`.
155
+
156
+ ```bash
157
+ pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
158
+ pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
159
+ ```
160
+
161
+ `--entra-user` works the same as `pgdb compare` (see above). The tracking
162
+ table needs `filename text primary key, applied_at timestamptz not null
163
+ default now(), applied_by text not null default current_user` (a migration
164
+ file that creates it, in the same directory, is the usual way to bootstrap
165
+ it — inserting into a not-yet-existing tracking table is tolerated so that
166
+ migration can still run).
167
+
168
+ The tracking table defaults to `public.schema_migrations`. Override it per-project in
169
+ `pyproject.toml`:
170
+
171
+ ```toml
172
+ [tool.pgdevkit]
173
+ migrations_table = "myschema.migrations"
174
+ ```
175
+
176
+ or per-invocation with `--tracking-table`, which takes precedence over the
177
+ pyproject.toml value.
178
+
179
+ `--ask` prints each pending file and asks yes/no/already-done/quit before
180
+ running it. Answering yes queues the file on a background worker and moves
181
+ straight to the next prompt — you can keep reviewing while earlier files are
182
+ still executing, instead of waiting on each one before seeing the next. A
183
+ `tqdm` progress bar tracks the queue; migrations still run one at a time, in
184
+ file order. Without `--ask`, `apply` queues every pending file up front and
185
+ shows the same progress bar. `--file <name>` applies a single file (still
186
+ through the same verify-and-track path) instead of walking all pending ones.
187
+ Pass `--yes` to skip the "about to run migrations against ..." confirmation
188
+ prompt (e.g. in CI).
189
+
190
+ After each file's DDL is applied, `apply` re-checks that every `CREATE TABLE`
191
+ statement's target actually exists (via `to_regclass`) before recording the
192
+ file as applied — catching a migration that silently rolled back. That check
193
+ parses each statement with `sqlglot` and only falls back to a regex (run
194
+ against comment-stripped SQL) for statements sqlglot's postgres dialect can't
195
+ parse, so a `CREATE TABLE` mentioned only in a `--` comment is never mistaken
196
+ for a real one.
197
+
198
+ `pgdevkit.migrate` is also usable directly as a library — `list_migration_files`,
199
+ `applied_migrations`, `pending_migrations`, and `apply_migration` are the same
200
+ functions the CLI calls, so a project can script around them without shelling
201
+ out.
202
+
148
203
  ## `pgdevkit.db` — helpers for application code
149
204
 
150
205
  Install with the `db` extra: `pip install pgdevkit[db]`.
@@ -125,6 +125,60 @@ bootstraps the `sa` login — additional logins are a known limitation.
125
125
  the same category as `psql` for the Postgres path) rather than a Python
126
126
  REPL.
127
127
 
128
+ ## `pgdb migrate`
129
+
130
+ Applies numbered, forward-only SQL migration files from a directory to a live
131
+ Postgres database, tracking each one in a `schema.table` (default
132
+ `public.schema_migrations`) so repeat runs only apply what's pending. Postgres only —
133
+ not available for `--dialect mssql`.
134
+
135
+ ```bash
136
+ pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
137
+ pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
138
+ ```
139
+
140
+ `--entra-user` works the same as `pgdb compare` (see above). The tracking
141
+ table needs `filename text primary key, applied_at timestamptz not null
142
+ default now(), applied_by text not null default current_user` (a migration
143
+ file that creates it, in the same directory, is the usual way to bootstrap
144
+ it — inserting into a not-yet-existing tracking table is tolerated so that
145
+ migration can still run).
146
+
147
+ The tracking table defaults to `public.schema_migrations`. Override it per-project in
148
+ `pyproject.toml`:
149
+
150
+ ```toml
151
+ [tool.pgdevkit]
152
+ migrations_table = "myschema.migrations"
153
+ ```
154
+
155
+ or per-invocation with `--tracking-table`, which takes precedence over the
156
+ pyproject.toml value.
157
+
158
+ `--ask` prints each pending file and asks yes/no/already-done/quit before
159
+ running it. Answering yes queues the file on a background worker and moves
160
+ straight to the next prompt — you can keep reviewing while earlier files are
161
+ still executing, instead of waiting on each one before seeing the next. A
162
+ `tqdm` progress bar tracks the queue; migrations still run one at a time, in
163
+ file order. Without `--ask`, `apply` queues every pending file up front and
164
+ shows the same progress bar. `--file <name>` applies a single file (still
165
+ through the same verify-and-track path) instead of walking all pending ones.
166
+ Pass `--yes` to skip the "about to run migrations against ..." confirmation
167
+ prompt (e.g. in CI).
168
+
169
+ After each file's DDL is applied, `apply` re-checks that every `CREATE TABLE`
170
+ statement's target actually exists (via `to_regclass`) before recording the
171
+ file as applied — catching a migration that silently rolled back. That check
172
+ parses each statement with `sqlglot` and only falls back to a regex (run
173
+ against comment-stripped SQL) for statements sqlglot's postgres dialect can't
174
+ parse, so a `CREATE TABLE` mentioned only in a `--` comment is never mistaken
175
+ for a real one.
176
+
177
+ `pgdevkit.migrate` is also usable directly as a library — `list_migration_files`,
178
+ `applied_migrations`, `pending_migrations`, and `apply_migration` are the same
179
+ functions the CLI calls, so a project can script around them without shelling
180
+ out.
181
+
128
182
  ## `pgdevkit.db` — helpers for application code
129
183
 
130
184
  Install with the `db` extra: `pip install pgdevkit[db]`.
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import queue
5
+ import threading
6
+ from pathlib import Path
7
+
8
+ import psycopg
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from rich import box
13
+ from tqdm import tqdm
14
+
15
+ from . import migrate, testdb
16
+ from .backends import get_backend
17
+ from .connection import build_conninfo
18
+ from .diff import DiffKind, compute_diff
19
+ from .fetch_missing import SUBFOLDER, find_missing_objects, layer_folder_for, reconstruct_ddl
20
+ from .parser import parse_directory
21
+
22
+ app = typer.Typer(name="pgdb", help="PostgreSQL database schema tools")
23
+ console = Console()
24
+ err_console = Console(stderr=True)
25
+
26
+ testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
27
+ app.add_typer(testdb_app, name="testdb")
28
+
29
+ migrate_app = typer.Typer(
30
+ name="migrate", help="Apply numbered, forward-only SQL migration files, tracked in a DB table"
31
+ )
32
+ app.add_typer(migrate_app, name="migrate")
33
+
34
+
35
+ @app.command()
36
+ def compare(
37
+ url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
38
+ entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
39
+ databricks_workspace_host: str | None = typer.Option(
40
+ None,
41
+ "--databricks-workspace-host",
42
+ help="Databricks workspace URL, e.g. https://adb-....azuredatabricks.net (required for Lakebase hosts)",
43
+ ),
44
+ databricks_instance: str | None = typer.Option(
45
+ None, "--databricks-instance", help="Lakebase instance name (required for Lakebase hosts)"
46
+ ),
47
+ report_extra_db: bool = typer.Option(False, "--report-extra-db", help="Report objects in DB but not in scripts"),
48
+ dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
49
+ scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
50
+ ) -> None:
51
+ """Compare SQL scripts to a live database and report differences."""
52
+ if not scripts_dir.is_dir():
53
+ err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
54
+ raise typer.Exit(2)
55
+
56
+ try:
57
+ conninfo = build_conninfo(
58
+ url,
59
+ entra_user,
60
+ databricks_workspace_host=databricks_workspace_host,
61
+ databricks_instance=databricks_instance,
62
+ )
63
+ except ValueError as e:
64
+ err_console.print(f"[red]Error:[/red] {e}")
65
+ raise typer.Exit(2)
66
+
67
+ try:
68
+ backend = get_backend(dialect)
69
+ except ValueError as e:
70
+ err_console.print(f"[red]Error:[/red] {e}")
71
+ raise typer.Exit(2)
72
+
73
+ with console.status("Parsing SQL scripts..."):
74
+ scripts_schema = parse_directory(scripts_dir, dialect=backend.dialect)
75
+
76
+ with console.status("Introspecting database..."):
77
+ db_schema = backend.introspect(conninfo)
78
+
79
+ diffs = compute_diff(scripts_schema, db_schema, report_extra_db=report_extra_db, dialect=backend.dialect)
80
+
81
+ if not diffs:
82
+ console.print("[green]No differences found.[/green]")
83
+ return
84
+
85
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
86
+ table.add_column("Kind", style="cyan", min_width=20)
87
+ table.add_column("Type", style="magenta", min_width=15)
88
+ table.add_column("Object")
89
+ table.add_column("Detail", style="dim")
90
+
91
+ kind_style = {
92
+ DiffKind.MISSING_IN_DB: "[yellow]missing in DB[/yellow]",
93
+ DiffKind.MISSING_IN_SCRIPTS: "[blue]missing in scripts[/blue]",
94
+ DiffKind.MISMATCH: "[red]mismatch[/red]",
95
+ }
96
+ for d in diffs:
97
+ table.add_row(kind_style[d.kind], d.object_type, d.object_name, d.detail)
98
+
99
+ console.print(table)
100
+ console.print(f"\n[bold red]{len(diffs)} difference(s) found.[/bold red]")
101
+ raise typer.Exit(1)
102
+
103
+
104
+ @app.command("fetch-missing")
105
+ def fetch_missing(
106
+ scripts_dir: Path = typer.Argument(..., help="The database/ folder to compare against and write into"),
107
+ url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
108
+ entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
109
+ write: bool = typer.Option(False, "--write", help="Write the reconstructed .sql files (default: dry run)"),
110
+ only: list[str] = typer.Option([], "--only", help="Only fetch schema.name (repeatable); default is everything"),
111
+ ) -> None:
112
+ """Find tables/views/functions that exist in the database but aren't
113
+ tracked under scripts_dir, and reverse-engineer their DDL into new files."""
114
+ if not scripts_dir.is_dir():
115
+ err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
116
+ raise typer.Exit(2)
117
+
118
+ conninfo = build_conninfo(url, entra_user)
119
+
120
+ with console.status("Comparing database/ against the live schema..."):
121
+ missing = find_missing_objects(scripts_dir, conninfo)
122
+
123
+ if only:
124
+ wanted = set(only)
125
+ missing = [m for m in missing if m.qualified_name in wanted]
126
+
127
+ if not missing:
128
+ console.print("[green]No missing objects.[/green]")
129
+ return
130
+
131
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
132
+ table.add_column("Type", style="magenta")
133
+ table.add_column("Object")
134
+ table.add_column("Destination", style="dim")
135
+ for m in missing:
136
+ dest = layer_folder_for(scripts_dir, m.schema) / SUBFOLDER[m.object_type] / f"{m.name}.sql"
137
+ table.add_row(m.object_type, m.qualified_name, str(dest))
138
+ console.print(table)
139
+
140
+ if not write:
141
+ console.print("\n[yellow]Dry run[/yellow] — pass --write to create these files.")
142
+ return
143
+
144
+ written = 0
145
+ with psycopg.connect(conninfo) as conn:
146
+ for m in missing:
147
+ dest_dir = layer_folder_for(scripts_dir, m.schema) / SUBFOLDER[m.object_type]
148
+ dest = dest_dir / f"{m.name}.sql"
149
+ if dest.exists():
150
+ console.print(f" [yellow]SKIP[/yellow] {dest} (already exists)")
151
+ continue
152
+ try:
153
+ ddl = reconstruct_ddl(conn, m)
154
+ except Exception as e: # noqa: BLE001
155
+ err_console.print(f"[red]Error[/red] reconstructing {m.qualified_name}: {e}")
156
+ continue
157
+ dest_dir.mkdir(parents=True, exist_ok=True)
158
+ dest.write_text(ddl, encoding="utf-8")
159
+ console.print(f" [green]WROTE[/green] {dest}")
160
+ written += 1
161
+
162
+ console.print(f"\nWrote {written} file(s).")
163
+
164
+
165
+ @testdb_app.command("up")
166
+ def testdb_up() -> None:
167
+ """Ensure the container is running, the workspace DB exists, and schema is applied."""
168
+ testdb.ensure_testdb()
169
+ info = testdb.status()
170
+ console.print(f"[green]Test DB ready:[/green] {info['database']} ({info['dsn']})")
171
+
172
+
173
+ @testdb_app.command("reset")
174
+ def testdb_reset() -> None:
175
+ """Drop and recreate only this workspace's database, then reapply schema + seed data."""
176
+ testdb.reset_testdb()
177
+ info = testdb.status()
178
+ console.print(f"[green]Test DB reset:[/green] {info['database']}")
179
+
180
+
181
+ @testdb_app.command("run-sql")
182
+ def testdb_run_sql(
183
+ file: Path | None = typer.Argument(None, help="Path to a .sql file"),
184
+ sql: str | None = typer.Option(None, "--sql", help="Inline SQL string"),
185
+ results: bool = typer.Option(False, "--results", help="Print query results as a table"),
186
+ ) -> None:
187
+ """Run SQL against this workspace's database."""
188
+ if (file is None) == (sql is None):
189
+ err_console.print("[red]Error:[/red] pass exactly one of FILE or --sql")
190
+ raise typer.Exit(2)
191
+ statement = file.read_text(encoding="utf-8") if file else sql
192
+ assert statement is not None
193
+ rows = testdb.run_sql(statement)
194
+
195
+ if rows is None:
196
+ console.print("OK")
197
+ return
198
+ if not results:
199
+ console.print(f"OK — {len(rows)} row(s)")
200
+ return
201
+ if not rows:
202
+ console.print("(0 row(s))")
203
+ return
204
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
205
+ for col in rows[0]:
206
+ table.add_column(col)
207
+ for row in rows:
208
+ table.add_row(*(str(v) for v in row.values()))
209
+ console.print(table)
210
+ console.print(f"({len(rows)} row(s))")
211
+
212
+
213
+ @testdb_app.command("status")
214
+ def testdb_status() -> None:
215
+ """Show container state, this workspace's database name, and DSN."""
216
+ for key, value in testdb.status().items():
217
+ console.print(f"{key}: {value}")
218
+
219
+
220
+ @testdb_app.command("shell")
221
+ def testdb_shell() -> None:
222
+ """Drop into an interactive shell (psql, or sqlcmd for MSSQL) against
223
+ this workspace's database."""
224
+ binary, argv = testdb.shell_argv()
225
+ os.execvp(binary, argv)
226
+
227
+
228
+ @testdb_app.command("clean")
229
+ def testdb_clean(
230
+ all: bool = typer.Option(False, "--all", help="Drop every database belonging to this project"),
231
+ ) -> None:
232
+ """Drop this workspace's database (or every database of this project with --all)."""
233
+ testdb.clean_testdb(all=all)
234
+ console.print("[green]Cleaned.[/green]")
235
+
236
+
237
+ @migrate_app.command("check")
238
+ def migrate_check(
239
+ migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
240
+ url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
241
+ entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
242
+ tracking_table: str | None = typer.Option(
243
+ None,
244
+ "--tracking-table",
245
+ help="schema.table recording applied migrations "
246
+ "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
247
+ ),
248
+ ) -> None:
249
+ """List which migration files under migrations_dir are applied vs. pending."""
250
+ if not migrations_dir.is_dir():
251
+ err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
252
+ raise typer.Exit(2)
253
+
254
+ conninfo = build_conninfo(url, entra_user)
255
+ tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
256
+ local_files = migrate.list_migration_files(migrations_dir)
257
+ try:
258
+ applied = migrate.applied_migrations(conninfo, tracking_table)
259
+ except migrate.TrackingTableMissing:
260
+ err_console.print(f"[yellow]⚠[/yellow] {tracking_table} not found — nothing recorded as applied yet")
261
+ applied = {}
262
+
263
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
264
+ table.add_column("File")
265
+ table.add_column("Status")
266
+ table.add_column("Applied at", style="dim")
267
+ pending = []
268
+ for f in local_files:
269
+ if f.name in applied:
270
+ applied_at, applied_by = applied[f.name]
271
+ table.add_row(f.name, "[green]applied[/green]", f"{applied_at:%Y-%m-%d %H:%M} ({applied_by})")
272
+ else:
273
+ table.add_row(f.name, "[yellow]pending[/yellow]", "")
274
+ pending.append(f.name)
275
+ console.print(table)
276
+ console.print(f"\n{len(pending)} pending, {len(applied)} applied, {len(local_files)} total")
277
+
278
+
279
+ @migrate_app.command("apply")
280
+ def migrate_apply(
281
+ migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
282
+ url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
283
+ entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
284
+ tracking_table: str | None = typer.Option(
285
+ None,
286
+ "--tracking-table",
287
+ help="schema.table recording applied migrations "
288
+ "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
289
+ ),
290
+ file: str | None = typer.Option(
291
+ None, "--file", help="Apply only this one filename (relative to migrations_dir) instead of all pending"
292
+ ),
293
+ ask: bool = typer.Option(False, "--ask", help="Show and confirm each migration before running it"),
294
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"),
295
+ ) -> None:
296
+ """Apply pending migration files, in filename order, tracking each in tracking_table."""
297
+ if not migrations_dir.is_dir():
298
+ err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
299
+ raise typer.Exit(2)
300
+
301
+ conninfo = build_conninfo(url, entra_user)
302
+ tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
303
+ target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
304
+ if not yes:
305
+ typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
306
+
307
+ if file:
308
+ targets = [migrations_dir / file]
309
+ else:
310
+ try:
311
+ targets = migrate.pending_migrations(migrations_dir, conninfo, tracking_table)
312
+ except migrate.TrackingTableMissing:
313
+ err_console.print(
314
+ f"[yellow]⚠[/yellow] {tracking_table} not found — treating every migration as pending"
315
+ )
316
+ targets = migrate.list_migration_files(migrations_dir)
317
+
318
+ if not targets:
319
+ console.print("No pending migrations.")
320
+ return
321
+
322
+ # A single background worker applies queued migrations in file order (each still waits
323
+ # for the previous one to land) while --ask keeps prompting for the *next* file, instead
324
+ # of the review blocking on every execution.
325
+ work_q: queue.Queue[tuple[Path, bool] | None] = queue.Queue()
326
+ failure: Exception | None = None
327
+ stop = threading.Event()
328
+ outcomes: list[tuple[str, str]] = []
329
+ bar = tqdm(total=len(targets), unit="migration", desc="Applying")
330
+ # tqdm isn't guaranteed thread-safe without an explicit lock, and both the worker
331
+ # thread and this (the main/--ask) thread call bar.update()/bar.write().
332
+ bar_lock = threading.Lock()
333
+
334
+ def bar_write(msg: str) -> None:
335
+ with bar_lock:
336
+ bar.write(msg)
337
+
338
+ def bar_step() -> None:
339
+ with bar_lock:
340
+ bar.update(1)
341
+
342
+ def worker() -> None:
343
+ nonlocal failure
344
+ for path, already_done in iter(work_q.get, None):
345
+ if not stop.is_set():
346
+ try:
347
+ result = migrate.apply_migration(conninfo, path, tracking_table, already_done=already_done)
348
+ except Exception as e: # noqa: BLE001
349
+ failure = e
350
+ stop.set()
351
+ bar_write(f"FAILED {path.name}: {e}")
352
+ outcomes.append((path.name, "failed"))
353
+ else:
354
+ if result.executed:
355
+ for tbl in result.verified_tables:
356
+ bar_write(f" table {tbl} exists")
357
+ bar_write(f"Applied {path.name}")
358
+ outcomes.append((path.name, "applied"))
359
+ else:
360
+ bar_write(f"Recorded {path.name} as already applied (not executed)")
361
+ outcomes.append((path.name, "recorded"))
362
+ bar_step()
363
+ work_q.task_done()
364
+
365
+ thread = threading.Thread(target=worker, daemon=True)
366
+ thread.start()
367
+
368
+ quit_requested = False
369
+ for path in targets:
370
+ if stop.is_set():
371
+ break
372
+ already_done = False
373
+ if ask:
374
+ already_done = migrate.already_fully_applied(conninfo, path)
375
+ if already_done:
376
+ bar_write(f"Auto: {path.name} is already fully present in the database — marking as already done")
377
+ else:
378
+ # Hold the lock across the whole show-file+prompt step (not just each write) so
379
+ # the worker's bar_write() calls block until the question is answered instead
380
+ # of interleaving with the prompt or the migration text while it's being read.
381
+ with bar_lock:
382
+ bar.write(f"\n=== {path.name} ===")
383
+ bar.write(path.read_text(encoding="utf-8"))
384
+ answer = (
385
+ typer.prompt("[Y]es execute / [n]o skip / [a]lready done / [q]uit", default="y")
386
+ .strip()
387
+ .lower()
388
+ )
389
+ if answer in ("q", "quit"):
390
+ quit_requested = True
391
+ break
392
+ if answer in ("n", "no"):
393
+ bar_write(f"Skipped {path.name}")
394
+ outcomes.append((path.name, "skipped"))
395
+ bar_step()
396
+ continue
397
+ if answer in ("a", "already", "already done"):
398
+ already_done = True
399
+ elif answer not in ("", "y", "yes"):
400
+ bar_write(f"Skipped {path.name}")
401
+ outcomes.append((path.name, "skipped"))
402
+ bar_step()
403
+ continue
404
+
405
+ work_q.put((path, already_done))
406
+
407
+ work_q.put(None)
408
+ thread.join()
409
+ bar.close()
410
+
411
+ processed = {name for name, _ in outcomes}
412
+ for path in targets:
413
+ if stop.is_set() and path.name not in processed:
414
+ outcomes.append((path.name, "not run (stopped after earlier failure)"))
415
+
416
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
417
+ table.add_column("File")
418
+ table.add_column("Result")
419
+ status_style = {
420
+ "applied": "[green]applied[/green]",
421
+ "recorded": "[green]recorded (already done)[/green]",
422
+ "skipped": "[yellow]skipped[/yellow]",
423
+ "failed": "[red]failed[/red]",
424
+ }
425
+ for name, status in outcomes:
426
+ table.add_row(name, status_style.get(status, status))
427
+ console.print(table)
428
+
429
+ if quit_requested:
430
+ console.print("Aborted.")
431
+ raise typer.Exit(1)
432
+ if failure is not None:
433
+ err_console.print(f"[red]✗[/red] {failure}")
434
+ raise typer.Exit(1)