pgdevkit 0.11.0__tar.gz → 0.13.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 (138) hide show
  1. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/PKG-INFO +54 -7
  2. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/README.md +52 -5
  3. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/cli.py +98 -24
  4. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/connection.py +16 -0
  5. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/__init__.py +5 -0
  6. pgdevkit-0.13.0/pgdevkit/db/fetch.py +269 -0
  7. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/migrate.py +83 -23
  8. pgdevkit-0.13.0/pgdevkit/migrate_mssql.py +270 -0
  9. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pyproject.toml +3 -3
  10. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/skills/pgdevkit/SKILL.md +27 -13
  11. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/skills/pgdevkit/references/dynamic-sql.md +3 -0
  12. pgdevkit-0.13.0/tests/db/test_fetch.py +253 -0
  13. pgdevkit-0.13.0/tests/test_migrate_mssql.py +341 -0
  14. pgdevkit-0.13.0/tests/test_migrate_mssql_live.py +49 -0
  15. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/uv.lock +3 -3
  16. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/.github/workflows/auto-release.yml +0 -0
  17. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/.github/workflows/python-publish.yml +0 -0
  18. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/.github/workflows/python-test.yml +0 -0
  19. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/.gitignore +0 -0
  20. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/.python-version +0 -0
  21. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/docs/database-layout.md +0 -0
  22. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/__init__.py +0 -0
  23. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/areas.py +0 -0
  24. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/backends/__init__.py +0 -0
  25. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/backends/base.py +0 -0
  26. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/backends/mssql.py +0 -0
  27. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/backends/postgres.py +0 -0
  28. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/complex_types.py +0 -0
  29. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/connection.py +0 -0
  30. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/crud.py +0 -0
  31. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/loader.py +0 -0
  32. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/model.py +0 -0
  33. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/mssql_crud.py +0 -0
  34. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/db/mssql_sql.py +0 -0
  35. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/dialect.py +0 -0
  36. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/diff.py +0 -0
  37. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/envtag.py +0 -0
  38. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/fetch_missing.py +0 -0
  39. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/introspect.py +0 -0
  40. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/lakebase.py +0 -0
  41. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/models.py +0 -0
  42. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/mssql_introspect.py +0 -0
  43. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/parser.py +0 -0
  44. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/schemas.py +0 -0
  45. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/sql_text.py +0 -0
  46. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/stats.py +0 -0
  47. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/__init__.py +0 -0
  48. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/_docker.py +0 -0
  49. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/api.py +0 -0
  50. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/config.py +0 -0
  51. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/constants.py +0 -0
  52. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/container.py +0 -0
  53. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  54. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/api.py +0 -0
  55. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  56. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/container.py +0 -0
  57. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/naming.py +0 -0
  58. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/query.py +0 -0
  59. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/pgdevkit/testdb/schema.py +0 -0
  60. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  61. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/__init__.py +0 -0
  62. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/conftest.py +0 -0
  63. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/db/__init__.py +0 -0
  64. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/db/test_complex_types.py +0 -0
  65. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/db/test_connection.py +0 -0
  66. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/db/test_crud.py +0 -0
  67. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/db/test_loader.py +0 -0
  68. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/db/test_mssql_crud_live.py +0 -0
  69. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/db/test_mssql_crud_sql.py +0 -0
  70. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/fixtures/01_schema.sql +0 -0
  71. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/fixtures/02_types.sql +0 -0
  72. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/fixtures/03_tables.sql +0 -0
  73. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/fixtures/04_views.sql +0 -0
  74. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/fixtures/05_functions.sql +0 -0
  75. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/fixtures/06_indexes.sql +0 -0
  76. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_areas.py +0 -0
  77. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_cli_compare.py +0 -0
  78. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_compare.py +0 -0
  79. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_compare_mssql_live.py +0 -0
  80. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_connection.py +0 -0
  81. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_dialect.py +0 -0
  82. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_diff_mssql.py +0 -0
  83. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_envtag.py +0 -0
  84. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_fetch_missing.py +0 -0
  85. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_fetch_missing_cli.py +0 -0
  86. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_lakebase.py +0 -0
  87. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_migrate.py +0 -0
  88. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_migrate_areas.py +0 -0
  89. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_migrate_env.py +0 -0
  90. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_migrate_grants.py +0 -0
  91. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_migrate_schemas.py +0 -0
  92. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_mssql_introspect.py +0 -0
  93. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_parser_areas.py +0 -0
  94. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_parser_mssql.py +0 -0
  95. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_parser_schemas.py +0 -0
  96. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_schemas.py +0 -0
  97. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_stats_cli.py +0 -0
  98. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/test_stats_mssql_live.py +0 -0
  99. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/__init__.py +0 -0
  100. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/conftest.py +0 -0
  101. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  102. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  103. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  104. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  105. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  106. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  107. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  108. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  109. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  110. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  111. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  112. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  113. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  114. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  115. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  116. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  117. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  118. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  119. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  120. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  121. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  122. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  123. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_api.py +0 -0
  124. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_api_mssql.py +0 -0
  125. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_api_mssql_live.py +0 -0
  126. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_cli.py +0 -0
  127. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_config.py +0 -0
  128. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_config_mssql.py +0 -0
  129. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_constants.py +0 -0
  130. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_container.py +0 -0
  131. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_container_mssql.py +0 -0
  132. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_docker.py +0 -0
  133. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_mssql_constants.py +0 -0
  134. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_naming.py +0 -0
  135. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_query.py +0 -0
  136. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_schema.py +0 -0
  137. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_schema_filtering.py +0 -0
  138. {pgdevkit-0.11.0 → pgdevkit-0.13.0}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
