pgdevkit 0.10.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.10.0 → pgdevkit-0.12.0}/PKG-INFO +42 -6
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/README.md +41 -5
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/cli.py +105 -25
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/connection.py +16 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/migrate.py +83 -23
- pgdevkit-0.12.0/pgdevkit/migrate_mssql.py +270 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/stats.py +121 -1
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pyproject.toml +1 -1
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/skills/pgdevkit/SKILL.md +2 -0
- 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.10.0 → pgdevkit-0.12.0}/tests/test_stats_cli.py +1 -0
- pgdevkit-0.12.0/tests/test_stats_mssql_live.py +59 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/uv.lock +1 -1
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/.gitignore +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/.python-version +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/conftest.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_envtag.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.10.0 → pgdevkit-0.12.0}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.10.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)
|
|
@@ -227,16 +239,22 @@ def update_stats(
|
|
|
227
239
|
table: list[str] = typer.Option([], "--table", help="Only update schema.name (repeatable); default is every table"),
|
|
228
240
|
exact: bool = typer.Option(False, "--exact", help="Use count(*) for row counts instead of the planner estimate"),
|
|
229
241
|
analyze: bool = typer.Option(False, "--analyze", help="Run ANALYZE first so column stats are fresh"),
|
|
242
|
+
dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
|
|
230
243
|
) -> None:
|
|
231
244
|
"""Store table stats in _stats/_tables.json (keyed by schema.name, sorted)
|
|
232
245
|
and column stats in _stats/<schema.name>.json."""
|
|
233
246
|
if not scripts_dir.is_dir():
|
|
234
247
|
err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
|
|
235
248
|
raise typer.Exit(2)
|
|
249
|
+
try:
|
|
250
|
+
dialect = get_backend(dialect).dialect.name
|
|
251
|
+
except ValueError as e:
|
|
252
|
+
err_console.print(f"[red]Error:[/red] {e}")
|
|
253
|
+
raise typer.Exit(2)
|
|
236
254
|
conninfo = build_conninfo(url, entra_user)
|
|
237
255
|
try:
|
|
238
256
|
with console.status("Collecting stats..."):
|
|
239
|
-
tables, columns = stats.collect_stats(conninfo, set(table) or None, exact=exact, analyze=analyze)
|
|
257
|
+
tables, columns = stats.collect_stats(conninfo, set(table) or None, exact=exact, analyze=analyze, dialect=dialect)
|
|
240
258
|
except KeyError as e:
|
|
241
259
|
err_console.print(f"[red]Error:[/red] unknown table(s): {e.args[0]}")
|
|
242
260
|
raise typer.Exit(2)
|
|
@@ -370,30 +388,78 @@ def testdb_list_orphaned() -> None:
|
|
|
370
388
|
console.print(name)
|
|
371
389
|
|
|
372
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
|
+
|
|
373
428
|
@migrate_app.command("check")
|
|
374
429
|
def migrate_check(
|
|
375
430
|
migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
|
|
376
|
-
url: str = typer.Option(
|
|
377
|
-
|
|
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
|
+
),
|
|
378
443
|
tracking_table: str | None = typer.Option(
|
|
379
444
|
None,
|
|
380
445
|
"--tracking-table",
|
|
381
446
|
help="schema.table recording applied migrations "
|
|
382
|
-
"(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)",
|
|
383
449
|
),
|
|
384
450
|
area: list[str] = _AREA_OPTION,
|
|
385
451
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
386
452
|
schema: list[str] = _SCHEMA_OPTION,
|
|
387
453
|
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
388
454
|
env: str | None = _MIGRATE_ENV_OPTION,
|
|
455
|
+
dialect: str = _MIGRATE_DIALECT_OPTION,
|
|
389
456
|
) -> None:
|
|
390
457
|
"""List which migration files under migrations_dir are applied vs. pending."""
|
|
391
458
|
if not migrations_dir.is_dir():
|
|
392
459
|
err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
|
|
393
460
|
raise typer.Exit(2)
|
|
394
461
|
|
|
395
|
-
conninfo =
|
|
396
|
-
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)
|
|
397
463
|
local_files = migrate.list_migration_files(
|
|
398
464
|
migrations_dir,
|
|
399
465
|
areas=_as_set(area),
|
|
@@ -403,7 +469,7 @@ def migrate_check(
|
|
|
403
469
|
env=env,
|
|
404
470
|
)
|
|
405
471
|
try:
|
|
406
|
-
applied = migrate.applied_migrations(conninfo, tracking_table)
|
|
472
|
+
applied = migrate.applied_migrations(conninfo, tracking_table, dialect)
|
|
407
473
|
except migrate.TrackingTableMissing:
|
|
408
474
|
err_console.print(f"[yellow]⚠[/yellow] {tracking_table} not found — nothing recorded as applied yet")
|
|
409
475
|
applied = {}
|
|
@@ -427,13 +493,24 @@ def migrate_check(
|
|
|
427
493
|
@migrate_app.command("apply")
|
|
428
494
|
def migrate_apply(
|
|
429
495
|
migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
|
|
430
|
-
url: str = typer.Option(
|
|
431
|
-
|
|
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
|
+
),
|
|
432
508
|
tracking_table: str | None = typer.Option(
|
|
433
509
|
None,
|
|
434
510
|
"--tracking-table",
|
|
435
511
|
help="schema.table recording applied migrations "
|
|
436
|
-
"(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)",
|
|
437
514
|
),
|
|
438
515
|
file: str | None = typer.Option(
|
|
439
516
|
None, "--file", help="Apply only this one filename (relative to migrations_dir) instead of all pending"
|
|
@@ -445,17 +522,17 @@ def migrate_apply(
|
|
|
445
522
|
schema: list[str] = _SCHEMA_OPTION,
|
|
446
523
|
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
447
524
|
env: str | None = _MIGRATE_ENV_OPTION,
|
|
525
|
+
dialect: str = _MIGRATE_DIALECT_OPTION,
|
|
448
526
|
) -> None:
|
|
449
527
|
"""Apply pending migration files, in filename order, tracking each in tracking_table."""
|
|
450
528
|
if not migrations_dir.is_dir():
|
|
451
529
|
err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
|
|
452
530
|
raise typer.Exit(2)
|
|
453
531
|
|
|
454
|
-
conninfo =
|
|
455
|
-
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)
|
|
456
533
|
areas, exclude_areas = _as_set(area), _as_set(exclude_area)
|
|
457
534
|
schemas, exclude_schemas = _as_set(schema), _as_set(exclude_schema)
|
|
458
|
-
target_desc = url
|
|
535
|
+
target_desc = _describe_target(url, dialect)
|
|
459
536
|
if not yes:
|
|
460
537
|
typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
|
|
461
538
|
|
|
@@ -472,6 +549,7 @@ def migrate_apply(
|
|
|
472
549
|
schemas=schemas,
|
|
473
550
|
exclude_schemas=exclude_schemas,
|
|
474
551
|
env=env,
|
|
552
|
+
dialect=dialect,
|
|
475
553
|
)
|
|
476
554
|
except migrate.TrackingTableMissing:
|
|
477
555
|
err_console.print(
|
|
@@ -511,7 +589,9 @@ def migrate_apply(
|
|
|
511
589
|
for path, already_done in iter(work_q.get, None):
|
|
512
590
|
if not stop.is_set():
|
|
513
591
|
try:
|
|
514
|
-
result = migrate.apply_migration(
|
|
592
|
+
result = migrate.apply_migration(
|
|
593
|
+
conninfo, path, tracking_table, already_done=already_done, dialect=dialect
|
|
594
|
+
)
|
|
515
595
|
except Exception as e: # noqa: BLE001
|
|
516
596
|
failure = e
|
|
517
597
|
stop.set()
|
|
@@ -538,7 +618,7 @@ def migrate_apply(
|
|
|
538
618
|
break
|
|
539
619
|
already_done = False
|
|
540
620
|
if ask:
|
|
541
|
-
already_done = migrate.already_fully_applied(conninfo, path)
|
|
621
|
+
already_done = migrate.already_fully_applied(conninfo, path, dialect)
|
|
542
622
|
if already_done:
|
|
543
623
|
bar_write(f"Auto: {path.name} is already fully present in the database — marking as already done")
|
|
544
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,
|