pgdevkit 0.11.0__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/PKG-INFO +42 -6
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/README.md +41 -5
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/cli.py +98 -24
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/connection.py +16 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/migrate.py +83 -23
- pgdevkit-0.12.0/pgdevkit/migrate_mssql.py +270 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pyproject.toml +1 -1
- pgdevkit-0.12.0/tests/test_migrate_mssql.py +341 -0
- pgdevkit-0.12.0/tests/test_migrate_mssql_live.py +49 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/uv.lock +1 -1
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.gitignore +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.python-version +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/stats.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/conftest.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_envtag.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_stats_cli.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_stats_mssql_live.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.11.0 → pgdevkit-0.12.0}/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
|
+
Version: 0.12.0
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -73,6 +73,12 @@ Requires the `mssql` extra: `pip install pgdevkit[mssql]` (pulls in
|
|
|
73
73
|
own driver — no system ODBC driver install needed). MSSQL has no composite
|
|
74
74
|
type or native enum equivalent, so those areas of a `database/` tree don't
|
|
75
75
|
have a direct equivalent on this backend — see `docs/database-layout.md`.
|
|
76
|
+
|
|
77
|
+
`pgdb compare --dialect mssql --entra-user <identity>` appends
|
|
78
|
+
`Authentication=ActiveDirectoryDefault` to the connection string so mssql-python
|
|
79
|
+
gets an Entra ID token via `DefaultAzureCredential` (also install the `azure`
|
|
80
|
+
extra). The identity is whatever that credential chain resolves; the flag's
|
|
81
|
+
value only switches Entra auth on.
|
|
76
82
|
Current Azure SQL/SQL Server (2025+) does have a native `json` column type,
|
|
77
83
|
which parses/introspects/diffs like any other column type; see
|
|
78
84
|
"`pgdevkit.db` — helpers for application code" below for how JSON values are
|
|
@@ -324,16 +330,46 @@ REPL.
|
|
|
324
330
|
## `pgdb migrate`
|
|
325
331
|
|
|
326
332
|
Applies numbered, forward-only SQL migration files from a directory to a live
|
|
327
|
-
Postgres database, tracking each one in a `schema.table` (default
|
|
328
|
-
`public.schema_migrations`) so repeat runs only
|
|
329
|
-
|
|
333
|
+
Postgres or MSSQL database, tracking each one in a `schema.table` (default
|
|
334
|
+
`public.schema_migrations`, `dbo.schema_migrations` for MSSQL) so repeat runs only
|
|
335
|
+
apply what's pending.
|
|
330
336
|
|
|
331
337
|
```bash
|
|
332
338
|
pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
|
|
333
339
|
pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
|
|
334
340
|
```
|
|
335
341
|
|
|
336
|
-
|
|
342
|
+
### MSSQL
|
|
343
|
+
|
|
344
|
+
Pass `--dialect mssql` (needs the `mssql` extra) to both `check` and `apply`, with
|
|
345
|
+
`--url` as an ODBC connection string:
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
pgdb migrate apply path/to/database/_migration_scripts --dialect mssql \
|
|
349
|
+
--url "Server=host,1433;Database=db;UID=user;PWD=pass"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
- Migration files are T-SQL: they are split into batches on standalone `GO` lines
|
|
353
|
+
(needed for e.g. `CREATE VIEW`, which must be first in its batch), and all batches
|
|
354
|
+
of one file run in a single transaction that is rolled back if any batch fails
|
|
355
|
+
(including a failure in a later statement of a multi-statement batch). `GO <count>`
|
|
356
|
+
is not honored: the batch runs once.
|
|
357
|
+
- The tracking table needs `filename nvarchar(450) primary key, applied_at
|
|
358
|
+
datetimeoffset not null default sysdatetimeoffset(), applied_by nvarchar(128) not
|
|
359
|
+
null default suser_sname()`. As on Postgres, a migration creating it can bootstrap it.
|
|
360
|
+
Override its name with `mssql_migrations_table` in `[tool.pgdevkit]` (the Postgres
|
|
361
|
+
`migrations_table` key is deliberately ignored for MSSQL) or with `--tracking-table`.
|
|
362
|
+
- `--ask` auto-detects "already done" for single-statement `GO` batches that create a
|
|
363
|
+
table, view or schema, or add a column; anything else is asked about.
|
|
364
|
+
- Post-apply verification checks every `CREATE TABLE` target via `OBJECT_ID`.
|
|
365
|
+
- `--entra-user` appends `Authentication=ActiveDirectoryDefault` to the connection
|
|
366
|
+
string, so mssql-python fetches an Entra ID token through azure-identity's
|
|
367
|
+
`DefaultAzureCredential` (install `pgdevkit[mssql,azure]`). The identity is whatever
|
|
368
|
+
that credential chain resolves — the flag's value only switches Entra auth on. It
|
|
369
|
+
can't be combined with an `Authentication=` already in the connection string.
|
|
370
|
+
- `pgdevkit.migrate.missing_privileges` is not available for MSSQL.
|
|
371
|
+
|
|
372
|
+
For Postgres, `--entra-user` works the same as `pgdb compare` (see above). The tracking
|
|
337
373
|
table needs `filename text primary key, applied_at timestamptz not null
|
|
338
374
|
default now(), applied_by text not null default current_user` (a migration
|
|
339
375
|
file that creates it, in the same directory, is the usual way to bootstrap
|
|
@@ -373,7 +409,7 @@ for a real one.
|
|
|
373
409
|
`pgdevkit.migrate` is also usable directly as a library — `list_migration_files`,
|
|
374
410
|
`applied_migrations`, `pending_migrations`, and `apply_migration` are the same
|
|
375
411
|
functions the CLI calls, so a project can script around them without shelling
|
|
376
|
-
out.
|
|
412
|
+
out. The database-touching ones take `dialect="postgres" | "mssql"`.
|
|
377
413
|
|
|
378
414
|
## `pgdevkit.db` — helpers for application code
|
|
379
415
|
|
|
@@ -52,6 +52,12 @@ Requires the `mssql` extra: `pip install pgdevkit[mssql]` (pulls in
|
|
|
52
52
|
own driver — no system ODBC driver install needed). MSSQL has no composite
|
|
53
53
|
type or native enum equivalent, so those areas of a `database/` tree don't
|
|
54
54
|
have a direct equivalent on this backend — see `docs/database-layout.md`.
|
|
55
|
+
|
|
56
|
+
`pgdb compare --dialect mssql --entra-user <identity>` appends
|
|
57
|
+
`Authentication=ActiveDirectoryDefault` to the connection string so mssql-python
|
|
58
|
+
gets an Entra ID token via `DefaultAzureCredential` (also install the `azure`
|
|
59
|
+
extra). The identity is whatever that credential chain resolves; the flag's
|
|
60
|
+
value only switches Entra auth on.
|
|
55
61
|
Current Azure SQL/SQL Server (2025+) does have a native `json` column type,
|
|
56
62
|
which parses/introspects/diffs like any other column type; see
|
|
57
63
|
"`pgdevkit.db` — helpers for application code" below for how JSON values are
|
|
@@ -303,16 +309,46 @@ REPL.
|
|
|
303
309
|
## `pgdb migrate`
|
|
304
310
|
|
|
305
311
|
Applies numbered, forward-only SQL migration files from a directory to a live
|
|
306
|
-
Postgres database, tracking each one in a `schema.table` (default
|
|
307
|
-
`public.schema_migrations`) so repeat runs only
|
|
308
|
-
|
|
312
|
+
Postgres or MSSQL database, tracking each one in a `schema.table` (default
|
|
313
|
+
`public.schema_migrations`, `dbo.schema_migrations` for MSSQL) so repeat runs only
|
|
314
|
+
apply what's pending.
|
|
309
315
|
|
|
310
316
|
```bash
|
|
311
317
|
pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
|
|
312
318
|
pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
|
|
313
319
|
```
|
|
314
320
|
|
|
315
|
-
|
|
321
|
+
### MSSQL
|
|
322
|
+
|
|
323
|
+
Pass `--dialect mssql` (needs the `mssql` extra) to both `check` and `apply`, with
|
|
324
|
+
`--url` as an ODBC connection string:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
pgdb migrate apply path/to/database/_migration_scripts --dialect mssql \
|
|
328
|
+
--url "Server=host,1433;Database=db;UID=user;PWD=pass"
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
- Migration files are T-SQL: they are split into batches on standalone `GO` lines
|
|
332
|
+
(needed for e.g. `CREATE VIEW`, which must be first in its batch), and all batches
|
|
333
|
+
of one file run in a single transaction that is rolled back if any batch fails
|
|
334
|
+
(including a failure in a later statement of a multi-statement batch). `GO <count>`
|
|
335
|
+
is not honored: the batch runs once.
|
|
336
|
+
- The tracking table needs `filename nvarchar(450) primary key, applied_at
|
|
337
|
+
datetimeoffset not null default sysdatetimeoffset(), applied_by nvarchar(128) not
|
|
338
|
+
null default suser_sname()`. As on Postgres, a migration creating it can bootstrap it.
|
|
339
|
+
Override its name with `mssql_migrations_table` in `[tool.pgdevkit]` (the Postgres
|
|
340
|
+
`migrations_table` key is deliberately ignored for MSSQL) or with `--tracking-table`.
|
|
341
|
+
- `--ask` auto-detects "already done" for single-statement `GO` batches that create a
|
|
342
|
+
table, view or schema, or add a column; anything else is asked about.
|
|
343
|
+
- Post-apply verification checks every `CREATE TABLE` target via `OBJECT_ID`.
|
|
344
|
+
- `--entra-user` appends `Authentication=ActiveDirectoryDefault` to the connection
|
|
345
|
+
string, so mssql-python fetches an Entra ID token through azure-identity's
|
|
346
|
+
`DefaultAzureCredential` (install `pgdevkit[mssql,azure]`). The identity is whatever
|
|
347
|
+
that credential chain resolves — the flag's value only switches Entra auth on. It
|
|
348
|
+
can't be combined with an `Authentication=` already in the connection string.
|
|
349
|
+
- `pgdevkit.migrate.missing_privileges` is not available for MSSQL.
|
|
350
|
+
|
|
351
|
+
For Postgres, `--entra-user` works the same as `pgdb compare` (see above). The tracking
|
|
316
352
|
table needs `filename text primary key, applied_at timestamptz not null
|
|
317
353
|
default now(), applied_by text not null default current_user` (a migration
|
|
318
354
|
file that creates it, in the same directory, is the usual way to bootstrap
|
|
@@ -352,7 +388,7 @@ for a real one.
|
|
|
352
388
|
`pgdevkit.migrate` is also usable directly as a library — `list_migration_files`,
|
|
353
389
|
`applied_migrations`, `pending_migrations`, and `apply_migration` are the same
|
|
354
390
|
functions the CLI calls, so a project can script around them without shelling
|
|
355
|
-
out.
|
|
391
|
+
out. The database-touching ones take `dialect="postgres" | "mssql"`.
|
|
356
392
|
|
|
357
393
|
## `pgdevkit.db` — helpers for application code
|
|
358
394
|
|
|
@@ -9,13 +9,14 @@ from pathlib import Path
|
|
|
9
9
|
import psycopg
|
|
10
10
|
import typer
|
|
11
11
|
from rich.console import Console
|
|
12
|
+
from rich.markup import escape
|
|
12
13
|
from rich.table import Table
|
|
13
14
|
from rich import box
|
|
14
15
|
from tqdm import tqdm
|
|
15
16
|
|
|
16
|
-
from . import migrate, stats, testdb
|
|
17
|
+
from . import migrate, migrate_mssql, stats, testdb
|
|
17
18
|
from .backends import get_backend
|
|
18
|
-
from .connection import build_conninfo
|
|
19
|
+
from .connection import build_conninfo, build_mssql_conninfo
|
|
19
20
|
from .diff import DiffKind, compute_diff
|
|
20
21
|
from .fetch_missing import SUBFOLDER, find_missing_objects, layer_folder_for, reconstruct_ddl
|
|
21
22
|
from .parser import parse_directory
|
|
@@ -47,6 +48,9 @@ _TESTDB_ENV_OPTION = typer.Option(
|
|
|
47
48
|
"--env",
|
|
48
49
|
help="Environment to apply: skips any <name>.<other-env>.sql file (e.g. grants.prod.sql); untagged files always apply",
|
|
49
50
|
)
|
|
51
|
+
_MIGRATE_DIALECT_OPTION = typer.Option(
|
|
52
|
+
"postgres", "--dialect", help="postgres (default) or mssql; mssql needs the mssql extra and uses --url as an ODBC connection string"
|
|
53
|
+
)
|
|
50
54
|
_MIGRATE_ENV_OPTION = typer.Option(
|
|
51
55
|
None,
|
|
52
56
|
"--env",
|
|
@@ -70,7 +74,12 @@ app.add_typer(migrate_app, name="migrate")
|
|
|
70
74
|
@app.command()
|
|
71
75
|
def compare(
|
|
72
76
|
url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
|
|
73
|
-
entra_user: str | None = typer.Option(
|
|
77
|
+
entra_user: str | None = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--entra-user",
|
|
80
|
+
help="Azure Entra user (triggers token auth); with --dialect mssql this adds "
|
|
81
|
+
"Authentication=ActiveDirectoryDefault and the value itself is not used",
|
|
82
|
+
),
|
|
74
83
|
databricks_workspace_host: str | None = typer.Option(
|
|
75
84
|
None,
|
|
76
85
|
"--databricks-workspace-host",
|
|
@@ -100,18 +109,21 @@ def compare(
|
|
|
100
109
|
)
|
|
101
110
|
|
|
102
111
|
try:
|
|
103
|
-
|
|
104
|
-
url,
|
|
105
|
-
entra_user,
|
|
106
|
-
databricks_workspace_host=databricks_workspace_host,
|
|
107
|
-
databricks_instance=databricks_instance,
|
|
108
|
-
)
|
|
112
|
+
backend = get_backend(dialect)
|
|
109
113
|
except ValueError as e:
|
|
110
114
|
err_console.print(f"[red]Error:[/red] {e}")
|
|
111
115
|
raise typer.Exit(2)
|
|
112
116
|
|
|
113
117
|
try:
|
|
114
|
-
backend
|
|
118
|
+
if backend.dialect.name == "mssql":
|
|
119
|
+
conninfo = build_mssql_conninfo(url, entra_user)
|
|
120
|
+
else:
|
|
121
|
+
conninfo = build_conninfo(
|
|
122
|
+
url,
|
|
123
|
+
entra_user,
|
|
124
|
+
databricks_workspace_host=databricks_workspace_host,
|
|
125
|
+
databricks_instance=databricks_instance,
|
|
126
|
+
)
|
|
115
127
|
except ValueError as e:
|
|
116
128
|
err_console.print(f"[red]Error:[/red] {e}")
|
|
117
129
|
raise typer.Exit(2)
|
|
@@ -376,30 +388,78 @@ def testdb_list_orphaned() -> None:
|
|
|
376
388
|
console.print(name)
|
|
377
389
|
|
|
378
390
|
|
|
391
|
+
def _describe_target(url: str, dialect: str) -> str:
|
|
392
|
+
"""Where a migrate command points, without credentials: host/db for a Postgres URL,
|
|
393
|
+
Server/Database only for an MSSQL connection string (never UID/PWD)."""
|
|
394
|
+
if dialect == "mssql":
|
|
395
|
+
parts = dict(
|
|
396
|
+
(k.strip().lower(), v.strip()) for k, _, v in (p.partition("=") for p in url.split(";")) if k.strip()
|
|
397
|
+
)
|
|
398
|
+
server = parts.get("server") or parts.get("data source") or "?"
|
|
399
|
+
database = parts.get("database") or parts.get("initial catalog")
|
|
400
|
+
return f"{server}/{database}" if database else server
|
|
401
|
+
return url.rsplit("@", 1)[-1] if "@" in url else url
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _migrate_target(
|
|
405
|
+
url: str, entra_user: str | None, dialect: str, tracking_table: str | None, migrations_dir: Path
|
|
406
|
+
) -> tuple[str, str, str]:
|
|
407
|
+
"""Resolve (conninfo, dialect name, tracking table) for a migrate command."""
|
|
408
|
+
try:
|
|
409
|
+
resolved = get_backend(dialect).dialect
|
|
410
|
+
except ValueError as e:
|
|
411
|
+
err_console.print(f"[red]Error:[/red] {e}")
|
|
412
|
+
raise typer.Exit(2)
|
|
413
|
+
if resolved.name == "mssql":
|
|
414
|
+
try:
|
|
415
|
+
migrate_mssql.require_driver()
|
|
416
|
+
except ImportError as e:
|
|
417
|
+
err_console.print(f"[red]Error:[/red] {escape(str(e))}")
|
|
418
|
+
raise typer.Exit(2)
|
|
419
|
+
try:
|
|
420
|
+
conninfo = (build_mssql_conninfo if resolved.name == "mssql" else build_conninfo)(url, entra_user)
|
|
421
|
+
except ValueError as e:
|
|
422
|
+
err_console.print(f"[red]Error:[/red] {e}")
|
|
423
|
+
raise typer.Exit(2)
|
|
424
|
+
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir, dialect=resolved)
|
|
425
|
+
return conninfo, resolved.name, tracking_table
|
|
426
|
+
|
|
427
|
+
|
|
379
428
|
@migrate_app.command("check")
|
|
380
429
|
def migrate_check(
|
|
381
430
|
migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
|
|
382
|
-
url: str = typer.Option(
|
|
383
|
-
|
|
431
|
+
url: str = typer.Option(
|
|
432
|
+
...,
|
|
433
|
+
"--url",
|
|
434
|
+
help="PostgreSQL DSN (postgresql://user:pass@host:port/db), or with --dialect mssql "
|
|
435
|
+
"a connection string (Server=host,1433;Database=db;UID=user;PWD=pass)",
|
|
436
|
+
),
|
|
437
|
+
entra_user: str | None = typer.Option(
|
|
438
|
+
None,
|
|
439
|
+
"--entra-user",
|
|
440
|
+
help="Azure Entra user (triggers token auth); with --dialect mssql this adds "
|
|
441
|
+
"Authentication=ActiveDirectoryDefault and the value itself is not used",
|
|
442
|
+
),
|
|
384
443
|
tracking_table: str | None = typer.Option(
|
|
385
444
|
None,
|
|
386
445
|
"--tracking-table",
|
|
387
446
|
help="schema.table recording applied migrations "
|
|
388
|
-
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations
|
|
447
|
+
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations "
|
|
448
|
+
"-- dbo.schema_migrations with --dialect mssql)",
|
|
389
449
|
),
|
|
390
450
|
area: list[str] = _AREA_OPTION,
|
|
391
451
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
392
452
|
schema: list[str] = _SCHEMA_OPTION,
|
|
393
453
|
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
394
454
|
env: str | None = _MIGRATE_ENV_OPTION,
|
|
455
|
+
dialect: str = _MIGRATE_DIALECT_OPTION,
|
|
395
456
|
) -> None:
|
|
396
457
|
"""List which migration files under migrations_dir are applied vs. pending."""
|
|
397
458
|
if not migrations_dir.is_dir():
|
|
398
459
|
err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
|
|
399
460
|
raise typer.Exit(2)
|
|
400
461
|
|
|
401
|
-
conninfo =
|
|
402
|
-
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
|
|
462
|
+
conninfo, dialect, tracking_table = _migrate_target(url, entra_user, dialect, tracking_table, migrations_dir)
|
|
403
463
|
local_files = migrate.list_migration_files(
|
|
404
464
|
migrations_dir,
|
|
405
465
|
areas=_as_set(area),
|
|
@@ -409,7 +469,7 @@ def migrate_check(
|
|
|
409
469
|
env=env,
|
|
410
470
|
)
|
|
411
471
|
try:
|
|
412
|
-
applied = migrate.applied_migrations(conninfo, tracking_table)
|
|
472
|
+
applied = migrate.applied_migrations(conninfo, tracking_table, dialect)
|
|
413
473
|
except migrate.TrackingTableMissing:
|
|
414
474
|
err_console.print(f"[yellow]⚠[/yellow] {tracking_table} not found — nothing recorded as applied yet")
|
|
415
475
|
applied = {}
|
|
@@ -433,13 +493,24 @@ def migrate_check(
|
|
|
433
493
|
@migrate_app.command("apply")
|
|
434
494
|
def migrate_apply(
|
|
435
495
|
migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
|
|
436
|
-
url: str = typer.Option(
|
|
437
|
-
|
|
496
|
+
url: str = typer.Option(
|
|
497
|
+
...,
|
|
498
|
+
"--url",
|
|
499
|
+
help="PostgreSQL DSN (postgresql://user:pass@host:port/db), or with --dialect mssql "
|
|
500
|
+
"a connection string (Server=host,1433;Database=db;UID=user;PWD=pass)",
|
|
501
|
+
),
|
|
502
|
+
entra_user: str | None = typer.Option(
|
|
503
|
+
None,
|
|
504
|
+
"--entra-user",
|
|
505
|
+
help="Azure Entra user (triggers token auth); with --dialect mssql this adds "
|
|
506
|
+
"Authentication=ActiveDirectoryDefault and the value itself is not used",
|
|
507
|
+
),
|
|
438
508
|
tracking_table: str | None = typer.Option(
|
|
439
509
|
None,
|
|
440
510
|
"--tracking-table",
|
|
441
511
|
help="schema.table recording applied migrations "
|
|
442
|
-
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations
|
|
512
|
+
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations "
|
|
513
|
+
"-- dbo.schema_migrations with --dialect mssql)",
|
|
443
514
|
),
|
|
444
515
|
file: str | None = typer.Option(
|
|
445
516
|
None, "--file", help="Apply only this one filename (relative to migrations_dir) instead of all pending"
|
|
@@ -451,17 +522,17 @@ def migrate_apply(
|
|
|
451
522
|
schema: list[str] = _SCHEMA_OPTION,
|
|
452
523
|
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
453
524
|
env: str | None = _MIGRATE_ENV_OPTION,
|
|
525
|
+
dialect: str = _MIGRATE_DIALECT_OPTION,
|
|
454
526
|
) -> None:
|
|
455
527
|
"""Apply pending migration files, in filename order, tracking each in tracking_table."""
|
|
456
528
|
if not migrations_dir.is_dir():
|
|
457
529
|
err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
|
|
458
530
|
raise typer.Exit(2)
|
|
459
531
|
|
|
460
|
-
conninfo =
|
|
461
|
-
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
|
|
532
|
+
conninfo, dialect, tracking_table = _migrate_target(url, entra_user, dialect, tracking_table, migrations_dir)
|
|
462
533
|
areas, exclude_areas = _as_set(area), _as_set(exclude_area)
|
|
463
534
|
schemas, exclude_schemas = _as_set(schema), _as_set(exclude_schema)
|
|
464
|
-
target_desc = url
|
|
535
|
+
target_desc = _describe_target(url, dialect)
|
|
465
536
|
if not yes:
|
|
466
537
|
typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
|
|
467
538
|
|
|
@@ -478,6 +549,7 @@ def migrate_apply(
|
|
|
478
549
|
schemas=schemas,
|
|
479
550
|
exclude_schemas=exclude_schemas,
|
|
480
551
|
env=env,
|
|
552
|
+
dialect=dialect,
|
|
481
553
|
)
|
|
482
554
|
except migrate.TrackingTableMissing:
|
|
483
555
|
err_console.print(
|
|
@@ -517,7 +589,9 @@ def migrate_apply(
|
|
|
517
589
|
for path, already_done in iter(work_q.get, None):
|
|
518
590
|
if not stop.is_set():
|
|
519
591
|
try:
|
|
520
|
-
result = migrate.apply_migration(
|
|
592
|
+
result = migrate.apply_migration(
|
|
593
|
+
conninfo, path, tracking_table, already_done=already_done, dialect=dialect
|
|
594
|
+
)
|
|
521
595
|
except Exception as e: # noqa: BLE001
|
|
522
596
|
failure = e
|
|
523
597
|
stop.set()
|
|
@@ -544,7 +618,7 @@ def migrate_apply(
|
|
|
544
618
|
break
|
|
545
619
|
already_done = False
|
|
546
620
|
if ask:
|
|
547
|
-
already_done = migrate.already_fully_applied(conninfo, path)
|
|
621
|
+
already_done = migrate.already_fully_applied(conninfo, path, dialect)
|
|
548
622
|
if already_done:
|
|
549
623
|
bar_write(f"Auto: {path.name} is already fully present in the database — marking as already done")
|
|
550
624
|
else:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from typing import Literal
|
|
4
5
|
from urllib.parse import quote, urlparse, urlunparse
|
|
5
6
|
|
|
@@ -67,6 +68,21 @@ def get_azure_postgres_password(
|
|
|
67
68
|
return token.token
|
|
68
69
|
|
|
69
70
|
|
|
71
|
+
def build_mssql_conninfo(conn_str: str, entra_user: str | None = None) -> str:
|
|
72
|
+
"""MSSQL counterpart of `build_conninfo`. Without `entra_user` the ODBC connection
|
|
73
|
+
string is used as-is. With it, `Authentication=ActiveDirectoryDefault` is appended so
|
|
74
|
+
mssql-python acquires an Entra ID token itself via azure-identity's
|
|
75
|
+
`DefaultAzureCredential` (needs the `azure` extra); the identity is whatever that
|
|
76
|
+
credential chain resolves, so `entra_user` only switches Entra auth on and is not
|
|
77
|
+
sent to the server. A connection string that already sets `Authentication` is left
|
|
78
|
+
to speak for itself and rejected here to avoid ambiguity."""
|
|
79
|
+
if entra_user is None:
|
|
80
|
+
return conn_str
|
|
81
|
+
if re.search(r"(^|;)\s*Authentication\s*=", conn_str, re.IGNORECASE):
|
|
82
|
+
raise ValueError("--entra-user can't be combined with an Authentication= setting in the connection string")
|
|
83
|
+
return f"{conn_str.rstrip().rstrip(';')};Authentication=ActiveDirectoryDefault"
|
|
84
|
+
|
|
85
|
+
|
|
70
86
|
def build_conninfo(
|
|
71
87
|
url: str,
|
|
72
88
|
entra_user: str | None = None,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Apply numbered, forward-only SQL migration files to a live Postgres database,
|
|
2
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
|
|
4
|
-
|
|
3
|
+
apply what's pending. Ported from a hand-rolled per-project script. Every function that
|
|
4
|
+
touches a database takes `dialect="postgres"` (default) or `"mssql"`; the MSSQL
|
|
5
|
+
implementation lives in `.migrate_mssql` and is only imported when asked for, so the
|
|
6
|
+
`mssql` extra stays optional.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
from __future__ import annotations
|
|
@@ -19,12 +21,12 @@ from psycopg import errors as pg_errors
|
|
|
19
21
|
from psycopg import sql as pg_sql
|
|
20
22
|
|
|
21
23
|
from .areas import filter_by_area
|
|
24
|
+
from .dialect import Dialect, resolve_dialect
|
|
22
25
|
from .envtag import env_allowed
|
|
23
26
|
from .schemas import filter_by_schema
|
|
24
27
|
from .sql_text import strip_line_comments
|
|
25
28
|
|
|
26
29
|
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
27
|
-
_DEFAULT_TRACKING_TABLE = "public.schema_migrations"
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class MigrationVerificationError(RuntimeError):
|
|
@@ -47,6 +49,10 @@ def _tracking_table_identifier(tracking_table: str) -> pg_sql.Identifier:
|
|
|
47
49
|
return pg_sql.Identifier(schema, table)
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
def _is_mssql(dialect: str | Dialect) -> bool:
|
|
53
|
+
return resolve_dialect(dialect).name == "mssql"
|
|
54
|
+
|
|
55
|
+
|
|
50
56
|
def _find_pyproject(start: Path) -> Path | None:
|
|
51
57
|
for directory in [start, *start.parents]:
|
|
52
58
|
candidate = directory / "pyproject.toml"
|
|
@@ -55,17 +61,20 @@ def _find_pyproject(start: Path) -> Path | None:
|
|
|
55
61
|
return None
|
|
56
62
|
|
|
57
63
|
|
|
58
|
-
def default_tracking_table(start: Path | None = None) -> str:
|
|
64
|
+
def default_tracking_table(start: Path | None = None, dialect: str | Dialect = "postgres") -> str:
|
|
59
65
|
"""The project's configured tracking table: `[tool.pgdevkit].migrations_table` in the
|
|
60
66
|
nearest pyproject.toml at or above `start` (default: cwd), or "public.schema_migrations"
|
|
61
|
-
if neither is set.
|
|
67
|
+
if neither is set. For MSSQL the key is `mssql_migrations_table` (default
|
|
68
|
+
"dbo.schema_migrations"), so a Postgres-style `migrations_table` never leaks into it. Lets a project fix its tracking table once instead of passing
|
|
62
69
|
--tracking-table on every `pgdb migrate` invocation."""
|
|
70
|
+
default = f"{resolve_dialect(dialect).default_schema}.schema_migrations"
|
|
63
71
|
pyproject = _find_pyproject((start or Path.cwd()).resolve())
|
|
64
72
|
if pyproject is None:
|
|
65
|
-
return
|
|
73
|
+
return default
|
|
66
74
|
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
67
75
|
section = data.get("tool", {}).get("pgdevkit", {})
|
|
68
|
-
|
|
76
|
+
key = "mssql_migrations_table" if resolve_dialect(dialect).name == "mssql" else "migrations_table"
|
|
77
|
+
return section.get(key, default)
|
|
69
78
|
|
|
70
79
|
|
|
71
80
|
def _split_sql(sql: str) -> list[str]:
|
|
@@ -213,13 +222,22 @@ def _target_exists(con: psycopg.Connection, target: tuple[str, ...]) -> bool:
|
|
|
213
222
|
return bool(row and row[0])
|
|
214
223
|
|
|
215
224
|
|
|
216
|
-
def already_fully_applied(conninfo: str, path: Path) -> bool:
|
|
225
|
+
def already_fully_applied(conninfo: str, path: Path, dialect: str | Dialect = "postgres") -> bool:
|
|
217
226
|
"""Whether every statement in this migration is a recognized create-if-missing shape
|
|
218
227
|
(table/index/sequence/view/schema, or add-column) AND its target already exists in the
|
|
219
228
|
database — i.e. re-running the migration would do nothing. Used by `--ask` to
|
|
220
229
|
auto-answer "already done" without prompting, so migrations that are trivially no-ops
|
|
221
230
|
don't interrupt review. A single unrecognized or not-yet-applied statement means
|
|
222
|
-
False — this never guesses.
|
|
231
|
+
False — this never guesses. For MSSQL the unit is a `GO`-separated batch and the
|
|
232
|
+
recognized shapes are table, view, schema and ADD column."""
|
|
233
|
+
if _is_mssql(dialect):
|
|
234
|
+
from . import migrate_mssql
|
|
235
|
+
|
|
236
|
+
batches = migrate_mssql.split_batches(path.read_text(encoding="utf-8"))
|
|
237
|
+
mssql_targets = [migrate_mssql.idempotent_target(b) for b in batches]
|
|
238
|
+
if not batches or any(t is None for t in mssql_targets):
|
|
239
|
+
return False
|
|
240
|
+
return migrate_mssql.targets_exist(conninfo, cast(list[tuple[str, ...]], mssql_targets))
|
|
223
241
|
stmts = _split_sql(path.read_text(encoding="utf-8"))
|
|
224
242
|
if not stmts:
|
|
225
243
|
return False
|
|
@@ -250,8 +268,14 @@ def list_migration_files(
|
|
|
250
268
|
return [f for f in files if env_allowed(f, env)]
|
|
251
269
|
|
|
252
270
|
|
|
253
|
-
def applied_migrations(
|
|
271
|
+
def applied_migrations(
|
|
272
|
+
conninfo: str, tracking_table: str, dialect: str | Dialect = "postgres"
|
|
273
|
+
) -> dict[str, tuple[datetime, str]]:
|
|
254
274
|
"""Filename -> (applied_at, applied_by) for every migration recorded in the tracking table."""
|
|
275
|
+
if _is_mssql(dialect):
|
|
276
|
+
from . import migrate_mssql
|
|
277
|
+
|
|
278
|
+
return migrate_mssql.applied_migrations(conninfo, tracking_table)
|
|
255
279
|
table = _tracking_table_identifier(tracking_table)
|
|
256
280
|
with psycopg.connect(conninfo) as con:
|
|
257
281
|
try:
|
|
@@ -273,8 +297,9 @@ def pending_migrations(
|
|
|
273
297
|
schemas: frozenset[str] | None = None,
|
|
274
298
|
exclude_schemas: frozenset[str] | None = None,
|
|
275
299
|
env: str | None = None,
|
|
300
|
+
dialect: str | Dialect = "postgres",
|
|
276
301
|
) -> list[Path]:
|
|
277
|
-
applied = applied_migrations(conninfo, tracking_table)
|
|
302
|
+
applied = applied_migrations(conninfo, tracking_table, dialect)
|
|
278
303
|
files = list_migration_files(
|
|
279
304
|
migrations_dir,
|
|
280
305
|
areas=areas,
|
|
@@ -286,9 +311,15 @@ def pending_migrations(
|
|
|
286
311
|
return [p for p in files if p.name not in applied]
|
|
287
312
|
|
|
288
313
|
|
|
289
|
-
def record_applied(
|
|
314
|
+
def record_applied(
|
|
315
|
+
conninfo: str, tracking_table: str, filename: str, dialect: str | Dialect = "postgres"
|
|
316
|
+
) -> bool:
|
|
290
317
|
"""Best-effort insert into the tracking table. Returns False without raising if the
|
|
291
318
|
tracking table doesn't exist yet — e.g. this migration is the one that creates it."""
|
|
319
|
+
if _is_mssql(dialect):
|
|
320
|
+
from . import migrate_mssql
|
|
321
|
+
|
|
322
|
+
return migrate_mssql.record_applied(conninfo, tracking_table, filename)
|
|
292
323
|
table = _tracking_table_identifier(tracking_table)
|
|
293
324
|
with psycopg.connect(conninfo) as con:
|
|
294
325
|
try:
|
|
@@ -303,17 +334,25 @@ def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool:
|
|
|
303
334
|
return False
|
|
304
335
|
|
|
305
336
|
|
|
306
|
-
def created_table_names(sql: str) -> list[str]:
|
|
337
|
+
def created_table_names(sql: str, dialect: str | Dialect = "postgres") -> list[str]:
|
|
307
338
|
"""Table names any CREATE TABLE statement in this raw SQL script targets. Public
|
|
308
339
|
wrapper around the same detection `apply_migration` uses internally, for callers that
|
|
309
340
|
run a script directly (e.g. via `execute_sql_script`) instead of through a tracked
|
|
310
341
|
migration file, and still want to know what tables -- if any -- it created."""
|
|
342
|
+
if _is_mssql(dialect):
|
|
343
|
+
from . import migrate_mssql
|
|
344
|
+
|
|
345
|
+
return migrate_mssql.created_table_names(migrate_mssql.split_batches(sql))
|
|
311
346
|
return _created_table_names(_split_sql(sql))
|
|
312
347
|
|
|
313
348
|
|
|
314
|
-
def verify_created_tables(conninfo: str, stmts: list[str]) -> list[str]:
|
|
349
|
+
def verify_created_tables(conninfo: str, stmts: list[str], dialect: str | Dialect = "postgres") -> list[str]:
|
|
315
350
|
"""Table names from this migration's CREATE TABLE statements that do NOT exist in the
|
|
316
|
-
database. Empty means everything landed."""
|
|
351
|
+
database. Empty means everything landed. For MSSQL, `stmts` are `GO`-separated batches."""
|
|
352
|
+
if _is_mssql(dialect):
|
|
353
|
+
from . import migrate_mssql
|
|
354
|
+
|
|
355
|
+
return migrate_mssql.missing_tables(conninfo, migrate_mssql.created_table_names(stmts))
|
|
317
356
|
tables = _created_table_names(stmts)
|
|
318
357
|
if not tables:
|
|
319
358
|
return []
|
|
@@ -326,12 +365,16 @@ def verify_created_tables(conninfo: str, stmts: list[str]) -> list[str]:
|
|
|
326
365
|
return missing
|
|
327
366
|
|
|
328
367
|
|
|
329
|
-
def missing_privileges(
|
|
368
|
+
def missing_privileges(
|
|
369
|
+
conninfo: str, role: str, tables: list[str], privilege: str = "select", dialect: str | Dialect = "postgres"
|
|
370
|
+
) -> list[str]:
|
|
330
371
|
"""Table names from `tables` that `role` cannot currently exercise `privilege` on
|
|
331
372
|
(checked via Postgres's own `has_table_privilege`). Empty means the role can access
|
|
332
373
|
all of them. For guarding against the classic "migration creates a table, nobody
|
|
333
374
|
grants it to the app's runtime role" gap: check the tables a migration just created
|
|
334
375
|
against the role that will actually query them at runtime."""
|
|
376
|
+
if _is_mssql(dialect):
|
|
377
|
+
raise NotImplementedError("missing_privileges is only available for the postgres dialect")
|
|
335
378
|
if not tables:
|
|
336
379
|
return []
|
|
337
380
|
missing = []
|
|
@@ -350,12 +393,18 @@ def _execute_stmts(conninfo: str, stmts: list[str]) -> None:
|
|
|
350
393
|
con.commit()
|
|
351
394
|
|
|
352
395
|
|
|
353
|
-
def execute_sql_script(conninfo: str, sql: str) -> None:
|
|
396
|
+
def execute_sql_script(conninfo: str, sql: str, dialect: str | Dialect = "postgres") -> None:
|
|
354
397
|
"""Run a raw SQL script as one committed transaction, split into statements the same
|
|
355
398
|
statement-boundary-safe way apply_migration is (dollar-quoted blocks, string literals,
|
|
356
399
|
and line comments never get split mid-statement). Unlike apply_migration, this does
|
|
357
400
|
no tracking-table bookkeeping and isn't forward-only -- for scripts meant to re-run
|
|
358
|
-
every time, like an idempotent `GRANT ... ON ALL TABLES IN SCHEMA` privilege sync.
|
|
401
|
+
every time, like an idempotent `GRANT ... ON ALL TABLES IN SCHEMA` privilege sync.
|
|
402
|
+
For MSSQL the script is split on `GO` lines instead and runs as one transaction."""
|
|
403
|
+
if _is_mssql(dialect):
|
|
404
|
+
from . import migrate_mssql
|
|
405
|
+
|
|
406
|
+
migrate_mssql.execute_batches(conninfo, migrate_mssql.split_batches(sql))
|
|
407
|
+
return
|
|
359
408
|
_execute_stmts(conninfo, _split_sql(sql))
|
|
360
409
|
|
|
361
410
|
|
|
@@ -372,25 +421,36 @@ def apply_migration(
|
|
|
372
421
|
tracking_table: str,
|
|
373
422
|
*,
|
|
374
423
|
already_done: bool = False,
|
|
424
|
+
dialect: str | Dialect = "postgres",
|
|
375
425
|
) -> ApplyResult:
|
|
376
426
|
sql = path.read_text(encoding="utf-8")
|
|
377
427
|
filename = path.name
|
|
378
|
-
|
|
428
|
+
mssql = _is_mssql(dialect)
|
|
429
|
+
if mssql:
|
|
430
|
+
from . import migrate_mssql
|
|
431
|
+
|
|
432
|
+
stmts = migrate_mssql.split_batches(sql)
|
|
433
|
+
else:
|
|
434
|
+
stmts = _split_sql(sql)
|
|
379
435
|
|
|
380
436
|
if not already_done:
|
|
381
|
-
|
|
437
|
+
if mssql:
|
|
438
|
+
migrate_mssql.execute_batches(conninfo, stmts)
|
|
439
|
+
else:
|
|
440
|
+
_execute_stmts(conninfo, stmts)
|
|
382
441
|
|
|
383
442
|
# Tracking insert is a separate connection/transaction so a missing tracking table
|
|
384
443
|
# never rolls back the DDL that was just applied.
|
|
385
|
-
record_applied(conninfo, tracking_table, filename)
|
|
444
|
+
record_applied(conninfo, tracking_table, filename, dialect)
|
|
386
445
|
|
|
387
446
|
if already_done:
|
|
388
447
|
return ApplyResult(filename, executed=False, verified_tables=[])
|
|
389
448
|
|
|
390
|
-
missing = verify_created_tables(conninfo, stmts)
|
|
449
|
+
missing = verify_created_tables(conninfo, stmts, dialect)
|
|
391
450
|
if missing:
|
|
392
451
|
raise MigrationVerificationError(
|
|
393
452
|
f"{filename}: table(s) not found after apply — migration may have rolled back: "
|
|
394
453
|
+ ", ".join(missing)
|
|
395
454
|
)
|
|
396
|
-
|
|
455
|
+
created = migrate_mssql.created_table_names(stmts) if mssql else _created_table_names(stmts)
|
|
456
|
+
return ApplyResult(filename, executed=True, verified_tables=created)
|