From 8cc66b526192d3181df3f15a8fdb19af496c4708 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 7 Apr 2026 01:18:04 +0800 Subject: [PATCH 1/8] chore: bump version to 1.0.0.dev9 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8aeec914..d1c14529 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rhosocial_activerecord_postgres" -version = "1.0.0.dev8" +version = "1.0.0.dev9" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } From 81e95bbc9589ac2f16312326f2d23d9f40b51885 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:02:27 +0800 Subject: [PATCH 2/8] feat(connection): add connection pool context awareness support (#19) * test(connection): add concurrent connection pool tests Add comprehensive tests for connection pool concurrency: - Test concurrent connections are distinct - Test concurrent operations don't cause deadlocks - Test query results don't get mixed between connections - Test concurrent transactions are properly isolated - Add stress tests for high concurrent load Both sync (BackendPool) and async (AsyncBackendPool) tests are included. * feat: add connection pool context awareness providers Implement IBasicConnectionProvider and IQueryConnectionProvider for PostgreSQL backend to support testsuite connection pool tests. - Add BasicConnectionProvider for feature.basic.connection tests - Add QueryConnectionProvider for feature.query.connection tests - Register both providers in registry.py * feat: add testsuite connection pool context awareness tests Import tests from testsuite for connection pool context awareness: - feature/basic/connection/ - ActiveRecord context and CRUD tests - feature/query/connection/ - ActiveQuery, CTEQuery, SetOperationQuery tests These tests use the provider pattern to run backend-agnostic tests defined in testsuite. * ci: install dependencies from git instead of PyPI Change rhosocial-activerecord to git+https@release/v1.0.0.dev22 Change rhosocial-activerecord-testsuite to git+https@release/v1.0.0.dev9 * chore: add changelog fragment for PR #19 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/test.yml | 6 +- changelog.d/19.added.md | 1 + tests/providers/basic_connection.py | 161 +++++ tests/providers/query_connection.py | 153 +++++ tests/providers/registry.py | 6 + .../feature/basic/connection/conftest.py | 8 + .../connection/test_active_record_context.py | 14 + .../connection/test_active_record_crud.py | 14 + .../feature/connection/conftest.py | 277 +++++++++ .../test_connection_pool_concurrent.py | 570 ++++++++++++++++++ .../feature/query/connection/conftest.py | 8 + .../connection/test_active_query_context.py | 14 + .../connection/test_cte_query_context.py | 14 + .../connection/test_set_operation_context.py | 14 + 14 files changed, 1257 insertions(+), 3 deletions(-) create mode 100644 changelog.d/19.added.md create mode 100644 tests/providers/basic_connection.py create mode 100644 tests/providers/query_connection.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d09f635e..b6012bd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,10 +65,10 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Install activerecord and testsuite from PyPI + - name: Install activerecord and testsuite from git run: | - pip install rhosocial-activerecord==1.0.0.dev21 - pip install rhosocial-activerecord-testsuite==1.0.0.dev8 + pip install git+https://github.com/rhosocial/python-activerecord@release/v1.0.0.dev22 + pip install git+https://github.com/rhosocial/python-activerecord-testsuite@release/v1.0.0.dev9 - name: Create postgres scenario config run: | diff --git a/changelog.d/19.added.md b/changelog.d/19.added.md new file mode 100644 index 00000000..0bf77e96 --- /dev/null +++ b/changelog.d/19.added.md @@ -0,0 +1 @@ +Added connection pool context awareness support for PostgreSQL backend, enabling proper connection management across sync/async contexts and transaction boundaries. diff --git a/tests/providers/basic_connection.py b/tests/providers/basic_connection.py new file mode 100644 index 00000000..c28dcb75 --- /dev/null +++ b/tests/providers/basic_connection.py @@ -0,0 +1,161 @@ +# tests/providers/basic_connection.py +""" +Concrete implementation of IBasicConnectionProvider for PostgreSQL backend. + +This provider sets up connection pools and models for testing +ActiveRecord context awareness. +""" +from typing import Type, Tuple, Optional, List + +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.connection.pool import BackendPool, AsyncBackendPool, PoolConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +from rhosocial.activerecord.testsuite.feature.basic.connection.interfaces import IBasicConnectionProvider +from .scenarios import get_scenario, get_enabled_scenarios + + +class SyncTestUser(ActiveRecord): + """Sync test user model for connection pool tests.""" + __table_name__ = "test_users" + id: Optional[int] = None + name: str + email: str + + +class AsyncTestUser(AsyncActiveRecord): + """Async test user model for connection pool tests.""" + __table_name__ = "test_users" + id: Optional[int] = None + name: str + email: str + + +class BasicConnectionProvider(IBasicConnectionProvider): + """ + PostgreSQL backend implementation for basic connection pool context tests. + """ + + def __init__(self): + self._active_backends: List = [] + self._active_async_backends: List = [] + + def get_test_scenarios(self) -> list: + """Returns available test scenarios.""" + return list(get_enabled_scenarios().keys()) + + def _create_test_table(self, backend): + """Create the test_users table.""" + try: + backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) + except Exception: + pass + + backend.execute(""" + CREATE TABLE test_users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL + ) + """, options=ExecutionOptions(stmt_type=StatementType.DDL)) + + async def _create_test_table_async(self, backend): + """Create the test_users table asynchronously.""" + try: + await backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) + except Exception: + pass + + await backend.execute(""" + CREATE TABLE test_users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL + ) + """, options=ExecutionOptions(stmt_type=StatementType.DDL)) + + def setup_sync_pool_and_model(self, scenario_name: str) -> Tuple[BackendPool, Type[ActiveRecord]]: + """Setup sync connection pool and model for context tests.""" + _, config = get_scenario(scenario_name) + + # Create connection pool + pool_config = PoolConfig( + min_size=1, + max_size=5, + backend_factory=lambda: PostgresBackend(connection_config=config) + ) + pool = BackendPool.create(pool_config) + + # Create table + with pool.connection() as backend: + self._create_test_table(backend) + self._active_backends.append(backend) + + # Configure model to use a separate backend (not pool backend) + # This tests that model.backend() returns pool context backend + SyncTestUser.configure(config, PostgresBackend) + self._active_backends.append(SyncTestUser.__backend__) + + return pool, SyncTestUser + + async def setup_async_pool_and_model(self, scenario_name: str) -> Tuple[AsyncBackendPool, Type[AsyncActiveRecord]]: + """Setup async connection pool and model for context tests.""" + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + _, config = get_scenario(scenario_name) + + # Create connection pool + pool_config = PoolConfig( + min_size=1, + max_size=5, + backend_factory=lambda: AsyncPostgresBackend(connection_config=config) + ) + + # Create pool + pool = await AsyncBackendPool.create(pool_config) + + # Create table + async with pool.connection() as backend: + await self._create_test_table_async(backend) + self._active_async_backends.append(backend) + + # Configure model + await AsyncTestUser.configure(config, AsyncPostgresBackend) + self._active_async_backends.append(AsyncTestUser.__backend__) + + return pool, AsyncTestUser + + def cleanup_sync(self, scenario_name: str, pool: BackendPool): + """Cleanup after sync tests.""" + pool.close(timeout=1.0) + + # Disconnect backends + for backend in self._active_backends: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + + async def cleanup_async(self, scenario_name: str, pool: AsyncBackendPool): + """Cleanup after async tests.""" + await pool.close(timeout=1.0) + + # Disconnect backends + for backend in self._active_async_backends: + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() + + def setup_sync_pool_for_crud(self, scenario_name: str) -> Tuple[BackendPool, Type[ActiveRecord]]: + """Setup sync connection pool for CRUD tests.""" + return self.setup_sync_pool_and_model(scenario_name) + + async def setup_async_pool_for_crud(self, scenario_name: str) -> Tuple[AsyncBackendPool, Type[AsyncActiveRecord]]: + """Setup async connection pool for CRUD tests.""" + return await self.setup_async_pool_and_model(scenario_name) diff --git a/tests/providers/query_connection.py b/tests/providers/query_connection.py new file mode 100644 index 00000000..985d390e --- /dev/null +++ b/tests/providers/query_connection.py @@ -0,0 +1,153 @@ +# tests/providers/query_connection.py +""" +Concrete implementation of IQueryConnectionProvider for PostgreSQL backend. + +This provider sets up connection pools and models for testing +query classes context awareness (ActiveQuery, CTEQuery, SetOperationQuery). +""" +from typing import Type, Tuple, Optional, List + +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.connection.pool import BackendPool, AsyncBackendPool, PoolConfig +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +from rhosocial.activerecord.testsuite.feature.query.connection.interfaces import IQueryConnectionProvider +from .scenarios import get_scenario, get_enabled_scenarios + + +class SyncQueryTestUser(ActiveRecord): + """Sync test user model for query connection pool tests.""" + __table_name__ = "test_users" + id: Optional[int] = None + name: str + email: str + + +class AsyncQueryTestUser(AsyncActiveRecord): + """Async test user model for query connection pool tests.""" + __table_name__ = "test_users" + id: Optional[int] = None + name: str + email: str + + +class QueryConnectionProvider(IQueryConnectionProvider): + """ + PostgreSQL backend implementation for query connection pool context tests. + """ + + def __init__(self): + self._active_backends: List = [] + self._active_async_backends: List = [] + + def get_test_scenarios(self) -> list: + """Returns available test scenarios.""" + return list(get_enabled_scenarios().keys()) + + def _create_test_table(self, backend): + """Create the test_users table.""" + try: + backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) + except Exception: + pass + + backend.execute(""" + CREATE TABLE test_users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL + ) + """, options=ExecutionOptions(stmt_type=StatementType.DDL)) + + async def _create_test_table_async(self, backend): + """Create the test_users table asynchronously.""" + try: + await backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) + except Exception: + pass + + await backend.execute(""" + CREATE TABLE test_users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL + ) + """, options=ExecutionOptions(stmt_type=StatementType.DDL)) + + def setup_sync_pool_and_model(self, scenario_name: str) -> Tuple[BackendPool, Type[ActiveRecord]]: + """Setup sync connection pool and model for query context tests.""" + _, config = get_scenario(scenario_name) + + # Create connection pool + pool_config = PoolConfig( + min_size=1, + max_size=5, + backend_factory=lambda: PostgresBackend(connection_config=config) + ) + pool = BackendPool.create(pool_config) + + # Create table + with pool.connection() as backend: + self._create_test_table(backend) + self._active_backends.append(backend) + + # Configure model to use a separate backend (not pool backend) + # This tests that model.backend() returns pool context backend + SyncQueryTestUser.configure(config, PostgresBackend) + self._active_backends.append(SyncQueryTestUser.__backend__) + + return pool, SyncQueryTestUser + + async def setup_async_pool_and_model(self, scenario_name: str) -> Tuple[AsyncBackendPool, Type[AsyncActiveRecord]]: + """Setup async connection pool and model for query context tests.""" + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + _, config = get_scenario(scenario_name) + + # Create connection pool + pool_config = PoolConfig( + min_size=1, + max_size=5, + backend_factory=lambda: AsyncPostgresBackend(connection_config=config) + ) + + # Create pool + pool = await AsyncBackendPool.create(pool_config) + + # Create table + async with pool.connection() as backend: + await self._create_test_table_async(backend) + self._active_async_backends.append(backend) + + # Configure model + await AsyncQueryTestUser.configure(config, AsyncPostgresBackend) + self._active_async_backends.append(AsyncQueryTestUser.__backend__) + + return pool, AsyncQueryTestUser + + def cleanup_sync(self, scenario_name: str, pool: BackendPool): + """Cleanup after sync tests.""" + pool.close(timeout=1.0) + + # Disconnect backends + for backend in self._active_backends: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + + async def cleanup_async(self, scenario_name: str, pool: AsyncBackendPool): + """Cleanup after async tests.""" + await pool.close(timeout=1.0) + + # Disconnect backends + for backend in self._active_async_backends: + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 488ce965..69a1c98f 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -10,6 +10,8 @@ from .events import EventsProvider from .mixins import MixinsProvider from .query import QueryProvider +from .basic_connection import BasicConnectionProvider +from .query_connection import QueryConnectionProvider # Create a single, global instance of the ProviderRegistry for this backend. provider_registry = ProviderRegistry() @@ -20,3 +22,7 @@ provider_registry.register("feature.events.IEventsProvider", EventsProvider) provider_registry.register("feature.mixins.IMixinsProvider", MixinsProvider) provider_registry.register("feature.query.IQueryProvider", QueryProvider) + +# Register connection pool context awareness providers +provider_registry.register("feature.basic.connection.IBasicConnectionProvider", BasicConnectionProvider) +provider_registry.register("feature.query.connection.IQueryConnectionProvider", QueryConnectionProvider) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py new file mode 100644 index 00000000..f92b1c1d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py +""" +Pytest configuration for basic connection pool feature tests. + +This file imports fixtures from the corresponding testsuite, making them +available to the tests in this directory. +""" +from rhosocial.activerecord.testsuite.feature.basic.connection.conftest import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py new file mode 100644 index 00000000..cdd54c73 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py @@ -0,0 +1,14 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py +""" +Basic ActiveRecord Context Test Module for PostgreSQL backend. + +This module imports and runs the shared tests from the testsuite package, +ensuring PostgreSQL backend compatibility for connection pool context awareness. +""" +from rhosocial.activerecord.testsuite.feature.basic.connection.conftest import ( + sync_pool_and_model, + async_pool_and_model, +) + +# Import shared tests from testsuite package +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_context import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py new file mode 100644 index 00000000..61731018 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py @@ -0,0 +1,14 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py +""" +Basic ActiveRecord CRUD Test Module for PostgreSQL backend. + +This module imports and runs the shared tests from the testsuite package, +ensuring PostgreSQL backend compatibility for connection pool CRUD operations. +""" +from rhosocial.activerecord.testsuite.feature.basic.connection.conftest import ( + sync_pool_for_crud, + async_pool_for_crud, +) + +# Import shared tests from testsuite package +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_crud import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py new file mode 100644 index 00000000..0f5c450e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py @@ -0,0 +1,277 @@ +# tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py +""" +Pytest configuration for connection pool tests. + +This module provides fixtures for testing connection pools with PostgreSQL backends. +""" + +import asyncio +import sys +import os +from typing import Dict, Any, Generator + +import pytest +import pytest_asyncio +import yaml + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, AsyncPostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.connection.pool import ( + PoolConfig, + BackendPool, + AsyncBackendPool, +) + + +def ensure_compatible_event_loop(): + if sys.platform == "win32": + policy = asyncio.get_event_loop_policy() + if not isinstance(policy, asyncio.WindowsSelectorEventLoopPolicy): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy) + + +ensure_compatible_event_loop() + +# --- Scenario Loading Logic --- + +SCENARIO_MAP: Dict[str, Dict[str, Any]] = {} + + +def register_scenario(name: str, config: Dict[str, Any]): + SCENARIO_MAP[name] = config + + +def _load_scenarios_from_config(): + """Load scenarios from a configuration file.""" + config_path = None + env_config_path = os.getenv("POSTGRES_SCENARIOS_CONFIG_PATH") + + if env_config_path and os.path.exists(env_config_path): + config_path = env_config_path + else: + default_path = os.path.join( + os.path.dirname(__file__), + "../../../../config", + "postgres_scenarios.yaml" + ) + if os.path.exists(default_path): + config_path = default_path + elif env_config_path: + print(f"Warning: Scenario file specified in POSTGRES_SCENARIOS_CONFIG_PATH not found: {env_config_path}") + return + + if not config_path: + raise FileNotFoundError( + "No PostgreSQL scenarios configuration file found. " + "Set POSTGRES_SCENARIOS_CONFIG_PATH or place postgres_scenarios.yaml in tests/config." + ) + + try: + with open(config_path, 'r', encoding='utf-8') as f: + config_data = yaml.safe_load(f) + + if 'scenarios' not in config_data: + raise ValueError(f"Configuration file {config_path} does not contain 'scenarios' key") + + for scenario_name, config in config_data['scenarios'].items(): + if config: + register_scenario(scenario_name, config) + + except ImportError: + raise ImportError("PyYAML is required to load scenario configuration files") + + +_load_scenarios_from_config() + + +def get_scenario_config(name: str) -> Dict[str, Any]: + if name not in SCENARIO_MAP: + if SCENARIO_MAP: + name = next(iter(SCENARIO_MAP)) + else: + raise ValueError("No scenarios registered or enabled") + return SCENARIO_MAP[name] + + +def get_scenario_names(): + return list(SCENARIO_MAP.keys()) + + +# --- Pool Fixtures --- + +def create_postgres_backend_factory(config_dict: Dict[str, Any]): + """Create a factory function that produces PostgresBackend instances.""" + def factory(): + config = PostgresConnectionConfig(**config_dict) + backend = PostgresBackend(connection_config=config) + backend.connect() + return backend + return factory + + +def create_async_postgres_backend_factory(config_dict: Dict[str, Any]): + """Create a factory function that produces AsyncPostgresBackend instances.""" + def factory(): + config = PostgresConnectionConfig(**config_dict) + backend = AsyncPostgresBackend(connection_config=config) + # Note: connect() will be called by the pool when needed + return backend + return factory + + +@pytest.fixture(scope="function") +def postgres_pool() -> Generator[BackendPool, None, None]: + """Create a BackendPool with PostgreSQL backends for testing.""" + scenario_names = get_scenario_names() + if not scenario_names: + pytest.skip("No PostgreSQL scenarios configured") + + config_dict = get_scenario_config(scenario_names[0]) + config_dict.pop('enabled', None) + + pool_config = PoolConfig( + min_size=1, + max_size=5, + timeout=30.0, + validate_on_borrow=True, + validation_query="SELECT 1", + backend_factory=create_postgres_backend_factory(config_dict), + ) + + pool = BackendPool.create(pool_config) + yield pool + pool.close(timeout=5.0, force=True) + + +@pytest_asyncio.fixture(scope="function") +async def async_postgres_pool() -> AsyncBackendPool: + """Create an AsyncBackendPool with PostgreSQL backends for testing.""" + scenario_names = get_scenario_names() + if not scenario_names: + pytest.skip("No PostgreSQL scenarios configured") + + config_dict = get_scenario_config(scenario_names[0]) + config_dict.pop('enabled', None) + + pool_config = PoolConfig( + min_size=1, + max_size=5, + timeout=30.0, + validate_on_borrow=True, + validation_query="SELECT 1", + backend_factory=create_async_postgres_backend_factory(config_dict), + ) + + pool = await AsyncBackendPool.create(pool_config) + yield pool + await pool.close(timeout=5.0, force=True) + + +@pytest.fixture(scope="function") +def postgres_pool_large() -> Generator[BackendPool, None, None]: + """Create a larger BackendPool for stress testing.""" + scenario_names = get_scenario_names() + if not scenario_names: + pytest.skip("No PostgreSQL scenarios configured") + + config_dict = get_scenario_config(scenario_names[0]) + config_dict.pop('enabled', None) + + pool_config = PoolConfig( + min_size=2, + max_size=10, + timeout=60.0, + validate_on_borrow=False, # Faster for stress tests + backend_factory=create_postgres_backend_factory(config_dict), + ) + + pool = BackendPool.create(pool_config) + yield pool + pool.close(timeout=5.0, force=True) + + +@pytest_asyncio.fixture(scope="function") +async def async_postgres_pool_large() -> AsyncBackendPool: + """Create a larger AsyncBackendPool for stress testing.""" + scenario_names = get_scenario_names() + if not scenario_names: + pytest.skip("No PostgreSQL scenarios configured") + + config_dict = get_scenario_config(scenario_names[0]) + config_dict.pop('enabled', None) + + pool_config = PoolConfig( + min_size=2, + max_size=10, + timeout=60.0, + validate_on_borrow=False, + backend_factory=create_async_postgres_backend_factory(config_dict), + ) + + pool = await AsyncBackendPool.create(pool_config) + yield pool + await pool.close(timeout=5.0, force=True) + + +# --- Table Setup Fixtures --- + +@pytest.fixture(scope="function") +def postgres_pool_with_tables(postgres_pool: BackendPool) -> Generator[BackendPool, None, None]: + """Create a pool with test tables initialized.""" + with postgres_pool.connection() as backend: + backend.execute("DROP TABLE IF EXISTS concurrent_test_users") + backend.execute("DROP TABLE IF EXISTS concurrent_test_posts") + backend.execute(""" + CREATE TABLE concurrent_test_users ( + id SERIAL PRIMARY KEY, + thread_id INTEGER, + name VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + backend.execute(""" + CREATE TABLE concurrent_test_posts ( + id SERIAL PRIMARY KEY, + thread_id INTEGER, + user_id INTEGER, + title VARCHAR(255), + content TEXT + ) + """) + + yield postgres_pool + + with postgres_pool.connection() as backend: + backend.execute("DROP TABLE IF EXISTS concurrent_test_posts") + backend.execute("DROP TABLE IF EXISTS concurrent_test_users") + + +@pytest_asyncio.fixture(scope="function") +async def async_postgres_pool_with_tables(async_postgres_pool: AsyncBackendPool) -> AsyncBackendPool: + """Create an async pool with test tables initialized.""" + async with async_postgres_pool.connection() as backend: + await backend.execute("DROP TABLE IF EXISTS concurrent_test_users") + await backend.execute("DROP TABLE IF EXISTS concurrent_test_posts") + await backend.execute(""" + CREATE TABLE concurrent_test_users ( + id SERIAL PRIMARY KEY, + task_id INTEGER, + name VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + await backend.execute(""" + CREATE TABLE concurrent_test_posts ( + id SERIAL PRIMARY KEY, + task_id INTEGER, + user_id INTEGER, + title VARCHAR(255), + content TEXT + ) + """) + + yield async_postgres_pool + + async with async_postgres_pool.connection() as backend: + await backend.execute("DROP TABLE IF EXISTS concurrent_test_posts") + await backend.execute("DROP TABLE IF EXISTS concurrent_test_users") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py b/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py new file mode 100644 index 00000000..ba6bd101 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py @@ -0,0 +1,570 @@ +# tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py +""" +Concurrent connection pool tests for PostgreSQL backend. + +Tests that verify: +1. Concurrently acquired connections are distinct +2. Concurrent operations don't cause deadlocks +3. Query results don't get mixed up between connections +""" + +import asyncio +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List, Dict, Any, Tuple +from datetime import datetime + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.connection.pool import ( + PoolConfig, + BackendPool, + AsyncBackendPool, +) + + +# ============================================================ +# Synchronous Concurrent Tests +# ============================================================ + +class TestBackendPoolConcurrent: + """Tests for concurrent operations with BackendPool (PostgreSQL).""" + + def test_concurrent_connections_are_distinct(self, postgres_pool: BackendPool): + """Verify that concurrently acquired connections are distinct. + + This test proves that when multiple threads acquire connections + simultaneously, each thread gets a different connection instance. + """ + num_threads = 5 + connection_ids: List[int] = [] + connection_ids_lock = threading.Lock() + barrier = threading.Barrier(num_threads) + errors: List[Exception] = [] + errors_lock = threading.Lock() + + def acquire_and_record(thread_id: int): + try: + # Synchronize all threads to start at the same time + barrier.wait() + + # Acquire connection + backend = postgres_pool.acquire() + try: + conn_id = id(backend) + with connection_ids_lock: + connection_ids.append((thread_id, conn_id)) + + # Hold connection briefly to ensure overlap + time.sleep(0.1) + finally: + postgres_pool.release(backend) + except Exception as e: + with errors_lock: + errors.append(e) + + # Run concurrent acquires + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(acquire_and_record, i) for i in range(num_threads)] + for future in as_completed(futures): + future.result() + + # Verify no errors + assert len(errors) == 0, f"Errors occurred: {errors}" + + # Verify all connections are distinct + assert len(connection_ids) == num_threads + unique_ids = set(conn_id for _, conn_id in connection_ids) + assert len(unique_ids) == num_threads, ( + f"Expected {num_threads} distinct connections, " + f"but got {len(unique_ids)}: {connection_ids}" + ) + + def test_concurrent_operations_no_deadlock(self, postgres_pool_with_tables: BackendPool): + """Verify that concurrent complex operations don't cause deadlock. + + This test simulates multiple threads performing complex operations + (insert, select, update) simultaneously on different connections, + proving no deadlock occurs. + """ + pool = postgres_pool_with_tables + num_threads = 4 + results: Dict[int, List[Any]] = {} + results_lock = threading.Lock() + start_barrier = threading.Barrier(num_threads) + errors: List[Tuple[int, Exception]] = [] + errors_lock = threading.Lock() + + def complex_operation(thread_id: int): + try: + # Synchronize all threads + start_barrier.wait() + + with pool.connection() as backend: + # Insert records for this thread + for i in range(10): + backend.execute( + "INSERT INTO concurrent_test_users (thread_id, name) VALUES (%s, %s)", + [thread_id, f"user_{thread_id}_{i}"] + ) + + # Query back the data + query_result = backend.execute( + "SELECT * FROM concurrent_test_users WHERE thread_id = %s", + [thread_id] + ) + + # Update some records + backend.execute( + "UPDATE concurrent_test_users SET name = %s WHERE thread_id = %s AND id <= %s", + [f"updated_{thread_id}", thread_id, thread_id + 5] + ) + + with results_lock: + results[thread_id] = query_result.data + + except Exception as e: + with errors_lock: + errors.append((thread_id, e)) + + # Run concurrent operations with timeout + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(complex_operation, i) for i in range(num_threads)] + + # Wait for all with timeout (deadlock detection) + for future in as_completed(futures, timeout=60): + future.result() + + # Verify no errors + assert len(errors) == 0, f"Errors occurred: {errors}" + + # Verify each thread got correct results + for thread_id in range(num_threads): + assert thread_id in results, f"Thread {thread_id} has no results" + assert len(results[thread_id]) == 10, ( + f"Thread {thread_id} expected 10 rows, got {len(results[thread_id])}" + ) + # Verify all rows belong to this thread + for row in results[thread_id]: + assert row['thread_id'] == thread_id, ( + f"Thread {thread_id} got wrong data: {row}" + ) + + def test_concurrent_query_results_not_mixed(self, postgres_pool_with_tables: BackendPool): + """Verify that concurrent queries on different connections don't mix results. + + This test proves that when multiple connections query different data + simultaneously, each connection gets only its own data without any mix-up. + """ + pool = postgres_pool_with_tables + num_threads = 5 + records_per_thread = 20 + + # First, set up shared data + with pool.connection() as backend: + # Insert data for each thread group + for thread_id in range(num_threads): + for i in range(records_per_thread): + backend.execute( + "INSERT INTO concurrent_test_posts (thread_id, title, content) VALUES (%s, %s, %s)", + [thread_id, f"title_{thread_id}_{i}", f"content_{thread_id}_{i}"] + ) + + # Now run concurrent reads + results: Dict[int, List[Dict]] = {} + results_lock = threading.Lock() + barrier = threading.Barrier(num_threads) + errors: List[Tuple[int, Exception]] = [] + errors_lock = threading.Lock() + + def read_thread_data(thread_id: int): + try: + # Synchronize start + barrier.wait() + + with pool.connection() as backend: + # Perform multiple reads with some computation + time.sleep(0.05) # Simulate processing + + result = backend.execute( + "SELECT * FROM concurrent_test_posts WHERE thread_id = %s ORDER BY id", + [thread_id] + ) + + time.sleep(0.05) # More processing + + with results_lock: + results[thread_id] = result.data + + except Exception as e: + with errors_lock: + errors.append((thread_id, e)) + + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(read_thread_data, i) for i in range(num_threads)] + for future in as_completed(futures, timeout=30): + future.result() + + # Verify no errors + assert len(errors) == 0, f"Errors occurred: {errors}" + + # Verify each thread got exactly its own data + for thread_id in range(num_threads): + assert thread_id in results + data = results[thread_id] + assert len(data) == records_per_thread, ( + f"Thread {thread_id}: expected {records_per_thread} records, " + f"got {len(data)}" + ) + + # Verify all records belong to this thread's group + for row in data: + assert row['thread_id'] == thread_id, ( + f"Thread {thread_id} got data from group {row['thread_id']}!" + ) + assert row['title'].startswith(f"title_{thread_id}_"), ( + f"Thread {thread_id} got wrong title: {row['title']}" + ) + + def test_concurrent_transactions_isolated(self, postgres_pool_with_tables: BackendPool): + """Verify that concurrent transactions on different connections are isolated. + + This test proves that uncommitted changes in one transaction + are not visible to other concurrent transactions. + """ + pool = postgres_pool_with_tables + num_threads = 3 + + # Insert initial data + with pool.transaction() as backend: + for i in range(num_threads): + backend.execute( + "INSERT INTO concurrent_test_users (thread_id, name) VALUES (%s, 'initial')", + [i] + ) + + results: Dict[int, Dict] = {} + results_lock = threading.Lock() + barrier = threading.Barrier(num_threads) + errors: List[Tuple[int, Exception]] = [] + errors_lock = threading.Lock() + + def transaction_test(thread_id: int): + try: + barrier.wait() + + with pool.transaction() as backend: + # Update own row + backend.execute( + "UPDATE concurrent_test_users SET name = %s WHERE thread_id = %s", + [f'modified_by_{thread_id}', thread_id] + ) + + # Read all rows - should only see committed data + # from other threads, not their uncommitted changes + time.sleep(0.2) # Allow other threads to make changes + + all_rows = backend.execute( + "SELECT * FROM concurrent_test_users ORDER BY thread_id" + ) + + own_row = backend.execute( + "SELECT * FROM concurrent_test_users WHERE thread_id = %s", + [thread_id] + ) + + with results_lock: + results[thread_id] = { + 'all_rows': all_rows.data, + 'own_row': own_row.data + } + + except Exception as e: + with errors_lock: + errors.append((thread_id, e)) + + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(transaction_test, i) for i in range(num_threads)] + for future in as_completed(futures, timeout=30): + future.result() + + # Verify no errors (no deadlock) + assert len(errors) == 0, f"Errors occurred: {errors}" + + # Verify each transaction saw its own changes + for thread_id in range(num_threads): + assert thread_id in results + own_row = results[thread_id]['own_row'] + assert len(own_row) == 1 + # Own row should show the modification + assert own_row[0]['name'] == f'modified_by_{thread_id}' + + def test_pool_stress_concurrent_acquire_release(self, postgres_pool_large: BackendPool): + """Stress test: rapidly acquire and release connections concurrently. + + This test verifies pool stability under high concurrent load. + """ + pool = postgres_pool_large + num_threads = 10 + operations_per_thread = 50 + + success_count = 0 + success_lock = threading.Lock() + errors: List[Tuple[int, int, Exception]] = [] + errors_lock = threading.Lock() + + def rapid_acquire_release(thread_id: int): + nonlocal success_count + for i in range(operations_per_thread): + try: + with pool.connection() as backend: + # Quick operation + backend.execute("SELECT 1") + time.sleep(0.001) # Tiny delay + + with success_lock: + success_count += 1 + except Exception as e: + with errors_lock: + errors.append((thread_id, i, e)) + + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(rapid_acquire_release, i) for i in range(num_threads)] + for future in as_completed(futures, timeout=120): + future.result() + + # Verify high success rate + expected = num_threads * operations_per_thread + success_rate = success_count / expected + assert success_rate >= 0.95, ( + f"Success rate too low: {success_rate:.2%} " + f"({success_count}/{expected}), errors: {errors[:5]}" + ) + + # Verify pool is still healthy + stats = pool.get_stats() + assert stats.current_in_use == 0, "Connections still in use after test" + + +# ============================================================ +# Asynchronous Concurrent Tests +# ============================================================ + +class TestAsyncBackendPoolConcurrent: + """Tests for concurrent operations with AsyncBackendPool (PostgreSQL).""" + + @pytest.mark.asyncio + async def test_concurrent_connections_are_distinct(self, async_postgres_pool: AsyncBackendPool): + """Verify that concurrently acquired async connections are distinct.""" + num_concurrent = 5 + connection_ids: List[int] = [] + connection_ids_lock = asyncio.Lock() + start_event = asyncio.Event() + + async def acquire_and_record(task_id: int): + # Wait for all tasks to be ready + await start_event.wait() + + async with async_postgres_pool.connection() as backend: + conn_id = id(backend) + async with connection_ids_lock: + connection_ids.append((task_id, conn_id)) + + # Hold connection briefly + await asyncio.sleep(0.1) + + # Start all tasks simultaneously + tasks = [acquire_and_record(i) for i in range(num_concurrent)] + start_event.set() # Release all tasks at once + await asyncio.gather(*tasks) + + # Verify all connections are distinct + assert len(connection_ids) == num_concurrent + unique_ids = set(conn_id for _, conn_id in connection_ids) + assert len(unique_ids) == num_concurrent, ( + f"Expected {num_concurrent} distinct connections, " + f"but got {len(unique_ids)}" + ) + + @pytest.mark.asyncio + async def test_concurrent_operations_no_deadlock(self, async_postgres_pool_with_tables: AsyncBackendPool): + """Verify that concurrent async operations don't cause deadlock.""" + pool = async_postgres_pool_with_tables + num_concurrent = 4 + results: Dict[int, List[Any]] = {} + results_lock = asyncio.Lock() + start_event = asyncio.Event() + + async def complex_operation(task_id: int): + await start_event.wait() + + async with pool.connection() as backend: + # Insert records + for i in range(10): + await backend.execute( + "INSERT INTO concurrent_test_users (task_id, name) VALUES (%s, %s)", + [task_id, f"user_{task_id}_{i}"] + ) + + # Query back + query_result = await backend.execute( + "SELECT * FROM concurrent_test_users WHERE task_id = %s", + [task_id] + ) + + async with results_lock: + results[task_id] = query_result.data + + # Run all tasks concurrently + tasks = [complex_operation(i) for i in range(num_concurrent)] + start_event.set() + await asyncio.gather(*tasks) + + # Verify results + for task_id in range(num_concurrent): + assert task_id in results + assert len(results[task_id]) == 10 + for row in results[task_id]: + assert row['task_id'] == task_id + + @pytest.mark.asyncio + async def test_concurrent_query_results_not_mixed(self, async_postgres_pool_with_tables: AsyncBackendPool): + """Verify that concurrent async queries don't mix results.""" + pool = async_postgres_pool_with_tables + num_concurrent = 5 + records_per_task = 20 + + # Setup shared data + async with pool.connection() as backend: + for task_id in range(num_concurrent): + for i in range(records_per_task): + await backend.execute( + "INSERT INTO concurrent_test_posts (task_id, title, content) VALUES (%s, %s, %s)", + [task_id, f"title_{task_id}_{i}", f"content_{task_id}_{i}"] + ) + + # Concurrent reads + results: Dict[int, List[Dict]] = {} + results_lock = asyncio.Lock() + start_event = asyncio.Event() + + async def read_task_data(task_id: int): + await start_event.wait() + + async with pool.connection() as backend: + await asyncio.sleep(0.05) + result = await backend.execute( + "SELECT * FROM concurrent_test_posts WHERE task_id = %s ORDER BY id", + [task_id] + ) + await asyncio.sleep(0.05) + + async with results_lock: + results[task_id] = result.data + + tasks = [read_task_data(i) for i in range(num_concurrent)] + start_event.set() + await asyncio.gather(*tasks) + + # Verify isolation + for task_id in range(num_concurrent): + assert task_id in results + data = results[task_id] + assert len(data) == records_per_task + for row in data: + assert row['task_id'] == task_id + assert row['title'].startswith(f"title_{task_id}_") + + @pytest.mark.asyncio + async def test_pool_stress_concurrent_acquire_release(self, async_postgres_pool_large: AsyncBackendPool): + """Stress test: rapid async acquire and release.""" + pool = async_postgres_pool_large + num_tasks = 10 + operations_per_task = 50 + + success_count = 0 + success_lock = asyncio.Lock() + errors: List[Tuple[int, int, Exception]] = [] + errors_lock = asyncio.Lock() + + async def rapid_acquire_release(task_id: int): + nonlocal success_count + for i in range(operations_per_task): + try: + async with pool.connection() as backend: + await backend.execute("SELECT 1") + await asyncio.sleep(0.001) + + async with success_lock: + success_count += 1 + except Exception as e: + async with errors_lock: + errors.append((task_id, i, e)) + + tasks = [rapid_acquire_release(i) for i in range(num_tasks)] + await asyncio.gather(*tasks) + + expected = num_tasks * operations_per_task + success_rate = success_count / expected + assert success_rate >= 0.95, ( + f"Success rate too low: {success_rate:.2%}, errors: {errors[:5]}" + ) + + stats = pool.get_stats() + assert stats.current_in_use == 0 + + @pytest.mark.asyncio + async def test_concurrent_with_semaphore_limit(self, async_postgres_pool: AsyncBackendPool): + """Test concurrent operations when pool size limits cause waiting. + + This test verifies that when more tasks than available connections + are running, they properly wait and don't deadlock. + """ + pool = async_postgres_pool + num_tasks = 10 + max_pool_size = pool.config.max_size + + concurrent_count = 0 + max_concurrent = 0 + count_lock = asyncio.Lock() + start_event = asyncio.Event() + completed: List[int] = [] + completed_lock = asyncio.Lock() + + async def tracked_operation(task_id: int): + nonlocal concurrent_count, max_concurrent + + await start_event.wait() + + async with pool.connection() as backend: + # Track concurrent execution + async with count_lock: + concurrent_count += 1 + max_concurrent = max(max_concurrent, concurrent_count) + + # Do some work + await backend.execute("SELECT 1") + await asyncio.sleep(0.1) + + async with count_lock: + concurrent_count -= 1 + + async with completed_lock: + completed.append(task_id) + + tasks = [tracked_operation(i) for i in range(num_tasks)] + start_event.set() + await asyncio.gather(*tasks) + + # Verify all tasks completed + assert len(completed) == num_tasks + + # Verify max concurrent didn't exceed pool size + assert max_concurrent <= max_pool_size, ( + f"Max concurrent {max_concurrent} exceeded pool size {max_pool_size}" + ) + + # Verify some waiting occurred (proves semaphore works) + assert max_concurrent > 0 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py new file mode 100644 index 00000000..86492b05 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py +""" +Pytest configuration for query connection pool feature tests. + +This file imports fixtures from the corresponding testsuite, making them +available to the tests in this directory. +""" +from rhosocial.activerecord.testsuite.feature.query.connection.conftest import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py new file mode 100644 index 00000000..4363b19d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py @@ -0,0 +1,14 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py +""" +ActiveQuery Context Test Module for PostgreSQL backend. + +This module imports and runs the shared tests from the testsuite package, +ensuring PostgreSQL backend compatibility for ActiveQuery connection pool context awareness. +""" +from rhosocial.activerecord.testsuite.feature.query.connection.conftest import ( + sync_pool_and_model, + async_pool_and_model, +) + +# Import shared tests from testsuite package +from rhosocial.activerecord.testsuite.feature.query.connection.test_active_query_context import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py new file mode 100644 index 00000000..94bfc1f9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py @@ -0,0 +1,14 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py +""" +CTEQuery Context Test Module for PostgreSQL backend. + +This module imports and runs the shared tests from the testsuite package, +ensuring PostgreSQL backend compatibility for CTEQuery connection pool context awareness. +""" +from rhosocial.activerecord.testsuite.feature.query.connection.conftest import ( + sync_pool_and_model, + async_pool_and_model, +) + +# Import shared tests from testsuite package +from rhosocial.activerecord.testsuite.feature.query.connection.test_cte_query_context import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py new file mode 100644 index 00000000..3eef9b3e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py @@ -0,0 +1,14 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py +""" +SetOperationQuery Context Test Module for PostgreSQL backend. + +This module imports and runs the shared tests from the testsuite package, +ensuring PostgreSQL backend compatibility for SetOperationQuery connection pool context awareness. +""" +from rhosocial.activerecord.testsuite.feature.query.connection.conftest import ( + sync_pool_and_model, + async_pool_and_model, +) + +# Import shared tests from testsuite package +from rhosocial.activerecord.testsuite.feature.query.connection.test_set_operation_context import * From 7cc0ccb38ea8553d16d090a9053e5448ef2dba31 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 7 Apr 2026 23:08:56 +0800 Subject: [PATCH 3/8] refactor(transaction): use Expression/Dialect system for SQL generation Refactor PostgreSQL transaction management to use the Expression/Dialect system for SQL generation, ensuring consistency with the rest of the codebase. Changes: - Transaction managers now accept backend instance instead of connection - SQL generation delegated to dialect's format_* methods via Expression - Implement TransactionControlSupport protocol in PostgresDialect - Remove PostgresTransactionMixin and AsyncPostgresTransactionMixin - All I/O operations now use backend.execute() instead of direct cursor - Add support for TransactionMode.READ_ONLY in BEGIN statements --- .../impl/postgres/backend/async_backend.py | 9 +- .../backend/impl/postgres/backend/sync.py | 9 +- .../backend/impl/postgres/dialect.py | 149 ++++ .../backend/impl/postgres/transaction.py | 748 ++++-------------- .../postgres/test_transaction_postgres.py | 16 +- .../backend/test_transaction_savepoint.py | 18 +- 6 files changed, 317 insertions(+), 632 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py index 8aa14b70..2daa393e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -154,10 +154,8 @@ def __init__(self, **kwargs): # See PostgresXMLAdapter documentation for details. self._register_postgres_adapters() - # Initialize transaction manager with connection (will be set when connected) - # Pass None for connection initially, it will be updated later - # Use the logger from the base class - self._transaction_manager = AsyncPostgresTransactionManager(None, self.logger) + # Initialize transaction manager (will use backend.execute()) + self._transaction_manager = AsyncPostgresTransactionManager(self, self.logger) self.log(logging.INFO, "AsyncPostgreSQLBackend initialized") @@ -242,9 +240,6 @@ def dialect(self): @property def transaction_manager(self): """Get the async PostgreSQL transaction manager.""" - # Update the transaction manager's connection if needed - if self._transaction_manager: - self._transaction_manager._connection = self._connection return self._transaction_manager async def connect(self): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index 9f69e8a5..7aa5c2c8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -154,10 +154,8 @@ def __init__(self, **kwargs): # See PostgresXMLAdapter documentation for details. self._register_postgres_adapters() - # Initialize transaction manager with connection (will be set when connected) - # Pass None for connection initially, it will be updated later - # Use the logger from the base class - self._transaction_manager = PostgresTransactionManager(None, self.logger) + # Initialize transaction manager (will use backend.execute()) + self._transaction_manager = PostgresTransactionManager(self, self.logger) self.log(logging.INFO, "PostgreSQLBackend initialized") @@ -241,9 +239,6 @@ def dialect(self): @property def transaction_manager(self): """Get the PostgreSQL transaction manager.""" - # Update the transaction manager's connection if needed - if self._transaction_manager: - self._transaction_manager._connection = self._connection return self._transaction_manager def connect(self): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 1b647ca0..37dfcb58 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -64,6 +64,7 @@ TruncateSupport, ILIKESupport, IntrospectionSupport, + TransactionControlSupport, ) from .mixins import ( PostgresExtensionMixin, @@ -149,6 +150,14 @@ RefreshMaterializedViewExpression, ExplainExpression, ) + from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + CommitTransactionExpression, + RollbackTransactionExpression, + SavepointExpression, + ReleaseSavepointExpression, + SetTransactionExpression, + ) class PostgresDialect( @@ -242,6 +251,8 @@ class PostgresDialect( TableSupport, # Introspection protocol IntrospectionSupport, + # Transaction control protocol + TransactionControlSupport, # PostgreSQL-specific protocols PostgresExtensionSupport, PostgresMaterializedViewSupport, @@ -1044,3 +1055,141 @@ def supports_drop_trigger(self) -> bool: return True # endregion + + # region Transaction Control Support + def supports_transaction_mode(self) -> bool: + """PostgreSQL supports READ ONLY and READ WRITE transaction modes.""" + return True + + def supports_isolation_level_in_begin(self) -> bool: + """PostgreSQL supports isolation level specification in BEGIN statement.""" + return True + + def supports_read_only_transaction(self) -> bool: + """PostgreSQL supports READ ONLY transactions.""" + return True + + def supports_deferrable_transaction(self) -> bool: + """PostgreSQL supports DEFERRABLE transactions (for SERIALIZABLE isolation).""" + return True + + def supports_savepoint(self) -> bool: + """PostgreSQL supports savepoints.""" + return True + + def format_begin_transaction( + self, expr: "BeginTransactionExpression" + ) -> Tuple[str, tuple]: + """Format BEGIN TRANSACTION statement for PostgreSQL. + + PostgreSQL syntax: + BEGIN [ ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE } ] + [ { READ WRITE | READ ONLY } ] + [ { NOT DEFERRABLE | DEFERRABLE } ] + + DEFERRABLE is only meaningful for SERIALIZABLE isolation level. + """ + params = expr.get_params() + parts = ["BEGIN"] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + parts.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + parts.append("READ ONLY") + elif mode_name == "READ_WRITE": + parts.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None and isolation: + isolation_name = isolation.name if hasattr(isolation, "name") else str(isolation) + if isolation_name == "SERIALIZABLE": + parts.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + return " ".join(parts), () + + def format_commit_transaction( + self, expr: "CommitTransactionExpression" + ) -> Tuple[str, tuple]: + """Format COMMIT TRANSACTION statement for PostgreSQL.""" + return "COMMIT", () + + def format_rollback_transaction( + self, expr: "RollbackTransactionExpression" + ) -> Tuple[str, tuple]: + """Format ROLLBACK TRANSACTION statement for PostgreSQL. + + Supports ROLLBACK [ TO SAVEPOINT savepoint_name ]. + """ + params = expr.get_params() + savepoint = params.get("savepoint") + if savepoint: + return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () + return "ROLLBACK", () + + def format_savepoint( + self, expr: "SavepointExpression" + ) -> Tuple[str, tuple]: + """Format SAVEPOINT statement for PostgreSQL.""" + params = expr.get_params() + name = params.get("name", "") + return f"SAVEPOINT {self.format_identifier(name)}", () + + def format_release_savepoint( + self, expr: "ReleaseSavepointExpression" + ) -> Tuple[str, tuple]: + """Format RELEASE SAVEPOINT statement for PostgreSQL.""" + params = expr.get_params() + name = params.get("name", "") + return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () + + def format_set_transaction( + self, expr: "SetTransactionExpression" + ) -> Tuple[str, tuple]: + """Format SET TRANSACTION statement for PostgreSQL. + + PostgreSQL supports setting transaction characteristics for the current + transaction or for subsequent transactions. + + Syntax: + SET TRANSACTION { ISOLATION LEVEL { ... } | { READ WRITE | READ ONLY } | [ NOT ] DEFERRABLE } [, ...] + SET SESSION CHARACTERISTICS AS TRANSACTION { ... } + """ + params = expr.get_params() + parts = [] + + if params.get("session"): + parts.append("SET SESSION CHARACTERISTICS AS TRANSACTION") + else: + parts.append("SET TRANSACTION") + + options = [] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + options.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + options.append("READ ONLY") + elif mode_name == "READ_WRITE": + options.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None: + options.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + if options: + parts.append(" ".join(options)) + + return " ".join(parts), () + + # endregion diff --git a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py index 4984eb52..3b0cb5c4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py @@ -1,9 +1,12 @@ # src/rhosocial/activerecord/backend/impl/postgres/transaction.py -"""postgres transaction management with sync and async support.""" +"""PostgreSQL transaction management with sync and async support. -from typing import Dict, Optional, List +This module provides simplified transaction management for PostgreSQL. +All SQL generation is delegated to the dialect's format_* methods via +the Expression system, ensuring single SQL statement per format method. +""" +from typing import Dict, Optional, List, TYPE_CHECKING import logging -from psycopg.errors import Error as PsycopgError from rhosocial.activerecord.backend.errors import TransactionError from rhosocial.activerecord.backend.transaction import ( @@ -11,75 +14,75 @@ AsyncTransactionManager, IsolationLevel, TransactionState, + TransactionMode, ) +if TYPE_CHECKING: + from .backend import PostgresBackend + from .async_backend import AsyncPostgresBackend -class PostgresTransactionMixin: - """Shared logic for postgres transaction management. - Contains non-I/O logic shared between sync and async implementations: - - Isolation level mapping - - Savepoint name generation - - SQL statement building +class PostgresTransactionManager(TransactionManager): + """Synchronous PostgreSQL transaction manager implementation. + + All transaction operations are delegated to the base class + which uses backend.execute() for SQL execution. + PostgreSQL-specific features like DEFERRABLE are supported. """ - # postgres supported isolation level mappings + # PostgreSQL supported isolation level mappings _ISOLATION_LEVELS: Dict[IsolationLevel, str] = { IsolationLevel.READ_UNCOMMITTED: "READ UNCOMMITTED", - IsolationLevel.READ_COMMITTED: "READ COMMITTED", # postgres default + IsolationLevel.READ_COMMITTED: "READ COMMITTED", # PostgreSQL default IsolationLevel.REPEATABLE_READ: "REPEATABLE READ", IsolationLevel.SERIALIZABLE: "SERIALIZABLE", } - def __init__(self, connection, logger=None): - """Initialize transaction manager. + def __init__(self, backend: "PostgresBackend", logger=None): + """Initialize PostgreSQL transaction manager. Args: - connection: postgres database connection - logger: Optional logger instance + backend: PostgreSQL backend instance. + logger: Optional logger instance. """ - self._active_savepoint = None - self._savepoint_counter = 0 - self._deferred_constraints: List[str] = [] + super().__init__(backend, logger) + # PostgreSQL default isolation level is READ COMMITTED + self._isolation_level = IsolationLevel.READ_COMMITTED + # DEFERRABLE mode for SERIALIZABLE transactions self._is_deferrable: Optional[bool] = None - self._active_transaction = False - self._state = TransactionState.INACTIVE + # Deferred constraints list + self._deferred_constraints: List[str] = [] - def _get_savepoint_name(self, level: int) -> str: - """Generate savepoint name for nested transactions. + def _build_begin_sql(self) -> tuple: + """Build BEGIN TRANSACTION SQL with PostgreSQL-specific options. - Args: - level: Transaction nesting level + This method creates a BeginTransactionExpression and delegates + SQL generation to the backend's dialect. All options including + DEFERRABLE are handled by the Expression/Dialect system. Returns: - Savepoint name + Tuple of (SQL string, parameters tuple). """ - return f"SP_{level}" + from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression - def _build_begin_statement(self) -> str: - """Build BEGIN statement with isolation level and deferrable mode. + expr = BeginTransactionExpression(self._backend.dialect) - Returns: - SQL BEGIN statement - """ - sql_parts = ["BEGIN"] + if self._isolation_level is not None: + expr.isolation_level(self._isolation_level) - # Add isolation level - if self._isolation_level: - level = self._ISOLATION_LEVELS.get(self._isolation_level) - if level: - sql_parts.append(f"ISOLATION LEVEL {level}") + if self._transaction_mode == TransactionMode.READ_ONLY: + expr.read_only() - # Add deferrable mode for SERIALIZABLE transactions + # DEFERRABLE is handled by the Expression/Dialect system if self._isolation_level == IsolationLevel.SERIALIZABLE and self._is_deferrable is not None: - sql_parts.append("DEFERRABLE" if self._is_deferrable else "NOT DEFERRABLE") + expr.deferrable(self._is_deferrable) - return " ".join(sql_parts) + return expr.to_sql() def set_deferrable(self, deferrable: bool = True) -> None: """Set transaction deferrable mode. - In postgres, DEFERRABLE only affects SERIALIZABLE transactions. + In PostgreSQL, DEFERRABLE only affects SERIALIZABLE transactions. Args: deferrable: Whether constraints should be deferrable @@ -87,21 +90,13 @@ def set_deferrable(self, deferrable: bool = True) -> None: Raises: TransactionError: If called on active transaction """ - self._is_deferrable = deferrable if self.is_active: error_msg = "Cannot change deferrable mode of active transaction" self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None + raise TransactionError(error_msg) + self._is_deferrable = deferrable self.log(logging.DEBUG, f"Set deferrable mode to {deferrable}") - def get_active_savepoint(self) -> Optional[str]: - """Get name of active savepoint. - - Returns: - Active savepoint name or None - """ - return self._active_savepoint - def get_deferred_constraints(self) -> List[str]: """Get list of currently deferred constraints. @@ -110,70 +105,6 @@ def get_deferred_constraints(self) -> List[str]: """ return self._deferred_constraints.copy() - def supports_savepoint(self) -> bool: - """Check if savepoints are supported. - - Returns: - Always True for postgres - """ - return True - - @property - def is_active(self) -> bool: - """Check if transaction is active. - - Returns: - True if in transaction - """ - if not self._connection or self._connection.closed: - return False - - # Check based on autocommit mode - if self._connection.autocommit: - return False - - # If we've explicitly tracked transaction state, use that - return self._active_transaction - - -class PostgresTransactionManager(PostgresTransactionMixin, TransactionManager): - """Synchronous postgres transaction manager implementation.""" - - def __init__(self, connection, logger=None): - """Initialize sync transaction manager. - - Args: - connection: postgres database connection (sync) - logger: Optional logger instance - """ - TransactionManager.__init__(self, connection, logger) - PostgresTransactionMixin.__init__(self, connection, logger) - - def _set_isolation_level(self) -> None: - """Set transaction isolation level. - - This is called at the start of each transaction. - - Raises: - TransactionError: If setting isolation level fails - """ - if self._isolation_level: - level = self._ISOLATION_LEVELS.get(self._isolation_level) - if level: - try: - self.log(logging.DEBUG, f"Setting isolation level to {level}") - cursor = self._connection.cursor() - cursor.execute(f"SET TRANSACTION ISOLATION LEVEL {level}") - cursor.close() - except PsycopgError as e: - error_msg = f"Failed to set isolation level to {level}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - else: - error_msg = f"Unsupported isolation level: {self._isolation_level}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - def defer_constraint(self, constraint_name: str) -> None: """Defer constraint checking until transaction commit. @@ -185,162 +116,13 @@ def defer_constraint(self, constraint_name: str) -> None: """ try: self.log(logging.DEBUG, f"Deferring constraint: {constraint_name}") - cursor = self._connection.cursor() - cursor.execute(f"SET CONSTRAINTS {constraint_name} DEFERRED") - cursor.close() + self._backend.execute(f"SET CONSTRAINTS {constraint_name} DEFERRED") self._deferred_constraints.append(constraint_name) self.log(logging.INFO, f"Deferred constraint: {constraint_name}") - except PsycopgError as e: + except Exception as e: error_msg = f"Failed to defer constraint {constraint_name}: {str(e)}" self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - def _do_begin(self) -> None: - """Begin postgres transaction. - - Sets isolation level and starts transaction. - - Raises: - TransactionError: If begin fails - """ - try: - self.log(logging.DEBUG, "Beginning transaction") - - # Set connection to non-autocommit mode - if self._connection.autocommit: - self._connection.autocommit = False - self.log(logging.DEBUG, "Set autocommit mode to False") - - # Build and execute BEGIN statement - begin_sql = self._build_begin_statement() - cursor = self._connection.cursor() - self.log(logging.DEBUG, f"Executing: {begin_sql}") - cursor.execute(begin_sql) - cursor.close() - - self._active_transaction = True - self._state = TransactionState.ACTIVE - self.log(logging.INFO, f"Started transaction with isolation level {self._isolation_level}") - - except PsycopgError as e: - error_msg = f"Failed to begin transaction: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - def _do_commit(self) -> None: - """Commit postgres transaction. - - Raises: - TransactionError: If commit fails - """ - try: - self.log(logging.DEBUG, "Committing transaction") - self._connection.commit() - self._active_transaction = False - self._state = TransactionState.COMMITTED - self.log(logging.INFO, "Transaction committed") - except PsycopgError as e: - error_msg = f"Failed to commit transaction: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - finally: - self._active_savepoint = None - self._savepoint_counter = 0 - self._deferred_constraints.clear() - - def _do_rollback(self) -> None: - """Rollback postgres transaction. - - Raises: - TransactionError: If rollback fails - """ - try: - self.log(logging.DEBUG, "Rolling back transaction") - self._connection.rollback() - self._active_transaction = False - self._state = TransactionState.ROLLED_BACK - self.log(logging.INFO, "Transaction rolled back") - except PsycopgError as e: - error_msg = f"Failed to rollback transaction: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - finally: - self._active_savepoint = None - self._savepoint_counter = 0 - self._deferred_constraints.clear() - - def _do_create_savepoint(self, name: str) -> None: - """Create postgres savepoint. - - Args: - name: Savepoint name - - Raises: - TransactionError: If create savepoint fails - """ - try: - # postgres requires active transaction for savepoints - if not self.is_active: - self.log(logging.DEBUG, "No active transaction, beginning one before creating savepoint") - self._do_begin() - - cursor = self._connection.cursor() - sql = f"SAVEPOINT {name}" - self.log(logging.DEBUG, f"Executing: {sql}") - cursor.execute(sql) - cursor.close() - self._active_savepoint = name - self.log(logging.INFO, f"Created savepoint: {name}") - except PsycopgError as e: - error_msg = f"Failed to create savepoint {name}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - def _do_release_savepoint(self, name: str) -> None: - """Release postgres savepoint. - - Args: - name: Savepoint name - - Raises: - TransactionError: If release savepoint fails - """ - try: - cursor = self._connection.cursor() - sql = f"RELEASE SAVEPOINT {name}" - self.log(logging.DEBUG, f"Executing: {sql}") - cursor.execute(sql) - cursor.close() - if self._active_savepoint == name: - self._active_savepoint = None - self.log(logging.INFO, f"Released savepoint: {name}") - except PsycopgError as e: - error_msg = f"Failed to release savepoint {name}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - def _do_rollback_savepoint(self, name: str) -> None: - """Rollback to postgres savepoint. - - Args: - name: Savepoint name - - Raises: - TransactionError: If rollback to savepoint fails - """ - try: - cursor = self._connection.cursor() - sql = f"ROLLBACK TO SAVEPOINT {name}" - self.log(logging.DEBUG, f"Executing: {sql}") - cursor.execute(sql) - cursor.close() - if self._active_savepoint == name: - self._active_savepoint = None - self.log(logging.INFO, f"Rolled back to savepoint: {name}") - except PsycopgError as e: - error_msg = f"Failed to rollback to savepoint {name}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None + raise TransactionError(error_msg) from e def get_current_isolation_level(self) -> IsolationLevel: """Get current transaction isolation level. @@ -352,93 +134,105 @@ def get_current_isolation_level(self) -> IsolationLevel: TransactionError: If getting isolation level fails """ try: - cursor = self._connection.cursor() - cursor.execute("SHOW transaction_isolation") - result = cursor.fetchone() - cursor.close() - - if result: - # Convert postgres level name to IsolationLevel enum - level_name = result[0].upper().replace(" ", "_") + result = self._backend.execute("SHOW transaction_isolation") + if result.data: + # result.data is a list of dicts with column names as keys + level_name = result.data[0]['transaction_isolation'].upper().replace(" ", "_") for isolation_level, pg_level in self._ISOLATION_LEVELS.items(): if pg_level.upper().replace(" ", "_") == level_name: return isolation_level - - # Default to READ COMMITTED if not found return IsolationLevel.READ_COMMITTED - - except PsycopgError as e: + except Exception as e: error_msg = f"Failed to get transaction isolation level: {str(e)}" self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None + raise TransactionError(error_msg) from e + + def get_active_savepoint(self) -> Optional[str]: + """Get the name of the most recently created active savepoint. + Returns: + Name of the most recent savepoint, or None if no savepoints are active. + """ + if self._active_savepoints: + return self._active_savepoints[-1] + return None + + def commit(self) -> None: + """Commit the current transaction and clear deferred constraints.""" + super().commit() + # Clear deferred constraints when outermost transaction commits + if self._transaction_level == 0: + self._deferred_constraints.clear() + + def rollback(self) -> None: + """Rollback the current transaction and clear deferred constraints.""" + super().rollback() + # Clear deferred constraints when outermost transaction rolls back + if self._transaction_level == 0: + self._deferred_constraints.clear() -class AsyncPostgresTransactionMixin: - """Async version of shared logic for postgres transaction management. - Contains non-I/O logic shared between sync and async implementations: - - Isolation level mapping - - Savepoint name generation - - SQL statement building +class AsyncPostgresTransactionManager(AsyncTransactionManager): + """Asynchronous PostgreSQL transaction manager implementation. + + All transaction operations are delegated to the base class + which uses backend.execute() for SQL execution. + PostgreSQL-specific features like DEFERRABLE are supported. """ - # postgres supported isolation level mappings + # PostgreSQL supported isolation level mappings _ISOLATION_LEVELS: Dict[IsolationLevel, str] = { IsolationLevel.READ_UNCOMMITTED: "READ UNCOMMITTED", - IsolationLevel.READ_COMMITTED: "READ COMMITTED", # postgres default + IsolationLevel.READ_COMMITTED: "READ COMMITTED", # PostgreSQL default IsolationLevel.REPEATABLE_READ: "REPEATABLE READ", IsolationLevel.SERIALIZABLE: "SERIALIZABLE", } - def __init__(self, connection, logger=None): - """Initialize transaction manager. + def __init__(self, backend: "AsyncPostgresBackend", logger=None): + """Initialize async PostgreSQL transaction manager. Args: - connection: postgres database connection - logger: Optional logger instance + backend: Async PostgreSQL backend instance. + logger: Optional logger instance. """ - self._active_savepoint = None - self._savepoint_counter = 0 - self._deferred_constraints: List[str] = [] + super().__init__(backend, logger) + # PostgreSQL default isolation level is READ COMMITTED + self._isolation_level = IsolationLevel.READ_COMMITTED + # DEFERRABLE mode for SERIALIZABLE transactions self._is_deferrable: Optional[bool] = None - self._active_transaction = False - self._state = TransactionState.INACTIVE + # Deferred constraints list + self._deferred_constraints: List[str] = [] - def _get_savepoint_name(self, level: int) -> str: - """Generate savepoint name for nested transactions. + def _build_begin_sql(self) -> tuple: + """Build BEGIN TRANSACTION SQL with PostgreSQL-specific options. - Args: - level: Transaction nesting level + This method creates a BeginTransactionExpression and delegates + SQL generation to the backend's dialect. All options including + DEFERRABLE are handled by the Expression/Dialect system. Returns: - Savepoint name + Tuple of (SQL string, parameters tuple). """ - return f"SP_{level}" + from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression - def _build_begin_statement(self) -> str: - """Build BEGIN statement with isolation level and deferrable mode. + expr = BeginTransactionExpression(self._backend.dialect) - Returns: - SQL BEGIN statement - """ - sql_parts = ["BEGIN"] + if self._isolation_level is not None: + expr.isolation_level(self._isolation_level) - # Add isolation level - if self._isolation_level: - level = self._ISOLATION_LEVELS.get(self._isolation_level) - if level: - sql_parts.append(f"ISOLATION LEVEL {level}") + if self._transaction_mode == TransactionMode.READ_ONLY: + expr.read_only() - # Add deferrable mode for SERIALIZABLE transactions + # DEFERRABLE is handled by the Expression/Dialect system if self._isolation_level == IsolationLevel.SERIALIZABLE and self._is_deferrable is not None: - sql_parts.append("DEFERRABLE" if self._is_deferrable else "NOT DEFERRABLE") + expr.deferrable(self._is_deferrable) - return " ".join(sql_parts) + return expr.to_sql() async def set_deferrable(self, deferrable: bool = True) -> None: """Set transaction deferrable mode. - In postgres, DEFERRABLE only affects SERIALIZABLE transactions. + In PostgreSQL, DEFERRABLE only affects SERIALIZABLE transactions. Args: deferrable: Whether constraints should be deferrable @@ -446,21 +240,13 @@ async def set_deferrable(self, deferrable: bool = True) -> None: Raises: TransactionError: If called on active transaction """ - self._is_deferrable = deferrable if self.is_active: error_msg = "Cannot change deferrable mode of active transaction" self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None + raise TransactionError(error_msg) + self._is_deferrable = deferrable self.log(logging.DEBUG, f"Set deferrable mode to {deferrable}") - def get_active_savepoint(self) -> Optional[str]: - """Get name of active savepoint. - - Returns: - Active savepoint name or None - """ - return self._active_savepoint - def get_deferred_constraints(self) -> List[str]: """Get list of currently deferred constraints. @@ -469,86 +255,8 @@ def get_deferred_constraints(self) -> List[str]: """ return self._deferred_constraints.copy() - async def supports_savepoint(self) -> bool: - """Check if savepoints are supported. - - Returns: - Always True for postgres - """ - return True - - @property - def is_active(self) -> bool: - """Check if transaction is active. - - Returns: - True if in transaction - """ - if not self._connection or self._connection.closed: - return False - - # Check based on autocommit mode - if self._connection.autocommit: - return False - - # If we've explicitly tracked transaction state, use that - return self._active_transaction - - -class AsyncPostgresTransactionManager(AsyncTransactionManager): - """Asynchronous postgres transaction manager implementation.""" - - # postgres supported isolation level mappings - _ISOLATION_LEVELS: Dict[IsolationLevel, str] = { - IsolationLevel.READ_UNCOMMITTED: "READ UNCOMMITTED", - IsolationLevel.READ_COMMITTED: "READ COMMITTED", # postgres default - IsolationLevel.REPEATABLE_READ: "REPEATABLE READ", - IsolationLevel.SERIALIZABLE: "SERIALIZABLE", - } - - def __init__(self, connection, logger=None): - """Initialize async transaction manager. - - Args: - connection: postgres database connection (async) - logger: Optional logger instance - """ - super().__init__(connection, logger) - self._active_savepoint = None - self._savepoint_counter = 0 - self._deferred_constraints: List[str] = [] - self._is_deferrable: Optional[bool] = None - self._active_transaction = False - self._state = TransactionState.INACTIVE - self._isolation_level: Optional[IsolationLevel] = None - - async def _set_isolation_level(self) -> None: - """Set transaction isolation level asynchronously. - - This is called at the start of each transaction. - - Raises: - TransactionError: If setting isolation level fails - """ - if self._isolation_level: - level = self._ISOLATION_LEVELS.get(self._isolation_level) - if level: - try: - self.log(logging.DEBUG, f"Setting isolation level to {level}") - cursor = self._connection.cursor() - await cursor.execute(f"SET TRANSACTION ISOLATION LEVEL {level}") - await cursor.close() - except PsycopgError as e: - error_msg = f"Failed to set isolation level to {level}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - else: - error_msg = f"Unsupported isolation level: {self._isolation_level}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - async def defer_constraint(self, constraint_name: str) -> None: - """Defer constraint checking until transaction commit (async). + """Defer constraint checking until transaction commit. Args: constraint_name: Name of the constraint to defer @@ -558,190 +266,13 @@ async def defer_constraint(self, constraint_name: str) -> None: """ try: self.log(logging.DEBUG, f"Deferring constraint: {constraint_name}") - cursor = self._connection.cursor() - await cursor.execute(f"SET CONSTRAINTS {constraint_name} DEFERRED") - await cursor.close() + await self._backend.execute(f"SET CONSTRAINTS {constraint_name} DEFERRED") self._deferred_constraints.append(constraint_name) self.log(logging.INFO, f"Deferred constraint: {constraint_name}") - except PsycopgError as e: + except Exception as e: error_msg = f"Failed to defer constraint {constraint_name}: {str(e)}" self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - def _build_begin_statement(self) -> str: - """Build BEGIN statement with isolation level and deferrable mode. - - Returns: - SQL BEGIN statement - """ - sql_parts = ["BEGIN"] - - # Add isolation level - if self._isolation_level: - level = self._ISOLATION_LEVELS.get(self._isolation_level) - if level: - sql_parts.append(f"ISOLATION LEVEL {level}") - - # Add deferrable mode for SERIALIZABLE transactions - if self._isolation_level == IsolationLevel.SERIALIZABLE and self._is_deferrable is not None: - sql_parts.append("DEFERRABLE" if self._is_deferrable else "NOT DEFERRABLE") - - return " ".join(sql_parts) - - async def _do_begin(self) -> None: - """Begin postgres transaction asynchronously. - - Sets isolation level and starts transaction. - - Raises: - TransactionError: If begin fails - """ - try: - self.log(logging.DEBUG, "Beginning transaction") - - # Set connection to non-autocommit mode - if self._connection.autocommit: - self._connection.autocommit = False - self.log(logging.DEBUG, "Set autocommit mode to False") - - # Build and execute BEGIN statement - begin_sql = self._build_begin_statement() - cursor = self._connection.cursor() - self.log(logging.DEBUG, f"Executing: {begin_sql}") - await cursor.execute(begin_sql) - await cursor.close() - - self._active_transaction = True - self._state = TransactionState.ACTIVE - self.log(logging.INFO, f"Started transaction with isolation level {self._isolation_level}") - - except PsycopgError as e: - error_msg = f"Failed to begin transaction: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - async def _do_commit(self) -> None: - """Commit postgres transaction asynchronously. - - Raises: - TransactionError: If commit fails - """ - try: - self.log(logging.DEBUG, "Committing transaction") - await self._connection.commit() - self._active_transaction = False - self._state = TransactionState.COMMITTED - self.log(logging.INFO, "Transaction committed") - except PsycopgError as e: - error_msg = f"Failed to commit transaction: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - finally: - self._active_savepoint = None - self._savepoint_counter = 0 - self._deferred_constraints.clear() - - async def _do_rollback(self) -> None: - """Rollback postgres transaction asynchronously. - - Raises: - TransactionError: If rollback fails - """ - try: - self.log(logging.DEBUG, "Rolling back transaction") - await self._connection.rollback() - self._active_transaction = False - self._state = TransactionState.ROLLED_BACK - self.log(logging.INFO, "Transaction rolled back") - except PsycopgError as e: - error_msg = f"Failed to rollback transaction: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - finally: - self._active_savepoint = None - self._savepoint_counter = 0 - self._deferred_constraints.clear() - - async def _do_create_savepoint(self, name: str) -> None: - """Create postgres savepoint asynchronously. - - Args: - name: Savepoint name - - Raises: - TransactionError: If create savepoint fails - """ - try: - # postgres requires active transaction for savepoints - if not self.is_active: - self.log(logging.DEBUG, "No active transaction, beginning one before creating savepoint") - await self._do_begin() - - cursor = self._connection.cursor() - sql = f"SAVEPOINT {name}" - self.log(logging.DEBUG, f"Executing: {sql}") - await cursor.execute(sql) - await cursor.close() - self._active_savepoint = name - self.log(logging.INFO, f"Created savepoint: {name}") - except PsycopgError as e: - error_msg = f"Failed to create savepoint {name}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - async def _do_release_savepoint(self, name: str) -> None: - """Release postgres savepoint asynchronously. - - Args: - name: Savepoint name - - Raises: - TransactionError: If release savepoint fails - """ - try: - cursor = self._connection.cursor() - sql = f"RELEASE SAVEPOINT {name}" - self.log(logging.DEBUG, f"Executing: {sql}") - await cursor.execute(sql) - await cursor.close() - if self._active_savepoint == name: - self._active_savepoint = None - self.log(logging.INFO, f"Released savepoint: {name}") - except PsycopgError as e: - error_msg = f"Failed to release savepoint {name}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - async def _do_rollback_savepoint(self, name: str) -> None: - """Rollback to postgres savepoint asynchronously. - - Args: - name: Savepoint name - - Raises: - TransactionError: If rollback to savepoint fails - """ - try: - cursor = self._connection.cursor() - sql = f"ROLLBACK TO SAVEPOINT {name}" - self.log(logging.DEBUG, f"Executing: {sql}") - await cursor.execute(sql) - await cursor.close() - if self._active_savepoint == name: - self._active_savepoint = None - self.log(logging.INFO, f"Rolled back to savepoint: {name}") - except PsycopgError as e: - error_msg = f"Failed to rollback to savepoint {name}: {str(e)}" - self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None - - async def supports_savepoint(self) -> bool: - """Check if savepoints are supported. - - Returns: - Always True for postgres - """ - return True + raise TransactionError(error_msg) from e async def get_current_isolation_level(self) -> IsolationLevel: """Get current transaction isolation level asynchronously. @@ -753,22 +284,39 @@ async def get_current_isolation_level(self) -> IsolationLevel: TransactionError: If getting isolation level fails """ try: - cursor = self._connection.cursor() - await cursor.execute("SHOW transaction_isolation") - result = await cursor.fetchone() - await cursor.close() - - if result: - # Convert postgres level name to IsolationLevel enum - level_name = result[0].upper().replace(" ", "_") + result = await self._backend.execute("SHOW transaction_isolation") + if result.data: + # result.data is a list of dicts with column names as keys + level_name = result.data[0]['transaction_isolation'].upper().replace(" ", "_") for isolation_level, pg_level in self._ISOLATION_LEVELS.items(): if pg_level.upper().replace(" ", "_") == level_name: return isolation_level - - # Default to READ COMMITTED if not found return IsolationLevel.READ_COMMITTED - - except PsycopgError as e: + except Exception as e: error_msg = f"Failed to get transaction isolation level: {str(e)}" self.log(logging.ERROR, error_msg) - raise TransactionError(error_msg) from None + raise TransactionError(error_msg) from e + + def get_active_savepoint(self) -> Optional[str]: + """Get the name of the most recently created active savepoint. + + Returns: + Name of the most recent savepoint, or None if no savepoints are active. + """ + if self._active_savepoints: + return self._active_savepoints[-1] + return None + + async def commit(self) -> None: + """Commit the current transaction and clear deferred constraints.""" + await super().commit() + # Clear deferred constraints when outermost transaction commits + if self._transaction_level == 0: + self._deferred_constraints.clear() + + async def rollback(self) -> None: + """Rollback the current transaction and clear deferred constraints.""" + await super().rollback() + # Clear deferred constraints when outermost transaction rolls back + if self._transaction_level == 0: + self._deferred_constraints.clear() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_transaction_postgres.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_transaction_postgres.py index 55818a39..fa9a5e2c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_transaction_postgres.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_transaction_postgres.py @@ -144,8 +144,8 @@ def test_begin_statement_with_isolation_level(self, postgres_backend, test_table """Test that BEGIN statement includes isolation level.""" tx_manager = postgres_backend.transaction_manager tx_manager.isolation_level = IsolationLevel.REPEATABLE_READ - - begin_sql = tx_manager._build_begin_statement() + + begin_sql, _ = tx_manager._build_begin_sql() assert "ISOLATION LEVEL REPEATABLE READ" in begin_sql def test_begin_statement_with_deferrable(self, postgres_backend, test_table): @@ -153,8 +153,8 @@ def test_begin_statement_with_deferrable(self, postgres_backend, test_table): tx_manager = postgres_backend.transaction_manager tx_manager.isolation_level = IsolationLevel.SERIALIZABLE tx_manager.set_deferrable(True) - - begin_sql = tx_manager._build_begin_statement() + + begin_sql, _ = tx_manager._build_begin_sql() assert "DEFERRABLE" in begin_sql @@ -272,8 +272,8 @@ async def test_async_begin_statement_with_isolation_level(self, async_postgres_b """Test that BEGIN statement includes isolation level (async).""" tx_manager = async_postgres_backend.transaction_manager tx_manager.isolation_level = IsolationLevel.REPEATABLE_READ - - begin_sql = tx_manager._build_begin_statement() + + begin_sql, _ = tx_manager._build_begin_sql() assert "ISOLATION LEVEL REPEATABLE READ" in begin_sql @pytest.mark.asyncio @@ -282,6 +282,6 @@ async def test_async_begin_statement_with_deferrable(self, async_postgres_backen tx_manager = async_postgres_backend.transaction_manager tx_manager.isolation_level = IsolationLevel.SERIALIZABLE tx_manager._is_deferrable = True - - begin_sql = tx_manager._build_begin_statement() + + begin_sql, _ = tx_manager._build_begin_sql() assert "DEFERRABLE" in begin_sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_savepoint.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_savepoint.py index fe212f6b..c6925e36 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_savepoint.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_savepoint.py @@ -344,19 +344,17 @@ async def test_async_nested_savepoints(self, async_postgres_backend, async_test_ async def test_async_get_active_savepoint(self, async_postgres_backend, async_test_table): """Test getting active savepoint name (async).""" tx_manager = async_postgres_backend.transaction_manager - - # Check _active_savepoint attribute directly since - # AsyncPostgresTransactionManager doesn't have get_active_savepoint method - assert tx_manager._active_savepoint is None - + + assert tx_manager.get_active_savepoint() is None + await tx_manager.begin() sp_name = await tx_manager.savepoint("async_active_sp") - - assert tx_manager._active_savepoint == sp_name - + + assert tx_manager.get_active_savepoint() == sp_name + await tx_manager.release(sp_name) - assert tx_manager._active_savepoint is None - + assert tx_manager.get_active_savepoint() is None + await tx_manager.commit() @pytest.mark.asyncio From 87eea51a9524e27086e6a2eee5b784829117c424 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 7 Apr 2026 23:09:53 +0800 Subject: [PATCH 4/8] test(transaction): add unit tests for PostgreSQL transaction expressions Add comprehensive tests for transaction expression classes: - BeginTransactionExpression with isolation levels and modes - CommitTransactionExpression and RollbackTransactionExpression - SavepointExpression and ReleaseSavepointExpression - SetTransactionExpression including session characteristics - Transaction capability verification tests --- .../feature/backend/postgres/conftest.py | 11 + .../postgres/test_expressions_transaction.py | 207 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py create mode 100644 tests/rhosocial/activerecord_test/feature/backend/postgres/test_expressions_transaction.py diff --git a/tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py b/tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py new file mode 100644 index 00000000..460be036 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py @@ -0,0 +1,11 @@ +# tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py +"""Fixtures for PostgreSQL transaction expression tests.""" +import pytest + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +@pytest.fixture(scope="function") +def postgres_dialect(): + """Fixture providing PostgresDialect instance for testing transaction expressions.""" + return PostgresDialect() diff --git a/tests/rhosocial/activerecord_test/feature/backend/postgres/test_expressions_transaction.py b/tests/rhosocial/activerecord_test/feature/backend/postgres/test_expressions_transaction.py new file mode 100644 index 00000000..7b9167e0 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/postgres/test_expressions_transaction.py @@ -0,0 +1,207 @@ +# tests/rhosocial/activerecord_test/feature/backend/postgres/test_expressions_transaction.py +"""Tests for PostgreSQL transaction expression classes.""" +import pytest +from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + CommitTransactionExpression, + RollbackTransactionExpression, + SavepointExpression, + ReleaseSavepointExpression, + SetTransactionExpression, +) +from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode + + +class TestPostgreSQLBeginTransactionExpression: + """Tests for PostgreSQL BeginTransactionExpression.""" + + def test_basic_begin(self, postgres_dialect): + """Test basic BEGIN statement.""" + expr = BeginTransactionExpression(postgres_dialect) + sql, params = expr.to_sql() + assert sql == "BEGIN" + assert params == () + + def test_begin_with_isolation_level(self, postgres_dialect): + """Test BEGIN with isolation level.""" + expr = BeginTransactionExpression(postgres_dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE) + sql, params = expr.to_sql() + assert sql == "BEGIN ISOLATION LEVEL SERIALIZABLE" + assert params == () + + def test_begin_read_only(self, postgres_dialect): + """Test BEGIN READ ONLY.""" + expr = BeginTransactionExpression(postgres_dialect) + expr.read_only() + sql, params = expr.to_sql() + assert sql == "BEGIN READ ONLY" + assert params == () + + def test_begin_read_write(self, postgres_dialect): + """Test BEGIN READ WRITE.""" + expr = BeginTransactionExpression(postgres_dialect) + expr.read_write() + sql, params = expr.to_sql() + assert sql == "BEGIN READ WRITE" + assert params == () + + def test_begin_with_isolation_and_read_only(self, postgres_dialect): + """Test BEGIN with isolation level and READ ONLY.""" + expr = BeginTransactionExpression(postgres_dialect) + expr.isolation_level(IsolationLevel.REPEATABLE_READ).read_only() + sql, params = expr.to_sql() + assert "ISOLATION LEVEL REPEATABLE READ" in sql + assert "READ ONLY" in sql + assert params == () + + def test_begin_deferrable(self, postgres_dialect): + """Test BEGIN with DEFERRABLE (only for SERIALIZABLE).""" + expr = BeginTransactionExpression(postgres_dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE).deferrable() + sql, params = expr.to_sql() + assert "ISOLATION LEVEL SERIALIZABLE" in sql + assert "DEFERRABLE" in sql + assert params == () + + def test_begin_not_deferrable(self, postgres_dialect): + """Test BEGIN with NOT DEFERRABLE.""" + expr = BeginTransactionExpression(postgres_dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE).deferrable(False) + sql, params = expr.to_sql() + assert "NOT DEFERRABLE" in sql + assert params == () + + def test_begin_deferrable_ignored_for_non_serializable(self, postgres_dialect): + """Test DEFERRABLE is ignored for non-SERIALIZABLE isolation levels.""" + expr = BeginTransactionExpression(postgres_dialect) + expr.isolation_level(IsolationLevel.READ_COMMITTED).deferrable() + sql, params = expr.to_sql() + assert "DEFERRABLE" not in sql + assert "READ COMMITTED" in sql + + @pytest.mark.parametrize("level,expected_name", [ + (IsolationLevel.READ_UNCOMMITTED, "READ UNCOMMITTED"), + (IsolationLevel.READ_COMMITTED, "READ COMMITTED"), + (IsolationLevel.REPEATABLE_READ, "REPEATABLE READ"), + (IsolationLevel.SERIALIZABLE, "SERIALIZABLE"), + ]) + def test_all_isolation_levels(self, postgres_dialect, level, expected_name): + """Test all isolation levels.""" + expr = BeginTransactionExpression(postgres_dialect) + expr.isolation_level(level) + sql, params = expr.to_sql() + assert expected_name in sql + assert params == () + + +class TestPostgreSQLCommitRollback: + """Tests for PostgreSQL COMMIT and ROLLBACK.""" + + def test_commit(self, postgres_dialect): + """Test COMMIT statement.""" + expr = CommitTransactionExpression(postgres_dialect) + sql, params = expr.to_sql() + assert sql == "COMMIT" + assert params == () + + def test_rollback(self, postgres_dialect): + """Test ROLLBACK statement.""" + expr = RollbackTransactionExpression(postgres_dialect) + sql, params = expr.to_sql() + assert sql == "ROLLBACK" + assert params == () + + def test_rollback_to_savepoint(self, postgres_dialect): + """Test ROLLBACK TO SAVEPOINT statement.""" + expr = RollbackTransactionExpression(postgres_dialect) + expr.to_savepoint("my_savepoint") + sql, params = expr.to_sql() + assert "ROLLBACK" in sql + assert "SAVEPOINT" in sql + assert params == () + + +class TestPostgreSQLSavepoint: + """Tests for PostgreSQL SAVEPOINT operations.""" + + def test_savepoint(self, postgres_dialect): + """Test SAVEPOINT statement.""" + expr = SavepointExpression(postgres_dialect, "my_savepoint") + sql, params = expr.to_sql() + assert "SAVEPOINT" in sql + assert "my_savepoint" in sql + assert params == () + + def test_release_savepoint(self, postgres_dialect): + """Test RELEASE SAVEPOINT statement.""" + expr = ReleaseSavepointExpression(postgres_dialect, "my_savepoint") + sql, params = expr.to_sql() + assert "RELEASE SAVEPOINT" in sql + assert "my_savepoint" in sql + assert params == () + + +class TestPostgreSQLSetTransaction: + """Tests for PostgreSQL SET TRANSACTION.""" + + def test_set_transaction_isolation(self, postgres_dialect): + """Test SET TRANSACTION with isolation level.""" + expr = SetTransactionExpression(postgres_dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE) + sql, params = expr.to_sql() + assert "SET TRANSACTION" in sql + assert "ISOLATION LEVEL SERIALIZABLE" in sql + assert params == () + + def test_set_transaction_read_only(self, postgres_dialect): + """Test SET TRANSACTION READ ONLY.""" + expr = SetTransactionExpression(postgres_dialect) + expr.read_only() + sql, params = expr.to_sql() + assert "SET TRANSACTION" in sql + assert "READ ONLY" in sql + assert params == () + + def test_set_session_characteristics(self, postgres_dialect): + """Test SET SESSION CHARACTERISTICS AS TRANSACTION.""" + expr = SetTransactionExpression(postgres_dialect) + expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_only() + sql, params = expr.to_sql() + assert "SET SESSION CHARACTERISTICS AS TRANSACTION" in sql + assert "ISOLATION LEVEL SERIALIZABLE" in sql + assert "READ ONLY" in sql + assert params == () + + def test_set_transaction_deferrable(self, postgres_dialect): + """Test SET TRANSACTION DEFERRABLE.""" + expr = SetTransactionExpression(postgres_dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE).deferrable() + sql, params = expr.to_sql() + assert "ISOLATION LEVEL SERIALIZABLE" in sql + assert "DEFERRABLE" in sql + assert params == () + + +class TestPostgreSQLTransactionCapabilities: + """Tests for PostgreSQL transaction capabilities.""" + + def test_supports_transaction_mode(self, postgres_dialect): + """Test PostgreSQL supports transaction mode.""" + assert postgres_dialect.supports_transaction_mode() == True + + def test_supports_isolation_level_in_begin(self, postgres_dialect): + """Test PostgreSQL supports isolation level in BEGIN.""" + assert postgres_dialect.supports_isolation_level_in_begin() == True + + def test_supports_read_only_transaction(self, postgres_dialect): + """Test PostgreSQL supports READ ONLY transactions.""" + assert postgres_dialect.supports_read_only_transaction() == True + + def test_supports_deferrable_transaction(self, postgres_dialect): + """Test PostgreSQL supports DEFERRABLE transactions.""" + assert postgres_dialect.supports_deferrable_transaction() == True + + def test_supports_savepoint(self, postgres_dialect): + """Test PostgreSQL supports savepoints.""" + assert postgres_dialect.supports_savepoint() == True From c0672f59d1a61fda532e0686690811e2398ab7c7 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:51:30 +0800 Subject: [PATCH 5/8] test(transaction): add comprehensive transaction mode and advanced feature tests (#20) * test(transaction): add comprehensive transaction mode and advanced feature tests - Add test_transaction_mode_effect.py for READ ONLY/READ WRITE mode effects - Verify READ ONLY mode rejects INSERT/UPDATE/DELETE operations - Verify READ WRITE mode allows all write operations - Test mode persistence across transactions - Test mode combined with isolation level - Include both sync and async test coverage - Add test_transaction_advanced_features.py for advanced PostgreSQL features - Test DEFERRABLE transaction behavior with SERIALIZABLE isolation - Test SET TRANSACTION statement for isolation level and access mode - Test SESSION CHARACTERISTICS persistence and reset - Include async test counterparts - Fix test_transaction_deferrable.py to use public API - Replace _deferred_constraints private attribute access with get_deferred_constraints() All tests verify PostgreSQL transaction features work correctly with the transaction manager and expression system. Co-Authored-By: Claude Opus 4.6 * fix(transaction): respect PostgreSQL optional syntax for BEGIN statement PostgreSQL does not require explicit isolation level in BEGIN statement. When isolation_level is None (not explicitly set), BEGIN should not include ISOLATION LEVEL clause, allowing SESSION CHARACTERISTICS to take effect. Changes: - Remove default isolation_level initialization in PostgresTransactionManager - Remove default isolation_level initialization in AsyncPostgresTransactionManager - Update test_session_characteristics_with_isolation_level to verify that SESSION CHARACTERISTICS work when isolation_level is None - Add async counterpart test for sync/async parity This ensures: 1. BEGIN without explicit isolation level respects SESSION CHARACTERISTICS 2. Users can still set explicit isolation_level to override session defaults 3. Synchronous and asynchronous behaviors are equivalent Co-Authored-By: Claude Opus 4.6 * chore: add changelog fragment for PR #20 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- changelog.d/20.fixed.md | 1 + .../backend/impl/postgres/transaction.py | 12 +- .../test_transaction_advanced_features.py | 511 ++++++++++++++++++ .../backend/test_transaction_deferrable.py | 17 +- .../backend/test_transaction_mode_effect.py | 332 ++++++++++++ 5 files changed, 860 insertions(+), 13 deletions(-) create mode 100644 changelog.d/20.fixed.md create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py diff --git a/changelog.d/20.fixed.md b/changelog.d/20.fixed.md new file mode 100644 index 00000000..dc6b190e --- /dev/null +++ b/changelog.d/20.fixed.md @@ -0,0 +1 @@ +Fixed PostgreSQL transaction manager to respect optional BEGIN syntax - isolation level is no longer forced, allowing SESSION CHARACTERISTICS to take effect when not explicitly set. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py index 3b0cb5c4..0acb2611 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py @@ -46,8 +46,10 @@ def __init__(self, backend: "PostgresBackend", logger=None): logger: Optional logger instance. """ super().__init__(backend, logger) - # PostgreSQL default isolation level is READ COMMITTED - self._isolation_level = IsolationLevel.READ_COMMITTED + # PostgreSQL default isolation level is READ COMMITTED, but we don't + # set it here to allow SESSION CHARACTERISTICS to take effect. + # Users can explicitly set isolation_level property if needed. + # self._isolation_level = None (inherited from base class) # DEFERRABLE mode for SERIALIZABLE transactions self._is_deferrable: Optional[bool] = None # Deferred constraints list @@ -196,8 +198,10 @@ def __init__(self, backend: "AsyncPostgresBackend", logger=None): logger: Optional logger instance. """ super().__init__(backend, logger) - # PostgreSQL default isolation level is READ COMMITTED - self._isolation_level = IsolationLevel.READ_COMMITTED + # PostgreSQL default isolation level is READ COMMITTED, but we don't + # set it here to allow SESSION CHARACTERISTICS to take effect. + # Users can explicitly set isolation_level property if needed. + # self._isolation_level = None (inherited from base class) # DEFERRABLE mode for SERIALIZABLE transactions self._is_deferrable: Optional[bool] = None # Deferred constraints list diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py new file mode 100644 index 00000000..be6eb8e9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py @@ -0,0 +1,511 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py +""" +PostgreSQL advanced transaction feature tests. + +This module tests DEFERRABLE transactions, SET TRANSACTION, and SESSION CHARACTERISTICS. +""" +import pytest +import pytest_asyncio +from decimal import Decimal + +from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode + + +class TestSyncDeferrableTransaction: + """Tests for DEFERRABLE transaction behavior with SERIALIZABLE isolation.""" + + @pytest.fixture + def test_tables(self, postgres_backend): + """Create test tables with deferred constraint.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_child CASCADE") + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_parent CASCADE") + + postgres_backend.execute(""" + CREATE TABLE test_deferred_parent ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + postgres_backend.execute(""" + CREATE TABLE test_deferred_child ( + id SERIAL PRIMARY KEY, + parent_id INT NOT NULL, + amount DECIMAL(10, 2), + CONSTRAINT deferred_fk + FOREIGN KEY (parent_id) REFERENCES test_deferred_parent(id) + DEFERRABLE INITIALLY IMMEDIATE + ) + """) + yield + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_child CASCADE") + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_parent CASCADE") + + def test_deferrable_serializable_allows_deferred_checking(self, postgres_backend, test_tables): + """Test DEFERRABLE SERIALIZABLE transaction with deferred constraint checking. + + DEFERRABLE affects when constraint checking occurs: + - INITIALLY IMMEDIATE: constraints checked at statement level by default + - SET CONSTRAINTS ... DEFERRED: defer until transaction commit + + This test verifies the transaction manager correctly sets DEFERRABLE mode. + """ + tx_manager = postgres_backend.transaction_manager + + # Set SERIALIZABLE isolation with DEFERRABLE + tx_manager.isolation_level = IsolationLevel.SERIALIZABLE + tx_manager.set_deferrable(True) + + with postgres_backend.transaction(): + # Defer the constraint + tx_manager.defer_constraint("deferred_fk") + + # Insert child before parent - normally would fail immediately + # but deferred constraint allows it until commit + postgres_backend.execute( + "INSERT INTO test_deferred_child (parent_id, amount) VALUES (%s, %s)", + (1, Decimal("100.00")) + ) + + # Insert parent to satisfy constraint before commit + postgres_backend.execute( + "INSERT INTO test_deferred_parent (id, name) VALUES (%s, %s)", + (1, "Parent1") + ) + + # Verify data was committed + rows = postgres_backend.fetch_all("SELECT * FROM test_deferred_parent") + assert len(rows) == 1 + + def test_not_deferrable_serializable(self, postgres_backend, test_tables): + """Test NOT DEFERRABLE SERIALIZABLE transaction.""" + tx_manager = postgres_backend.transaction_manager + + tx_manager.isolation_level = IsolationLevel.SERIALIZABLE + tx_manager.set_deferrable(False) + + # Transaction should start normally + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_deferred_parent (name) VALUES (%s)", + ("Parent2",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_deferred_parent") + assert len(rows) == 1 + + def test_deferrable_requires_serializable(self, postgres_backend, test_tables): + """Test that DEFERRABLE setting is only meaningful for SERIALIZABLE isolation. + + The dialect should ignore DEFERRABLE for non-SERIALIZABLE isolation levels. + """ + tx_manager = postgres_backend.transaction_manager + + # Set READ COMMITTED with DEFERRABLE (should be ignored) + tx_manager.isolation_level = IsolationLevel.READ_COMMITTED + tx_manager.set_deferrable(True) + + # Transaction should work normally (DEFERRABLE ignored) + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_deferred_parent (name) VALUES (%s)", + ("Parent3",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_deferred_parent") + assert len(rows) == 1 + + +class TestSyncSetTransaction: + """Tests for SET TRANSACTION statement.""" + + @pytest.fixture + def test_table(self, postgres_backend): + """Create a test table.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_set_trans_table") + postgres_backend.execute(""" + CREATE TABLE test_set_trans_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_set_trans_table" + postgres_backend.execute("DROP TABLE IF EXISTS test_set_trans_table") + + def test_set_transaction_isolation_level(self, postgres_backend, test_table): + """Test setting isolation level via SET TRANSACTION expression. + + SET TRANSACTION sets characteristics for the current transaction. + It must be called before any queries in the transaction. + """ + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + # Start transaction + postgres_backend.transaction_manager.begin() + + # Set isolation level using expression + expr = SetTransactionExpression(postgres_backend.dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE) + sql, params = expr.to_sql() + postgres_backend.execute(sql, params) + + # Verify isolation level was set + current_level = postgres_backend.transaction_manager.get_current_isolation_level() + assert current_level == IsolationLevel.SERIALIZABLE + + postgres_backend.transaction_manager.commit() + + def test_set_transaction_read_only(self, postgres_backend, test_table): + """Test setting READ ONLY mode via SET TRANSACTION expression.""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + postgres_backend.transaction_manager.begin() + + expr = SetTransactionExpression(postgres_backend.dialect) + expr.read_only() + sql, params = expr.to_sql() + postgres_backend.execute(sql, params) + + # Verify READ ONLY mode by attempting a write + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_set_trans_table (name) VALUES (%s)", + ("ShouldFail",) + ) + assert "read-only" in str(exc_info.value).lower() + + # Transaction is aborted after error, rollback to clean up + try: + postgres_backend.transaction_manager.rollback() + except Exception: + pass # Transaction may already be terminated + + +class TestSyncSessionCharacteristics: + """Tests for SESSION CHARACTERISTICS.""" + + @pytest.fixture + def test_table(self, postgres_backend): + """Create a test table.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_session_table") + postgres_backend.execute(""" + CREATE TABLE test_session_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_session_table" + postgres_backend.execute("DROP TABLE IF EXISTS test_session_table") + + def test_session_characteristics_persist_across_transactions(self, postgres_backend, test_table): + """Test that SESSION CHARACTERISTICS affect all subsequent transactions.""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + # Set session characteristics to READ ONLY + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_only() + sql, params = expr.to_sql() + postgres_backend.execute(sql, params) + + # First transaction should be READ ONLY + tx_manager = postgres_backend.transaction_manager + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_session_table (name) VALUES (%s)", + ("ShouldFail1",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + tx_manager.rollback() + + # Second transaction should also be READ ONLY + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_session_table (name) VALUES (%s)", + ("ShouldFail2",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + tx_manager.rollback() + finally: + # Reset session characteristics to READ WRITE for teardown + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + def test_reset_session_characteristics(self, postgres_backend, test_table): + """Test resetting session characteristics to READ WRITE.""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + # Set session characteristics to READ ONLY + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_only() + postgres_backend.execute(expr.to_sql()[0]) + + # Reset to READ WRITE + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + # Now transactions should allow writes + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_session_table (name) VALUES (%s)", + ("ShouldSucceed",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_session_table") + assert len(rows) == 1 + + def test_session_characteristics_with_isolation_level(self, postgres_backend, test_table): + """Test session characteristics with isolation level. + + When isolation_level is not explicitly set (None), SESSION CHARACTERISTICS + should take effect. This test verifies: + 1. SESSION CHARACTERISTICS can set default isolation level + 2. BEGIN without explicit isolation level uses session default + """ + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + # Set session characteristics with SERIALIZABLE isolation + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + # Verify isolation_level is None (not explicitly set) + tx_manager = postgres_backend.transaction_manager + assert tx_manager.isolation_level is None, \ + "isolation_level should be None to allow SESSION CHARACTERISTICS to take effect" + + # Use begin() directly (not context manager) to avoid implicit isolation level setting + tx_manager.begin() + level = tx_manager.get_current_isolation_level() + assert level == IsolationLevel.SERIALIZABLE, \ + f"Expected SERIALIZABLE from SESSION CHARACTERISTICS, got {level}" + tx_manager.commit() + + # Now test with explicit isolation level override + tx_manager.isolation_level = IsolationLevel.READ_COMMITTED + with postgres_backend.transaction(): + level = tx_manager.get_current_isolation_level() + assert level == IsolationLevel.READ_COMMITTED, \ + f"Explicit isolation level should override SESSION CHARACTERISTICS, got {level}" + finally: + # Reset to default READ WRITE with READ COMMITTED + tx_manager = postgres_backend.transaction_manager + tx_manager.isolation_level = None + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + +class TestAsyncDeferrableTransaction: + """Async tests for DEFERRABLE transaction behavior.""" + + @pytest_asyncio.fixture + async def async_test_tables(self, async_postgres_backend): + """Create test tables with deferred constraint.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_child CASCADE") + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_parent CASCADE") + + await async_postgres_backend.execute(""" + CREATE TABLE test_async_deferred_parent ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + await async_postgres_backend.execute(""" + CREATE TABLE test_async_deferred_child ( + id SERIAL PRIMARY KEY, + parent_id INT NOT NULL, + amount DECIMAL(10, 2), + CONSTRAINT async_deferred_fk + FOREIGN KEY (parent_id) REFERENCES test_async_deferred_parent(id) + DEFERRABLE INITIALLY IMMEDIATE + ) + """) + yield + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_child CASCADE") + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_parent CASCADE") + + @pytest.mark.asyncio + async def test_async_deferrable_serializable(self, async_postgres_backend, async_test_tables): + """Test DEFERRABLE SERIALIZABLE transaction (async).""" + tx_manager = async_postgres_backend.transaction_manager + + tx_manager.isolation_level = IsolationLevel.SERIALIZABLE + await tx_manager.set_deferrable(True) + + async with async_postgres_backend.transaction(): + await tx_manager.defer_constraint("async_deferred_fk") + + await async_postgres_backend.execute( + "INSERT INTO test_async_deferred_child (parent_id, amount) VALUES (%s, %s)", + (1, Decimal("100.00")) + ) + + await async_postgres_backend.execute( + "INSERT INTO test_async_deferred_parent (id, name) VALUES (%s, %s)", + (1, "AsyncParent1") + ) + + rows = await async_postgres_backend.fetch_all("SELECT * FROM test_async_deferred_parent") + assert len(rows) == 1 + + +class TestAsyncSetTransaction: + """Async tests for SET TRANSACTION statement.""" + + @pytest_asyncio.fixture + async def async_test_table(self, async_postgres_backend): + """Create a test table.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_set_trans_table") + await async_postgres_backend.execute(""" + CREATE TABLE test_async_set_trans_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_async_set_trans_table" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_set_trans_table") + + @pytest.mark.asyncio + async def test_async_set_transaction_isolation_level(self, async_postgres_backend, async_test_table): + """Test setting isolation level via SET TRANSACTION (async).""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + await async_postgres_backend.transaction_manager.begin() + + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE) + sql, params = expr.to_sql() + await async_postgres_backend.execute(sql, params) + + current_level = await async_postgres_backend.transaction_manager.get_current_isolation_level() + assert current_level == IsolationLevel.SERIALIZABLE + + await async_postgres_backend.transaction_manager.commit() + + @pytest.mark.asyncio + async def test_async_set_transaction_read_only(self, async_postgres_backend, async_test_table): + """Test setting READ ONLY mode via SET TRANSACTION (async).""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + await async_postgres_backend.transaction_manager.begin() + + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.read_only() + await async_postgres_backend.execute(expr.to_sql()[0]) + + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_async_set_trans_table (name) VALUES (%s)", + ("ShouldFail",) + ) + assert "read-only" in str(exc_info.value).lower() + + # Transaction is aborted after error, rollback to clean up + try: + await async_postgres_backend.transaction_manager.rollback() + except Exception: + pass # Transaction may already be terminated + + +class TestAsyncSessionCharacteristics: + """Async tests for SESSION CHARACTERISTICS.""" + + @pytest_asyncio.fixture + async def async_test_table(self, async_postgres_backend): + """Create a test table.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_session_table") + await async_postgres_backend.execute(""" + CREATE TABLE test_async_session_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_async_session_table" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_session_table") + + @pytest.mark.asyncio + async def test_async_session_characteristics_persist(self, async_postgres_backend, async_test_table): + """Test that SESSION CHARACTERISTICS affect subsequent transactions (async).""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).read_only() + await async_postgres_backend.execute(expr.to_sql()[0]) + + tx_manager = async_postgres_backend.transaction_manager + await tx_manager.begin() + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_async_session_table (name) VALUES (%s)", + ("ShouldFail",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + await tx_manager.rollback() + + # Second transaction also READ ONLY + await tx_manager.begin() + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_async_session_table (name) VALUES (%s)", + ("ShouldFail2",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + await tx_manager.rollback() + finally: + # Reset session characteristics to READ WRITE for teardown + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).read_write() + await async_postgres_backend.execute(expr.to_sql()[0]) + + @pytest.mark.asyncio + async def test_async_session_characteristics_with_isolation_level(self, async_postgres_backend, async_test_table): + """Test session characteristics with isolation level (async). + + When isolation_level is not explicitly set (None), SESSION CHARACTERISTICS + should take effect. This test verifies: + 1. SESSION CHARACTERISTICS can set default isolation level + 2. BEGIN without explicit isolation level uses session default + """ + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + # Set session characteristics with SERIALIZABLE isolation + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_write() + await async_postgres_backend.execute(expr.to_sql()[0]) + + # Verify isolation_level is None (not explicitly set) + tx_manager = async_postgres_backend.transaction_manager + assert tx_manager.isolation_level is None, \ + "isolation_level should be None to allow SESSION CHARACTERISTICS to take effect" + + # Use begin() directly (not context manager) to avoid implicit isolation level setting + await tx_manager.begin() + level = await tx_manager.get_current_isolation_level() + assert level == IsolationLevel.SERIALIZABLE, \ + f"Expected SERIALIZABLE from SESSION CHARACTERISTICS, got {level}" + await tx_manager.commit() + + # Now test with explicit isolation level override + tx_manager.isolation_level = IsolationLevel.READ_COMMITTED + async with async_postgres_backend.transaction(): + level = await tx_manager.get_current_isolation_level() + assert level == IsolationLevel.READ_COMMITTED, \ + f"Explicit isolation level should override SESSION CHARACTERISTICS, got {level}" + finally: + # Reset to default READ WRITE with no explicit isolation level + tx_manager = async_postgres_backend.transaction_manager + tx_manager.isolation_level = None + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).read_write() + await async_postgres_backend.execute(expr.to_sql()[0]) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py index 38541cd8..de3885f7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py @@ -211,13 +211,12 @@ async def test_async_defer_multiple_constraints(self, async_postgres_backend, as async def test_async_get_deferred_constraints(self, async_postgres_backend, async_test_table_with_deferrable_fk): """Test getting list of deferred constraints (async).""" tx_manager = async_postgres_backend.transaction_manager - + async with async_postgres_backend.transaction(): await tx_manager.defer_constraint("async_deferred_fk") - - # Check _deferred_constraints attribute directly since - # AsyncPostgresTransactionManager doesn't have get_deferred_constraints method - deferred = tx_manager._deferred_constraints + + # Use public API to get deferred constraints + deferred = tx_manager.get_deferred_constraints() assert "async_deferred_fk" in deferred @pytest.mark.asyncio @@ -227,7 +226,7 @@ async def test_async_deferred_constraints_cleared_after_commit(self, async_postg async with async_postgres_backend.transaction(): await tx_manager.defer_constraint("async_deferred_fk") - assert len(tx_manager._deferred_constraints) == 1 + assert len(tx_manager.get_deferred_constraints()) == 1 await async_postgres_backend.execute( "INSERT INTO test_deferred_parent (id, name) VALUES (%s, %s)", @@ -238,7 +237,7 @@ async def test_async_deferred_constraints_cleared_after_commit(self, async_postg (3, Decimal("100.00")) ) - assert len(tx_manager._deferred_constraints) == 0 + assert len(tx_manager.get_deferred_constraints()) == 0 @pytest.mark.asyncio async def test_async_deferred_constraints_cleared_after_rollback(self, async_postgres_backend, async_test_table_with_deferrable_fk): @@ -248,9 +247,9 @@ async def test_async_deferred_constraints_cleared_after_rollback(self, async_pos try: async with async_postgres_backend.transaction(): await tx_manager.defer_constraint("async_deferred_fk") - assert len(tx_manager._deferred_constraints) == 1 + assert len(tx_manager.get_deferred_constraints()) == 1 raise Exception("Force rollback") except Exception: pass - assert len(tx_manager._deferred_constraints) == 0 + assert len(tx_manager.get_deferred_constraints()) == 0 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py new file mode 100644 index 00000000..021b43ac --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py @@ -0,0 +1,332 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py +""" +PostgreSQL transaction access mode effect tests. + +This module tests the actual effect of READ ONLY and READ WRITE transaction modes. +It verifies that the modes actually affect database operations as expected. +""" +import pytest +import pytest_asyncio +from decimal import Decimal + +from rhosocial.activerecord.backend.transaction import TransactionMode + + +class TestSyncTransactionModeEffect: + """Synchronous transaction access mode effect tests.""" + + @pytest.fixture + def test_table(self, postgres_backend): + """Create a test table.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + postgres_backend.execute(""" + CREATE TABLE test_mode_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + amount DECIMAL(10, 2) + ) + """) + yield "test_mode_table" + postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + + def test_read_write_mode_allows_insert(self, postgres_backend, test_table): + """Test that READ WRITE mode allows INSERT operations.""" + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ReadWriteTest", Decimal("100.00")) + ) + + rows = postgres_backend.fetch_all("SELECT name FROM test_mode_table") + assert len(rows) == 1 + assert rows[0]["name"] == "ReadWriteTest" + + def test_read_write_mode_allows_update(self, postgres_backend, test_table): + """Test that READ WRITE mode allows UPDATE operations.""" + # First insert a row + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("Original", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + postgres_backend.execute( + "UPDATE test_mode_table SET amount = %s WHERE name = %s", + (Decimal("200.00"), "Original") + ) + + rows = postgres_backend.fetch_all("SELECT amount FROM test_mode_table WHERE name = %s", ("Original",)) + assert len(rows) == 1 + assert rows[0]["amount"] == Decimal("200.00") + + def test_read_write_mode_allows_delete(self, postgres_backend, test_table): + """Test that READ WRITE mode allows DELETE operations.""" + # First insert a row + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ToBeDeleted", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + postgres_backend.execute( + "DELETE FROM test_mode_table WHERE name = %s", + ("ToBeDeleted",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 0 + + def test_read_only_mode_allows_select(self, postgres_backend, test_table): + """Test that READ ONLY mode allows SELECT operations.""" + # Insert test data first + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ReadOnlySelect", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + with postgres_backend.transaction(): + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + + assert len(rows) == 1 + assert rows[0]["name"] == "ReadOnlySelect" + + def test_read_only_mode_rejects_insert(self, postgres_backend, test_table): + """Test that READ ONLY mode rejects INSERT operations. + + PostgreSQL raises an error when attempting to write in READ ONLY mode. + Error code: 25006 (read_only_sql_transaction) + """ + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + tx_manager.begin() + # PostgreSQL raises an error for write operations in READ ONLY mode + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("100.00")) + ) + # Verify it's a read-only transaction error + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + # Transaction is auto-aborted after error, check if rollback is needed + if tx_manager.is_active: + tx_manager.rollback() + + # Verify no data was inserted + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 0 + + def test_read_only_mode_rejects_update(self, postgres_backend, test_table): + """Test that READ ONLY mode rejects UPDATE operations.""" + # Insert test data first + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("Original", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "UPDATE test_mode_table SET amount = %s WHERE name = %s", + (Decimal("200.00"), "Original") + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + # Verify data was not modified + rows = postgres_backend.fetch_all("SELECT amount FROM test_mode_table WHERE name = %s", ("Original",)) + assert rows[0]["amount"] == Decimal("100.00") + + def test_read_only_mode_rejects_delete(self, postgres_backend, test_table): + """Test that READ ONLY mode rejects DELETE operations.""" + # Insert test data first + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ToBeDeleted", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "DELETE FROM test_mode_table WHERE name = %s", + ("ToBeDeleted",) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + # Verify data was not deleted + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 1 + + def test_mode_cannot_change_during_active_transaction(self, postgres_backend, test_table): + """Test that transaction mode cannot be changed during an active transaction.""" + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + from rhosocial.activerecord.backend.errors import TransactionError + with pytest.raises(TransactionError): + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + def test_mode_persists_across_transactions(self, postgres_backend, test_table): + """Test that transaction mode setting persists across multiple transactions.""" + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + # First transaction + with postgres_backend.transaction(): + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert rows == [] + + # Second transaction should still be READ ONLY + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("100.00")) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + def test_mode_combined_with_isolation_level(self, postgres_backend, test_table): + """Test that mode can be combined with isolation level.""" + from rhosocial.activerecord.backend.transaction import IsolationLevel + + tx_manager = postgres_backend.transaction_manager + tx_manager.isolation_level = IsolationLevel.REPEATABLE_READ + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + # Insert test data first (outside transaction) + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("CombinedTest", Decimal("100.00")) + ) + + tx_manager.begin() + # Should be able to read + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 1 + + # Should not be able to write + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("200.00")) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + +class TestAsyncTransactionModeEffect: + """Asynchronous transaction access mode effect tests.""" + + @pytest_asyncio.fixture + async def async_test_table(self, async_postgres_backend): + """Create a test table.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + await async_postgres_backend.execute(""" + CREATE TABLE test_mode_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + amount DECIMAL(10, 2) + ) + """) + yield "test_mode_table" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + + @pytest.mark.asyncio + async def test_async_read_write_mode_allows_insert(self, async_postgres_backend, async_test_table): + """Test that READ WRITE mode allows INSERT operations (async).""" + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + async with async_postgres_backend.transaction(): + await async_postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("AsyncReadWriteTest", Decimal("100.00")) + ) + + rows = await async_postgres_backend.fetch_all("SELECT name FROM test_mode_table") + assert len(rows) == 1 + assert rows[0]["name"] == "AsyncReadWriteTest" + + @pytest.mark.asyncio + async def test_async_read_only_mode_rejects_insert(self, async_postgres_backend, async_test_table): + """Test that READ ONLY mode rejects INSERT operations (async).""" + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + await tx_manager.begin() + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("100.00")) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + if tx_manager.is_active: + await tx_manager.rollback() + + rows = await async_postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 0 + + @pytest.mark.asyncio + async def test_async_read_only_mode_allows_select(self, async_postgres_backend, async_test_table): + """Test that READ ONLY mode allows SELECT operations (async).""" + await async_postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("AsyncReadOnlySelect", Decimal("100.00")) + ) + + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + async with async_postgres_backend.transaction(): + rows = await async_postgres_backend.fetch_all("SELECT * FROM test_mode_table") + + assert len(rows) == 1 + assert rows[0]["name"] == "AsyncReadOnlySelect" + + @pytest.mark.asyncio + async def test_async_mode_cannot_change_during_active_transaction(self, async_postgres_backend, async_test_table): + """Test that transaction mode cannot be changed during an active transaction (async).""" + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + async with async_postgres_backend.transaction(): + from rhosocial.activerecord.backend.errors import TransactionError + with pytest.raises(TransactionError): + tx_manager.transaction_mode = TransactionMode.READ_ONLY From 59df2a2c360a2388d15d5c70f5739c79ba2d3146 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:47:57 +0800 Subject: [PATCH 6/8] feat(introspection): add PostgreSQL server status overview support (#21) * feat(introspection): add PostgreSQL server status overview support Add comprehensive server status introspection for PostgreSQL backend: - SyncPostgreSQLStatusIntrospector and AsyncPostgreSQLStatusIntrospector - Support for configuration, performance, connections, storage info - PostgreSQL-specific: WAL, replication, archive, security, extensions - CLI 'status' subcommand with JSON and Rich output formats - Lazy initialization via introspector.status property * fix(introspection): resolve PostgreSQL version compatibility issues Fixed WAL info queries to work across PostgreSQL 9.6 to 18: - PG 9.6 uses pg_current_xlog_location() instead of pg_current_wal_lsn() - PG 9.6 uses pg_xlog_location_diff() instead of pg_wal_lsn_diff() - pg_ls_waldir() only exists in PostgreSQL 10+ - PG 17+ uses pg_stat_checkpointer for checkpoint statistics - Use pg_control_checkpoint() for checkpoint LSN info - PG 13+ uses wal_keep_size, older versions use wal_keep_segments Added _get_version_tuple() method for version comparison. Co-Authored-By: Claude Opus 4.6 * test(backend): add status introspector test coverage for PostgreSQL Add comprehensive tests for PostgreSQL status introspector including: - get_overview, list_configuration, list_performance_metrics - get_connection_info, get_storage_info, list_databases, list_users - PostgreSQL-specific: get_wal_info, get_replication_info, get_archive_info, get_security_info, list_extensions Both synchronous and asynchronous API tests are included. Co-Authored-By: Claude Opus 4.6 * chore: add changelog fragment for PR #21 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- changelog.d/21.added.md | 1 + .../backend/impl/postgres/__main__.py | 469 +++++- .../impl/postgres/introspection/__init__.py | 6 + .../postgres/introspection/introspector.py | 20 + .../introspection/status_introspector.py | 1418 +++++++++++++++++ .../introspection/test_status_introspector.py | 624 ++++++++ 6 files changed, 2537 insertions(+), 1 deletion(-) create mode 100644 changelog.d/21.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_status_introspector.py diff --git a/changelog.d/21.added.md b/changelog.d/21.added.md new file mode 100644 index 00000000..9cf5ced4 --- /dev/null +++ b/changelog.d/21.added.md @@ -0,0 +1 @@ +Added PostgreSQL server status overview support with comprehensive introspection capabilities including WAL, replication, archive, security, and extensions info. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__main__.py b/src/rhosocial/activerecord/backend/impl/postgres/__main__.py index a0843e25..d02c674f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__main__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__main__.py @@ -48,6 +48,7 @@ ) from rhosocial.activerecord.backend.errors import ConnectionError, QueryError from rhosocial.activerecord.backend.output import JsonOutputProvider, CsvOutputProvider, TsvOutputProvider +from rhosocial.activerecord.backend.introspection.status import StatusCategory from rhosocial.activerecord.backend.dialect.protocols import ( WindowFunctionSupport, CTESupport, @@ -100,6 +101,8 @@ "indexes", "foreign-keys", "triggers", "database" ] +STATUS_TYPES = ["all", "config", "performance", "connections", "storage", "databases", "users"] + def _serialize_for_output(obj: Any) -> Any: """Serialize object for JSON output, handling non-serializable types. @@ -357,6 +360,50 @@ def parse_args(): help="Include system tables in output", ) + # status subcommand + status_parser = subparsers.add_parser( + "status", + help="Display server status overview", + parents=[parent_parser], + epilog="""Examples: + # Show complete status overview + %(prog)s all --database mydb + + # Show configuration parameters only + %(prog)s config --database mydb + + # Show performance metrics only + %(prog)s performance --database mydb + + # Show connection information + %(prog)s connections --database mydb + + # Show storage information + %(prog)s storage --database mydb + + # Show databases list + %(prog)s databases --database mydb + + # Show users list + %(prog)s users --database mydb + + # Output as JSON + %(prog)s all --database mydb -o json + + # Using environment variables for connection + export POSTGRES_HOST=localhost POSTGRES_DATABASE=mydb POSTGRES_USER=postgres POSTGRES_PASSWORD=secret + %(prog)s all +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + status_parser.add_argument( + "type", + nargs="?", + default="all", + choices=STATUS_TYPES, + help="Status type: all (default), config, performance, connections, storage, databases, users", + ) + return parser.parse_args() @@ -925,12 +972,410 @@ async def handle_introspect_async(args, backend: AsyncPostgresBackend, provider: await backend.disconnect() +def handle_status_sync(args, backend: PostgresBackend, provider: Any): + """Handle status subcommand synchronously.""" + try: + backend.connect() + backend.introspect_and_adapt() + + status_introspector = backend.introspector.status + status_type = args.type + + if status_type == "all": + # Get complete overview + status = status_introspector.get_overview() + if args.output == "json" or not RICH_AVAILABLE: + print(json.dumps(_serialize_for_output(status), indent=2)) + else: + _display_status_rich(status, args.verbose) + elif status_type == "config": + config_items = status_introspector.list_configuration(StatusCategory.CONFIGURATION) + data = _serialize_for_output(config_items) + provider.display_results(data, title="Configuration") + elif status_type == "performance": + perf_items = status_introspector.list_configuration(StatusCategory.PERFORMANCE) + data = _serialize_for_output(perf_items) + provider.display_results(data, title="Performance") + elif status_type == "connections": + conn_info = status_introspector.get_connection_info() + data = _serialize_for_output(conn_info) + provider.display_results([data], title="Connections") + elif status_type == "storage": + storage_info = status_introspector.get_storage_info() + data = _serialize_for_output(storage_info) + provider.display_results([data], title="Storage") + elif status_type == "databases": + databases = status_introspector.list_databases() + data = _serialize_for_output(databases) + provider.display_results(data, title="Databases") + elif status_type == "users": + users = status_introspector.list_users() + data = _serialize_for_output(users) + provider.display_results(data, title="Users") + + except ConnectionError as e: + provider.display_connection_error(e) + sys.exit(1) + except QueryError as e: + provider.display_query_error(e) + sys.exit(1) + except Exception as e: + print(f"Error during status introspection: {e}", file=sys.stderr) + sys.exit(1) + finally: + if backend._connection: # type: ignore + backend.disconnect() + + +async def handle_status_async(args, backend: AsyncPostgresBackend, provider: Any): + """Handle status subcommand asynchronously.""" + try: + await backend.connect() + await backend.introspect_and_adapt() + + status_introspector = backend.introspector.status + status_type = args.type + + if status_type == "all": + # Get complete overview + status = await status_introspector.get_overview() + if args.output == "json" or not RICH_AVAILABLE: + print(json.dumps(_serialize_for_output(status), indent=2)) + else: + _display_status_rich(status, args.verbose) + elif status_type == "config": + config_items = await status_introspector.list_configuration(StatusCategory.CONFIGURATION) + data = _serialize_for_output(config_items) + provider.display_results(data, title="Configuration") + elif status_type == "performance": + perf_items = await status_introspector.list_configuration(StatusCategory.PERFORMANCE) + data = _serialize_for_output(perf_items) + provider.display_results(data, title="Performance") + elif status_type == "connections": + conn_info = await status_introspector.get_connection_info() + data = _serialize_for_output(conn_info) + provider.display_results([data], title="Connections") + elif status_type == "storage": + storage_info = await status_introspector.get_storage_info() + data = _serialize_for_output(storage_info) + provider.display_results([data], title="Storage") + elif status_type == "databases": + databases = await status_introspector.list_databases() + data = _serialize_for_output(databases) + provider.display_results(data, title="Databases") + elif status_type == "users": + users = await status_introspector.list_users() + data = _serialize_for_output(users) + provider.display_results(data, title="Users") + + except ConnectionError as e: + provider.display_connection_error(e) + sys.exit(1) + except QueryError as e: + provider.display_query_error(e) + sys.exit(1) + except Exception as e: + print(f"Error during status introspection: {e}", file=sys.stderr) + sys.exit(1) + finally: + if backend._connection: # type: ignore + await backend.disconnect() + + +def _display_status_rich(status: Any, verbose: int = 0): + """Display status using rich console. + + Args: + status: ServerOverview object + verbose: Verbosity level for output detail + """ + from rich.console import Console + from rich.table import Table + + console = Console(force_terminal=True) + + # Header + console.print("\n[bold cyan]PostgreSQL Server Status[/bold cyan]\n") + console.print(f"[bold]Version:[/bold] {status.server_version}") + console.print(f"[bold]Vendor:[/bold] {status.server_vendor}") + + # Session info + if hasattr(status, 'session') and status.session: + session = status.session + console.print() + console.print("[bold green]Session[/bold green]") + if session.user: + console.print(f" [bold]User:[/bold] {session.user}") + if session.database: + console.print(f" [bold]Database:[/bold] {session.database}") + if session.schema: + console.print(f" [bold]Schema:[/bold] {session.schema}") + if session.host: + console.print(f" [bold]Host:[/bold] {session.host}") + if session.ssl_enabled is not None: + ssl_status = "Enabled" if session.ssl_enabled else "Disabled" + console.print(f" [bold]SSL:[/bold] {ssl_status}") + if session.ssl_enabled and session.ssl_version: + console.print(f" [bold]SSL Version:[/bold] {session.ssl_version}") + if session.ssl_enabled and session.ssl_cipher: + console.print(f" [bold]SSL Cipher:[/bold] {session.ssl_cipher}") + if session.password_used is not None: + auth_method = "Password" if session.password_used else "Other" + console.print(f" [bold]Auth Method:[/bold] {auth_method}") + + # Connection info + if hasattr(status, 'connections') and status.connections: + conn = status.connections + if conn.active_count is not None: + console.print(f"[bold]Active Connections:[/bold] {conn.active_count}") + if conn.max_connections is not None: + console.print(f"[bold]Max Connections:[/bold] {conn.max_connections}") + + console.print() + + # Configuration section + config_items = [item for item in status.configuration + if item.category == StatusCategory.CONFIGURATION] + if config_items: + console.print("[bold green]Configuration[/bold green]") + config_table = Table(show_header=True, header_style="bold") + config_table.add_column("Parameter") + config_table.add_column("Value") + if verbose >= 1: + config_table.add_column("Description") + config_table.add_column("Readonly") + + for item in config_items: + row = [item.name, str(item.value)] + if verbose >= 1: + row.extend([ + item.description or "", + "Yes" if item.is_readonly else "No" + ]) + config_table.add_row(*row) + + console.print(config_table) + console.print() + + # Performance section + perf_items = [item for item in status.configuration + if item.category == StatusCategory.PERFORMANCE] + if perf_items: + console.print("[bold green]Performance[/bold green]") + perf_table = Table(show_header=True, header_style="bold") + perf_table.add_column("Parameter") + perf_table.add_column("Value") + if verbose >= 1: + perf_table.add_column("Unit") + + for item in perf_items: + row = [item.name, str(item.value)] + if verbose >= 1: + row.append(item.unit or "") + perf_table.add_row(*row) + + console.print(perf_table) + console.print() + + # Storage section + if hasattr(status, 'storage') and status.storage: + console.print("[bold green]Storage[/bold green]") + storage_table = Table(show_header=True, header_style="bold") + storage_table.add_column("Metric") + storage_table.add_column("Value") + + if status.storage.total_size_bytes is not None: + storage_table.add_row("Total Size", _format_size(status.storage.total_size_bytes)) + + console.print(storage_table) + console.print() + + # Databases section + if status.databases: + console.print("[bold green]Databases[/bold green]") + db_table = Table(show_header=True, header_style="bold") + db_table.add_column("Name") + db_table.add_column("Size") + if verbose >= 1: + db_table.add_column("Tables") + db_table.add_column("Views") + + for db in status.databases: + row = [db.name, _format_size(db.size_bytes) if db.size_bytes else "N/A"] + if verbose >= 1: + row.append(str(db.table_count) if db.table_count is not None else "N/A") + row.append(str(db.view_count) if db.view_count is not None else "N/A") + db_table.add_row(*row) + + console.print(db_table) + console.print() + + # Show schema details for current database + for db in status.databases: + if db.extra and 'schemas' in db.extra: + schemas = db.extra['schemas'] + if schemas: + console.print(f"[bold green]Schemas in {db.name}[/bold green]") + schema_table = Table(show_header=True, header_style="bold") + schema_table.add_column("Schema") + schema_table.add_column("Tables") + schema_table.add_column("Views") + + for schema_name, counts in sorted(schemas.items()): + schema_table.add_row( + schema_name, + str(counts.get('tables', 0)), + str(counts.get('views', 0)) + ) + + console.print(schema_table) + console.print() + + # Users section + if status.users: + console.print("[bold green]Users[/bold green]") + user_table = Table(show_header=True, header_style="bold") + user_table.add_column("Name") + user_table.add_column("Superuser") + + for user in status.users: + user_table.add_row(user.name, "Yes" if user.is_superuser else "No") + + console.print(user_table) + console.print() + + # WAL section + if hasattr(status, 'wal') and status.wal: + wal = status.wal + console.print("[bold green]WAL (Write-Ahead Logging)[/bold green]") + wal_table = Table(show_header=True, header_style="bold") + wal_table.add_column("Parameter") + wal_table.add_column("Value") + + if wal.wal_level: + wal_table.add_row("WAL Level", str(wal.wal_level)) + if wal.wal_files is not None: + wal_table.add_row("WAL Files", str(wal.wal_files)) + if wal.wal_size_bytes is not None: + wal_table.add_row("WAL Size", _format_size(wal.wal_size_bytes)) + if wal.checkpoint_count is not None: + wal_table.add_row("Checkpoints", str(wal.checkpoint_count)) + if wal.checkpoint_time: + wal_table.add_row("Last Checkpoint", str(wal.checkpoint_time)) + if wal.wal_segments is not None: + wal_table.add_row("WAL Segments", str(wal.wal_segments)) + + console.print(wal_table) + console.print() + + # Replication section + if hasattr(status, 'replication') and status.replication: + repl = status.replication + console.print("[bold green]Replication[/bold green]") + repl_table = Table(show_header=True, header_style="bold") + repl_table.add_column("Parameter") + repl_table.add_column("Value") + + if repl.is_primary is not None: + repl_table.add_row("Role", "Primary" if repl.is_primary else "Standby") + if repl.replication_slots is not None: + repl_table.add_row("Replication Slots", str(repl.replication_slots)) + if repl.active_replications is not None: + repl_table.add_row("Active Replications", str(repl.active_replications)) + if repl.streaming is not None: + repl_table.add_row("Streaming", "Yes" if repl.streaming else "No") + if repl.lag_bytes is not None: + repl_table.add_row("Replication Lag", _format_size(repl.lag_bytes)) + + console.print(repl_table) + console.print() + + # Archive section + if hasattr(status, 'archive') and status.archive: + archive = status.archive + console.print("[bold green]Archive[/bold green]") + archive_table = Table(show_header=True, header_style="bold") + archive_table.add_column("Parameter") + archive_table.add_column("Value") + + if archive.archive_mode: + archive_table.add_row("Archive Mode", str(archive.archive_mode)) + if archive.archive_command: + # Truncate long commands + cmd = str(archive.archive_command) + if len(cmd) > 50: + cmd = cmd[:47] + "..." + archive_table.add_row("Archive Command", cmd) + if archive.archive_timeout is not None: + archive_table.add_row("Archive Timeout", f"{archive.archive_timeout}s") + if archive.archived_count is not None: + archive_table.add_row("Archived Count", str(archive.archived_count)) + if archive.failed_count is not None: + archive_table.add_row("Failed Archives", str(archive.failed_count)) + + console.print(archive_table) + console.print() + + # Security section + if hasattr(status, 'security') and status.security: + security = status.security + console.print("[bold green]Security[/bold green]") + security_table = Table(show_header=True, header_style="bold") + security_table.add_column("Parameter") + security_table.add_column("Value") + + if security.ssl_enabled is not None: + security_table.add_row("SSL Enabled", "Yes" if security.ssl_enabled else "No") + if security.ssl_cert_file: + security_table.add_row("SSL Cert File", str(security.ssl_cert_file)) + if security.ssl_key_file: + security_table.add_row("SSL Key File", str(security.ssl_key_file)) + if security.ssl_ca_file: + security_table.add_row("SSL CA File", str(security.ssl_ca_file)) + if security.password_encryption: + security_table.add_row("Password Encryption", str(security.password_encryption)) + if security.row_security is not None: + security_table.add_row("Row-Level Security", "Enabled" if security.row_security else "Disabled") + + console.print(security_table) + console.print() + + # Extensions section + if hasattr(status, 'extensions') and status.extensions: + console.print("[bold green]Extensions[/bold green]") + ext_table = Table(show_header=True, header_style="bold") + ext_table.add_column("Name") + ext_table.add_column("Version") + ext_table.add_column("Schema") + + for ext in status.extensions: + ext_table.add_row( + ext.name, + ext.version or "N/A", + ext.schema or "N/A" + ) + + console.print(ext_table) + console.print() + + +def _format_size(size_bytes: int) -> str: + """Format byte size to human-readable string.""" + units = ["B", "KB", "MB", "GB", "TB"] + size = float(size_bytes) + for unit in units: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} PB" + + def main(): args = parse_args() # Must specify a subcommand if args.command is None: - print("Error: Please specify a command: 'info', 'query', or 'introspect'", file=sys.stderr) + print("Error: Please specify a command: 'info', 'query', 'introspect', or 'status'", file=sys.stderr) print("Use --help for more information.", file=sys.stderr) sys.exit(1) @@ -963,6 +1408,28 @@ def main(): handle_introspect_sync(args, backend, provider) return + # Handle status subcommand + if args.command == "status": + if not args.database: + print("Error: --database is required for status", file=sys.stderr) + sys.exit(1) + + config = PostgresConnectionConfig( + host=args.host, + port=args.port, + database=args.database, + username=args.user, + password=args.password, + ) + + if args.use_async: + backend = AsyncPostgresBackend(connection_config=config) + asyncio.run(handle_status_async(args, backend, provider)) + else: + backend = PostgresBackend(connection_config=config) + handle_status_sync(args, backend, provider) + return + # Handle query subcommand numeric_level = getattr(logging, args.log_level.upper(), None) if not isinstance(numeric_level, int): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/__init__.py index 3ee6cc6b..94018285 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/__init__.py @@ -3,8 +3,14 @@ SyncPostgreSQLIntrospector, AsyncPostgreSQLIntrospector, ) +from .status_introspector import ( + SyncPostgreSQLStatusIntrospector, + AsyncPostgreSQLStatusIntrospector, +) __all__ = [ "SyncPostgreSQLIntrospector", "AsyncPostgreSQLIntrospector", + "SyncPostgreSQLStatusIntrospector", + "AsyncPostgreSQLStatusIntrospector", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py index 1601221b..4d34772d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py @@ -51,6 +51,10 @@ TriggerInfo, ViewInfo, ) +from .status_introspector import ( + SyncPostgreSQLStatusIntrospector, + AsyncPostgreSQLStatusIntrospector, +) class PostgreSQLIntrospectorMixin(IntrospectorMixin): @@ -314,6 +318,14 @@ class SyncPostgreSQLIntrospector(PostgreSQLIntrospectorMixin, SyncAbstractIntros def __init__(self, backend: Any, executor: SyncIntrospectorExecutor) -> None: super().__init__(backend, executor) + self._status_instance: Optional[SyncPostgreSQLStatusIntrospector] = None + + @property + def status(self) -> SyncPostgreSQLStatusIntrospector: + """PostgreSQL status introspector (lazily created).""" + if self._status_instance is None: + self._status_instance = SyncPostgreSQLStatusIntrospector(self._backend) + return self._status_instance class AsyncPostgreSQLIntrospector(PostgreSQLIntrospectorMixin, AsyncAbstractIntrospector): @@ -334,3 +346,11 @@ class AsyncPostgreSQLIntrospector(PostgreSQLIntrospectorMixin, AsyncAbstractIntr def __init__(self, backend: Any, executor: AsyncIntrospectorExecutor) -> None: super().__init__(backend, executor) + self._status_instance: Optional[AsyncPostgreSQLStatusIntrospector] = None + + @property + def status(self) -> AsyncPostgreSQLStatusIntrospector: + """PostgreSQL status introspector (lazily created).""" + if self._status_instance is None: + self._status_instance = AsyncPostgreSQLStatusIntrospector(self._backend) + return self._status_instance diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py new file mode 100644 index 00000000..156f766b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py @@ -0,0 +1,1418 @@ +# src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py +""" +PostgreSQL server status introspector. + +Provides server status information by querying PostgreSQL's pg_settings, +pg_stat_activity, and other system views. + +Design principle: Sync and Async are separate and cannot coexist. +- SyncPostgreSQLStatusIntrospector: for synchronous backends +- AsyncPostgreSQLStatusIntrospector: for asynchronous backends +""" + +from typing import Any, Dict, List, Optional + +from rhosocial.activerecord.backend.introspection.status import ( + StatusItem, + StatusCategory, + ServerOverview, + DatabaseBriefInfo, + UserInfo, + ConnectionInfo, + StorageInfo, + SessionInfo, + WALInfo, + ReplicationInfo, + ArchiveInfo, + SecurityInfo, + ExtensionInfo, + SyncAbstractStatusIntrospector, + AsyncAbstractStatusIntrospector, +) + + +# PostgreSQL settings to include in status overview +# Format: (setting_name, category, description, unit, is_readonly) +POSTGRES_CONFIG_VARIABLES = [ + # Configuration + ("listen_addresses", StatusCategory.CONFIGURATION, "Server listen addresses", None, False), + ("port", StatusCategory.CONFIGURATION, "Server port", None, True), + ("max_connections", StatusCategory.CONFIGURATION, "Maximum connections", None, True), + ("shared_buffers", StatusCategory.CONFIGURATION, "Shared buffers size", "bytes", True), + ("work_mem", StatusCategory.CONFIGURATION, "Work memory size", "bytes", True), + ("maintenance_work_mem", StatusCategory.CONFIGURATION, "Maintenance work memory", "bytes", True), + ("dynamic_shared_memory", StatusCategory.CONFIGURATION, "Dynamic shared memory", None, False), + ("wal_level", StatusCategory.CONFIGURATION, "WAL level", None, False), + ("wal_keep_size", StatusCategory.CONFIGURATION, "WAL keep size", "bytes", False), # Changed from wal_keep_segments + ("synchronous_commit", StatusCategory.PERFORMANCE, "Synchronous commit", None, False), + ("checkpoint_timeout", StatusCategory.PERFORMANCE, "Checkpoint timeout", "seconds", False), + ("checkpoint_completion_target", StatusCategory.PERFORMANCE, "Checkpoint completion target", "seconds", False), + ("archive_timeout", StatusCategory.PERFORMANCE, "Archive timeout", "seconds", False), + ("archive_command", StatusCategory.PERFORMANCE, "Archive command", None, False), + ("log_timezone", StatusCategory.CONFIGURATION, "Log timezone", None, False), + ("log_destination", StatusCategory.CONFIGURATION, "Log destination", None, False), + ("logging_collector", StatusCategory.CONFIGURATION, "Logging collector", None, False), + ("cluster_name", StatusCategory.CONFIGURATION, "Cluster name", None, False), + + # Security + ("authentication_timeout", StatusCategory.SECURITY, "Authentication timeout", "seconds", False), + ("password_encryption", StatusCategory.SECURITY, "Password encryption", None, True), + ("ssl", StatusCategory.SECURITY, "SSL enabled", None, False), + ("krb_server_keyfile", StatusCategory.SECURITY, "Kerberos keyfile", None, False), + + # Replication + ("max_wal_senders", StatusCategory.REPLICATION, "Maximum WAL senders", None, False), + ("max_replication_slots", StatusCategory.REPLICATION, "Maximum replication slots", None, False), + ("hot_standby", StatusCategory.REPLICATION, "Hot standby mode", None, False), +] + + +# Performance metrics from pg_stat_bgwriter and other views +# Format: (variable_name, category, description, unit) +POSTGRES_STATUS_VARIABLES = [ + # Checkpoint statistics (available in PostgreSQL 10-16, removed in 17+) + ("checkpoints_timed", StatusCategory.PERFORMANCE, "Scheduled checkpoints", "checkpoints"), + ("checkpoints_req", StatusCategory.PERFORMANCE, "Requested checkpoints", "checkpoints"), + + # Buffer pool statistics + ("buffers_alloc", StatusCategory.PERFORMANCE, "Buffer allocations", None), + ("buffers_backend_fsync", StatusCategory.PERFORMANCE, "Buffer backend fsync", None), + ("buffers_checkpoint", StatusCategory.PERFORMANCE, "Buffers written by checkpoint", None), + + # Connection statistics (from pg_stat_activity) + ("numbackends", StatusCategory.CONNECTION, "Number of backend connections", None), + ("sessions", StatusCategory.CONNECTION, "Number of sessions", None), + ("active", StatusCategory.CONNECTION, "Number of active sessions", None), + ("idle", StatusCategory.CONNECTION, "Number of idle sessions", None), + ("waiting", StatusCategory.CONNECTION, "Number of waiting sessions", None), + ("total", StatusCategory.CONNECTION, "Total connections", None), + ("max_conn", StatusCategory.CONNECTION, "Maximum connections", None), +] + + +class PostgreSQLStatusIntrospectorMixin: + """Mixin providing shared PostgreSQL status introspection logic. + + Both SyncPostgreSQLStatusIntrospector and AsyncPostgreSQLStatusIntrospector + inherit from this mixin to share: + - Query execution helpers + - _parse_* implementations + """ + + def _get_vendor_name(self) -> str: + """Get PostgreSQL vendor name.""" + return "PostgreSQL" + + def _parse_setting_value(self, value: Any) -> Any: + """Parse PostgreSQL setting value to appropriate Python type. + + PostgreSQL settings can be: + - bool: 'on', 'off', 'true', 'false', 'yes', 'no' + - int: numeric values + - str: string values + - bytes: bytes values with unit + """ + if isinstance(value, bytes): + return value.decode('utf-8') + if isinstance(value, bool): + return value + if isinstance(value, str): + if value in ('on', 'true', 'yes'): + return True + if value in ('off', 'false', 'no'): + return False + if isinstance(value, int): + return value + return str(value) + + def _get_version_string(self) -> str: + """Get PostgreSQL version string.""" + # Try to get version from backend's _server_version_cache attribute first + # (set by introspect_and_adapt() during backend initialization) + version = getattr(self._backend, '_server_version_cache', None) + if version: + return ".".join(str(v) for v in version) + + # Fall back to _version attribute + version = getattr(self._backend, '_version', None) + if version: + return ".".join(str(v) for v in version) + + # Try dialect version + if hasattr(self._backend, '_dialect') and hasattr(self._backend._dialect, 'version'): + version = self._backend._dialect.version + if version: + return ".".join(str(v) for v in version) + + # Last resort: query server directly + try: + version = self._backend.get_server_version() + if version: + return ".".join(str(v) for v in version) + except Exception: + pass + + return "Unknown" + + def _get_version_tuple(self) -> tuple: + """Get PostgreSQL version as tuple for comparison. + + Returns tuple like (18, 3, 0) or (9, 6, 24). + """ + # Try to get version from backend's _server_version_cache attribute first + version = getattr(self._backend, '_server_version_cache', None) + if version: + return tuple(version) + + # Fall back to _version attribute + version = getattr(self._backend, '_version', None) + if version: + return tuple(version) + + # Try dialect version + if hasattr(self._backend, '_dialect') and hasattr(self._backend._dialect, 'version'): + version = self._backend._dialect.version + if version: + return tuple(version) + + # Last resort: query server directly + try: + version = self._backend.get_server_version() + if version: + return tuple(version) + except Exception: + pass + + return (0, 0, 0) # Unknown version + + def _create_status_item( + self, + name: str, + value: Any, + category: StatusCategory, + description: Optional[str] = None, + unit: Optional[str] = None, + is_readonly: bool = False, + ) -> StatusItem: + """Create a StatusItem with parsed value.""" + return StatusItem( + name=name, + value=self._parse_setting_value(value), + category=category, + description=description, + unit=unit, + is_readonly=is_readonly, + ) + + def _build_server_overview( + self, + configuration: List[StatusItem], + performance: List[StatusItem], + connections: ConnectionInfo, + storage: StorageInfo, + databases: List[DatabaseBriefInfo], + users: List[UserInfo], + session: Optional[SessionInfo] = None, + wal: Optional[WALInfo] = None, + replication: Optional[ReplicationInfo] = None, + archive: Optional[ArchiveInfo] = None, + security: Optional[SecurityInfo] = None, + extensions: Optional[List[ExtensionInfo]] = None, + ) -> ServerOverview: + """Build ServerOverview from collected data.""" + return ServerOverview( + server_version=self._get_version_string(), + server_vendor=self._get_vendor_name(), + session=session, + configuration=configuration, + performance=performance, + connections=connections, + storage=storage, + databases=databases, + users=users, + wal=wal, + replication=replication, + archive=archive, + security=security, + extensions=extensions or [], + ) + + +class SyncPostgreSQLStatusIntrospector( + PostgreSQLStatusIntrospectorMixin, SyncAbstractStatusIntrospector +): + """Synchronous PostgreSQL status introspector. + + Usage:: + + backend = PostgreSQLBackend(connection_config=config) + backend.connect() + status = backend.introspector.status.get_overview() + print(status.server_version) + """ + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + + def _exec_query(self, sql: str, params: Optional[tuple] = None) -> List[Dict[str, Any]]: + """Execute a SQL query synchronously.""" + return self._backend.execute(sql, params).data or [] + + def get_overview(self) -> ServerOverview: + """Get complete PostgreSQL status overview.""" + configuration = self.list_configuration() + performance = self.list_performance_metrics() + connections = self.get_connection_info() + storage = self.get_storage_info() + databases = self.list_databases() + users = self.list_users() + session = self.get_session_info() + wal = self.get_wal_info() + replication = self.get_replication_info() + archive = self.get_archive_info() + security = self.get_security_info() + extensions = self.list_extensions() + + return self._build_server_overview( + configuration=configuration, + performance=performance, + connections=connections, + storage=storage, + databases=databases, + users=users, + session=session, + wal=wal, + replication=replication, + archive=archive, + security=security, + extensions=extensions, + ) + + def list_configuration( + self, category: Optional[StatusCategory] = None + ) -> List[StatusItem]: + """List PostgreSQL configuration parameters from pg_settings.""" + items = [] + + # Query pg_settings + sql = "SELECT name, setting, unit FROM pg_settings ORDER BY name" + rows = self._exec_query(sql) + + settings_dict = {row['name']: row for row in rows} + + for name, cat, desc, unit, is_readonly in POSTGRES_CONFIG_VARIABLES: + if category and cat != category: + continue + + # Get setting value from pg_settings + row = settings_dict.get(name) + if row: + item = self._create_status_item( + name=name, + value=row.get('setting'), + category=cat, + description=desc, + unit=row.get('unit') if row.get('unit') else unit, + is_readonly=is_readonly, + ) + items.append(item) + + return items + + def list_performance_metrics( + self, category: Optional[StatusCategory] = None + ) -> List[StatusItem]: + """List PostgreSQL performance metrics from pg_stat_activity.""" + items = [] + + # Query pg_stat_activity for performance-related settings + sql = """ + SELECT name, setting, unit FROM pg_settings + WHERE name IN ('checkpoint_timeout', 'checkpoint_completion_target', 'archive_timeout', 'archive_command') + ORDER BY name + """ + rows = self._exec_query(sql) + + for row in rows: + name = row['name'] + for var_name, var_cat, desc, unit, is_readonly in POSTGRES_CONFIG_VARIABLES: + if name == var_name: + item = self._create_status_item( + name=name, + value=row.get('setting'), + category=var_cat, + description=desc, + unit=row.get('unit') if row.get('unit') else unit, + is_readonly=is_readonly, + ) + items.append(item) + break + + return items + + def get_connection_info(self) -> ConnectionInfo: + """Get connection information from pg_stat_activity.""" + sql = """ + SELECT count(*) as total_connections + FROM pg_stat_activity + WHERE datname = current_database() + """ + rows = self._exec_query(sql) + total = rows[0].get('total_connections', 0) if rows else 0 + + max_conn = None + try: + sql = "SHOW max_connections" + result = self._exec_query(sql) + if result: + max_conn = int(list(result[0].values())[0]) + except Exception: + pass + + return ConnectionInfo( + active_count=total, + max_connections=max_conn, + ) + + def get_storage_info(self) -> StorageInfo: + """Get storage information.""" + db_name = getattr(self._backend.config, 'database', 'postgres') + + # Get database size + sql = "SELECT pg_database_size(%s) as size_bytes" + rows = self._exec_query(sql, (db_name,)) + size_bytes = rows[0].get('size_bytes') if rows else None + + return StorageInfo( + total_size_bytes=size_bytes, + ) + + def list_databases(self) -> List[DatabaseBriefInfo]: + """List databases from pg_database with schema info for current database. + + For the current database, includes schema information with table/view counts + in the extra field. + """ + # Get list of databases + sql = """ + SELECT datname as name, pg_database_size(datname) as size_bytes + FROM pg_database + WHERE datistemplate = false OR datname = current_database() + ORDER BY datname + """ + rows = self._exec_query(sql) + + # Get current database name + current_db = None + try: + result = self._exec_query("SELECT current_database()") + if result: + current_db = result[0].get('current_database') + except Exception: + pass + + # Get schema info for current database + schema_info: Dict[str, Dict[str, int]] = {} + if current_db: + try: + # Query schemata with table/view counts + sql = """ + SELECT + n.nspname as schema_name, + COUNT(CASE WHEN c.relkind = 'r' THEN 1 END) as table_count, + COUNT(CASE WHEN c.relkind = 'v' THEN 1 END) as view_count + FROM pg_namespace n + LEFT JOIN pg_class c ON c.relnamespace = n.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + GROUP BY n.nspname + ORDER BY n.nspname + """ + schema_rows = self._exec_query(sql) + for row in schema_rows: + schema_name = row.get('schema_name') + if schema_name: + schema_info[schema_name] = { + 'tables': row.get('table_count', 0) or 0, + 'views': row.get('view_count', 0) or 0, + } + except Exception: + pass + + databases = [] + for row in rows: + db_name = row['name'] + db_info = DatabaseBriefInfo( + name=db_name, + size_bytes=row.get('size_bytes'), + ) + + # Add schema info for current database + if db_name == current_db and schema_info: + db_info.extra['schemas'] = schema_info + # Also set total table/view counts + db_info.table_count = sum(s['tables'] for s in schema_info.values()) + db_info.view_count = sum(s['views'] for s in schema_info.values()) + + databases.append(db_info) + + return databases + + def list_users(self) -> List[UserInfo]: + """List users from pg_roles.""" + sql = """ + SELECT rolname as name, rolsuper as is_superuser, + pg_catalog.pg_get_userbyid(oid) as owner + FROM pg_roles + ORDER BY rolname + """ + rows = self._exec_query(sql) + + return [ + UserInfo( + name=row['name'], + is_superuser=row['is_superuser'], + extra={'owner': row.get('owner')} if row.get('owner') else {}, + ) + for row in rows + ] + + def get_session_info(self) -> SessionInfo: + """Get current session/connection information.""" + session = SessionInfo() + + # Get current user + try: + rows = self._exec_query("SELECT current_user") + if rows: + session.user = rows[0].get('current_user') + except Exception: + pass + + # Get current database + try: + rows = self._exec_query("SELECT current_database()") + if rows: + session.database = rows[0].get('current_database') + except Exception: + pass + + # Get current schema + try: + rows = self._exec_query("SELECT current_schema()") + if rows: + session.schema = rows[0].get('current_schema') + except Exception: + pass + + # Get client host address + try: + rows = self._exec_query("SELECT inet_client_addr()") + if rows: + host = rows[0].get('inet_client_addr') + if host: + session.host = str(host) + except Exception: + pass + + # Get SSL status from pg_stat_ssl + try: + rows = self._exec_query(""" + SELECT ssl, version, cipher + FROM pg_stat_ssl + WHERE pid = pg_backend_pid() + """) + if rows and rows[0]: + ssl_info = rows[0] + ssl_value = ssl_info.get('ssl') + # PostgreSQL returns boolean or 't'/'f' + session.ssl_enabled = ssl_value is True or ssl_value == 't' or ssl_value == 'true' + session.ssl_version = ssl_info.get('version') + session.ssl_cipher = ssl_info.get('cipher') + except Exception: + pass + + # Check if password was used for authentication + session.password_used = bool(getattr(self._backend.config, 'password', None)) + + return session + + def get_wal_info(self) -> WALInfo: + """Get WAL (Write-Ahead Logging) information.""" + wal_info = WALInfo() + version = self._get_version_tuple() + + # Get WAL level + try: + rows = self._exec_query("SHOW wal_level") + if rows: + wal_info.wal_level = list(rows[0].values())[0] + except Exception: + pass + + # Get WAL position/size + # PostgreSQL 10+: pg_current_wal_lsn() + # PostgreSQL 9.6 and below: pg_current_xlog_location() + try: + if version >= (10, 0, 0): + rows = self._exec_query("SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')::bigint as wal_bytes") + else: + # PG 9.6 and below use xlog terminology + rows = self._exec_query("SELECT pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::bigint as wal_bytes") + if rows and rows[0].get('wal_bytes'): + wal_info.wal_size_bytes = int(rows[0]['wal_bytes']) + except Exception: + pass + + # Get WAL files count + # pg_ls_waldir() only exists in PostgreSQL 10+ + if version >= (10, 0, 0): + try: + rows = self._exec_query(""" + SELECT count(*) as wal_files + FROM pg_ls_waldir() + """) + if rows: + wal_info.wal_files = int(rows[0].get('wal_files', 0)) + except Exception: + pass + + # Get checkpoint statistics + # PostgreSQL 10-16: pg_stat_bgwriter has checkpoints_timed and checkpoints_req + # PostgreSQL 17+: these columns moved to pg_stat_checkpointer + try: + if version >= (17, 0, 0): + # PostgreSQL 17+ uses pg_stat_checkpointer + rows = self._exec_query(""" + SELECT + COALESCE(num_timed, 0) + + COALESCE(num_requested, 0) as checkpoint_count + FROM pg_stat_checkpointer + """) + else: + # PostgreSQL 10-16 uses pg_stat_bgwriter + rows = self._exec_query(""" + SELECT + COALESCE(checkpoints_timed, 0) + + COALESCE(checkpoints_req, 0) as checkpoint_count + FROM pg_stat_bgwriter + """) + if rows: + wal_info.checkpoint_count = int(rows[0].get('checkpoint_count', 0)) + except Exception: + pass + + # Get last checkpoint info + # pg_control_checkpoint() exists in PostgreSQL 9.6+ + # Returns a record with checkpoint LSN as the first column + if version >= (10, 0, 0): + try: + rows = self._exec_query(""" + SELECT (pg_control_checkpoint()).checkpoint_lsn as checkpoint_info + """) + if rows and rows[0].get('checkpoint_info'): + wal_info.checkpoint_time = rows[0]['checkpoint_info'] + except Exception: + # Some PostgreSQL versions may not support this syntax + pass + + # Get WAL keep size/segments + # PostgreSQL 13+: wal_keep_size + # PostgreSQL 12 and below: wal_keep_segments + if version >= (13, 0, 0): + try: + rows = self._exec_query("SHOW wal_keep_size") + if rows: + wal_info.wal_segments = list(rows[0].values())[0] + except Exception: + pass + else: + try: + rows = self._exec_query("SHOW wal_keep_segments") + if rows: + wal_info.wal_segments = list(rows[0].values())[0] + except Exception: + pass + + return wal_info + + def get_replication_info(self) -> ReplicationInfo: + """Get replication status information.""" + replication_info = ReplicationInfo() + + # Check if primary or standby + try: + rows = self._exec_query("SELECT pg_is_in_recovery()") + if rows: + is_in_recovery = list(rows[0].values())[0] + # pg_is_in_recovery returns False for primary, True for standby + replication_info.is_primary = not (is_in_recovery is True or is_in_recovery == 't' or is_in_recovery == 'true') + replication_info.is_standby = is_in_recovery is True or is_in_recovery == 't' or is_in_recovery == 'true' + except Exception: + pass + + # Get replication slots count + try: + rows = self._exec_query(""" + SELECT count(*) as slot_count + FROM pg_replication_slots + """) + if rows: + replication_info.replication_slots = int(rows[0].get('slot_count', 0)) + except Exception: + pass + + # Get active replications count + try: + rows = self._exec_query(""" + SELECT count(*) as active_count + FROM pg_stat_replication + WHERE state = 'streaming' + """) + if rows: + replication_info.active_replications = int(rows[0].get('active_count', 0)) + replication_info.streaming = replication_info.active_replications > 0 + except Exception: + pass + + # Get replication lag (for standby) + if replication_info.is_standby: + try: + rows = self._exec_query(""" + SELECT pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())::bigint as lag_bytes + """) + if rows and rows[0].get('lag_bytes') is not None: + replication_info.lag_bytes = int(rows[0]['lag_bytes']) + except Exception: + pass + + return replication_info + + def get_archive_info(self) -> ArchiveInfo: + """Get archive status information.""" + archive_info = ArchiveInfo() + + # Get archive mode + try: + rows = self._exec_query("SHOW archive_mode") + if rows: + archive_info.archive_mode = list(rows[0].values())[0] + except Exception: + pass + + # Get archive command + try: + rows = self._exec_query("SHOW archive_command") + if rows: + archive_info.archive_command = list(rows[0].values())[0] + except Exception: + pass + + # Get archive timeout + try: + rows = self._exec_query("SHOW archive_timeout") + if rows: + timeout_str = list(rows[0].values())[0] + # Parse timeout (could be like '5min' or '300s') + if timeout_str and timeout_str != '0': + # Try to parse as integer (seconds) + try: + archive_info.archive_timeout = int(timeout_str) + except ValueError: + pass + except Exception: + pass + + # Get archive statistics + try: + rows = self._exec_query(""" + SELECT archived_count, failed_count, + last_archived_time::text as last_archived, + last_failed_time::text as last_failed + FROM pg_stat_archiver + """) + if rows: + row = rows[0] + archive_info.archived_count = int(row.get('archived_count', 0) or 0) + archive_info.failed_count = int(row.get('failed_count', 0) or 0) + archive_info.last_archived = row.get('last_archived') + archive_info.last_failed = row.get('last_failed') + except Exception: + pass + + return archive_info + + def get_security_info(self) -> SecurityInfo: + """Get security-related status information.""" + security_info = SecurityInfo() + + # Get SSL status + try: + rows = self._exec_query("SHOW ssl") + if rows: + ssl_value = list(rows[0].values())[0] + security_info.ssl_enabled = ssl_value == 'on' or ssl_value is True + except Exception: + pass + + # Get SSL certificate file + try: + rows = self._exec_query("SHOW ssl_cert_file") + if rows: + security_info.ssl_cert_file = list(rows[0].values())[0] + except Exception: + pass + + # Get SSL key file + try: + rows = self._exec_query("SHOW ssl_key_file") + if rows: + security_info.ssl_key_file = list(rows[0].values())[0] + except Exception: + pass + + # Get SSL CA file + try: + rows = self._exec_query("SHOW ssl_ca_file") + if rows: + security_info.ssl_ca_file = list(rows[0].values())[0] + except Exception: + pass + + # Get password encryption + try: + rows = self._exec_query("SHOW password_encryption") + if rows: + security_info.password_encryption = list(rows[0].values())[0] + except Exception: + pass + + # Check row-level security + try: + rows = self._exec_query("SHOW row_security") + if rows: + rls_value = list(rows[0].values())[0] + security_info.row_security = rls_value == 'on' or rls_value is True + except Exception: + pass + + return security_info + + def list_extensions(self) -> List[ExtensionInfo]: + """List installed extensions.""" + extensions = [] + + try: + rows = self._exec_query(""" + SELECT e.extname as name, + e.extversion as version, + n.nspname as schema, + pg_catalog.obj_description(e.oid, 'pg_extension') as description + FROM pg_extension e + LEFT JOIN pg_namespace n ON n.oid = e.extnamespace + ORDER BY e.extname + """) + + for row in rows: + ext = ExtensionInfo( + name=row['name'], + version=row.get('version'), + schema=row.get('schema'), + description=row.get('description'), + ) + extensions.append(ext) + except Exception: + pass + + return extensions + + +class AsyncPostgreSQLStatusIntrospector( + PostgreSQLStatusIntrospectorMixin, AsyncAbstractStatusIntrospector +): + """Asynchronous PostgreSQL status introspector. + + Usage:: + + backend = AsyncPostgreSQLBackend(connection_config=config) + await backend.connect() + status = await backend.introspector.status.get_overview() + print(status.server_version) + """ + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + + async def _exec_query_async(self, sql: str, params: Optional[tuple] = None) -> List[Dict[str, Any]]: + """Execute a SQL query asynchronously.""" + result = await self._backend.execute(sql, params) + return result.data if result else [] + + async def get_overview(self) -> ServerOverview: + """Get complete PostgreSQL status overview.""" + configuration = await self.list_configuration() + performance = await self.list_performance_metrics() + connections = await self.get_connection_info() + storage = await self.get_storage_info() + databases = await self.list_databases() + users = await self.list_users() + session = await self.get_session_info() + wal = await self.get_wal_info() + replication = await self.get_replication_info() + archive = await self.get_archive_info() + security = await self.get_security_info() + extensions = await self.list_extensions() + + return self._build_server_overview( + configuration=configuration, + performance=performance, + connections=connections, + storage=storage, + databases=databases, + users=users, + session=session, + wal=wal, + replication=replication, + archive=archive, + security=security, + extensions=extensions, + ) + + async def list_configuration( + self, category: Optional[StatusCategory] = None + ) -> List[StatusItem]: + """List PostgreSQL configuration parameters from pg_settings.""" + items = [] + + # Query pg_settings + sql = "SELECT name, setting, unit FROM pg_settings ORDER BY name" + rows = await self._exec_query_async(sql) + + settings_dict = {row['name']: row for row in rows} + + for name, cat, desc, unit, is_readonly in POSTGRES_CONFIG_VARIABLES: + if category and cat != category: + continue + + # Get setting value from pg_settings + row = settings_dict.get(name) + if row: + item = self._create_status_item( + name=name, + value=row.get('setting'), + category=cat, + description=desc, + unit=row.get('unit') if row.get('unit') else unit, + is_readonly=is_readonly, + ) + items.append(item) + + return items + + async def list_performance_metrics( + self, category: Optional[StatusCategory] = None + ) -> List[StatusItem]: + """List PostgreSQL performance metrics from pg_stat_activity.""" + items = [] + + # Query pg_stat_activity for performance-related settings + sql = """ + SELECT name, setting, unit FROM pg_settings + WHERE name IN ('checkpoint_timeout', 'checkpoint_completion_target', 'archive_timeout', 'archive_command') + ORDER BY name + """ + rows = await self._exec_query_async(sql) + + for row in rows: + name = row['name'] + for var_name, var_cat, desc, unit, is_readonly in POSTGRES_CONFIG_VARIABLES: + if name == var_name: + item = self._create_status_item( + name=name, + value=row.get('setting'), + category=var_cat, + description=desc, + unit=row.get('unit') if row.get('unit') else unit, + is_readonly=is_readonly, + ) + items.append(item) + break + + return items + + async def get_connection_info(self) -> ConnectionInfo: + """Get connection information from pg_stat_activity.""" + sql = """ + SELECT count(*) as total_connections + FROM pg_stat_activity + WHERE datname = current_database() + """ + rows = await self._exec_query_async(sql) + total = rows[0].get('total_connections', 1) if rows else 0 + + max_conn = None + try: + sql = "SHOW max_connections" + result = await self._exec_query_async(sql) + if result: + max_conn = int(list(result[0].values())[0]) + except Exception: + pass + + return ConnectionInfo( + active_count=total, + max_connections=max_conn, + ) + + async def get_storage_info(self) -> StorageInfo: + """Get storage information.""" + db_name = getattr(self._backend.config, 'database', 'postgres') + + # Get database size + sql = "SELECT pg_database_size(%s) as size_bytes" + rows = await self._exec_query_async(sql, (db_name,)) + size_bytes = rows[0].get('size_bytes') if rows else None + + return StorageInfo( + total_size_bytes=size_bytes, + ) + + async def list_databases(self) -> List[DatabaseBriefInfo]: + """List databases from pg_database with schema info for current database. + + For the current database, includes schema information with table/view counts + in the extra field. + """ + # Get list of databases + sql = """ + SELECT datname as name, pg_database_size(datname) as size_bytes + FROM pg_database + WHERE datistemplate = false OR datname = current_database() + ORDER BY datname + """ + rows = await self._exec_query_async(sql) + + # Get current database name + current_db = None + try: + result = await self._exec_query_async("SELECT current_database()") + if result: + current_db = result[0].get('current_database') + except Exception: + pass + + # Get schema info for current database + schema_info: Dict[str, Dict[str, int]] = {} + if current_db: + try: + # Query schemata with table/view counts + sql = """ + SELECT + n.nspname as schema_name, + COUNT(CASE WHEN c.relkind = 'r' THEN 1 END) as table_count, + COUNT(CASE WHEN c.relkind = 'v' THEN 1 END) as view_count + FROM pg_namespace n + LEFT JOIN pg_class c ON c.relnamespace = n.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + GROUP BY n.nspname + ORDER BY n.nspname + """ + schema_rows = await self._exec_query_async(sql) + for row in schema_rows: + schema_name = row.get('schema_name') + if schema_name: + schema_info[schema_name] = { + 'tables': row.get('table_count', 0) or 0, + 'views': row.get('view_count', 0) or 0, + } + except Exception: + pass + + databases = [] + for row in rows: + db_name = row['name'] + db_info = DatabaseBriefInfo( + name=db_name, + size_bytes=row.get('size_bytes'), + ) + + # Add schema info for current database + if db_name == current_db and schema_info: + db_info.extra['schemas'] = schema_info + # Also set total table/view counts + db_info.table_count = sum(s['tables'] for s in schema_info.values()) + db_info.view_count = sum(s['views'] for s in schema_info.values()) + + databases.append(db_info) + + return databases + + async def list_users(self) -> List[UserInfo]: + """List users from pg_roles.""" + sql = """ + SELECT rolname as name, rolsuper as is_superuser, + pg_catalog.pg_get_userbyid(oid) as owner + FROM pg_roles + ORDER BY rolname + """ + rows = await self._exec_query_async(sql) + + return [ + UserInfo( + name=row['name'], + is_superuser=row['is_superuser'], + extra={'owner': row.get('owner')} if row.get('owner') else {}, + ) + for row in rows + ] + + async def get_session_info(self) -> SessionInfo: + """Get current session/connection information.""" + session = SessionInfo() + + # Get current user + try: + rows = await self._exec_query_async("SELECT current_user") + if rows: + session.user = rows[0].get('current_user') + except Exception: + pass + + # Get current database + try: + rows = await self._exec_query_async("SELECT current_database()") + if rows: + session.database = rows[0].get('current_database') + except Exception: + pass + + # Get current schema + try: + rows = await self._exec_query_async("SELECT current_schema()") + if rows: + session.schema = rows[0].get('current_schema') + except Exception: + pass + + # Get client host address + try: + rows = await self._exec_query_async("SELECT inet_client_addr()") + if rows: + host = rows[0].get('inet_client_addr') + if host: + session.host = str(host) + except Exception: + pass + + # Get SSL status + try: + rows = await self._exec_query_async("SELECT ssl_is_used()") + if rows: + ssl_used = rows[0].get('ssl_is_used') + session.ssl_enabled = ssl_used is True or ssl_used == 't' or ssl_used == 'true' + + if session.ssl_enabled: + # Get SSL version + rows = await self._exec_query_async("SELECT ssl_version FROM pg_stat_ssl WHERE pid = pg_backend_pid()") + if rows and rows[0].get('ssl_version'): + session.ssl_version = rows[0].get('ssl_version') + + # Get SSL cipher + rows = await self._exec_query_async("SELECT ssl_cipher FROM pg_stat_ssl WHERE pid = pg_backend_pid()") + if rows and rows[0].get('ssl_cipher'): + session.ssl_cipher = rows[0].get('ssl_cipher') + except Exception: + pass + + # Check if password was used for authentication + session.password_used = bool(getattr(self._backend.config, 'password', None)) + + return session + + async def get_wal_info(self) -> WALInfo: + """Get WAL (Write-Ahead Logging) information.""" + wal_info = WALInfo() + version = self._get_version_tuple() + + # Get WAL level + try: + rows = await self._exec_query_async("SHOW wal_level") + if rows: + wal_info.wal_level = list(rows[0].values())[0] + except Exception: + pass + + # Get WAL position/size + # PostgreSQL 10+: pg_current_wal_lsn() + # PostgreSQL 9.6 and below: pg_current_xlog_location() + try: + if version >= (10, 0, 0): + rows = await self._exec_query_async("SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')::bigint as wal_bytes") + else: + # PG 9.6 and below use xlog terminology + rows = await self._exec_query_async("SELECT pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::bigint as wal_bytes") + if rows and rows[0].get('wal_bytes'): + wal_info.wal_size_bytes = int(rows[0]['wal_bytes']) + except Exception: + pass + + # Get WAL files count + # pg_ls_waldir() only exists in PostgreSQL 10+ + if version >= (10, 0, 0): + try: + rows = await self._exec_query_async(""" + SELECT count(*) as wal_files + FROM pg_ls_waldir() + """) + if rows: + wal_info.wal_files = int(rows[0].get('wal_files', 0)) + except Exception: + pass + + # Get checkpoint statistics + # PostgreSQL 10-16: pg_stat_bgwriter has checkpoints_timed and checkpoints_req + # PostgreSQL 17+: these columns moved to pg_stat_checkpointer + try: + if version >= (17, 0, 0): + # PostgreSQL 17+ uses pg_stat_checkpointer + rows = await self._exec_query_async(""" + SELECT + COALESCE(num_timed, 0) + + COALESCE(num_requested, 0) as checkpoint_count + FROM pg_stat_checkpointer + """) + else: + # PostgreSQL 10-16 uses pg_stat_bgwriter + rows = await self._exec_query_async(""" + SELECT + COALESCE(checkpoints_timed, 0) + + COALESCE(checkpoints_req, 0) as checkpoint_count + FROM pg_stat_bgwriter + """) + if rows: + wal_info.checkpoint_count = int(rows[0].get('checkpoint_count', 0)) + except Exception: + pass + + # Get last checkpoint info + # pg_control_checkpoint() exists in PostgreSQL 9.6+ + # Returns a record with checkpoint LSN as the first column + if version >= (10, 0, 0): + try: + rows = await self._exec_query_async(""" + SELECT (pg_control_checkpoint()).checkpoint_lsn as checkpoint_info + """) + if rows and rows[0].get('checkpoint_info'): + wal_info.checkpoint_time = rows[0]['checkpoint_info'] + except Exception: + # Some PostgreSQL versions may not support this syntax + pass + + # Get WAL keep size/segments + # PostgreSQL 13+: wal_keep_size + # PostgreSQL 12 and below: wal_keep_segments + if version >= (13, 0, 0): + try: + rows = await self._exec_query_async("SHOW wal_keep_size") + if rows: + wal_info.wal_segments = list(rows[0].values())[0] + except Exception: + pass + else: + try: + rows = await self._exec_query_async("SHOW wal_keep_segments") + if rows: + wal_info.wal_segments = list(rows[0].values())[0] + except Exception: + pass + + return wal_info + + async def get_replication_info(self) -> ReplicationInfo: + """Get replication status information.""" + replication_info = ReplicationInfo() + + # Check if primary or standby + try: + rows = await self._exec_query_async("SELECT pg_is_in_recovery()") + if rows: + is_in_recovery = list(rows[0].values())[0] + # pg_is_in_recovery returns False for primary, True for standby + replication_info.is_primary = not (is_in_recovery is True or is_in_recovery == 't' or is_in_recovery == 'true') + replication_info.is_standby = is_in_recovery is True or is_in_recovery == 't' or is_in_recovery == 'true' + except Exception: + pass + + # Get replication slots count + try: + rows = await self._exec_query_async(""" + SELECT count(*) as slot_count + FROM pg_replication_slots + """) + if rows: + replication_info.replication_slots = int(rows[0].get('slot_count', 0)) + except Exception: + pass + + # Get active replications count + try: + rows = await self._exec_query_async(""" + SELECT count(*) as active_count + FROM pg_stat_replication + WHERE state = 'streaming' + """) + if rows: + replication_info.active_replications = int(rows[0].get('active_count', 0)) + replication_info.streaming = replication_info.active_replications > 0 + except Exception: + pass + + # Get replication lag (for standby) + if replication_info.is_standby: + try: + rows = await self._exec_query_async(""" + SELECT pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())::bigint as lag_bytes + """) + if rows and rows[0].get('lag_bytes') is not None: + replication_info.lag_bytes = int(rows[0]['lag_bytes']) + except Exception: + pass + + return replication_info + + async def get_archive_info(self) -> ArchiveInfo: + """Get archive status information.""" + archive_info = ArchiveInfo() + + # Get archive mode + try: + rows = await self._exec_query_async("SHOW archive_mode") + if rows: + archive_info.archive_mode = list(rows[0].values())[0] + except Exception: + pass + + # Get archive command + try: + rows = await self._exec_query_async("SHOW archive_command") + if rows: + archive_info.archive_command = list(rows[0].values())[0] + except Exception: + pass + + # Get archive timeout + try: + rows = await self._exec_query_async("SHOW archive_timeout") + if rows: + timeout_str = list(rows[0].values())[0] + # Parse timeout (could be like '5min' or '300s') + if timeout_str and timeout_str != '0': + # Try to parse as integer (seconds) + try: + archive_info.archive_timeout = int(timeout_str) + except ValueError: + pass + except Exception: + pass + + # Get archive statistics + try: + rows = await self._exec_query_async(""" + SELECT archived_count, failed_count, + last_archived_time::text as last_archived, + last_failed_time::text as last_failed + FROM pg_stat_archiver + """) + if rows: + row = rows[0] + archive_info.archived_count = int(row.get('archived_count', 0) or 0) + archive_info.failed_count = int(row.get('failed_count', 0) or 0) + archive_info.last_archived = row.get('last_archived') + archive_info.last_failed = row.get('last_failed') + except Exception: + pass + + return archive_info + + async def get_security_info(self) -> SecurityInfo: + """Get security-related status information.""" + security_info = SecurityInfo() + + # Get SSL status + try: + rows = await self._exec_query_async("SHOW ssl") + if rows: + ssl_value = list(rows[0].values())[0] + security_info.ssl_enabled = ssl_value == 'on' or ssl_value is True + except Exception: + pass + + # Get SSL certificate file + try: + rows = await self._exec_query_async("SHOW ssl_cert_file") + if rows: + security_info.ssl_cert_file = list(rows[0].values())[0] + except Exception: + pass + + # Get SSL key file + try: + rows = await self._exec_query_async("SHOW ssl_key_file") + if rows: + security_info.ssl_key_file = list(rows[0].values())[0] + except Exception: + pass + + # Get SSL CA file + try: + rows = await self._exec_query_async("SHOW ssl_ca_file") + if rows: + security_info.ssl_ca_file = list(rows[0].values())[0] + except Exception: + pass + + # Get password encryption + try: + rows = await self._exec_query_async("SHOW password_encryption") + if rows: + security_info.password_encryption = list(rows[0].values())[0] + except Exception: + pass + + # Check row-level security + try: + rows = await self._exec_query_async("SHOW row_security") + if rows: + rls_value = list(rows[0].values())[0] + security_info.row_security = rls_value == 'on' or rls_value is True + except Exception: + pass + + return security_info + + async def list_extensions(self) -> List[ExtensionInfo]: + """List installed extensions.""" + extensions = [] + + try: + rows = await self._exec_query_async(""" + SELECT e.extname as name, + e.extversion as version, + n.nspname as schema, + pg_catalog.obj_description(e.oid, 'pg_extension') as description + FROM pg_extension e + LEFT JOIN pg_namespace n ON n.oid = e.extnamespace + ORDER BY e.extname + """) + + for row in rows: + ext = ExtensionInfo( + name=row['name'], + version=row.get('version'), + schema=row.get('schema'), + description=row.get('description'), + ) + extensions.append(ext) + except Exception: + pass + + return extensions diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_status_introspector.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_status_introspector.py new file mode 100644 index 00000000..b592f5a4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_status_introspector.py @@ -0,0 +1,624 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_status_introspector.py +""" +Tests for PostgreSQL status introspector. + +This module tests the SyncPostgreSQLStatusIntrospector functionality +for retrieving server status information via pg_settings, pg_stat_activity, +and other system views. +""" + +import pytest + +from rhosocial.activerecord.backend.introspection.status import ( + StatusItem, + StatusCategory, + ServerOverview, + DatabaseBriefInfo, + UserInfo, + ConnectionInfo, + StorageInfo, + WALInfo, + ReplicationInfo, + ArchiveInfo, + SecurityInfo, + ExtensionInfo, +) + + +class TestSyncPostgreSQLStatusIntrospector: + """Tests for synchronous PostgreSQL status introspector.""" + + def test_get_overview(self, postgres_backend_single): + """Test get_overview returns valid ServerOverview.""" + status = postgres_backend_single.introspector.status + + overview = status.get_overview() + + assert isinstance(overview, ServerOverview) + assert overview.server_vendor == "PostgreSQL" + assert overview.server_version is not None + assert isinstance(overview.configuration, list) + assert isinstance(overview.performance, list) + assert isinstance(overview.storage, StorageInfo) + assert isinstance(overview.databases, list) + # PostgreSQL has users + assert isinstance(overview.users, list) + + def test_get_overview_version_matches_dialect(self, postgres_backend_single): + """Test that overview version matches dialect version.""" + status = postgres_backend_single.introspector.status + overview = status.get_overview() + + expected_version = ".".join(map(str, postgres_backend_single.dialect.version)) + assert overview.server_version == expected_version + + def test_get_overview_contains_postgresql_version_info(self, postgres_backend_single): + """Test that overview contains PostgreSQL version info in extra.""" + status = postgres_backend_single.introspector.status + overview = status.get_overview() + + # PostgreSQL should have version info + assert overview.server_version is not None + + def test_list_configuration(self, postgres_backend_single): + """Test list_configuration returns configuration items.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration() + + assert isinstance(items, list) + assert len(items) > 0 + + # Check that all items are StatusItem instances + for item in items: + assert isinstance(item, StatusItem) + assert item.name is not None + assert item.value is not None + + def test_list_configuration_with_category_filter(self, postgres_backend_single): + """Test list_configuration with category filter.""" + status = postgres_backend_single.introspector.status + + config_items = status.list_configuration(category=StatusCategory.CONFIGURATION) + + for item in config_items: + assert item.category == StatusCategory.CONFIGURATION + + def test_list_configuration_contains_expected_items(self, postgres_backend_single): + """Test that configuration contains expected PostgreSQL settings.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration() + item_names = [item.name for item in items] + + # Check for some common PostgreSQL settings + assert "port" in item_names + assert "max_connections" in item_names + + def test_list_configuration_values_are_parsed(self, postgres_backend_single): + """Test that configuration values are returned.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration() + + # port should exist and have a value + port_item = next((i for i in items if i.name == "port"), None) + if port_item: + # PostgreSQL's _parse_setting_value preserves string values + assert port_item.value is not None + + def test_list_performance_metrics(self, postgres_backend_single): + """Test list_performance_metrics returns performance items.""" + status = postgres_backend_single.introspector.status + + items = status.list_performance_metrics() + + assert isinstance(items, list) + + # All items should be PERFORMANCE category + for item in items: + assert item.category == StatusCategory.PERFORMANCE + + def test_get_connection_info(self, postgres_backend_single): + """Test get_connection_info returns ConnectionInfo.""" + status = postgres_backend_single.introspector.status + + conn_info = status.get_connection_info() + + assert isinstance(conn_info, ConnectionInfo) + # PostgreSQL has connection info + assert conn_info.active_count is not None or conn_info.max_connections is not None + + def test_get_storage_info(self, postgres_backend_single): + """Test get_storage_info returns StorageInfo.""" + status = postgres_backend_single.introspector.status + + storage = status.get_storage_info() + + assert isinstance(storage, StorageInfo) + # PostgreSQL should have storage info + assert storage.extra is not None + + def test_list_databases(self, postgres_backend_single): + """Test list_databases returns database list.""" + status = postgres_backend_single.introspector.status + + databases = status.list_databases() + + assert isinstance(databases, list) + assert len(databases) >= 1 + + # All items should be DatabaseBriefInfo instances + for db in databases: + assert isinstance(db, DatabaseBriefInfo) + assert db.name is not None + + def test_list_databases_with_tables(self, backend_with_tables): + """Test list_databases includes table count.""" + status = backend_with_tables.introspector.status + + databases = status.list_databases() + + # At least one database should have tables + assert len(databases) >= 1 + + def test_list_users(self, postgres_backend_single): + """Test list_users returns user list.""" + status = postgres_backend_single.introspector.status + + users = status.list_users() + + assert isinstance(users, list) + + # PostgreSQL typically has at least one user + for user in users: + assert isinstance(user, UserInfo) + assert user.name is not None + + def test_get_wal_info(self, postgres_backend_single): + """Test get_wal_info returns WALInfo.""" + status = postgres_backend_single.introspector.status + + wal_info = status.get_wal_info() + + assert isinstance(wal_info, WALInfo) + # PostgreSQL should have WAL info + assert wal_info.extra is not None + + def test_get_replication_info(self, postgres_backend_single): + """Test get_replication_info returns ReplicationInfo.""" + status = postgres_backend_single.introspector.status + + repl_info = status.get_replication_info() + + assert isinstance(repl_info, ReplicationInfo) + # Replication may or may not be configured + + def test_get_archive_info(self, postgres_backend_single): + """Test get_archive_info returns ArchiveInfo.""" + status = postgres_backend_single.introspector.status + + archive_info = status.get_archive_info() + + assert isinstance(archive_info, ArchiveInfo) + # Archive info should be available + + def test_get_security_info(self, postgres_backend_single): + """Test get_security_info returns SecurityInfo.""" + status = postgres_backend_single.introspector.status + + security_info = status.get_security_info() + + assert isinstance(security_info, SecurityInfo) + # Security info should be available + + def test_list_extensions(self, postgres_backend_single): + """Test list_extensions returns extension list.""" + status = postgres_backend_single.introspector.status + + extensions = status.list_extensions() + + assert isinstance(extensions, list) + + # All items should be ExtensionInfo instances + for ext in extensions: + assert isinstance(ext, ExtensionInfo) + assert ext.name is not None + + def test_status_item_has_description(self, postgres_backend_single): + """Test that status items have descriptions.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration() + + # Check that items have descriptions + for item in items: + assert item.description is not None + + def test_status_item_readonly_flag(self, postgres_backend_single): + """Test that readonly items are marked correctly.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration() + + # port should be readonly (requires restart) + port_item = next((i for i in items if i.name == "port"), None) + if port_item: + assert port_item.is_readonly is True + + +class TestPostgreSQLStatusIntrospectorMixin: + """Tests for PostgreSQLStatusIntrospectorMixin helper methods.""" + + def test_parse_setting_value_int(self, postgres_backend_single): + """Test _parse_setting_value handles integers.""" + status = postgres_backend_single.introspector.status + + # _parse_setting_value returns int if input is int, otherwise str + result = status._parse_setting_value(42) + assert result == 42 + assert isinstance(result, int) + + # String "42" is not converted to int + result_str = status._parse_setting_value("42") + assert result_str == "42" + assert isinstance(result_str, str) + + def test_parse_setting_value_str(self, postgres_backend_single): + """Test _parse_setting_value preserves non-integer strings.""" + status = postgres_backend_single.introspector.status + + result = status._parse_setting_value("utf8") + assert result == "utf8" + assert isinstance(result, str) + + def test_parse_setting_value_on_off(self, postgres_backend_single): + """Test _parse_setting_value handles on/off values.""" + status = postgres_backend_single.introspector.status + + result_on = status._parse_setting_value("on") + assert result_on is True + + result_off = status._parse_setting_value("off") + assert result_off is False + + def test_create_status_item(self, postgres_backend_single): + """Test _create_status_item creates proper StatusItem.""" + status = postgres_backend_single.introspector.status + + item = status._create_status_item( + name="test_param", + value="42", + category=StatusCategory.CONFIGURATION, + description="Test parameter", + unit="ms", + is_readonly=False, + ) + + assert isinstance(item, StatusItem) + assert item.name == "test_param" + # PostgreSQL's _parse_setting_value preserves string "42" + assert item.value == "42" + assert item.category == StatusCategory.CONFIGURATION + assert item.description == "Test parameter" + assert item.unit == "ms" + assert item.is_readonly is False + + def test_get_vendor_name(self, postgres_backend_single): + """Test _get_vendor_name returns PostgreSQL.""" + status = postgres_backend_single.introspector.status + + vendor = status._get_vendor_name() + assert vendor == "PostgreSQL" + + +class TestStatusIntrospectorCategories: + """Tests for different status categories.""" + + def test_configuration_category_items(self, postgres_backend_single): + """Test items in CONFIGURATION category.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration(category=StatusCategory.CONFIGURATION) + + for item in items: + assert item.category == StatusCategory.CONFIGURATION + + def test_performance_category_items(self, postgres_backend_single): + """Test items in PERFORMANCE category.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration(category=StatusCategory.PERFORMANCE) + + for item in items: + assert item.category == StatusCategory.PERFORMANCE + + def test_storage_category_items(self, postgres_backend_single): + """Test items in STORAGE category.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration(category=StatusCategory.STORAGE) + + for item in items: + assert item.category == StatusCategory.STORAGE + + def test_security_category_items(self, postgres_backend_single): + """Test items in SECURITY category.""" + status = postgres_backend_single.introspector.status + + items = status.list_configuration(category=StatusCategory.SECURITY) + + for item in items: + assert item.category == StatusCategory.SECURITY + + +class TestAsyncPostgreSQLStatusIntrospector: + """Tests for asynchronous PostgreSQL status introspector.""" + + @pytest.mark.asyncio + async def test_get_overview(self, async_postgres_backend_single): + """Test async get_overview returns valid ServerOverview.""" + status = async_postgres_backend_single.introspector.status + + overview = await status.get_overview() + + assert isinstance(overview, ServerOverview) + assert overview.server_vendor == "PostgreSQL" + assert overview.server_version is not None + assert isinstance(overview.configuration, list) + assert isinstance(overview.performance, list) + assert isinstance(overview.storage, StorageInfo) + assert isinstance(overview.databases, list) + + @pytest.mark.asyncio + async def test_get_overview_version_matches_dialect(self, async_postgres_backend_single): + """Test that async overview version matches dialect version.""" + status = async_postgres_backend_single.introspector.status + overview = await status.get_overview() + + expected_version = ".".join(map(str, async_postgres_backend_single.dialect.version)) + assert overview.server_version == expected_version + + @pytest.mark.asyncio + async def test_list_configuration(self, async_postgres_backend_single): + """Test async list_configuration returns configuration items.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration() + + assert isinstance(items, list) + assert len(items) > 0 + + # Check that all items are StatusItem instances + for item in items: + assert isinstance(item, StatusItem) + assert item.name is not None + assert item.value is not None + + @pytest.mark.asyncio + async def test_list_configuration_with_category_filter(self, async_postgres_backend_single): + """Test async list_configuration with category filter.""" + status = async_postgres_backend_single.introspector.status + + config_items = await status.list_configuration(category=StatusCategory.CONFIGURATION) + + for item in config_items: + assert item.category == StatusCategory.CONFIGURATION + + @pytest.mark.asyncio + async def test_list_configuration_contains_expected_items(self, async_postgres_backend_single): + """Test that async configuration contains expected PostgreSQL settings.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration() + item_names = [item.name for item in items] + + # Check for some common PostgreSQL settings + assert "port" in item_names + assert "max_connections" in item_names + + @pytest.mark.asyncio + async def test_list_performance_metrics(self, async_postgres_backend_single): + """Test async list_performance_metrics returns performance items.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_performance_metrics() + + assert isinstance(items, list) + + # All items should be PERFORMANCE category + for item in items: + assert item.category == StatusCategory.PERFORMANCE + + @pytest.mark.asyncio + async def test_get_connection_info(self, async_postgres_backend_single): + """Test async get_connection_info returns ConnectionInfo.""" + status = async_postgres_backend_single.introspector.status + + conn_info = await status.get_connection_info() + + assert isinstance(conn_info, ConnectionInfo) + assert conn_info.active_count is not None or conn_info.max_connections is not None + + @pytest.mark.asyncio + async def test_get_storage_info(self, async_postgres_backend_single): + """Test async get_storage_info returns StorageInfo.""" + status = async_postgres_backend_single.introspector.status + + storage = await status.get_storage_info() + + assert isinstance(storage, StorageInfo) + + @pytest.mark.asyncio + async def test_list_databases(self, async_postgres_backend_single): + """Test async list_databases returns database list.""" + status = async_postgres_backend_single.introspector.status + + databases = await status.list_databases() + + assert isinstance(databases, list) + assert len(databases) >= 1 + + for db in databases: + assert isinstance(db, DatabaseBriefInfo) + + @pytest.mark.asyncio + async def test_list_users(self, async_postgres_backend_single): + """Test async list_users returns user list.""" + status = async_postgres_backend_single.introspector.status + + users = await status.list_users() + + assert isinstance(users, list) + + for user in users: + assert isinstance(user, UserInfo) + + @pytest.mark.asyncio + async def test_get_wal_info(self, async_postgres_backend_single): + """Test async get_wal_info returns WALInfo.""" + status = async_postgres_backend_single.introspector.status + + wal_info = await status.get_wal_info() + + assert isinstance(wal_info, WALInfo) + + @pytest.mark.asyncio + async def test_get_replication_info(self, async_postgres_backend_single): + """Test async get_replication_info returns ReplicationInfo.""" + status = async_postgres_backend_single.introspector.status + + repl_info = await status.get_replication_info() + + assert isinstance(repl_info, ReplicationInfo) + + @pytest.mark.asyncio + async def test_get_archive_info(self, async_postgres_backend_single): + """Test async get_archive_info returns ArchiveInfo.""" + status = async_postgres_backend_single.introspector.status + + archive_info = await status.get_archive_info() + + assert isinstance(archive_info, ArchiveInfo) + + @pytest.mark.asyncio + async def test_get_security_info(self, async_postgres_backend_single): + """Test async get_security_info returns SecurityInfo.""" + status = async_postgres_backend_single.introspector.status + + security_info = await status.get_security_info() + + assert isinstance(security_info, SecurityInfo) + + @pytest.mark.asyncio + async def test_list_extensions(self, async_postgres_backend_single): + """Test async list_extensions returns extension list.""" + status = async_postgres_backend_single.introspector.status + + extensions = await status.list_extensions() + + assert isinstance(extensions, list) + + for ext in extensions: + assert isinstance(ext, ExtensionInfo) + + @pytest.mark.asyncio + async def test_status_item_has_description(self, async_postgres_backend_single): + """Test that async status items have descriptions.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration() + + for item in items: + assert item.description is not None + + @pytest.mark.asyncio + async def test_status_item_readonly_flag(self, async_postgres_backend_single): + """Test that async readonly items are marked correctly.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration() + + port_item = next((i for i in items if i.name == "port"), None) + if port_item: + assert port_item.is_readonly is True + + +class TestAsyncPostgreSQLStatusIntrospectorMixin: + """Tests for async PostgreSQLStatusIntrospectorMixin helper methods.""" + + @pytest.mark.asyncio + async def test_parse_setting_value_int(self, async_postgres_backend_single): + """Test _parse_setting_value handles integers.""" + status = async_postgres_backend_single.introspector.status + + # _parse_setting_value returns int if input is int, otherwise str + result = status._parse_setting_value(42) + assert result == 42 + assert isinstance(result, int) + + # String "42" is not converted to int + result_str = status._parse_setting_value("42") + assert result_str == "42" + assert isinstance(result_str, str) + + @pytest.mark.asyncio + async def test_parse_setting_value_str(self, async_postgres_backend_single): + """Test _parse_setting_value preserves non-integer strings.""" + status = async_postgres_backend_single.introspector.status + + result = status._parse_setting_value("utf8") + assert result == "utf8" + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_get_vendor_name(self, async_postgres_backend_single): + """Test _get_vendor_name returns PostgreSQL.""" + status = async_postgres_backend_single.introspector.status + + vendor = status._get_vendor_name() + assert vendor == "PostgreSQL" + + +class TestAsyncStatusIntrospectorCategories: + """Tests for different async status categories.""" + + @pytest.mark.asyncio + async def test_configuration_category_items(self, async_postgres_backend_single): + """Test async items in CONFIGURATION category.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration(category=StatusCategory.CONFIGURATION) + + for item in items: + assert item.category == StatusCategory.CONFIGURATION + + @pytest.mark.asyncio + async def test_performance_category_items(self, async_postgres_backend_single): + """Test async items in PERFORMANCE category.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration(category=StatusCategory.PERFORMANCE) + + for item in items: + assert item.category == StatusCategory.PERFORMANCE + + @pytest.mark.asyncio + async def test_storage_category_items(self, async_postgres_backend_single): + """Test async items in STORAGE category.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration(category=StatusCategory.STORAGE) + + for item in items: + assert item.category == StatusCategory.STORAGE + + @pytest.mark.asyncio + async def test_security_category_items(self, async_postgres_backend_single): + """Test async items in SECURITY category.""" + status = async_postgres_backend_single.introspector.status + + items = await status.list_configuration(category=StatusCategory.SECURITY) + + for item in items: + assert item.category == StatusCategory.SECURITY From 42a6a1a7d02c7907dbd3bc17c3ffdd9629cb98ba Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 8 Apr 2026 17:03:29 +0800 Subject: [PATCH 7/8] ci: use PyPI for activerecord and testsuite dependencies Change from git source to PyPI packages: - rhosocial-activerecord==1.0.0.dev22 - rhosocial-activerecord-testsuite==1.0.0.dev9 --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6012bd6..10f0dcca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,10 +65,10 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Install activerecord and testsuite from git + - name: Install activerecord and testsuite from PyPI run: | - pip install git+https://github.com/rhosocial/python-activerecord@release/v1.0.0.dev22 - pip install git+https://github.com/rhosocial/python-activerecord-testsuite@release/v1.0.0.dev9 + pip install rhosocial-activerecord==1.0.0.dev22 + pip install rhosocial-activerecord-testsuite==1.0.0.dev9 - name: Create postgres scenario config run: | From 35d54adb02a3c4078634d3ab47e522c264e36a98 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 8 Apr 2026 17:20:11 +0800 Subject: [PATCH 8/8] docs: update CHANGELOG for v1.0.0.dev9 --- CHANGELOG.md | 14 ++++++++++++++ changelog.d/19.added.md | 1 - changelog.d/20.fixed.md | 1 - changelog.d/21.added.md | 1 - 4 files changed, 14 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/19.added.md delete mode 100644 changelog.d/20.fixed.md delete mode 100644 changelog.d/21.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5129c433..63d531a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [v1.0.0.dev9] - 2026-04-08 + +### Added + +- Added connection pool context awareness support for PostgreSQL backend, enabling proper connection management across sync/async contexts and transaction boundaries. ([#19](https://github.com/rhosocial/python-activerecord-postgres/issues/19)) +- Added PostgreSQL server status overview support with comprehensive introspection capabilities including WAL, replication, archive, security, and extensions info. ([#21](https://github.com/rhosocial/python-activerecord-postgres/issues/21)) + + + +### Fixed + +- Fixed PostgreSQL transaction manager to respect optional BEGIN syntax - isolation level is no longer forced, allowing SESSION CHARACTERISTICS to take effect when not explicitly set. ([#20](https://github.com/rhosocial/python-activerecord-postgres/issues/20)) + + ## [v1.0.0.dev8] - 2026-04-06 ### Added diff --git a/changelog.d/19.added.md b/changelog.d/19.added.md deleted file mode 100644 index 0bf77e96..00000000 --- a/changelog.d/19.added.md +++ /dev/null @@ -1 +0,0 @@ -Added connection pool context awareness support for PostgreSQL backend, enabling proper connection management across sync/async contexts and transaction boundaries. diff --git a/changelog.d/20.fixed.md b/changelog.d/20.fixed.md deleted file mode 100644 index dc6b190e..00000000 --- a/changelog.d/20.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed PostgreSQL transaction manager to respect optional BEGIN syntax - isolation level is no longer forced, allowing SESSION CHARACTERISTICS to take effect when not explicitly set. diff --git a/changelog.d/21.added.md b/changelog.d/21.added.md deleted file mode 100644 index 9cf5ced4..00000000 --- a/changelog.d/21.added.md +++ /dev/null @@ -1 +0,0 @@ -Added PostgreSQL server status overview support with comprehensive introspection capabilities including WAL, replication, archive, security, and extensions info.