diff --git a/README.md b/README.md index 857400a..3c4c1d1 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,13 @@ changed any of these on a machine that already has one. Container management goes through the Docker API (the `docker` package, `docker.from_env()`, falling back to Podman's rootful/rootless socket) — it works against a real Docker daemon or Podman transparently, no CLI binary -required either way. +required either way. If no API socket is reachable (typical for rootless +Podman without a systemd user session, where there is no `podman.sock` and +`DOCKER_HOST` is unset), pgdevkit falls back to the `podman`/`docker` CLI +(first one found on `PATH`, `podman` first). Set +`PGDEVKIT_CONTAINER_ENGINE=podman` (or `docker`) to skip the socket probing +and always use that CLI; `auto`/unset is the default behaviour above. This +applies to both the Postgres and the MSSQL container. To point at a local Postgres install instead of the container — useful when neither is available, or you'd rather use peer authentication as the @@ -309,6 +315,58 @@ take effect when pgdevkit creates the container, not when restarting an existing stopped one (`docker rm -f pgdevkit-mssql` first if you've changed any of them). +`PGDEVKIT_TESTDB_MSSQL_CONTAINER_USER` (unset by default, i.e. the image's own +user) is passed as the container's `--user`. + +**Rootless Podman recipe.** The SQL Server image's default user can't start +there (`exec container process /opt/mssql/bin/launch_sqlservr.sh: Permission +denied`); running the container as root *inside* the user namespace fixes it +and doesn't give the container any real host privileges: + +```bash +export PGDEVKIT_CONTAINER_ENGINE=podman # no podman.sock needed +export PGDEVKIT_TESTDB_MSSQL_CONTAINER_USER=0 +``` + +**Generated schemas.** When the DDL is produced by code (e.g. from a metadata +export) instead of `database/` files, pass a hook to `ensure_testdb` / +`reset_testdb`. It receives an autocommit `mssql_python` connection to this +workspace's database, after the `database/` files are applied — or instead of +them with `apply_scripts=False`: + +```python +def create_tables(conn) -> None: + cur = conn.cursor() + for ddl in generate_ddl(): # your generator + cur.execute(ddl) + +ensure_testdb(apply=create_tables, apply_scripts=False) +``` + +Like the files, the hook runs on every call, so make it idempotent (or check +whether the objects exist first). Not available for Postgres. + +**Fast reset with database snapshots.** Build the schema (and seed data) once, +then capture it and revert to it per test instead of rebuilding: + +```python +from pgdevkit.testdb import ensure_testdb, restore_testdb, snapshot_testdb + +ensure_testdb(apply=create_tables, apply_scripts=False, force_reset=True) +snapshot_testdb() # once per session; name="template" by default + +restore_testdb() # per test: kicks other sessions off, reverts, keeps the snapshot +``` + +This uses SQL Server database snapshots (Developer/Enterprise edition — what +the managed container runs). A revert is only possible from a database's *sole* +snapshot, so `restore_testdb` raises if another snapshot of the database +exists, instead of dropping it. Snapshot files (sparse) are written next to the +database's data files, so they follow it onto a `PGDEVKIT_TESTDB_MSSQL_TMPFS` +mount; `PGDEVKIT_TESTDB_MSSQL_SNAPSHOT_DIR` (a path inside the container) +overrides that. Snapshots are dropped together with their database +(`force_reset`, `clean_testdb`) and never reported as orphans. + `pgdb testdb shell` execs into [`sqlcmd`](https://github.com/microsoft/go-sqlcmd) (an external prerequisite, the same category as `psql` for the Postgres path) rather than a Python diff --git a/pgdevkit/diff.py b/pgdevkit/diff.py index a3e04cc..96eb02b 100644 --- a/pgdevkit/diff.py +++ b/pgdevkit/diff.py @@ -192,11 +192,11 @@ def _parse_index_def(definition: str, dialect: Dialect = POSTGRES) -> dict | Non columns = [] for col in (params.args.get("columns") if params else None) or []: - columns.append(_norm_sql(col.sql(dialect=dialect.sqlglot_name))) + columns.append(_norm_sql(_unquote(col).sql(dialect=dialect.sqlglot_name))) where_node = params.args.get("where") if params else None - where_ast = _unwrap_paren(where_node.this) if where_node else None - where_display = _norm_sql(where_node.this.sql(dialect=dialect.sqlglot_name)) if where_node else None + where_ast = _unwrap_paren(_unquote(where_node.this)) if where_node else None + where_display = _norm_sql(_unquote(where_node.this).sql(dialect=dialect.sqlglot_name)) if where_node else None return { "unique": bool(parsed.args.get("unique")), @@ -207,8 +207,25 @@ def _parse_index_def(definition: str, dialect: Dialect = POSTGRES) -> dict | Non } +def _unquote(node: exp.Expression) -> exp.Expression: + """A copy with every identifier's quoting dropped, so `[Posting Date]` + in a script (SSMS and generators bracket everything) and the same column + as the catalog reports it compare equal, however each side quoted it.""" + node = node.copy() + for ident in node.find_all(exp.Identifier): + ident.set("quoted", False) + return node + + def _unwrap_paren(node): - return node.transform(lambda n: n.this if isinstance(n, exp.Paren) else n) + # transform() doesn't descend into a node it replaced, so a Paren nested + # inside another one ("([b]>(1))", how SQL Server stores a filter) needs + # another pass. + previous = None + while node != previous: + previous = node + node = node.transform(lambda n: n.this if isinstance(n, exp.Paren) else n) + return node def _norm_sql(s: str) -> str: @@ -232,7 +249,8 @@ def _norm_type(t: str, dialect: Dialect = POSTGRES) -> str: s = s[:-2].strip() match = re.search(r"\(([^)]*)\)", s) - params = f"({match.group(1)})" if match else "" + # sqlglot renders "decimal(38, 20)", the catalogs report "decimal(38,20)". + params = f"({''.join(match.group(1).split())})" if match else "" base = (s[: match.start()] + s[match.end() :]).strip() if match else s base = " ".join(base.split()) diff --git a/pgdevkit/mssql_introspect.py b/pgdevkit/mssql_introspect.py index 506de50..0bfe6b7 100644 --- a/pgdevkit/mssql_introspect.py +++ b/pgdevkit/mssql_introspect.py @@ -6,6 +6,7 @@ import sqlglot import sqlglot.expressions as exp +from .db.mssql_sql import ident from .models import ( ColumnDef, ConstraintDef, DatabaseSchema, FunctionDef, IndexDef, TableDef, ViewDef, ) @@ -264,12 +265,14 @@ def _load_indexes(conn: Any, db: DatabaseSchema) -> None: WHERE ic.object_id = ? AND ic.index_id = ? AND ic.is_included_column = 0 ORDER BY ic.key_ordinal """, (r["object_id"], r["index_id"])) - col_list = ", ".join(c["name"] for c in cols) unique = "UNIQUE " if r["is_unique"] else "" where_clause = f" WHERE {r['filter_definition']}" if r["filter_definition"] else "" + # Bracket-quoted: object names (NAV-style "Company$Cust. Ledger Entry") + # don't parse back unquoted, which would drop the diff to a raw text compare. + col_list = ", ".join(ident(c["name"]) for c in cols) definition = ( - f"create {unique}index {r['index_name']} " - f"on {r['schema']}.{r['table_name']} ({col_list}){where_clause}" + f"create {unique}index {ident(r['index_name'])} " + f"on {ident(r['schema'])}.{ident(r['table_name'])} ({col_list}){where_clause}" ).lower() idx = IndexDef(schema=r["schema"], table=r["table_name"], name=r["index_name"], definition=definition) db.indexes[idx.qualified_name] = idx diff --git a/pgdevkit/testdb/__init__.py b/pgdevkit/testdb/__init__.py index c80a646..b15c9a2 100644 --- a/pgdevkit/testdb/__init__.py +++ b/pgdevkit/testdb/__init__.py @@ -4,8 +4,10 @@ ensure_testdb, find_orphaned_dbs, reset_testdb, + restore_testdb, run_sql, shell_argv, + snapshot_testdb, status, workspace_db_names, ) @@ -16,8 +18,10 @@ "ensure_testdb", "find_orphaned_dbs", "reset_testdb", + "restore_testdb", "run_sql", "shell_argv", + "snapshot_testdb", "status", "workspace_db_names", ] diff --git a/pgdevkit/testdb/_docker.py b/pgdevkit/testdb/_docker.py index c3a49b0..4c3ff7a 100644 --- a/pgdevkit/testdb/_docker.py +++ b/pgdevkit/testdb/_docker.py @@ -1,8 +1,11 @@ from __future__ import annotations import os +import shutil +import subprocess import docker +import docker.errors # Candidate Docker-API-compatible socket URLs tried after plain # docker.from_env() (which only looks at DOCKER_HOST / the default Docker @@ -14,12 +17,110 @@ ] -def client() -> docker.DockerClient: - """A Docker-API client, working against a real Docker daemon or a - Podman one (Podman exposes the same API over its own socket) -- callers - never need to know or care which one is actually running. Shared by - both the Postgres and MSSQL container modules -- a container is a - container, regardless of which image runs inside it.""" +# Container engines whose CLI can stand in for the Docker SDK, in the order +# tried by auto-detection. Podman first: it is the one that routinely has no +# API socket (rootless without a systemd user session), while a `docker` CLI +# needs a reachable daemon just like the SDK does. +_CLI_ENGINES = ("podman", "docker") + + +class CliContainer: + """The slice of docker-py's `Container` that the container modules use + (`status`, `start()`), backed by the engine's CLI.""" + + def __init__(self, engine: str, name: str, status: str) -> None: + self._engine = engine + self.name = name + self.status = status + + def start(self) -> None: + _run_cli(self._engine, ["start", self.name]) + + +class CliContainers: + """The slice of docker-py's `ContainerCollection` that the container + modules use (`get`, `run`), implemented by shelling out to + `podman`/`docker`, for environments with no reachable API socket.""" + + def __init__(self, engine: str) -> None: + self._engine = engine + + def get(self, name: str) -> CliContainer: + result = _run_cli(self._engine, ["inspect", "--format", "{{.State.Status}}", name], check=False) + if result.returncode != 0: + if "no such" in result.stderr.lower(): + raise docker.errors.NotFound(result.stderr.strip()) + raise RuntimeError(f"`{self._engine} inspect {name}` failed: {result.stderr.strip()}") + return CliContainer(self._engine, name, result.stdout.strip()) + + def run( + self, + image: str, + *, + name: str, + ports: dict[str, int], + environment: dict[str, str], + detach: bool = True, # accepted for docker-py compatibility; the CLI always runs detached + command: list[str] | None = None, + user: str | None = None, + tmpfs: dict[str, str] | None = None, + shm_size: str | None = None, + mem_limit: str | None = None, + memswap_limit: str | None = None, + nano_cpus: int | None = None, + ) -> CliContainer: + # Values are passed as bare `-e KEY` with the real value in the child's + # environment, so e.g. the SA password never shows up in `ps` output. + args = ["run", "--detach", "--name", name] + for container_port, host_port in ports.items(): + args += ["--publish", f"{host_port}:{container_port}"] + for key in environment: + args += ["--env", key] + if user: + args += ["--user", user] + for path, options in (tmpfs or {}).items(): + args += ["--tmpfs", f"{path}:{options}" if options else path] + if shm_size: + args += ["--shm-size", shm_size] + if mem_limit: + args += ["--memory", mem_limit] + if memswap_limit: + args += ["--memory-swap", memswap_limit] + if nano_cpus: + args += ["--cpus", str(nano_cpus / 1_000_000_000)] + args.append(image) + args += command or [] + result = _run_cli(self._engine, args, env=environment, check=False) + if result.returncode != 0: + # Same exception type docker-py raises, so the callers' "name already + # in use -> start the existing container" handling applies unchanged. + raise docker.errors.APIError(result.stderr.strip() or f"`{self._engine} run` failed") + return CliContainer(self._engine, name, "running") + + +class CliClient: + """Stands in for `docker.DockerClient` when only a container CLI is available.""" + + def __init__(self, engine: str) -> None: + self.engine = engine + self.containers = CliContainers(engine) + + +def _run_cli( + engine: str, args: list[str], *, env: dict[str, str] | None = None, check: bool = True +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [engine, *args], + capture_output=True, + text=True, + env={**os.environ, **env} if env else None, + ) + if check and result.returncode != 0: + raise RuntimeError(f"`{engine} {args[0]}` failed: {result.stderr.strip()}") + return result + + +def _sdk_client() -> docker.DockerClient | None: try: c = docker.from_env() c.ping() @@ -35,9 +136,36 @@ def client() -> docker.DockerClient: return c except Exception: # noqa: BLE001 continue + return None + + +def client() -> docker.DockerClient | CliClient: + """A container client, working against a real Docker daemon or a Podman + one (Podman exposes the same API over its own socket) -- callers never + need to know or care which one is actually running. Shared by both the + Postgres and MSSQL container modules -- a container is a container, + regardless of which image runs inside it. + + PGDEVKIT_CONTAINER_ENGINE=docker|podman skips the SDK and drives that + engine's CLI directly. Unset (or "auto"), the SDK is tried first and the + first of `podman`/`docker` on PATH is the fallback when no API socket is + reachable -- e.g. rootless Podman without a systemd user session.""" + engine = os.environ.get("PGDEVKIT_CONTAINER_ENGINE", "").strip().lower() + if engine in ("docker", "podman"): + if shutil.which(engine) is None: + raise RuntimeError(f"PGDEVKIT_CONTAINER_ENGINE={engine}, but `{engine}` is not on PATH.") + return CliClient(engine) + if engine not in ("", "auto"): + raise ValueError(f"PGDEVKIT_CONTAINER_ENGINE must be docker, podman or auto, not {engine!r}") + sdk = _sdk_client() + if sdk is not None: + return sdk + for cli_engine in _CLI_ENGINES: + if shutil.which(cli_engine): + return CliClient(cli_engine) raise RuntimeError( - "Could not reach a Docker-compatible API. Set DOCKER_HOST, or make sure " - "Docker or Podman's API socket is running." + "Could not reach a Docker-compatible API and found no `podman`/`docker` CLI. Set " + "DOCKER_HOST, make sure Docker or Podman's API socket is running, or install a container CLI." ) diff --git a/pgdevkit/testdb/api.py b/pgdevkit/testdb/api.py index 6ab330a..24e793a 100644 --- a/pgdevkit/testdb/api.py +++ b/pgdevkit/testdb/api.py @@ -2,6 +2,7 @@ import asyncio from pathlib import Path +from typing import Any, Callable import psycopg from psycopg.sql import SQL, Identifier @@ -119,6 +120,8 @@ def ensure_testdb( exclude_areas: frozenset[str] | None = None, schemas: frozenset[str] | None = None, exclude_schemas: frozenset[str] | None = None, + apply: Callable[[Any], None] | None = None, + apply_scripts: bool = True, ) -> dict[str, str]: """Ensure the shared container is running, this workspace's database exists, and its schema is applied. Returns the {PREFIX}POSTGRES_* env @@ -131,13 +134,24 @@ def ensure_testdb( `areas`/`exclude_areas` and `schemas`/`exclude_schemas` restrict which database/ files get applied -- e.g. for a test DB scoped to one area or schema. Neither filters what gets *dropped* by force_reset/clean, only - what gets (re)applied.""" + what gets (re)applied. + + `apply` (MSSQL only) is a schema-provider hook for DDL generated by code + instead of kept as database/ files: it is called with an autocommit + connection to this workspace's database after the database/ files are + applied, or instead of them with `apply_scripts=False`. It runs on every + call, like the files do, so it must be idempotent -- or guard on whether + the objects already exist, or build the schema once and use + `snapshot_testdb`/`restore_testdb` to get back to it.""" config, db_name = _resolve(project_root) if config.engine == "mssql": return _mssql_api().ensure_testdb( config, db_name, force_reset, env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas, + apply=apply, apply_scripts=apply_scripts, ) + if apply is not None or not apply_scripts: + raise ValueError("ensure_testdb: apply/apply_scripts are only supported for engine = \"mssql\"") ensure_container() @@ -162,15 +176,43 @@ def reset_testdb( exclude_areas: frozenset[str] | None = None, schemas: frozenset[str] | None = None, exclude_schemas: frozenset[str] | None = None, + apply: Callable[[Any], None] | None = None, + apply_scripts: bool = True, ) -> dict[str, str]: """Drop and recreate only this workspace's database, then reapply schema and seed data.""" return ensure_testdb( project_root, force_reset=True, env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas, + apply=apply, apply_scripts=apply_scripts, ) +def snapshot_testdb(project_root: Path | None = None, name: str = "template") -> str: + """MSSQL only: capture this workspace's database as the database snapshot + `name` (replacing an earlier one of that name) and return the snapshot's + database name. Pair with `restore_testdb` for a reset that takes + milliseconds instead of rebuilding the schema. Needs a SQL Server edition + with snapshot support (the managed container runs Developer). The sparse + snapshot files live next to the data files unless + PGDEVKIT_TESTDB_MSSQL_SNAPSHOT_DIR (a path in the container) is set.""" + config, db_name = _resolve(project_root) + if config.engine != "mssql": + raise ValueError("snapshot_testdb is only supported for engine = \"mssql\"") + return _mssql_api().snapshot_testdb(config, db_name, name) + + +def restore_testdb(project_root: Path | None = None, name: str = "template") -> None: + """MSSQL only: revert this workspace's database to the snapshot `name` + taken by `snapshot_testdb`, disconnecting other sessions first. The snapshot + stays, so it can be restored any number of times. SQL Server only reverts + from a database's *sole* snapshot, so keep one snapshot per database.""" + config, db_name = _resolve(project_root) + if config.engine != "mssql": + raise ValueError("restore_testdb is only supported for engine = \"mssql\"") + _mssql_api().restore_testdb(config, db_name, name) + + async def _find_orphaned_dbs(config: ProjectConfig) -> list[str]: prefix = f"{slugify(config.name)}_" actual = await _dbs_with_prefix(prefix) diff --git a/pgdevkit/testdb/container.py b/pgdevkit/testdb/container.py index 64c2b21..abf409e 100644 --- a/pgdevkit/testdb/container.py +++ b/pgdevkit/testdb/container.py @@ -26,7 +26,7 @@ def _available(timeout: float = 3.0) -> bool: return False -def _create_container(client: docker.DockerClient) -> None: +def _create_container(client: docker.DockerClient | _docker.CliClient) -> None: try: client.containers.run( constants.IMAGE, diff --git a/pgdevkit/testdb/mssql/api.py b/pgdevkit/testdb/mssql/api.py index 6b7438d..7104a8f 100644 --- a/pgdevkit/testdb/mssql/api.py +++ b/pgdevkit/testdb/mssql/api.py @@ -2,8 +2,9 @@ import asyncio import json +import re from pathlib import Path -from typing import Any +from typing import Any, Callable import mssql_python @@ -59,6 +60,9 @@ def _run() -> None: cur.execute("SELECT 1 FROM sys.databases WHERE name = ?", [db_name]) if not cur.fetchone(): return + # SQL Server refuses to drop a database that still has snapshots. + for snapshot in _snapshots_of(cur, db_name): + cur.execute(f"DROP DATABASE IF EXISTS {ident(snapshot)}") # One statement kills other sessions and drops -- SQL Server's # equivalent of Postgres's pg_terminate_backend()+DROP DATABASE. cur.execute(f"ALTER DATABASE {ident(db_name)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE") @@ -142,6 +146,17 @@ def _exec(batch: str = batch) -> None: await asyncio.to_thread(conn.close) +async def _run_apply_hook(db_name: str, apply: Callable[[Any], None]) -> None: + def _run() -> None: + conn = mssql_python.connect(_db_dsn(db_name), autocommit=True) + try: + apply(conn) + finally: + conn.close() + + await asyncio.to_thread(_run) + + def ensure_testdb( config: ProjectConfig, db_name: str, @@ -152,6 +167,8 @@ def ensure_testdb( exclude_areas: frozenset[str] | None = None, schemas: frozenset[str] | None = None, exclude_schemas: frozenset[str] | None = None, + apply: Callable[[Any], None] | None = None, + apply_scripts: bool = True, ) -> dict[str, str]: ensure_mssql_container() @@ -159,10 +176,13 @@ async def _run() -> None: if force_reset: await _drop_database(db_name) await _ensure_database(db_name) - await _apply( - config, db_name, force_reset, - env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas, - ) + if apply_scripts: + await _apply( + config, db_name, force_reset, + env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas, + ) + if apply is not None: + await _run_apply_hook(db_name, apply) asyncio.run(_run()) return _env_for(config, db_name) @@ -177,7 +197,13 @@ def _list_names() -> list[str]: conn = mssql_python.connect(_admin_dsn(), autocommit=True) try: cur = conn.cursor() - cur.execute("SELECT name FROM sys.databases WHERE name LIKE ? ESCAPE '\\'", [f"{escaped_prefix}%"]) + # Snapshots (source_database_id set) belong to their source database: + # they are dropped together with it, never listed on their own -- else + # a live worktree's snapshot would look like an orphan. + cur.execute( + "SELECT name FROM sys.databases WHERE name LIKE ? ESCAPE '\\' AND source_database_id IS NULL", + [f"{escaped_prefix}%"], + ) return [row[0] for row in cur.fetchall()] finally: conn.close() @@ -214,6 +240,78 @@ async def _run() -> None: asyncio.run(_run()) +_SNAPSHOT_NAME = re.compile(r"[A-Za-z0-9_]+") + + +def _snapshot_db_name(db_name: str, name: str) -> str: + if not _SNAPSHOT_NAME.fullmatch(name): + raise ValueError(f"Snapshot name must match [A-Za-z0-9_]+, got {name!r}") + return f"{db_name}__ss_{name}" + + +def _literal(value: str) -> str: + return "N'" + value.replace("'", "''") + "'" + + +def _snapshots_of(cur: Any, db_name: str) -> list[str]: + cur.execute("SELECT name FROM sys.databases WHERE source_database_id = DB_ID(?) ORDER BY name", [db_name]) + return [row[0] for row in cur.fetchall()] + + +def snapshot_testdb(config: ProjectConfig, db_name: str, name: str) -> str: + """Create (or replace) the database snapshot `name` of `db_name` and + return the snapshot's database name. The snapshot's sparse files go next to + the source database's data files (so they follow it onto a tmpfs mount) unless + PGDEVKIT_TESTDB_MSSQL_SNAPSHOT_DIR says otherwise -- a path inside the container.""" + snapshot = _snapshot_db_name(db_name, name) + conn = mssql_python.connect(_admin_dsn(), autocommit=True) + try: + cur = conn.cursor() + cur.execute( + "SELECT name, physical_name FROM sys.master_files WHERE database_id = DB_ID(?) AND type = 0 ORDER BY file_id", + [db_name], + ) + files = [(row[0], row[1]) for row in cur.fetchall()] + if not files: + raise RuntimeError(f"Database {db_name!r} does not exist -- call ensure_testdb() first") + directory = constants.SNAPSHOT_DIR or files[0][1].rsplit("/", 1)[0] + cur.execute(f"DROP DATABASE IF EXISTS {ident(snapshot)}") + file_specs = ", ".join( + f"(NAME = {_literal(logical)}, FILENAME = {_literal(f'{directory}/{snapshot}_{logical}.ss')})" + for logical, _ in files + ) + cur.execute(f"CREATE DATABASE {ident(snapshot)} ON {file_specs} AS SNAPSHOT OF {ident(db_name)}") + finally: + conn.close() + return snapshot + + +def restore_testdb(config: ProjectConfig, db_name: str, name: str) -> None: + """Revert `db_name` to the snapshot `name`, disconnecting everyone else + first. The snapshot is kept, so it can be restored again. SQL Server only + reverts from a database's sole snapshot, so any other snapshot of + `db_name` has to be dropped first -- this raises instead of dropping it.""" + snapshot = _snapshot_db_name(db_name, name) + conn = mssql_python.connect(_admin_dsn(), autocommit=True) + try: + cur = conn.cursor() + snapshots = _snapshots_of(cur, db_name) + if snapshot not in snapshots: + raise RuntimeError(f"Database {db_name!r} has no snapshot {name!r} -- call snapshot_testdb() first") + others = [s for s in snapshots if s != snapshot] + if others: + raise RuntimeError( + f"SQL Server can only revert from the sole snapshot of a database; also found: {', '.join(others)}" + ) + cur.execute(f"ALTER DATABASE {ident(db_name)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE") + try: + cur.execute(f"RESTORE DATABASE {ident(db_name)} FROM DATABASE_SNAPSHOT = {_literal(snapshot)}") + finally: + cur.execute(f"ALTER DATABASE {ident(db_name)} SET MULTI_USER") + finally: + conn.close() + + def status(config: ProjectConfig, db_name: str) -> dict[str, str]: return { "engine": config.engine, diff --git a/pgdevkit/testdb/mssql/constants.py b/pgdevkit/testdb/mssql/constants.py index 98df12f..447a223 100644 --- a/pgdevkit/testdb/mssql/constants.py +++ b/pgdevkit/testdb/mssql/constants.py @@ -32,6 +32,13 @@ MEM_LIMIT = os.environ.get("PGDEVKIT_TESTDB_MSSQL_MEM_LIMIT", "") MEMSWAP_LIMIT = os.environ.get("PGDEVKIT_TESTDB_MSSQL_MEMSWAP_LIMIT", "") CPUS = os.environ.get("PGDEVKIT_TESTDB_MSSQL_CPUS", "") +# User the container runs as (`--user`); unset keeps the image's own default. +# Rootless Podman needs "0" -- the image's default `mssql` user can't exec +# launch_sqlservr.sh there ("Permission denied"). +# Directory *inside the container* for database-snapshot files; unset puts them +# next to the source database's data files. +SNAPSHOT_DIR = os.environ.get("PGDEVKIT_TESTDB_MSSQL_SNAPSHOT_DIR", "").rstrip("/") +CONTAINER_USER = os.environ.get("PGDEVKIT_TESTDB_MSSQL_CONTAINER_USER", "") def validate_sa_password(password: str) -> None: diff --git a/pgdevkit/testdb/mssql/container.py b/pgdevkit/testdb/mssql/container.py index 4c01b5b..4521966 100644 --- a/pgdevkit/testdb/mssql/container.py +++ b/pgdevkit/testdb/mssql/container.py @@ -23,7 +23,7 @@ def _available(timeout: float = 3.0) -> bool: return False -def _create_container(client: docker.DockerClient) -> None: +def _create_container(client: docker.DockerClient | _docker.CliClient) -> None: constants.validate_sa_password(constants.PASSWORD) try: client.containers.run( @@ -40,6 +40,7 @@ def _create_container(client: docker.DockerClient) -> None: "MSSQL_PID": "Developer", "MSSQL_MEMORY_LIMIT_MB": str(constants.MEMORY_LIMIT_MB), }, + **({"user": constants.CONTAINER_USER} if constants.CONTAINER_USER else {}), **_docker.resource_kwargs( tmpfs=constants.TMPFS, shm_size=constants.SHM_SIZE, diff --git a/pgdevkit/testdb/naming.py b/pgdevkit/testdb/naming.py index 44ca574..429aab1 100644 --- a/pgdevkit/testdb/naming.py +++ b/pgdevkit/testdb/naming.py @@ -67,7 +67,11 @@ def live_worktree_branches(repo: Path) -> list[str]: rev-parse --abbrev-ref HEAD`. Without this, a detached-HEAD worktree (e.g. a CI checkout, which defaults to one) would never appear "live" here, and its database would look orphaned and get dropped out from - under it.""" + under it. + + A *locked* worktree (`git worktree lock`, which also keeps `git worktree + prune` away from it) counts as live even when its path is missing -- the + documented use is a worktree on a drive that isn't mounted right now.""" result = subprocess.run( ["git", "worktree", "list", "--porcelain"], cwd=repo, @@ -80,15 +84,16 @@ def live_worktree_branches(repo: Path) -> list[str]: path: Path | None = None branch: str | None = None bare = False + locked = False def _flush() -> None: - if path is not None and not bare and branch is not None and path.exists(): + if path is not None and not bare and branch is not None and (locked or path.exists()): branches.append(branch) for line in result.stdout.splitlines(): if not line: _flush() - path, branch, bare = None, None, False + path, branch, bare, locked = None, None, False, False elif line.startswith("worktree "): path = Path(line[len("worktree ") :]) elif line.startswith("branch "): @@ -97,6 +102,8 @@ def _flush() -> None: branch = "HEAD" elif line == "bare": bare = True + elif line == "locked" or line.startswith("locked "): + locked = True _flush() return branches diff --git a/pgdevkit/testdb/schema.py b/pgdevkit/testdb/schema.py index 2e1692c..0c2ffed 100644 --- a/pgdevkit/testdb/schema.py +++ b/pgdevkit/testdb/schema.py @@ -112,11 +112,13 @@ def _get_sql_deps(sql: str, dialect: Dialect = POSTGRES) -> set[str]: if t.args.get("this") is not None and db_node is not None: if db_node.name.lower() in SYSTEM_SCHEMAS: continue - # exp.table_name(), not str(t): str() includes " AS alias" for - # an aliased reference (e.g. "FROM editing.visit v"), which - # would never match the plain declared name any dependent - # file's own CREATE target is recorded under. - deps.add(exp.table_name(t)) + # The raw identifier parts joined with ".", not str(t) (which + # includes " AS alias" for an aliased reference, e.g. "FROM + # editing.visit v") nor exp.table_name(t) (which re-quotes a + # name like [Company$Cust Ledger Entry] as dbo."Company$..."): + # neither would match the plain name a dependent file's own + # CREATE target is recorded under (its file name). + deps.add(".".join(part for part in (t.catalog, t.db, t.name) if part)) return deps diff --git a/pyproject.toml b/pyproject.toml index 7d0df5e..dc08303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ packages = ["pgdevkit"] [project] name = "pgdevkit" -version = "0.17.1" +version = "0.18.0" description = "A helper for developing with Postgres" readme = "README.md" requires-python = ">=3.14" diff --git a/skills/pgdevkit/SKILL.md b/skills/pgdevkit/SKILL.md index d460281..2ed521f 100644 --- a/skills/pgdevkit/SKILL.md +++ b/skills/pgdevkit/SKILL.md @@ -90,7 +90,7 @@ def _testdb_env(): ### CI -`ensure_testdb()`/`ensure_container()` talk to whatever Docker-compatible API is reachable (`DOCKER_HOST`, the default Docker socket, or Podman's socket as a fallback) — `ubuntu-latest`'s preinstalled Docker daemon just works, no setup step needed. There's no `PGDEVKIT_SKIP_CONTAINER` + service-container escape hatch wired through every fixture yet — if a project's CI has neither Docker nor Podman reachable, it needs its own workaround for now. +`ensure_testdb()`/`ensure_container()` talk to whatever Docker-compatible API is reachable (`DOCKER_HOST`, the default Docker socket, or Podman's socket as a fallback), and fall back to the `podman`/`docker` CLI when there is no API socket (force it with `PGDEVKIT_CONTAINER_ENGINE=podman|docker`) — `ubuntu-latest`'s preinstalled Docker daemon just works, no setup step needed. There's no `PGDEVKIT_SKIP_CONTAINER` + service-container escape hatch wired through every fixture yet — if a project's CI has no Docker/Podman at all (neither API socket nor CLI), it needs its own workaround for now. --- diff --git a/tests/test_compare_mssql_live.py b/tests/test_compare_mssql_live.py index ba7a90d..97c1e7a 100644 --- a/tests/test_compare_mssql_live.py +++ b/tests/test_compare_mssql_live.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -37,3 +38,77 @@ def test_introspection_matches_applied_scripts_with_no_diff(tmp_path: Path): assert diffs == [], diffs finally: clean_testdb(project) + + +_NAV_TABLES = ["Company$Detailed Cust_ Ledg_ Entry", "Compañía$Kunde é Posten", "Company$Item Ledger Entry"] + + +def _write_nav_style_database(project: Path) -> None: + """A NAV-export-like database/ layout: names with spaces, `$` and accents, + a composite primary key with a varbinary(900) column, every identifier + bracket-quoted, and a view over each table.""" + (project / "database").unlink() # _make_project symlinks the shared fixtures + tables = project / "database" / "dbo" / "tables" + views = project / "database" / "dbo" / "views" + tables.mkdir(parents=True) + views.mkdir(parents=True) + for name in _NAV_TABLES: + (tables / f"{name}.sql").write_text( + f"""CREATE TABLE dbo.[{name}] ( + [timestamp] timestamp NOT NULL, + [Entry No_] int NOT NULL, + [Key Bin] varbinary(900) NOT NULL, + [Description] nvarchar(100) NOT NULL DEFAULT '', + [Amount] decimal(38,20) NOT NULL DEFAULT 0, + [Posting Date] datetime NOT NULL DEFAULT '1753-01-01', + [Guid] uniqueidentifier NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + [Flag] tinyint NOT NULL DEFAULT 0, + CONSTRAINT [{name}$0] PRIMARY KEY CLUSTERED ([Entry No_], [Key Bin]) +); +CREATE INDEX [{name}$1] ON dbo.[{name}] ([Posting Date], [Flag]); +""", + encoding="utf-8", + ) + (views / f"{name} View.sql").write_text( + f"CREATE VIEW dbo.[{name} View] AS SELECT [Entry No_], [Amount] FROM dbo.[{name}];\n", encoding="utf-8" + ) + + +@requires_mssql +def test_nav_style_object_names_apply_introspect_and_compare_without_diffs(tmp_path: Path): + project = _make_project(tmp_path, "mssqlnav", "main", engine="mssql") + _write_nav_style_database(project) + try: + ensure_testdb(project) + conninfo = status(project)["dsn"] + + scripts = parse_directory(project / "database", dialect="mssql") + db = get_backend("mssql").introspect(conninfo) + + assert set(db.tables) == {f"dbo.{n}" for n in _NAV_TABLES} + assert set(db.views) == {f"dbo.{n} View" for n in _NAV_TABLES} + assert compute_diff(scripts, db, dialect="mssql") == [] + finally: + clean_testdb(project) + + +@requires_mssql +def test_nav_style_object_names_are_quoted_by_update_stats(tmp_path: Path): + from typer.testing import CliRunner + + from pgdevkit.cli import app + + project = _make_project(tmp_path, "mssqlnavstats", "main", engine="mssql") + _write_nav_style_database(project) + out = tmp_path / "out" + out.mkdir() + try: + ensure_testdb(project) + r = CliRunner().invoke( + app, ["update-stats", str(out), "--url", status(project)["dsn"], "--dialect", "mssql", "--exact"] + ) + assert r.exit_code == 0, r.output + tables = json.loads((out / "_stats" / "_tables.json").read_text()) + assert {f"dbo.{n}" for n in _NAV_TABLES} <= set(tables) + finally: + clean_testdb(project) diff --git a/tests/test_diff_mssql.py b/tests/test_diff_mssql.py index 14309aa..f538740 100644 --- a/tests/test_diff_mssql.py +++ b/tests/test_diff_mssql.py @@ -58,3 +58,23 @@ def test_diff_index_matches_equivalent_tsql_definitions(): db = DatabaseSchema(indexes={idx.qualified_name: idx}) diffs = compute_diff(scripts, db, dialect="mssql") assert diffs == [] + + +def test_norm_type_ignores_whitespace_inside_parameters(): + # sqlglot renders "decimal(38, 20)", sys.columns-based introspection "decimal(38,20)". + assert _norm_type("numeric(38, 20)", MSSQL) == _norm_type("decimal(38,20)", MSSQL) + + +def test_index_columns_compare_equal_whether_or_not_they_are_bracket_quoted(): + # A script generated by SSMS or a generator brackets every identifier; the + # catalog-built definition does too, but a hand-written script may not. + scripts = "create index [ix$1] on dbo.[company$cust ledger entry é] ([posting date], [flag])" + unquoted = "create index ix on dbo.t (a, b)" + assert _parse_index_def(scripts, MSSQL)["columns"] == ["posting date", "flag"] + assert _parse_index_def(unquoted, MSSQL)["columns"] == _parse_index_def("create index ix on dbo.t ([a], [b])", MSSQL)["columns"] + + +def test_index_where_clause_compares_equal_whether_or_not_bracket_quoted(): + quoted = _parse_index_def("create unique index ix on dbo.t ([a]) where ([b]>(1))", MSSQL) + plain = _parse_index_def("create unique index ix on dbo.t (a) where b > 1", MSSQL) + assert quoted["where_ast"] == plain["where_ast"] diff --git a/tests/testdb/test_api_mssql.py b/tests/testdb/test_api_mssql.py index 48cc268..40a8b94 100644 --- a/tests/testdb/test_api_mssql.py +++ b/tests/testdb/test_api_mssql.py @@ -3,7 +3,10 @@ import subprocess from pathlib import Path -from pgdevkit.testdb.api import shell_argv, status +import pytest + +from pgdevkit.testdb.api import ensure_testdb, restore_testdb, shell_argv, snapshot_testdb, status +from pgdevkit.testdb.mssql.api import _snapshot_db_name def _make_mssql_project(base: Path, name: str) -> Path: @@ -56,3 +59,29 @@ def test_shell_argv_uses_psql_for_postgres(tmp_path: Path): binary, argv = shell_argv(project) assert binary == "psql" assert argv[0] == "psql" + + +def test_snapshot_db_name_is_derived_from_the_source_db(): + assert _snapshot_db_name("proj_main", "template") == "proj_main__ss_template" + + +@pytest.mark.parametrize("name", ["", "a b", "x]; DROP DATABASE master;--", "é"]) +def test_snapshot_name_rejects_anything_but_word_characters(name: str): + with pytest.raises(ValueError, match="Snapshot name"): + _snapshot_db_name("proj_main", name) + + +def test_snapshot_and_hook_are_mssql_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PGDEVKIT_TESTDB_BRANCH", "main") + project = tmp_path / "pgproj" + project.mkdir() + (project / "pyproject.toml").write_text('[tool.pgdevkit]\nname = "pgproj"\n', encoding="utf-8") + + with pytest.raises(ValueError, match="mssql"): + snapshot_testdb(project) + with pytest.raises(ValueError, match="mssql"): + restore_testdb(project) + with pytest.raises(ValueError, match="mssql"): + ensure_testdb(project, apply=lambda conn: None) + with pytest.raises(ValueError, match="mssql"): + ensure_testdb(project, apply_scripts=False) diff --git a/tests/testdb/test_api_mssql_live.py b/tests/testdb/test_api_mssql_live.py index 377268b..df7d939 100644 --- a/tests/testdb/test_api_mssql_live.py +++ b/tests/testdb/test_api_mssql_live.py @@ -6,7 +6,15 @@ import pytest from pathlib import Path -from pgdevkit.testdb.api import clean_testdb, ensure_testdb, status +from pgdevkit.testdb.mssql import constants +from pgdevkit.testdb.api import ( + clean_testdb, + ensure_testdb, + find_orphaned_dbs, + restore_testdb, + snapshot_testdb, + status, +) from tests.testdb.conftest import requires_mssql # Selects these (live, container-requiring) tests in the dedicated GitHub @@ -64,3 +72,109 @@ def test_ensure_testdb_resolves_view_to_view_dependency(project_factory: Callabl assert rows == [(1, "sprocket")] finally: clean_testdb(project) + + +def _generated_ddl(conn) -> None: + cur = conn.cursor() + cur.execute("IF SCHEMA_ID('gen') IS NULL EXEC('CREATE SCHEMA gen')") + cur.execute("IF OBJECT_ID('gen.[Company$Cust_ Ledg_ Entry é]') IS NULL CREATE TABLE gen.[Company$Cust_ Ledg_ Entry é] (id int PRIMARY KEY)") + + +@requires_mssql +def test_apply_hook_runs_after_scripts(project_factory: Callable[..., Path]): + project = project_factory("mssqlhook1", "main", engine="mssql") + try: + ensure_testdb(project, apply=_generated_ddl) + ensure_testdb(project, apply=_generated_ddl) # idempotent hook, second call must not raise + + dsn = status(project)["dsn"] + assert _query(dsn, "SELECT count(*) FROM app.widget") == [(1,)] + assert _query(dsn, "SELECT count(*) FROM gen.[Company$Cust_ Ledg_ Entry é]") == [(0,)] + finally: + clean_testdb(project) + + +@requires_mssql +def test_apply_hook_can_replace_scripts(project_factory: Callable[..., Path]): + project = project_factory("mssqlhook2", "main", engine="mssql") + try: + ensure_testdb(project, apply=_generated_ddl, apply_scripts=False) + + dsn = status(project)["dsn"] + assert _query(dsn, "SELECT count(*) FROM sys.tables WHERE name = 'widget'") == [(0,)] + assert _query(dsn, "SELECT count(*) FROM gen.[Company$Cust_ Ledg_ Entry é]") == [(0,)] + finally: + clean_testdb(project) + + +@requires_mssql +def test_snapshot_restore_round_trip(project_factory: Callable[..., Path]): + project = project_factory("mssqlsnap1", "main", engine="mssql") + try: + ensure_testdb(project) + dsn = status(project)["dsn"] + snapshot_testdb(project) + + conn = mssql_python.connect(dsn, autocommit=True) # stays open: restore must kick it + conn.cursor().execute("DELETE FROM app.widget") + conn.cursor().execute("CREATE TABLE app.scratch (id int)") + + restore_testdb(project) + conn.close() + assert _query(dsn, "SELECT count(*) FROM app.widget") == [(1,)] + assert _query(dsn, "SELECT count(*) FROM sys.tables WHERE name = 'scratch'") == [(0,)] + + _query(dsn, "SELECT 1") + restore_testdb(project) # the snapshot survives a restore + finally: + clean_testdb(project) + + +@requires_mssql +def test_restore_without_snapshot_raises(project_factory: Callable[..., Path]): + project = project_factory("mssqlsnap2", "main", engine="mssql") + try: + ensure_testdb(project) + with pytest.raises(RuntimeError, match="no snapshot"): + restore_testdb(project) + finally: + clean_testdb(project) + + +@requires_mssql +def test_restore_refuses_when_other_snapshots_exist(project_factory: Callable[..., Path]): + project = project_factory("mssqlsnap3", "main", engine="mssql") + try: + ensure_testdb(project) + snapshot_testdb(project, "one") + snapshot_testdb(project, "two") + with pytest.raises(RuntimeError, match="sole snapshot"): + restore_testdb(project, "one") + finally: + clean_testdb(project) + + +@requires_mssql +def test_snapshot_is_not_an_orphan_and_goes_with_its_database(project_factory: Callable[..., Path]): + project = project_factory("mssqlsnap4", "main", engine="mssql") + try: + ensure_testdb(project) + snapshot = snapshot_testdb(project) + assert find_orphaned_dbs(project) == [] + assert _query(status(project)["dsn"], f"SELECT count(*) FROM sys.databases WHERE name = '{snapshot}'") == [(1,)] + finally: + clean_testdb(project) + assert _query(constants.conninfo("master"), f"SELECT count(*) FROM sys.databases WHERE name = '{snapshot}'") == [(0,)] + + +@requires_mssql +def test_force_reset_drops_snapshots(project_factory: Callable[..., Path]): + project = project_factory("mssqlsnap5", "main", engine="mssql") + try: + ensure_testdb(project) + snapshot_testdb(project) + ensure_testdb(project, force_reset=True) # must not trip over the dependent snapshot + with pytest.raises(RuntimeError, match="no snapshot"): + restore_testdb(project) + finally: + clean_testdb(project) diff --git a/tests/testdb/test_container_mssql.py b/tests/testdb/test_container_mssql.py index 339a84a..f4f1754 100644 --- a/tests/testdb/test_container_mssql.py +++ b/tests/testdb/test_container_mssql.py @@ -39,3 +39,21 @@ def test_create_container_passes_through_resource_overrides(monkeypatch): assert client.containers.run_kwargs["shm_size"] == "256m" assert client.containers.run_kwargs["mem_limit"] == "4g" assert client.containers.run_kwargs["nano_cpus"] == 2_000_000_000 + + +def test_create_container_omits_user_by_default(monkeypatch): + monkeypatch.setattr(constants, "CONTAINER_USER", "") + client = _FakeClient() + + _create_container(client) + + assert "user" not in client.containers.run_kwargs + + +def test_create_container_passes_container_user(monkeypatch): + monkeypatch.setattr(constants, "CONTAINER_USER", "0") + client = _FakeClient() + + _create_container(client) + + assert client.containers.run_kwargs["user"] == "0" diff --git a/tests/testdb/test_docker.py b/tests/testdb/test_docker.py index a465c9f..d2262c4 100644 --- a/tests/testdb/test_docker.py +++ b/tests/testdb/test_docker.py @@ -1,5 +1,9 @@ from __future__ import annotations +import docker.errors +import pytest + +from pgdevkit.testdb import _docker from pgdevkit.testdb._docker import _parse_tmpfs, resource_kwargs @@ -60,3 +64,150 @@ def test_resource_kwargs_cpus_converted_to_nano_cpus(): def test_resource_kwargs_shm_size_passed_through(): assert resource_kwargs(tmpfs="", shm_size="256m", mem_limit="", cpus="") == {"shm_size": "256m"} + + +class _Completed: + def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: + self.returncode, self.stdout, self.stderr = returncode, stdout, stderr + + +def _record_cli(monkeypatch, result: _Completed) -> list[tuple[list[str], dict | None]]: + calls: list[tuple[list[str], dict | None]] = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs.get("env"))) + return result + + monkeypatch.setattr(_docker.subprocess, "run", fake_run) + return calls + + +def test_cli_run_translates_kwargs_and_keeps_secrets_out_of_argv(monkeypatch): + calls = _record_cli(monkeypatch, _Completed()) + + _docker.CliContainers("podman").run( + "img:1", + name="c1", + detach=True, + ports={"1433/tcp": 14330}, + environment={"MSSQL_SA_PASSWORD": "s3cret!Pw"}, + user="0", + **_docker.resource_kwargs(tmpfs="/data:size=64m", shm_size="1g", mem_limit="2g", cpus="1.5"), + ) + + argv, env = calls[0] + assert argv == [ + "podman", "run", "--detach", "--name", "c1", + "--publish", "14330:1433/tcp", + "--env", "MSSQL_SA_PASSWORD", + "--user", "0", + "--tmpfs", "/data:size=64m", + "--shm-size", "1g", + "--memory", "2g", + "--memory-swap", "2g", + "--cpus", "1.5", + "img:1", + ] # fmt: skip + assert "s3cret!Pw" not in " ".join(argv) + assert env is not None and env["MSSQL_SA_PASSWORD"] == "s3cret!Pw" + + +def test_cli_run_appends_command_after_image(monkeypatch): + calls = _record_cli(monkeypatch, _Completed()) + + _docker.CliContainers("docker").run( + "pg:17", name="c", ports={"5432/tcp": 54322}, environment={}, command=["postgres", "-c", "fsync=off"] + ) + + assert calls[0][0][-4:] == ["pg:17", "postgres", "-c", "fsync=off"] + + +def test_cli_run_failure_raises_api_error_so_name_conflicts_are_handled(monkeypatch): + _record_cli(monkeypatch, _Completed(125, stderr='the container name "c" is already in use')) + + with pytest.raises(docker.errors.APIError, match="already in use"): + _docker.CliContainers("podman").run("img", name="c", ports={}, environment={}) + + +def test_cli_get_reports_status(monkeypatch): + calls = _record_cli(monkeypatch, _Completed(stdout="exited\n")) + + container = _docker.CliContainers("podman").get("c1") + + assert container.status == "exited" + assert calls[0][0] == ["podman", "inspect", "--format", "{{.State.Status}}", "c1"] + + +@pytest.mark.parametrize("message", ["Error: no such object: c1", "Error: No such object: c1"]) +def test_cli_get_missing_container_raises_not_found(monkeypatch, message): + _record_cli(monkeypatch, _Completed(1, stderr=message)) + + with pytest.raises(docker.errors.NotFound): + _docker.CliContainers("podman").get("c1") + + +def test_cli_get_other_failure_is_not_mistaken_for_missing(monkeypatch): + _record_cli(monkeypatch, _Completed(125, stderr="cannot connect to the engine")) + + with pytest.raises(RuntimeError, match="cannot connect"): + _docker.CliContainers("podman").get("c1") + + +def test_cli_container_start(monkeypatch): + calls = _record_cli(monkeypatch, _Completed()) + + _docker.CliContainer("podman", "c1", "exited").start() + + assert calls[0][0] == ["podman", "start", "c1"] + + +def test_client_forced_engine_skips_sdk(monkeypatch): + monkeypatch.setenv("PGDEVKIT_CONTAINER_ENGINE", "podman") + monkeypatch.setattr(_docker.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(_docker, "_sdk_client", lambda: pytest.fail("SDK must not be tried")) + + client = _docker.client() + + assert isinstance(client, _docker.CliClient) and client.engine == "podman" + + +def test_client_forced_engine_missing_cli_is_an_error(monkeypatch): + monkeypatch.setenv("PGDEVKIT_CONTAINER_ENGINE", "docker") + monkeypatch.setattr(_docker.shutil, "which", lambda name: None) + + with pytest.raises(RuntimeError, match="not on PATH"): + _docker.client() + + +def test_client_unknown_engine_is_rejected(monkeypatch): + monkeypatch.setenv("PGDEVKIT_CONTAINER_ENGINE", "nerdctl") + + with pytest.raises(ValueError, match="docker, podman or auto"): + _docker.client() + + +def test_client_prefers_sdk_when_reachable(monkeypatch): + monkeypatch.delenv("PGDEVKIT_CONTAINER_ENGINE", raising=False) + sentinel = object() + monkeypatch.setattr(_docker, "_sdk_client", lambda: sentinel) + + assert _docker.client() is sentinel + + +def test_client_falls_back_to_first_cli_on_path(monkeypatch): + monkeypatch.delenv("PGDEVKIT_CONTAINER_ENGINE", raising=False) + monkeypatch.setattr(_docker, "_sdk_client", lambda: None) + monkeypatch.setattr(_docker.shutil, "which", lambda name: "/usr/bin/docker" if name == "docker" else None) + + client = _docker.client() + + assert isinstance(client, _docker.CliClient) and client.engine == "docker" + + +def test_client_without_sdk_or_cli_is_an_error(monkeypatch): + monkeypatch.delenv("PGDEVKIT_CONTAINER_ENGINE", raising=False) + monkeypatch.setattr(_docker, "_sdk_client", lambda: None) + monkeypatch.setattr(_docker.shutil, "which", lambda name: None) + + with pytest.raises(RuntimeError, match="no `podman`/`docker` CLI"): + _docker.client() diff --git a/tests/testdb/test_naming.py b/tests/testdb/test_naming.py index 66cd380..e7a80c7 100644 --- a/tests/testdb/test_naming.py +++ b/tests/testdb/test_naming.py @@ -119,6 +119,41 @@ def test_live_worktree_branches_excludes_a_manually_deleted_worktree_dir(tmp_pat assert live_worktree_branches(repo) == ["main"] +def test_live_worktree_branches_finds_worktrees_nested_inside_the_repo(tmp_path: Path): + # Agent harnesses put worktrees under /.claude/worktrees/; the + # lookup must not care where a linked worktree lives, nor which of the + # worktrees it is called from. + repo = tmp_path / "repo" + _init_repo(repo, "main") + nested = repo / ".claude" / "worktrees" / "agent-1" + subprocess.run(["git", "worktree", "add", "-q", str(nested), "-b", "worktree/agent-1"], cwd=repo, check=True) + + assert set(live_worktree_branches(repo)) == {"main", "worktree/agent-1"} + assert set(live_worktree_branches(nested)) == {"main", "worktree/agent-1"} + assert current_branch(nested) == "worktree/agent-1" + + +def test_live_worktree_branches_keeps_a_locked_worktree_whose_path_is_missing(tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo, "main") + locked = tmp_path / "wt-locked" + subprocess.run(["git", "worktree", "add", "-q", str(locked), "-b", "locked-branch"], cwd=repo, check=True) + subprocess.run(["git", "worktree", "lock", "--reason", "on a removable drive", str(locked)], cwd=repo, check=True) + shutil.rmtree(locked) + + assert set(live_worktree_branches(repo)) == {"main", "locked-branch"} + + +def test_live_worktree_branches_keeps_a_locked_worktree_that_is_present(tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo, "main") + locked = tmp_path / "wt-locked" + subprocess.run(["git", "worktree", "add", "-q", str(locked), "-b", "locked-branch"], cwd=repo, check=True) + subprocess.run(["git", "worktree", "lock", str(locked)], cwd=repo, check=True) + + assert set(live_worktree_branches(repo)) == {"main", "locked-branch"} + + def test_expected_db_names_covers_every_live_branch(): config = ProjectConfig(name="proj") assert expected_db_names(config, ["main", "feature"]) == {"proj_main", "proj_feature"} diff --git a/tests/testdb/test_schema_mssql.py b/tests/testdb/test_schema_mssql.py index a0a0173..a619b08 100644 --- a/tests/testdb/test_schema_mssql.py +++ b/tests/testdb/test_schema_mssql.py @@ -33,3 +33,26 @@ def test_iter_sql_files_applies_schema_before_dependent_table_and_views(): Path("app/views/b_base_view.sql"), Path("app/views/a_wrapper_view.sql"), ] + + +def test_get_sql_deps_keeps_bracketed_nav_style_names_unquoted(): + # exp.table_name() would re-quote this as dbo."Company$Cust Ledger Entry é", + # which never equals the name derived from the file name. + sql = "CREATE VIEW dbo.[V] AS SELECT [a] FROM dbo.[Company$Cust Ledger Entry é] AS c" + assert _get_sql_deps(sql, MSSQL) == {"dbo.V", "dbo.Company$Cust Ledger Entry é"} + + +def test_iter_sql_files_orders_tables_before_views_with_nav_style_names(tmp_path: Path): + table = "Company$Cust_ Ledg_ Entry é" + tables = tmp_path / "dbo" / "tables" + views = tmp_path / "dbo" / "views" + tables.mkdir(parents=True) + views.mkdir(parents=True) + (tables / f"{table}.sql").write_text( + f"CREATE TABLE dbo.[{table}] ([a] int);\nCREATE INDEX [{table}$1] ON dbo.[{table}] ([a]);\n", encoding="utf-8" + ) + (views / "A View.sql").write_text(f"CREATE VIEW dbo.[A View] AS SELECT [a] FROM dbo.[{table}];\n", encoding="utf-8") + + order = [f.relative_to(tmp_path) for f, _ in _iter_sql_files(tmp_path, MSSQL)] + + assert order == [Path("dbo/tables") / f"{table}.sql", Path("dbo/views/A View.sql")] diff --git a/uv.lock b/uv.lock index c0dd63c..414f298 100644 --- a/uv.lock +++ b/uv.lock @@ -451,7 +451,7 @@ wheels = [ [[package]] name = "pgdevkit" -version = "0.17.1" +version = "0.18.0" source = { editable = "." } dependencies = [ { name = "docker" },