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.
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/PKG-INFO +56 -1
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/README.md +54 -0
- pgdevkit-0.3.6/pgdevkit/cli.py +434 -0
- pgdevkit-0.3.6/pgdevkit/migrate.py +369 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pyproject.toml +2 -1
- pgdevkit-0.3.6/tests/test_migrate.py +134 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/uv.lock +15 -1
- pgdevkit-0.3.3/pgdevkit/cli.py +0 -226
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.gitignore +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/.python-version +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/docs/database-layout.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/models.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/conftest.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_compare.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_dialect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.6}/tests/testdb/test_schema.py +0 -0
- {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
|
+
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)
|