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.
Files changed (136) hide show
  1. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/PKG-INFO +42 -6
  2. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/README.md +41 -5
  3. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/cli.py +98 -24
  4. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/connection.py +16 -0
  5. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/migrate.py +83 -23
  6. pgdevkit-0.12.0/pgdevkit/migrate_mssql.py +270 -0
  7. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pyproject.toml +1 -1
  8. pgdevkit-0.12.0/tests/test_migrate_mssql.py +341 -0
  9. pgdevkit-0.12.0/tests/test_migrate_mssql_live.py +49 -0
  10. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/uv.lock +1 -1
  11. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.github/workflows/auto-release.yml +0 -0
  12. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.github/workflows/python-publish.yml +0 -0
  13. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.github/workflows/python-test.yml +0 -0
  14. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.gitignore +0 -0
  15. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/.python-version +0 -0
  16. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/docs/database-layout.md +0 -0
  17. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/__init__.py +0 -0
  18. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/areas.py +0 -0
  19. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/__init__.py +0 -0
  20. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/base.py +0 -0
  21. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/mssql.py +0 -0
  22. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/backends/postgres.py +0 -0
  23. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/__init__.py +0 -0
  24. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/complex_types.py +0 -0
  25. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/connection.py +0 -0
  26. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/crud.py +0 -0
  27. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/loader.py +0 -0
  28. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/model.py +0 -0
  29. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/mssql_crud.py +0 -0
  30. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/db/mssql_sql.py +0 -0
  31. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/dialect.py +0 -0
  32. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/diff.py +0 -0
  33. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/envtag.py +0 -0
  34. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/fetch_missing.py +0 -0
  35. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/introspect.py +0 -0
  36. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/lakebase.py +0 -0
  37. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/models.py +0 -0
  38. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/mssql_introspect.py +0 -0
  39. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/parser.py +0 -0
  40. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/schemas.py +0 -0
  41. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/sql_text.py +0 -0
  42. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/stats.py +0 -0
  43. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/__init__.py +0 -0
  44. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/_docker.py +0 -0
  45. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/api.py +0 -0
  46. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/config.py +0 -0
  47. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/constants.py +0 -0
  48. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/container.py +0 -0
  49. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  50. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/api.py +0 -0
  51. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  52. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/mssql/container.py +0 -0
  53. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/naming.py +0 -0
  54. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/query.py +0 -0
  55. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/pgdevkit/testdb/schema.py +0 -0
  56. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/skills/pgdevkit/SKILL.md +0 -0
  57. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  58. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  59. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/__init__.py +0 -0
  60. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/conftest.py +0 -0
  61. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/__init__.py +0 -0
  62. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_complex_types.py +0 -0
  63. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_connection.py +0 -0
  64. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_crud.py +0 -0
  65. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_loader.py +0 -0
  66. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_mssql_crud_live.py +0 -0
  67. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/db/test_mssql_crud_sql.py +0 -0
  68. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/01_schema.sql +0 -0
  69. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/02_types.sql +0 -0
  70. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/03_tables.sql +0 -0
  71. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/04_views.sql +0 -0
  72. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/05_functions.sql +0 -0
  73. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/fixtures/06_indexes.sql +0 -0
  74. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_areas.py +0 -0
  75. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_cli_compare.py +0 -0
  76. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_compare.py +0 -0
  77. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_compare_mssql_live.py +0 -0
  78. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_connection.py +0 -0
  79. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_dialect.py +0 -0
  80. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_diff_mssql.py +0 -0
  81. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_envtag.py +0 -0
  82. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_fetch_missing.py +0 -0
  83. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_fetch_missing_cli.py +0 -0
  84. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_lakebase.py +0 -0
  85. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate.py +0 -0
  86. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_areas.py +0 -0
  87. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_env.py +0 -0
  88. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_grants.py +0 -0
  89. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_migrate_schemas.py +0 -0
  90. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_mssql_introspect.py +0 -0
  91. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_parser_areas.py +0 -0
  92. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_parser_mssql.py +0 -0
  93. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_parser_schemas.py +0 -0
  94. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_schemas.py +0 -0
  95. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_stats_cli.py +0 -0
  96. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/test_stats_mssql_live.py +0 -0
  97. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/__init__.py +0 -0
  98. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/conftest.py +0 -0
  99. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  100. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  101. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  102. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  103. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  104. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  105. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  106. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  107. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  108. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  109. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  110. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  111. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  112. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  113. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  114. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  115. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  116. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  117. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  118. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  119. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  120. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  121. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_api.py +0 -0
  122. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_api_mssql.py +0 -0
  123. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_api_mssql_live.py +0 -0
  124. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_cli.py +0 -0
  125. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_config.py +0 -0
  126. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_config_mssql.py +0 -0
  127. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_constants.py +0 -0
  128. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_container.py +0 -0
  129. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_container_mssql.py +0 -0
  130. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_docker.py +0 -0
  131. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_mssql_constants.py +0 -0
  132. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_naming.py +0 -0
  133. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_query.py +0 -0
  134. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_schema.py +0 -0
  135. {pgdevkit-0.11.0 → pgdevkit-0.12.0}/tests/testdb/test_schema_filtering.py +0 -0
  136. {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.11.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 apply what's pending. Postgres only —
329
- not available for `--dialect mssql`.
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
- `--entra-user` works the same as `pgdb compare` (see above). The tracking
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 apply what's pending. Postgres only —
308
- not available for `--dialect mssql`.
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
- `--entra-user` works the same as `pgdb compare` (see above). The tracking
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(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
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
- conninfo = build_conninfo(
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 = get_backend(dialect)
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(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
383
- entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
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 = build_conninfo(url, entra_user)
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(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
437
- entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
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 = build_conninfo(url, entra_user)
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.rsplit("@", 1)[-1] if "@" in url else 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(conninfo, path, tracking_table, already_done=already_done)
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 — MSSQL is not
4
- supported yet.
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. Lets a project fix its tracking table once instead of passing
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 _DEFAULT_TRACKING_TABLE
73
+ return default
66
74
  data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
67
75
  section = data.get("tool", {}).get("pgdevkit", {})
68
- return section.get("migrations_table", _DEFAULT_TRACKING_TABLE)
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(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
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(conninfo: str, tracking_table: str, filename: str) -> bool:
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(conninfo: str, role: str, tables: list[str], privilege: str = "select") -> list[str]:
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
- stmts = _split_sql(sql)
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
- _execute_stmts(conninfo, stmts)
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
- return ApplyResult(filename, executed=True, verified_tables=_created_table_names(stmts))
455
+ created = migrate_mssql.created_table_names(stmts) if mssql else _created_table_names(stmts)
456
+ return ApplyResult(filename, executed=True, verified_tables=created)