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
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ See `make help` for the full list of targets.
- **Style:** Ruff handles linting and formatting (config in `pyproject.toml`).
- **Tests:** Add or update tests for any behavioral change.

#### Running tests

```bash
uv run pytest tests/ # Unit tests (no database)
uv run pytest tests/ --db-url "postgresql://user:pass@host/db" # + integration tests
```

Integration tests (`@pytest.mark.db`) are skipped when `--db-url` is not provided.

### TUI

- **Style:** `gofmt` for formatting, `golangci-lint` for linting.
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ select = ["E", "F", "I", "W"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
"db: database integration tests (require --db-url)",
]

[dependency-groups]
dev = [
Expand Down
53 changes: 35 additions & 18 deletions src/skene/analyzers/schema_parsers/postgres_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,21 @@ def introspect_db(db_url: str, *, connect_timeout: int = 10) -> SchemaIndex:
A complete PostgreSQL connection string (libpq URL or keyword DSN).
connect_timeout:
Seconds to wait for the TCP connection to be established.

Raises
------
psycopg.Error
Connection or query errors are re-raised with the connection string
redacted. The password never appears in the exception message.
"""
index = SchemaIndex()

with psycopg.connect(db_url, connect_timeout=connect_timeout, row_factory=dict_row) as conn:
try:
conn = psycopg.connect(db_url, connect_timeout=connect_timeout, row_factory=dict_row)
except psycopg.Error as e:
raise psycopg.Error("Database connection failed") from e

with conn:
tables_by_file: dict[str, dict[str, TableInfo]] = {}

# --- 0. Discover user schemas (exclude pg_*, information_schema) ---
Expand Down Expand Up @@ -137,24 +148,30 @@ def introspect_db(db_url: str, *, connect_timeout: int = 10) -> SchemaIndex:
for row in cur.fetchall():
cols_by_table.setdefault((row["schema_name"], row["table_name"]), []).append(row)

# 2c. Foreign keys
# 2c. Foreign keys — use pg_constraint directly so we can pair
# referencing columns (conkey) with referenced columns (confkey)
# by their position in the array.
fk_query = """\
SELECT tc.table_schema AS schema_name,
tc.table_name,
array_agg(kcu.column_name ORDER BY kcu.ordinal_position) AS columns,
ccu.table_schema AS references_schema,
ccu.table_name AS references_table,
array_agg(ccu.column_name ORDER BY kcu.ordinal_position) AS references_columns
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = ANY(%s)
AND tc.table_schema = ANY(%s)
GROUP BY tc.table_schema, tc.table_name, ccu.table_schema, ccu.table_name
SELECT nsp.nspname AS schema_name,
cl.relname AS table_name,
array_agg(att.attname ORDER BY sub) AS columns,
ref_nsp.nspname AS references_schema,
ref_cl.relname AS references_table,
array_agg(ref_att.attname ORDER BY sub) AS references_columns
FROM pg_constraint fk
JOIN pg_class cl ON cl.oid = fk.conrelid
JOIN pg_namespace nsp ON nsp.oid = cl.relnamespace
JOIN LATERAL generate_subscripts(fk.conkey, 1) AS sub ON true
JOIN pg_attribute att ON att.attrelid = cl.oid
AND att.attnum = fk.conkey[sub]
JOIN pg_class ref_cl ON ref_cl.oid = fk.confrelid
JOIN pg_namespace ref_nsp ON ref_nsp.oid = ref_cl.relnamespace
JOIN pg_attribute ref_att ON ref_att.attrelid = ref_cl.oid
AND ref_att.attnum = fk.confkey[sub]
WHERE fk.contype = 'f'
AND cl.relname = ANY(%s)
AND nsp.nspname = ANY(%s)
GROUP BY nsp.nspname, cl.relname, ref_nsp.nspname, ref_cl.relname
"""
with conn.cursor() as cur:
cur.execute(fk_query, (table_names, user_schemas))
Expand Down
2 changes: 1 addition & 1 deletion src/skene/cli/commands/analyse_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ def analyse_journey_cmd(
status(f"Introspecting database: {db_display}")
try:
live_schema_index = introspect_db(db_url)
except Exception as e: # noqa: BLE001 — never leak connection details
except Exception as e: # noqa: BLE001 — message already sanitized by introspect_db
error(f"Failed to introspect database: {e}")
raise typer.Exit(1) from e
status(f"Introspection complete: {sum(len(t) for t in live_schema_index.files.values())} tables found")
Expand Down
33 changes: 33 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
"""Shared pytest fixtures for skene tests."""

from pathlib import Path
from typing import Any

import psycopg
import pytest

from skene.codebase import CodebaseExplorer


def pytest_addoption(parser: pytest.Parser) -> None:
"""Add --db-url option for live PostgreSQL integration tests."""
parser.addoption(
"--db-url",
action="store",
default=None,
help="PostgreSQL connection string for live database tests (e.g. postgresql://user:pass@localhost/db). "
"Tests marked with @pytest.mark.db are skipped when not provided.",
)


@pytest.fixture
def fixtures_path() -> Path:
"""Path to the test fixtures directory."""
Expand All @@ -23,3 +36,23 @@ def sample_repo_path(fixtures_path: Path) -> Path:
def codebase_explorer(sample_repo_path: Path) -> CodebaseExplorer:
"""CodebaseExplorer instance for the sample repository."""
return CodebaseExplorer(sample_repo_path)


@pytest.fixture
def pg_conn(request: pytest.FixtureRequest) -> psycopg.Connection[Any] | None:
"""Return a live psycopg connection when --db-url is provided, else None.

The connection object carries a ``._db_url`` attribute so tests can pass
the original URL back into :func:`introspect_db`::

def test_something(self, pg_conn):
pytest.skip("no --db-url") if pg_conn is None else None
index = introspect_db(pg_conn._db_url)
# ... use pg_conn for setup/teardown ...
"""
db_url = request.config.getoption("--db-url", default=None)
if db_url is None:
return None
conn = psycopg.connect(db_url)
conn._db_url = db_url # type: ignore[attr-defined]
return conn
Loading
Loading