Add Postgres table adapter with TableRepositoryInterface.
PR Title Check / check-title (pull_request) Successful in 7s
Code Quality Pipeline / code-quality (pull_request) Failing after 19s
Test Python Package / unit-tests (pull_request) Successful in 47s
Test Python Package / integration-tests (pull_request) Successful in 44s
Test Python Package / coverage-report (pull_request) Successful in 11s
PR Title Check / check-title (pull_request) Successful in 7s
Code Quality Pipeline / code-quality (pull_request) Failing after 19s
Test Python Package / unit-tests (pull_request) Successful in 47s
Test Python Package / integration-tests (pull_request) Successful in 44s
Test Python Package / coverage-report (pull_request) Successful in 11s
Introduce PostgresAdapter for dict-based row CRUD via psycopg3, including config, lazy exports, unit/integration tests with testcontainers, and an example UserTableRepository. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
co-authored by
Cursor
parent
f28d3c4844
commit
dc6f8a3e89
@@ -0,0 +1,76 @@
|
||||
"""Postgres container session helpers for integration tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
|
||||
from python_repositories.config import PostgresConfig
|
||||
|
||||
POSTGRES_IMAGE = "postgres:16"
|
||||
TEST_TABLE = "test_items"
|
||||
|
||||
|
||||
_postgres_uri: str | None = None
|
||||
_postgres_container: PostgresContainer | None = None
|
||||
_raw_postgres_client: psycopg.Connection | None = None
|
||||
_postgres_uri_refs = 0
|
||||
_raw_postgres_client_refs = 0
|
||||
|
||||
|
||||
def postgres_uri() -> Generator[str, None, None]:
|
||||
"""Yield a session-scoped Postgres URI, starting the container once."""
|
||||
global _postgres_uri, _postgres_container, _postgres_uri_refs
|
||||
if _postgres_uri is None:
|
||||
_postgres_container = PostgresContainer(POSTGRES_IMAGE, driver=None)
|
||||
_postgres_container.start()
|
||||
_postgres_uri = _postgres_container.get_connection_url()
|
||||
|
||||
_postgres_uri_refs += 1
|
||||
yield _postgres_uri
|
||||
_postgres_uri_refs -= 1
|
||||
|
||||
if _postgres_uri_refs == 0 and _postgres_container is not None:
|
||||
_postgres_container.stop()
|
||||
_postgres_container = None
|
||||
_postgres_uri = None
|
||||
|
||||
|
||||
def postgres_config_from_container(uri: str) -> PostgresConfig:
|
||||
"""Build PostgresConfig from a container URI."""
|
||||
return PostgresConfig(uri=uri, table=TEST_TABLE, primary_key="id")
|
||||
|
||||
|
||||
def raw_postgres_client_from_container(
|
||||
uri: str,
|
||||
) -> Generator[psycopg.Connection, None, None]:
|
||||
"""Yield a session-scoped raw Postgres client, reusing one client per session."""
|
||||
global _raw_postgres_client, _raw_postgres_client_refs
|
||||
if _raw_postgres_client is None:
|
||||
_raw_postgres_client = psycopg.connect(
|
||||
uri,
|
||||
row_factory=dict_row, # type: ignore[arg-type]
|
||||
autocommit=True,
|
||||
)
|
||||
with _raw_postgres_client.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {TEST_TABLE} (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
value INTEGER,
|
||||
email TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
_raw_postgres_client_refs += 1
|
||||
yield _raw_postgres_client
|
||||
_raw_postgres_client_refs -= 1
|
||||
|
||||
if _raw_postgres_client_refs == 0 and _raw_postgres_client is not None:
|
||||
with _raw_postgres_client.cursor() as cursor:
|
||||
cursor.execute(f"TRUNCATE TABLE {TEST_TABLE}")
|
||||
_raw_postgres_client.close()
|
||||
_raw_postgres_client = None
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Postgres integration test fixtures."""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from python_repositories.config import PostgresConfig
|
||||
from tests.integration.postgres._containers import (
|
||||
postgres_config_from_container,
|
||||
postgres_uri,
|
||||
raw_postgres_client_from_container,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def postgres_container() -> Generator[str, None, None]:
|
||||
"""Set up a Postgres container for testing and yield the Postgres URI."""
|
||||
yield from postgres_uri()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def postgres_config(postgres_container: str) -> PostgresConfig:
|
||||
"""Provide PostgresConfig built from the test container."""
|
||||
return postgres_config_from_container(postgres_container)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def raw_postgres_client(
|
||||
postgres_container: str,
|
||||
) -> Generator[psycopg.Connection, None, None]:
|
||||
"""Provide a raw Postgres client connected to the test container."""
|
||||
yield from raw_postgres_client_from_container(postgres_container)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Integration tests for the PostgresAdapter."""
|
||||
|
||||
from collections.abc import Generator
|
||||
import logging
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from python_repositories.adapters.postgres_adapter import PostgresAdapter
|
||||
from python_repositories.config import PostgresConfig
|
||||
from tests.integration.postgres._containers import TEST_TABLE
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.needs_postgres]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def row() -> Generator[dict[str, object], None, None]:
|
||||
"""Provide a sample row for tests."""
|
||||
yield {"id": "test-id", "name": "Alice", "value": 42}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def row_in_postgres(
|
||||
raw_postgres_client: psycopg.Connection,
|
||||
row: dict[str, object],
|
||||
) -> Generator[tuple[str, dict[str, object]], None, None]:
|
||||
"""Fixture to set up a known row in Postgres before each test."""
|
||||
with raw_postgres_client.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"INSERT INTO {TEST_TABLE} (id, name, value) VALUES (%s, %s, %s)",
|
||||
(row["id"], row["name"], row["value"]),
|
||||
)
|
||||
|
||||
yield str(row["id"]), row
|
||||
|
||||
with raw_postgres_client.cursor() as cursor:
|
||||
cursor.execute(f"DELETE FROM {TEST_TABLE} WHERE id = %s", (row["id"],))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def postgres_adapter(
|
||||
postgres_config: PostgresConfig,
|
||||
raw_postgres_client: psycopg.Connection,
|
||||
) -> Generator[PostgresAdapter, None, None]:
|
||||
"""Fixture to provide a connected PostgresAdapter instance."""
|
||||
_ = raw_postgres_client # ensure test table exists before connect probe
|
||||
adapter = PostgresAdapter(config=postgres_config)
|
||||
adapter.connect()
|
||||
yield adapter
|
||||
adapter.disconnect()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def clear_postgres(raw_postgres_client: psycopg.Connection) -> None:
|
||||
"""Fixture to clear all rows before each test."""
|
||||
with raw_postgres_client.cursor() as cursor:
|
||||
cursor.execute(f"TRUNCATE TABLE {TEST_TABLE}")
|
||||
|
||||
|
||||
def test_should_log_info_when_already_connected(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter logs info when connect is called while connected."""
|
||||
with caplog.at_level(logging.INFO):
|
||||
postgres_adapter.connect()
|
||||
assert "Already connected" in caplog.text
|
||||
assert "Postgres" in caplog.text
|
||||
|
||||
|
||||
def test_should_raise_connection_error_when_unable_to_connect() -> None:
|
||||
"""Test that PostgresAdapter raises ConnectionError when unable to connect."""
|
||||
adapter = PostgresAdapter(
|
||||
config=PostgresConfig(
|
||||
uri="postgresql://invalid:5432/nodb",
|
||||
table=TEST_TABLE,
|
||||
)
|
||||
)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.connect()
|
||||
assert adapter._client is None
|
||||
assert not adapter.is_connected()
|
||||
|
||||
|
||||
def test_should_raise_connection_error_when_table_missing(
|
||||
postgres_config: PostgresConfig,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter raises ConnectionError when the table is missing."""
|
||||
config = PostgresConfig(
|
||||
uri=postgres_config.uri,
|
||||
table="missing_table",
|
||||
primary_key="id",
|
||||
)
|
||||
adapter = PostgresAdapter(config=config)
|
||||
with pytest.raises(ConnectionError, match="does not exist"):
|
||||
adapter.connect()
|
||||
|
||||
|
||||
def test_should_log_error_on_exception_during_exit(
|
||||
postgres_config: PostgresConfig,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter logs an error when an exception occurs during exit."""
|
||||
try:
|
||||
with PostgresAdapter(config=postgres_config) as adapter:
|
||||
assert adapter.is_connected()
|
||||
raise ValueError("Simulated error")
|
||||
except ValueError:
|
||||
pass
|
||||
assert "Error while exiting context" in caplog.text
|
||||
|
||||
|
||||
def test_should_have_context_manager(postgres_config: PostgresConfig) -> None:
|
||||
"""Test that PostgresAdapter can be used as a context manager."""
|
||||
with PostgresAdapter(config=postgres_config) as adapter:
|
||||
assert adapter._client is not None
|
||||
assert adapter._client is None
|
||||
|
||||
|
||||
def test_should_fetch_one(
|
||||
row_in_postgres: tuple[str, dict[str, object]],
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter can fetch a row by primary key."""
|
||||
pk, row = row_in_postgres
|
||||
fetched = postgres_adapter.fetch_one(pk)
|
||||
assert fetched is not None
|
||||
assert fetched["id"] == row["id"]
|
||||
assert fetched["name"] == row["name"]
|
||||
assert fetched["value"] == row["value"]
|
||||
|
||||
|
||||
def test_should_fetch_none_for_missing_row(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that fetching a non-existent row returns None."""
|
||||
assert postgres_adapter.fetch_one("missing-id") is None
|
||||
|
||||
|
||||
def test_should_raise_value_error_on_invalid_fetch_one_pk(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter raises ValueError for an invalid primary key."""
|
||||
with pytest.raises(ValueError):
|
||||
postgres_adapter.fetch_one(None)
|
||||
|
||||
|
||||
def test_should_raise_connection_error_on_fetch_one_when_not_connected(
|
||||
postgres_config: PostgresConfig,
|
||||
) -> None:
|
||||
"""Test that fetch_one raises ConnectionError when not connected."""
|
||||
adapter = PostgresAdapter(config=postgres_config)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.fetch_one("some-id")
|
||||
|
||||
|
||||
def test_should_upsert_row(
|
||||
row: dict[str, object],
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter can insert a row."""
|
||||
assert postgres_adapter.fetch_one(row["id"]) is None
|
||||
postgres_adapter.upsert(row)
|
||||
fetched = postgres_adapter.fetch_one(row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["name"] == row["name"]
|
||||
|
||||
|
||||
def test_should_update_row(
|
||||
row_in_postgres: tuple[str, dict[str, object]],
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter can update an existing row."""
|
||||
pk, _ = row_in_postgres
|
||||
new_row = {"id": pk, "name": "Bob", "value": 99}
|
||||
postgres_adapter.upsert(new_row)
|
||||
fetched = postgres_adapter.fetch_one(pk)
|
||||
assert fetched is not None
|
||||
assert fetched["name"] == "Bob"
|
||||
assert fetched["value"] == 99
|
||||
|
||||
|
||||
def test_should_raise_value_error_on_invalid_upsert_row(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter raises ValueError for invalid upsert data."""
|
||||
with pytest.raises(ValueError):
|
||||
postgres_adapter.upsert({"name": "Alice"}) # missing primary key
|
||||
|
||||
|
||||
def test_should_raise_value_error_on_non_dict_upsert_row(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter raises ValueError when upsert row is not a dict."""
|
||||
with pytest.raises(ValueError):
|
||||
postgres_adapter.upsert("not-a-dict") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_should_raise_connection_error_on_upsert_when_not_connected(
|
||||
postgres_config: PostgresConfig,
|
||||
row: dict[str, object],
|
||||
) -> None:
|
||||
"""Test that upsert raises ConnectionError when not connected."""
|
||||
adapter = PostgresAdapter(config=postgres_config)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.upsert(row)
|
||||
|
||||
|
||||
def test_should_fetch_all_rows(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter can fetch all rows."""
|
||||
postgres_adapter.upsert({"id": "a", "name": "Alice", "value": 1})
|
||||
postgres_adapter.upsert({"id": "b", "name": "Bob", "value": 2})
|
||||
rows = postgres_adapter.fetch_all()
|
||||
assert len(rows) == 2
|
||||
ids = {row["id"] for row in rows}
|
||||
assert ids == {"a", "b"}
|
||||
|
||||
|
||||
def test_should_fetch_all_with_limit(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter respects fetch_all limit."""
|
||||
postgres_adapter.upsert({"id": "a", "name": "Alice", "value": 1})
|
||||
postgres_adapter.upsert({"id": "b", "name": "Bob", "value": 2})
|
||||
rows = postgres_adapter.fetch_all(limit=1)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_should_return_empty_list_for_empty_table(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that fetch_all returns an empty list for an empty table."""
|
||||
assert postgres_adapter.fetch_all() == []
|
||||
|
||||
|
||||
def test_should_raise_value_error_on_invalid_fetch_all_limit(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter raises ValueError for an invalid limit."""
|
||||
with pytest.raises(ValueError):
|
||||
postgres_adapter.fetch_all(limit=-1)
|
||||
|
||||
|
||||
def test_should_raise_connection_error_on_fetch_all_when_not_connected(
|
||||
postgres_config: PostgresConfig,
|
||||
) -> None:
|
||||
"""Test that fetch_all raises ConnectionError when not connected."""
|
||||
adapter = PostgresAdapter(config=postgres_config)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.fetch_all()
|
||||
|
||||
|
||||
def test_should_delete_row(
|
||||
row_in_postgres: tuple[str, dict[str, object]],
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that deleting a row removes it from the table."""
|
||||
pk, _ = row_in_postgres
|
||||
assert postgres_adapter.fetch_one(pk) is not None
|
||||
postgres_adapter.delete(pk)
|
||||
assert postgres_adapter.fetch_one(pk) is None
|
||||
|
||||
|
||||
def test_should_delete_idempotently(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that deleting a missing row does not raise."""
|
||||
postgres_adapter.delete("missing-id")
|
||||
|
||||
|
||||
def test_should_raise_value_error_on_invalid_delete_pk(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter raises ValueError for an invalid delete pk."""
|
||||
with pytest.raises(ValueError):
|
||||
postgres_adapter.delete(None)
|
||||
|
||||
|
||||
def test_should_raise_connection_error_on_delete_when_not_connected(
|
||||
postgres_config: PostgresConfig,
|
||||
) -> None:
|
||||
"""Test that delete raises ConnectionError when not connected."""
|
||||
adapter = PostgresAdapter(config=postgres_config)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.delete("some-id")
|
||||
|
||||
|
||||
def test_should_execute_sql(
|
||||
row_in_postgres: tuple[str, dict[str, object]],
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter can execute read SQL."""
|
||||
pk, _ = row_in_postgres
|
||||
rows = postgres_adapter.execute(
|
||||
f"SELECT * FROM {TEST_TABLE} WHERE id = %s",
|
||||
(pk,),
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["id"] == pk
|
||||
|
||||
|
||||
def test_should_raise_value_error_on_invalid_execute_sql(
|
||||
postgres_adapter: PostgresAdapter,
|
||||
) -> None:
|
||||
"""Test that PostgresAdapter raises ValueError for invalid SQL."""
|
||||
with pytest.raises(ValueError):
|
||||
postgres_adapter.execute("")
|
||||
|
||||
|
||||
def test_should_raise_connection_error_on_execute_when_not_connected(
|
||||
postgres_config: PostgresConfig,
|
||||
) -> None:
|
||||
"""Test that execute raises ConnectionError when not connected."""
|
||||
adapter = PostgresAdapter(config=postgres_config)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.execute("SELECT 1")
|
||||
Reference in New Issue
Block a user