| """Schema inspection for the campus PostgreSQL database. |
| |
| Two modes, in preference order: |
| |
| live pg_dump --schema-only against the configured DSN (read-only |
| operation) plus migration status from the migrations table |
| snapshot a schema_snapshot.sql on disk in the context dir |
| |
| Rivet never runs DDL. This module *inspects* only — the argv allowlist |
| in tools.guard has no path to a writing psql invocation because every |
| psql call here is built with a fixed read-only query. |
| """ |
|
|
| import re |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| from tools.guard import run_checked |
|
|
|
|
| @dataclass |
| class SchemaInfo: |
| source: str |
| table_count: int = 0 |
| tables: list = None |
| migration_status: str = "" |
| raw_available: bool = False |
| error: str = "" |
|
|
|
|
| class SchemaTools: |
| def __init__(self, context_dir: Path, dsn: str = "", |
| migrations_dir: str = ""): |
| self.context_dir = Path(context_dir) |
| self.dsn = dsn |
| self.migrations_dir = migrations_dir |
| self.snapshot_path = self.context_dir / "schema_snapshot.sql" |
|
|
| |
|
|
| def inspect(self) -> SchemaInfo: |
| if self.dsn: |
| info = self._inspect_live() |
| if not info.error: |
| return info |
| return self._inspect_snapshot() |
|
|
| def refresh_snapshot(self) -> SchemaInfo: |
| """Pull a fresh schema-only dump to the context dir (live mode).""" |
| if not self.dsn: |
| return SchemaInfo(source="none", error="no DSN configured") |
| result = run_checked( |
| ["pg_dump", "--schema-only", "--no-owner", "--no-privileges", |
| self.dsn], |
| timeout=120, |
| ) |
| if not result.ok: |
| return SchemaInfo(source="none", |
| error=result.stderr or result.blocked_reason) |
| self.snapshot_path.write_text(result.stdout) |
| return self._inspect_snapshot() |
|
|
| def table_definition(self, table: str) -> str: |
| """Extract one table's definition from the snapshot.""" |
| if not self.snapshot_path.exists(): |
| return "" |
| text = self.snapshot_path.read_text() |
| pattern = (r"CREATE TABLE[^;]*?\b" + re.escape(table) + r"\b[^;]*?;") |
| m = re.search(pattern, text, re.IGNORECASE | re.DOTALL) |
| return m.group(0) if m else "" |
|
|
| |
|
|
| def _inspect_live(self) -> SchemaInfo: |
| result = run_checked( |
| ["psql", self.dsn, "-tAc", |
| "SELECT tablename FROM pg_tables WHERE schemaname='public' " |
| "ORDER BY tablename"], |
| timeout=30, |
| ) |
| if not result.ok: |
| return SchemaInfo(source="live", |
| error=result.stderr or result.blocked_reason) |
| tables = [t for t in result.stdout.splitlines() if t.strip()] |
| return SchemaInfo( |
| source="live", table_count=len(tables), tables=tables, |
| migration_status=self._migration_status_live(), |
| raw_available=self.snapshot_path.exists(), |
| ) |
|
|
| def _migration_status_live(self) -> str: |
| applied = run_checked( |
| ["psql", self.dsn, "-tAc", |
| "SELECT count(*) FROM migrations"], |
| timeout=15, |
| ) |
| applied_count = applied.stdout.strip() if applied.ok else "?" |
| on_disk = "?" |
| if self.migrations_dir and Path(self.migrations_dir).is_dir(): |
| on_disk = str(len(list(Path(self.migrations_dir).glob("*.sql")))) |
| return f"applied={applied_count} on_disk={on_disk}" |
|
|
| def _inspect_snapshot(self) -> SchemaInfo: |
| if not self.snapshot_path.exists(): |
| return SchemaInfo( |
| source="none", |
| error=("no live DSN and no schema snapshot — schema claims " |
| "will be LOW confidence"), |
| ) |
| text = self.snapshot_path.read_text() |
| tables = re.findall( |
| r"CREATE TABLE(?:\s+IF NOT EXISTS)?\s+(?:public\.)?([\w\"]+)", |
| text, re.IGNORECASE, |
| ) |
| tables = [t.strip('"') for t in tables] |
| return SchemaInfo( |
| source="snapshot", table_count=len(tables), |
| tables=sorted(set(tables)), raw_available=True, |
| ) |
|
|