7
- Requires-Dist: psycopg[binary]>=3.2.0
7
+ Requires-Dist: psycopg[binary]>=3.3.0
8
8
  Requires-Dist: sqlglot>=30.11.0
9
9
  Provides-Extra: azure
10
10
  Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
@@ -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
 
@@ -391,6 +427,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
391
427
  `--entra-user`. For Lakebase hosts, also set the
392
428
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
393
429
  env vars.
430
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
431
+ — run one custom query and get every row back as dicts, as validated
432
+ Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
433
+ connection you already hold (it is borrowed: never committed, closed or
434
+ released); otherwise a connection is taken from `pool` or the pool registered
435
+ with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
436
+ (`QueryCanceled`); cancelling the awaiting task cancels it there too, and
437
+ `CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
438
+ expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
439
+ expression is only as safe as the strings it was built from: pass user values as
440
+ `exp.Placeholder` + `params`).
394
441
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
395
442
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
396
443
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
@@ -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
 
@@ -370,6 +406,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
370
406
  `--entra-user`. For Lakebase hosts, also set the
371
407
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
372
408
  env vars.
409
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
410
+ — run one custom query and get every row back as dicts, as validated
411
+ Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
412
+ connection you already hold (it is borrowed: never committed, closed or
413
+ released); otherwise a connection is taken from `pool` or the pool registered
414
+ with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
415
+ (`QueryCanceled`); cancelling the awaiting task cancels it there too, and
416
+ `CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
417
+ expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
418
+ expression is only as safe as the strings it was built from: pass user values as
419
+ `exp.Placeholder` + `params`).
373
420
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
374
421
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
375
422
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
@@ -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,
@@ -16,15 +16,19 @@ from .crud import (
16
16
  pg_upsert_many,
17
17
  pg_upsert_many_dict,
18
18
  )
19
+ from .fetch import ConnectionSource, SqlQuery, fetch_all, set_default_pool
19
20
  from .loader import SqlLoader
20
21
  from .model import PostgresTableModel, TableModel
21
22
 
22
23
  __all__ = [
23
24
  "ComplexHelper",
25
+ "ConnectionSource",
24
26
  "PgPool",
25
27
  "PostgresTableModel",
26
28
  "SqlLoader",
29
+ "SqlQuery",
27
30
  "TableModel",
31
+ "fetch_all",
28
32
  "pg_delete",
29
33
  "pg_delete_dict",
30
34
  "pg_insert",
@@ -37,4 +41,5 @@ __all__ = [
37
41
  "pg_upsert_dict",
38
42
  "pg_upsert_many",
39
43
  "pg_upsert_many_dict",
44
+ "set_default_pool",
40
45
  ]