Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Requires the `mssql` extra: `pip install pgdevkit[mssql]` (pulls in
own driver — no system ODBC driver install needed). MSSQL has no composite
type or native enum equivalent, so those areas of a `database/` tree don't
have a direct equivalent on this backend — see `docs/database-layout.md`.

`pgdb compare --dialect mssql --entra-user <identity>` appends
`Authentication=ActiveDirectoryDefault` to the connection string so mssql-python
gets an Entra ID token via `DefaultAzureCredential` (also install the `azure`
extra). The identity is whatever that credential chain resolves; the flag's
value only switches Entra auth on.
Current Azure SQL/SQL Server (2025+) does have a native `json` column type,
which parses/introspects/diffs like any other column type; see
"`pgdevkit.db` — helpers for application code" below for how JSON values are
Expand Down Expand Up @@ -303,16 +309,46 @@ REPL.
## `pgdb migrate`

Applies numbered, forward-only SQL migration files from a directory to a live
Postgres database, tracking each one in a `schema.table` (default
`public.schema_migrations`) so repeat runs only apply what's pending. Postgres only —
not available for `--dialect mssql`.
Postgres or MSSQL database, tracking each one in a `schema.table` (default
`public.schema_migrations`, `dbo.schema_migrations` for MSSQL) so repeat runs only
apply what's pending.

```bash
pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
```

`--entra-user` works the same as `pgdb compare` (see above). The tracking
### MSSQL

Pass `--dialect mssql` (needs the `mssql` extra) to both `check` and `apply`, with
`--url` as an ODBC connection string:

```bash
pgdb migrate apply path/to/database/_migration_scripts --dialect mssql \
--url "Server=host,1433;Database=db;UID=user;PWD=pass"
```

- Migration files are T-SQL: they are split into batches on standalone `GO` lines
(needed for e.g. `CREATE VIEW`, which must be first in its batch), and all batches
of one file run in a single transaction that is rolled back if any batch fails
(including a failure in a later statement of a multi-statement batch). `GO <count>`
is not honored: the batch runs once.
- The tracking table needs `filename nvarchar(450) primary key, applied_at
datetimeoffset not null default sysdatetimeoffset(), applied_by nvarchar(128) not
null default suser_sname()`. As on Postgres, a migration creating it can bootstrap it.
Override its name with `mssql_migrations_table` in `[tool.pgdevkit]` (the Postgres
`migrations_table` key is deliberately ignored for MSSQL) or with `--tracking-table`.
- `--ask` auto-detects "already done" for single-statement `GO` batches that create a
table, view or schema, or add a column; anything else is asked about.
- Post-apply verification checks every `CREATE TABLE` target via `OBJECT_ID`.
- `--entra-user` appends `Authentication=ActiveDirectoryDefault` to the connection
string, so mssql-python fetches an Entra ID token through azure-identity's
`DefaultAzureCredential` (install `pgdevkit[mssql,azure]`). The identity is whatever
that credential chain resolves — the flag's value only switches Entra auth on. It
can't be combined with an `Authentication=` already in the connection string.
- `pgdevkit.migrate.missing_privileges` is not available for MSSQL.

