pgdevkit 0.3.3__tar.gz → 0.3.4__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.4}/PKG-INFO +56 -1
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/README.md +54 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/cli.py +197 -1
- pgdevkit-0.3.4/pgdevkit/migrate.py +292 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pyproject.toml +2 -1
- pgdevkit-0.3.4/tests/test_migrate.py +76 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/uv.lock +15 -1
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/.gitignore +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/.python-version +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/docs/database-layout.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/models.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/conftest.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/db/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_compare.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_connection.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_dialect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.3.3 → pgdevkit-0.3.4}/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.4
|
|
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]`.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
|
|
6
8
|
import psycopg
|
|
@@ -8,8 +10,9 @@ import typer
|
|
|
8
10
|
from rich.console import Console
|
|
9
11
|
from rich.table import Table
|
|
10
12
|
from rich import box
|
|
13
|
+
from tqdm import tqdm
|
|
11
14
|
|
|
12
|
-
from . import testdb
|
|
15
|
+
from . import migrate, testdb
|
|
13
16
|
from .backends import get_backend
|
|
14
17
|
from .connection import build_conninfo
|
|
15
18
|
from .diff import DiffKind, compute_diff
|
|
@@ -23,6 +26,11 @@ err_console = Console(stderr=True)
|
|
|
23
26
|
testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
|
|
24
27
|
app.add_typer(testdb_app, name="testdb")
|
|
25
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
|
+
|
|
26
34
|
|
|
27
35
|
@app.command()
|
|
28
36
|
def compare(
|
|
@@ -224,3 +232,191 @@ def testdb_clean(
|
|
|
224
232
|
"""Drop this workspace's database (or every database of this project with --all)."""
|
|
225
233
|
testdb.clean_testdb(all=all)
|
|
226
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
|
+
bar_write(f"\n=== {path.name} ===")
|
|
375
|
+
bar_write(path.read_text(encoding="utf-8"))
|
|
376
|
+
answer = typer.prompt("[Y]es execute / [n]o skip / [a]lready done / [q]uit", default="y").strip().lower()
|
|
377
|
+
if answer in ("q", "quit"):
|
|
378
|
+
quit_requested = True
|
|
379
|
+
break
|
|
380
|
+
if answer in ("n", "no"):
|
|
381
|
+
bar_write(f"Skipped {path.name}")
|
|
382
|
+
outcomes.append((path.name, "skipped"))
|
|
383
|
+
bar_step()
|
|
384
|
+
continue
|
|
385
|
+
if answer in ("a", "already", "already done"):
|
|
386
|
+
already_done = True
|
|
387
|
+
elif answer not in ("", "y", "yes"):
|
|
388
|
+
bar_write(f"Skipped {path.name}")
|
|
389
|
+
outcomes.append((path.name, "skipped"))
|
|
390
|
+
bar_step()
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
work_q.put((path, already_done))
|
|
394
|
+
|
|
395
|
+
work_q.put(None)
|
|
396
|
+
thread.join()
|
|
397
|
+
bar.close()
|
|
398
|
+
|
|
399
|
+
processed = {name for name, _ in outcomes}
|
|
400
|
+
for path in targets:
|
|
401
|
+
if stop.is_set() and path.name not in processed:
|
|
402
|
+
outcomes.append((path.name, "not run (stopped after earlier failure)"))
|
|
403
|
+
|
|
404
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
405
|
+
table.add_column("File")
|
|
406
|
+
table.add_column("Result")
|
|
407
|
+
status_style = {
|
|
408
|
+
"applied": "[green]applied[/green]",
|
|
409
|
+
"recorded": "[green]recorded (already done)[/green]",
|
|
410
|
+
"skipped": "[yellow]skipped[/yellow]",
|
|
411
|
+
"failed": "[red]failed[/red]",
|
|
412
|
+
}
|
|
413
|
+
for name, status in outcomes:
|
|
414
|
+
table.add_row(name, status_style.get(status, status))
|
|
415
|
+
console.print(table)
|
|
416
|
+
|
|
417
|
+
if quit_requested:
|
|
418
|
+
console.print("Aborted.")
|
|
419
|
+
raise typer.Exit(1)
|
|
420
|
+
if failure is not None:
|
|
421
|
+
err_console.print(f"[red]✗[/red] {failure}")
|
|
422
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Apply numbered, forward-only SQL migration files to a live Postgres database,
|
|
2
|
+
tracked in a `schema.table` (default `public.schema_migrations`) so re-runs only
|
|
3
|
+
apply what's pending. Ported from a hand-rolled per-project script — MSSQL is not
|
|
4
|
+
supported yet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import LiteralString, cast
|
|
15
|
+
|
|
16
|
+
import psycopg
|
|
17
|
+
import sqlglot
|
|
18
|
+
from psycopg import errors as pg_errors
|
|
19
|
+
from psycopg import sql as pg_sql
|
|
20
|
+
|
|
21
|
+
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
22
|
+
_DEFAULT_TRACKING_TABLE = "public.schema_migrations"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MigrationVerificationError(RuntimeError):
|
|
26
|
+
"""A CREATE TABLE statement's target doesn't exist after applying the migration."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TrackingTableMissing(RuntimeError):
|
|
30
|
+
"""The migration-tracking table doesn't exist yet (e.g. before it's bootstrapped)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, tracking_table: str) -> None:
|
|
33
|
+
super().__init__(tracking_table)
|
|
34
|
+
self.tracking_table = tracking_table
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tracking_table_identifier(tracking_table: str) -> pg_sql.Identifier:
|
|
38
|
+
"""Parse 'schema.table' into a safely-quoted, injection-proof identifier."""
|
|
39
|
+
if not re.fullmatch(rf"{_IDENTIFIER}\.{_IDENTIFIER}", tracking_table):
|
|
40
|
+
raise ValueError(f"tracking_table must look like schema.table, got {tracking_table!r}")
|
|
41
|
+
schema, _, table = tracking_table.partition(".")
|
|
42
|
+
return pg_sql.Identifier(schema, table)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_pyproject(start: Path) -> Path | None:
|
|
46
|
+
for directory in [start, *start.parents]:
|
|
47
|
+
candidate = directory / "pyproject.toml"
|
|
48
|
+
if candidate.exists():
|
|
49
|
+
return candidate
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_tracking_table(start: Path | None = None) -> str:
|
|
54
|
+
"""The project's configured tracking table: `[tool.pgdevkit].migrations_table` in the
|
|
55
|
+
nearest pyproject.toml at or above `start` (default: cwd), or "public.schema_migrations"
|
|
56
|
+
if neither is set. Lets a project fix its tracking table once instead of passing
|
|
57
|
+
--tracking-table on every `pgdb migrate` invocation."""
|
|
58
|
+
pyproject = _find_pyproject((start or Path.cwd()).resolve())
|
|
59
|
+
if pyproject is None:
|
|
60
|
+
return _DEFAULT_TRACKING_TABLE
|
|
61
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
62
|
+
section = data.get("tool", {}).get("pgdevkit", {})
|
|
63
|
+
return section.get("migrations_table", _DEFAULT_TRACKING_TABLE)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _split_sql(sql: str) -> list[str]:
|
|
67
|
+
"""Split SQL on semicolons, ignoring those inside comments, '...' strings, or $$...$$ blocks."""
|
|
68
|
+
stmts: list[str] = []
|
|
69
|
+
buf: list[str] = []
|
|
70
|
+
i = 0
|
|
71
|
+
in_string = False
|
|
72
|
+
in_line_comment = False
|
|
73
|
+
dollar_tag: str | None = None
|
|
74
|
+
|
|
75
|
+
while i < len(sql):
|
|
76
|
+
c = sql[i]
|
|
77
|
+
if in_line_comment:
|
|
78
|
+
if c == "\n":
|
|
79
|
+
in_line_comment = False
|
|
80
|
+
buf.append(c)
|
|
81
|
+
elif dollar_tag is not None:
|
|
82
|
+
buf.append(c)
|
|
83
|
+
if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag:
|
|
84
|
+
buf.extend(list(dollar_tag[1:]))
|
|
85
|
+
i += len(dollar_tag)
|
|
86
|
+
dollar_tag = None
|
|
87
|
+
continue
|
|
88
|
+
elif in_string:
|
|
89
|
+
if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'":
|
|
90
|
+
buf.append(c)
|
|
91
|
+
buf.append(sql[i + 1])
|
|
92
|
+
i += 2
|
|
93
|
+
continue
|
|
94
|
+
elif c == "'":
|
|
95
|
+
in_string = False
|
|
96
|
+
buf.append(c)
|
|
97
|
+
elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-":
|
|
98
|
+
in_line_comment = True
|
|
99
|
+
buf.append(c)
|
|
100
|
+
elif c == "$":
|
|
101
|
+
m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:])
|
|
102
|
+
if m:
|
|
103
|
+
dollar_tag = m.group(0)
|
|
104
|
+
buf.extend(list(dollar_tag))
|
|
105
|
+
i += len(dollar_tag)
|
|
106
|
+
continue
|
|
107
|
+
buf.append(c)
|
|
108
|
+
elif c == "'":
|
|
109
|
+
in_string = True
|
|
110
|
+
buf.append(c)
|
|
111
|
+
elif c == ";":
|
|
112
|
+
stmt = "".join(buf).strip()
|
|
113
|
+
if stmt:
|
|
114
|
+
stmts.append(stmt)
|
|
115
|
+
buf = []
|
|
116
|
+
else:
|
|
117
|
+
buf.append(c)
|
|
118
|
+
i += 1
|
|
119
|
+
remainder = "".join(buf).strip()
|
|
120
|
+
if remainder:
|
|
121
|
+
stmts.append(remainder)
|
|
122
|
+
return stmts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _strip_line_comments(sql: str) -> str:
|
|
126
|
+
"""Drop '--' line comments, respecting string literals and $$...$$ blocks."""
|
|
127
|
+
buf: list[str] = []
|
|
128
|
+
i = 0
|
|
129
|
+
in_string = False
|
|
130
|
+
in_line_comment = False
|
|
131
|
+
dollar_tag: str | None = None
|
|
132
|
+
|
|
133
|
+
while i < len(sql):
|
|
134
|
+
c = sql[i]
|
|
135
|
+
if in_line_comment:
|
|
136
|
+
if c == "\n":
|
|
137
|
+
in_line_comment = False
|
|
138
|
+
buf.append(c)
|
|
139
|
+
elif dollar_tag is not None:
|
|
140
|
+
buf.append(c)
|
|
141
|
+
if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag:
|
|
142
|
+
buf.extend(list(dollar_tag[1:]))
|
|
143
|
+
i += len(dollar_tag)
|
|
144
|
+
dollar_tag = None
|
|
145
|
+
continue
|
|
146
|
+
elif in_string:
|
|
147
|
+
if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'":
|
|
148
|
+
buf.append(c)
|
|
149
|
+
buf.append(sql[i + 1])
|
|
150
|
+
i += 2
|
|
151
|
+
continue
|
|
152
|
+
elif c == "'":
|
|
153
|
+
in_string = False
|
|
154
|
+
buf.append(c)
|
|
155
|
+
elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-":
|
|
156
|
+
in_line_comment = True
|
|
157
|
+
elif c == "$":
|
|
158
|
+
m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:])
|
|
159
|
+
if m:
|
|
160
|
+
dollar_tag = m.group(0)
|
|
161
|
+
buf.extend(list(dollar_tag))
|
|
162
|
+
i += len(dollar_tag)
|
|
163
|
+
continue
|
|
164
|
+
buf.append(c)
|
|
165
|
+
elif c == "'":
|
|
166
|
+
in_string = True
|
|
167
|
+
buf.append(c)
|
|
168
|
+
else:
|
|
169
|
+
buf.append(c)
|
|
170
|
+
i += 1
|
|
171
|
+
return "".join(buf)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _created_table_names(stmts: list[str]) -> list[str]:
|
|
175
|
+
"""Names of tables any CREATE TABLE statement targets, parsed via sqlglot (falls back to
|
|
176
|
+
regex on comment-stripped text for statements sqlglot's postgres dialect can't parse)."""
|
|
177
|
+
names: list[str] = []
|
|
178
|
+
for stmt in stmts:
|
|
179
|
+
stripped = _strip_line_comments(stmt)
|
|
180
|
+
if not re.search(r"CREATE\s+TABLE", stripped, re.IGNORECASE):
|
|
181
|
+
continue # skip sqlglot entirely for statements that can't be a CREATE TABLE
|
|
182
|
+
try:
|
|
183
|
+
parsed = sqlglot.parse_one(stmt, dialect="postgres")
|
|
184
|
+
except Exception:
|
|
185
|
+
parsed = None
|
|
186
|
+
if parsed is not None and isinstance(parsed, sqlglot.exp.Create) and parsed.kind == "TABLE":
|
|
187
|
+
table = parsed.this.this if isinstance(parsed.this, sqlglot.exp.Schema) else parsed.this
|
|
188
|
+
if isinstance(table, sqlglot.exp.Table):
|
|
189
|
+
names.append(table.sql(dialect="postgres"))
|
|
190
|
+
continue
|
|
191
|
+
names.extend(
|
|
192
|
+
m.group(1)
|
|
193
|
+
for m in re.finditer(
|
|
194
|
+
r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."]+)', stripped, re.IGNORECASE
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
return names
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def list_migration_files(migrations_dir: Path) -> list[Path]:
|
|
201
|
+
return sorted(migrations_dir.glob("*.sql"))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
|
|
205
|
+
"""Filename -> (applied_at, applied_by) for every migration recorded in the tracking table."""
|
|
206
|
+
table = _tracking_table_identifier(tracking_table)
|
|
207
|
+
with psycopg.connect(conninfo) as con:
|
|
208
|
+
try:
|
|
209
|
+
rows = con.execute(
|
|
210
|
+
pg_sql.SQL("select filename, applied_at, applied_by from {} order by applied_at").format(table)
|
|
211
|
+
).fetchall()
|
|
212
|
+
except pg_errors.UndefinedTable as e:
|
|
213
|
+
raise TrackingTableMissing(tracking_table) from e
|
|
214
|
+
return {r[0]: (r[1], r[2]) for r in rows}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def pending_migrations(migrations_dir: Path, conninfo: str, tracking_table: str) -> list[Path]:
|
|
218
|
+
applied = applied_migrations(conninfo, tracking_table)
|
|
219
|
+
return [p for p in list_migration_files(migrations_dir) if p.name not in applied]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool:
|
|
223
|
+
"""Best-effort insert into the tracking table. Returns False without raising if the
|
|
224
|
+
tracking table doesn't exist yet — e.g. this migration is the one that creates it."""
|
|
225
|
+
table = _tracking_table_identifier(tracking_table)
|
|
226
|
+
with psycopg.connect(conninfo) as con:
|
|
227
|
+
try:
|
|
228
|
+
con.execute(
|
|
229
|
+
pg_sql.SQL("insert into {} (filename) values (%s) on conflict do nothing").format(table),
|
|
230
|
+
(filename,),
|
|
231
|
+
)
|
|
232
|
+
con.commit()
|
|
233
|
+
return True
|
|
234
|
+
except pg_errors.UndefinedTable:
|
|
235
|
+
con.rollback()
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def verify_created_tables(conninfo: str, stmts: list[str]) -> list[str]:
|
|
240
|
+
"""Table names from this migration's CREATE TABLE statements that do NOT exist in the
|
|
241
|
+
database. Empty means everything landed."""
|
|
242
|
+
tables = _created_table_names(stmts)
|
|
243
|
+
if not tables:
|
|
244
|
+
return []
|
|
245
|
+
missing = []
|
|
246
|
+
with psycopg.connect(conninfo) as con:
|
|
247
|
+
for tbl in tables:
|
|
248
|
+
row = con.execute("select to_regclass(%s)", (tbl,)).fetchone()
|
|
249
|
+
if not (row and row[0]):
|
|
250
|
+
missing.append(tbl)
|
|
251
|
+
return missing
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class ApplyResult:
|
|
256
|
+
filename: str
|
|
257
|
+
executed: bool # False if the caller marked it "already done" instead of running it
|
|
258
|
+
verified_tables: list[str]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def apply_migration(
|
|
262
|
+
conninfo: str,
|
|
263
|
+
path: Path,
|
|
264
|
+
tracking_table: str,
|
|
265
|
+
*,
|
|
266
|
+
already_done: bool = False,
|
|
267
|
+
) -> ApplyResult:
|
|
268
|
+
sql = path.read_text(encoding="utf-8")
|
|
269
|
+
filename = path.name
|
|
270
|
+
stmts = _split_sql(sql)
|
|
271
|
+
|
|
272
|
+
if not already_done:
|
|
273
|
+
# DDL in its own committed transaction.
|
|
274
|
+
with psycopg.connect(conninfo) as con:
|
|
275
|
+
for stmt in stmts:
|
|
276
|
+
con.execute(cast(LiteralString, stmt))
|
|
277
|
+
con.commit()
|
|
278
|
+
|
|
279
|
+
# Tracking insert is a separate connection/transaction so a missing tracking table
|
|
280
|
+
# never rolls back the DDL that was just applied.
|
|
281
|
+
record_applied(conninfo, tracking_table, filename)
|
|
282
|
+
|
|
283
|
+
if already_done:
|
|
284
|
+
return ApplyResult(filename, executed=False, verified_tables=[])
|
|
285
|
+
|
|
286
|
+
missing = verify_created_tables(conninfo, stmts)
|
|
287
|
+
if missing:
|
|
288
|
+
raise MigrationVerificationError(
|
|
289
|
+
f"{filename}: table(s) not found after apply — migration may have rolled back: "
|
|
290
|
+
+ ", ".join(missing)
|
|
291
|
+
)
|
|
292
|
+
return ApplyResult(filename, executed=True, verified_tables=_created_table_names(stmts))
|
|
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
|
|
|
11
11
|
|
|
12
12
|
[project]
|
|
13
13
|
name = "pgdevkit"
|
|
14
|
-
version = "0.3.
|
|
14
|
+
version = "0.3.4"
|
|
15
15
|
description = "A helper for developing with Postgres"
|
|
16
16
|
readme = "README.md"
|
|
17
17
|
requires-python = ">=3.14"
|
|
@@ -25,6 +25,7 @@ dependencies = [
|
|
|
25
25
|
azure = ["azure-identity>=1.19.0"]
|
|
26
26
|
cli = [
|
|
27
27
|
"rich>=13.0.0",
|
|
28
|
+
"tqdm>=4.66.0",
|
|
28
29
|
"typer>=0.26.7",
|
|
29
30
|
]
|
|
30
31
|
db = [
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pgdevkit.migrate import _created_table_names, _split_sql, _strip_line_comments, default_tracking_table
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_created_table_names_ignores_create_table_mentioned_in_a_comment():
|
|
7
|
+
sql = """
|
|
8
|
+
-- with no CREATE TABLE checked in under database/ or _migration_scripts/. Adding
|
|
9
|
+
-- it here (and as schema-as-code) so a fresh/reset test DB actually has it.
|
|
10
|
+
CREATE TABLE IF NOT EXISTS app.widgets (
|
|
11
|
+
id text NOT NULL,
|
|
12
|
+
CONSTRAINT widgets_pkey PRIMARY KEY (id)
|
|
13
|
+
);
|
|
14
|
+
"""
|
|
15
|
+
assert _created_table_names(_split_sql(sql)) == ["app.widgets"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_created_table_names_finds_multiple_tables():
|
|
19
|
+
sql = """
|
|
20
|
+
CREATE TABLE a.b (id int);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS c.d (id int);
|
|
22
|
+
"""
|
|
23
|
+
assert _created_table_names(_split_sql(sql)) == ["a.b", "c.d"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_created_table_names_ignores_non_create_table_statements():
|
|
27
|
+
sql = """
|
|
28
|
+
GRANT SELECT ON ALL TABLES IN SCHEMA app TO some_role;
|
|
29
|
+
ALTER TABLE app.widgets ADD COLUMN name text;
|
|
30
|
+
"""
|
|
31
|
+
assert _created_table_names(_split_sql(sql)) == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_created_table_names_falls_back_to_regex_for_unparseable_statements():
|
|
35
|
+
# sqlglot's postgres dialect can't parse DO $$ ... $$ blocks — the fallback
|
|
36
|
+
# regex must still run against comment-stripped text, not raw text.
|
|
37
|
+
sql = """
|
|
38
|
+
-- DO NOT CREATE TABLE this manually, use the migration
|
|
39
|
+
DO $$
|
|
40
|
+
BEGIN
|
|
41
|
+
CREATE TABLE app.dynamic_table (id int);
|
|
42
|
+
END $$;
|
|
43
|
+
"""
|
|
44
|
+
assert _created_table_names(_split_sql(sql)) == ["app.dynamic_table"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_strip_line_comments_preserves_string_literals_containing_dashes():
|
|
48
|
+
sql = "SELECT '--not-a-comment' AS x -- a real comment\nFROM t;"
|
|
49
|
+
stripped = _strip_line_comments(sql)
|
|
50
|
+
assert "--not-a-comment" in stripped
|
|
51
|
+
assert "a real comment" not in stripped
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_default_tracking_table_falls_back_when_no_pyproject_section(tmp_path):
|
|
55
|
+
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n")
|
|
56
|
+
assert default_tracking_table(tmp_path) == "public.schema_migrations"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_default_tracking_table_reads_pyproject_override(tmp_path):
|
|
60
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
61
|
+
"[tool.pgdevkit]\nmigrations_table = 'myschema.migrations'\n"
|
|
62
|
+
)
|
|
63
|
+
assert default_tracking_table(tmp_path) == "myschema.migrations"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_default_tracking_table_searches_parent_directories(tmp_path):
|
|
67
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
68
|
+
"[tool.pgdevkit]\nmigrations_table = 'myschema.migrations'\n"
|
|
69
|
+
)
|
|
70
|
+
nested = tmp_path / "database" / "_migration_scripts"
|
|
71
|
+
nested.mkdir(parents=True)
|
|
72
|
+
assert default_tracking_table(nested) == "myschema.migrations"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_default_tracking_table_falls_back_with_no_pyproject_at_all(tmp_path):
|
|
76
|
+
assert default_tracking_table(tmp_path / "nonexistent") == "public.schema_migrations"
|
|
@@ -313,7 +313,7 @@ wheels = [
|
|
|
313
313
|
|
|
314
314
|
[[package]]
|
|
315
315
|
name = "pgdevkit"
|
|
316
|
-
version = "0.3.
|
|
316
|
+
version = "0.3.4"
|
|
317
317
|
source = { editable = "." }
|
|
318
318
|
dependencies = [
|
|
319
319
|
{ name = "docker" },
|
|
@@ -327,6 +327,7 @@ azure = [
|
|
|
327
327
|
]
|
|
328
328
|
cli = [
|
|
329
329
|
{ name = "rich" },
|
|
330
|
+
{ name = "tqdm" },
|
|
330
331
|
{ name = "typer" },
|
|
331
332
|
]
|
|
332
333
|
db = [
|
|
@@ -358,6 +359,7 @@ requires-dist = [
|
|
|
358
359
|
{ name = "pydantic", marker = "extra == 'db'", specifier = ">=2.0" },
|
|
359
360
|
{ name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" },
|
|
360
361
|
{ name = "sqlglot", specifier = ">=30.11.0" },
|
|
362
|
+
{ name = "tqdm", marker = "extra == 'cli'", specifier = ">=4.66.0" },
|
|
361
363
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.26.7" },
|
|
362
364
|
]
|
|
363
365
|
provides-extras = ["azure", "cli", "db", "mssql"]
|
|
@@ -624,6 +626,18 @@ wheels = [
|
|
|
624
626
|
{ url = "https://files.pythonhosted.org/packages/e3/86/53edf106e8cd3c883ccd0c6b470bf00ddf877a86e667665343b2d597329d/sqlglot-30.11.0-py3-none-any.whl", hash = "sha256:cffdee57d1f2f5472dc9f13087e618cf795841172b7d5ef78b63a051a52d2710", size = 698721, upload-time = "2026-06-11T17:11:35.737Z" },
|
|
625
627
|
]
|
|
626
628
|
|
|
629
|
+
[[package]]
|
|
630
|
+
name = "tqdm"
|
|
631
|
+
version = "4.70.0"
|
|
632
|
+
source = { registry = "https://pypi.org/simple" }
|
|
633
|
+
dependencies = [
|
|
634
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
635
|
+
]
|
|
636
|
+
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
|
|
637
|
+
wheels = [
|
|
638
|
+
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
|
|
639
|
+
]
|
|
640
|
+
|
|
627
641
|
[[package]]
|
|
628
642
|
name = "ty"
|
|
629
643
|
version = "0.0.59"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
{pgdevkit-0.3.3 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|