For Postgres, `--entra-user` works the same as `pgdb compare` (see above). The tracking
table needs `filename text primary key, applied_at timestamptz not null
default now(), applied_by text not null default current_user` (a migration
file that creates it, in the same directory, is the usual way to bootstrap
Expand Down Expand Up @@ -352,7 +388,7 @@ for a real one.
`pgdevkit.migrate` is also usable directly as a library — `list_migration_files`,
`applied_migrations`, `pending_migrations`, and `apply_migration` are the same
functions the CLI calls, so a project can script around them without shelling
out.
out. The database-touching ones take `dialect="postgres" | "mssql"`.

## `pgdevkit.db` — helpers for application code

Expand Down
122 changes: 98 additions & 24 deletions pgdevkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
import psycopg
import typer
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from rich import box
from tqdm import tqdm

from . import migrate, stats, testdb
from . import migrate, migrate_mssql, stats, testdb
from .backends import get_backend
from .connection import build_conninfo
from .connection import build_conninfo, build_mssql_conninfo
from .diff import DiffKind, compute_diff
from .fetch_missing import SUBFOLDER, find_missing_objects, layer_folder_for, reconstruct_ddl
from .parser import parse_directory
Expand Down Expand Up @@ -47,6 +48,9 @@
"--env",
help="Environment to apply: skips any <name>.<other-env>.sql file (e.g. grants.prod.sql); untagged files always apply",
)
_MIGRATE_DIALECT_OPTION = typer.Option(
"postgres", "--dialect", help="postgres (default) or mssql; mssql needs the mssql extra and uses --url as an ODBC connection string"
)
_MIGRATE_ENV_OPTION = typer.Option(
None,
"--env",
Expand All @@ -70,7 +74,12 @@ def _as_set(values: list[str]) -> frozenset[str] | None:
@app.command()
def compare(
url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
entra_user: str | None = typer.Option(
None,
"--entra-user",
help="Azure Entra user (triggers token auth); with --dialect mssql this adds "
"Authentication=ActiveDirectoryDefault and the value itself is not used",
),
databricks_workspace_host: str | None = typer.Option(
None,
"--databricks-workspace-host",
Expand Down Expand Up @@ -100,18 +109,21 @@ def compare(
)

try:
conninfo = build_conninfo(
url,
entra_user,
databricks_workspace_host=databricks_workspace_host,
databricks_instance=databricks_instance,
)
backend = get_backend(dialect)
except ValueError as e:
err_console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(2)

try:
backend = get_backend(dialect)
if backend.dialect.name == "mssql":
conninfo = build_mssql_conninfo(url, entra_user)
else:
conninfo = build_conninfo(
url,
entra_user,
databricks_workspace_host=databricks_workspace_host,
databricks_instance=databricks_instance,
)
except ValueError as e:
err_console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(2)
Expand Down Expand Up @@ -376,30 +388,78 @@ def testdb_list_orphaned() -> None:
console.print(name)


def _describe_target(url: str, dialect: str) -> str:
"""Where a migrate command points, without credentials: host/db for a Postgres URL,
Server/Database only for an MSSQL connection string (never UID/PWD)."""
if dialect == "mssql":
parts = dict(
(k.strip().lower(), v.strip()) for k, _, v in (p.partition("=") for p in url.split(";")) if k.strip()
)
server = parts.get("server") or parts.get("data source") or "?"
database = parts.get("database") or parts.get("initial catalog")
return f"{server}/{database}" if database else server
return url.rsplit("@", 1)[-1] if "@" in url else url


def _migrate_target(
url: str, entra_user: str | None, dialect: str, tracking_table: str | None, migrations_dir: Path
) -> tuple[str, str, str]:
"""Resolve (conninfo, dialect name, tracking table) for a migrate command."""
try:
resolved = get_backend(dialect).dialect
except ValueError as e:
err_console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(2)
if resolved.name == "mssql":
try:
migrate_mssql.require_driver()
except ImportError as e:
err_console.print(f"[red]Error:[/red] {escape(str(e))}")
raise typer.Exit(2)
try:
conninfo = (build_mssql_conninfo if resolved.name == "mssql" else build_conninfo)(url, entra_user)
except ValueError as e:
err_console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(2)
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir, dialect=resolved)
return conninfo, resolved.name, tracking_table


@migrate_app.command("check")
def migrate_check(
migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
url: str = typer.Option(
...,
"--url",
help="PostgreSQL DSN (postgresql://user:pass@host:port/db), or with --dialect mssql "
"a connection string (Server=host,1433;Database=db;UID=user;PWD=pass)",
),
entra_user: str | None = typer.Option(
None,
"--entra-user",
help="Azure Entra user (triggers token auth); with --dialect mssql this adds "
"Authentication=ActiveDirectoryDefault and the value itself is not used",
),
tracking_table: str | None = typer.Option(
None,
"--tracking-table",
help="schema.table recording applied migrations "
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations "
"-- dbo.schema_migrations with --dialect mssql)",
),
area: list[str] = _AREA_OPTION,
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
schema: list[str] = _SCHEMA_OPTION,
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
env: str | None = _MIGRATE_ENV_OPTION,
dialect: str = _MIGRATE_DIALECT_OPTION,
) -> None:
"""List which migration files under migrations_dir are applied vs. pending."""
if not migrations_dir.is_dir():
err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
raise typer.Exit(2)

conninfo = build_conninfo(url, entra_user)
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
conninfo, dialect, tracking_table = _migrate_target(url, entra_user, dialect, tracking_table, migrations_dir)
local_files = migrate.list_migration_files(
migrations_dir,
areas=_as_set(area),
Expand All @@ -409,7 +469,7 @@ def migrate_check(
env=env,
)
try:
applied = migrate.applied_migrations(conninfo, tracking_table)
applied = migrate.applied_migrations(conninfo, tracking_table, dialect)
except migrate.TrackingTableMissing:
err_console.print(f"[yellow]⚠[/yellow] {tracking_table} not found — nothing recorded as applied yet")
applied = {}
Expand All @@ -433,13 +493,24 @@ def migrate_check(
@migrate_app.command("apply")
def migrate_apply(
migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
url: str = typer.Option(
...,
"--url",
help="PostgreSQL DSN (postgresql://user:pass@host:port/db), or with --dialect mssql "
"a connection string (Server=host,1433;Database=db;UID=user;PWD=pass)",
),
entra_user: str | None = typer.Option(
None,
"--entra-user",
help="Azure Entra user (triggers token auth); with --dialect mssql this adds "
"Authentication=ActiveDirectoryDefault and the value itself is not used",
),
tracking_table: str | None = typer.Option(
None,
"--tracking-table",
help="schema.table recording applied migrations "
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations "
"-- dbo.schema_migrations with --dialect mssql)",
),
file: str | None = typer.Option(
None, "--file", help="Apply only this one filename (relative to migrations_dir) instead of all pending"
Expand All @@ -451,17 +522,17 @@ def migrate_apply(
schema: list[str] = _SCHEMA_OPTION,
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
env: str | None = _MIGRATE_ENV_OPTION,
dialect: str = _MIGRATE_DIALECT_OPTION,
) -> None:
"""Apply pending migration files, in filename order, tracking each in tracking_table."""
if not migrations_dir.is_dir():
err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
raise typer.Exit(2)

conninfo = build_conninfo(url, entra_user)
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
conninfo, dialect, tracking_table = _migrate_target(url, entra_user, dialect, tracking_table, migrations_dir)
areas, exclude_areas = _as_set(area), _as_set(exclude_area)
schemas, exclude_schemas = _as_set(schema), _as_set(exclude_schema)
target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
target_desc = _describe_target(url, dialect)
if not yes:
typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)

Expand All @@ -478,6 +549,7 @@ def migrate_apply(
schemas=schemas,
exclude_schemas=exclude_schemas,
env=env,
dialect=dialect,
)
except migrate.TrackingTableMissing:
err_console.print(
Expand Down Expand Up @@ -517,7 +589,9 @@ def worker() -> None:
for path, already_done in iter(work_q.get, None):
if not stop.is_set():
try:
result = migrate.apply_migration(conninfo, path, tracking_table, already_done=already_done)
result = migrate.apply_migration(
conninfo, path, tracking_table, already_done=already_done, dialect=dialect
)
except Exception as e: # noqa: BLE001
failure = e
stop.set()
Expand All @@ -544,7 +618,7 @@ def worker() -> None:
break
already_done = False
if ask:
already_done = migrate.already_fully_applied(conninfo, path)
already_done = migrate.already_fully_applied(conninfo, path, dialect)
if already_done:
bar_write(f"Auto: {path.name} is already fully present in the database — marking as already done")
else:
Expand Down
16 changes: 16 additions & 0 deletions pgdevkit/connection.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
from typing import Literal
from urllib.parse import quote, urlparse, urlunparse

Expand Down Expand Up @@ -67,6 +68,21 @@ def get_azure_postgres_password(
return token.token


def build_mssql_conninfo(conn_str: str, entra_user: str | None = None) -> str:
"""MSSQL counterpart of `build_conninfo`. Without `entra_user` the ODBC connection
string is used as-is. With it, `Authentication=ActiveDirectoryDefault` is appended so
mssql-python acquires an Entra ID token itself via azure-identity's
`DefaultAzureCredential` (needs the `azure` extra); the identity is whatever that
credential chain resolves, so `entra_user` only switches Entra auth on and is not
sent to the server. A connection string that already sets `Authentication` is left
to speak for itself and rejected here to avoid ambiguity."""
if entra_user is None:
return conn_str
if re.search(r"(^|;)\s*Authentication\s*=", conn_str, re.IGNORECASE):
raise ValueError("--entra-user can't be combined with an Authentication= setting in the connection string")
return f"{conn_str.rstrip().rstrip(';')};Authentication=ActiveDirectoryDefault"


def build_conninfo(
url: str,
entra_user: str | None = None,
Expand Down
Loading
Loading