diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..bb5f4901 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf +*.py text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.cfg text eol=lf +*.ini text eol=lf +*.sh text eol=lf +*.bat text eol=crlf +*.ps1 text eol=lf diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 8913cc44..b0b920fc 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -24,7 +24,7 @@ jobs: check-fragment: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c2d370ab..2e723bf0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,10 +43,10 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -78,10 +78,10 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.13" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 358e7e4e..ad4f0351 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,181 @@ permissions: contents: read jobs: + benchmark-python-314-postgres-18: + name: Run benchmarks with Python 3.14 / postgres 18 + runs-on: ubuntu-latest + permissions: + contents: read + + services: + postgres: + image: postgres:18 + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres-exts: + image: vistart/postgres-exts:18 + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Check out the repository + uses: actions/checkout@v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Install activerecord and testsuite + run: | + pip install rhosocial-activerecord==1.0.0.dev27 + pip install rhosocial-activerecord-testsuite==1.0.0.dev14 + + - name: Create postgres scenario config + run: | + cat > postgres_scenarios.yaml << EOF + scenarios: + postgres_default: + host: 127.0.0.1 + port: 5432 + database: test_db + username: root + password: password + postgres_exts: + host: 127.0.0.1 + port: 5433 + database: test_db + username: root + password: password + EOF + echo "POSTGRES_SCENARIOS_CONFIG_PATH=$(pwd)/postgres_scenarios.yaml" >> $GITHUB_ENV + + - name: Install postgres client for health check + run: | + sudo apt-get update + sudo apt-get install --yes postgresql-client + + - name: Wait for postgres service + run: | + until pg_isready -h 127.0.0.1 -p 5432 -U root; do + echo "Waiting for postgres..." + sleep 2 + done + echo "postgres is ready!" + until pg_isready -h 127.0.0.1 -p 5433 -U root; do + echo "Waiting for postgres-exts..." + sleep 2 + done + echo "postgres-exts is ready!" + + - name: Run benchmark suite + run: | + set -o pipefail + export PYTHONPATH=src:tests + TESTSUITE_DIR=$(python - <<'PY' + import pathlib + import rhosocial.activerecord.testsuite as testsuite + + print(pathlib.Path(testsuite.__file__).parent) + PY + ) + python -m pytest \ + --rootdir=. \ + --confcutdir=. \ + "$TESTSUITE_DIR/benchmark/crud" \ + "$TESTSUITE_DIR/benchmark/query" \ + "$TESTSUITE_DIR/benchmark/transaction" \ + "$TESTSUITE_DIR/benchmark/mixin" \ + -m benchmark \ + --benchmark-size=small \ + --benchmark-sort=mean \ + --tb=short | tee benchmark-output.txt + python -m pytest \ + --rootdir=. \ + --confcutdir=. \ + tests/benchmark/backend \ + -m benchmark \ + --benchmark-size=small \ + --benchmark-sort=mean \ + --tb=short | tee -a benchmark-output.txt + + - name: Run FastAPI benchmark suite + run: | + set -o pipefail + export PYTHONPATH=src:tests + TESTSUITE_DIR=$(python - <<'PY' + import pathlib + import rhosocial.activerecord.testsuite as testsuite + + print(pathlib.Path(testsuite.__file__).parent) + PY + ) + python -m pytest \ + --rootdir=. \ + --confcutdir=. \ + "$TESTSUITE_DIR/benchmark/fastapi" \ + -m benchmark_fastapi \ + --benchmark-size=small \ + --benchmark-sort=mean \ + --tb=short | tee fastapi-benchmark-output.txt + + - name: Write benchmark conclusion + if: always() + run: | + { + echo "## Python 3.14 / postgres 18 Benchmark Conclusion" + echo + echo "- Python: 3.14" + echo "- postgres: 18" + echo "- Scope: testsuite generic benchmarks + PostgreSQL backend direct benchmarks" + echo "- Size: small" + echo "- Result: benchmark job fails only when benchmark execution or correctness checks fail." + echo + echo '```text' + grep -A 120 "benchmark:" benchmark-output.txt || cat benchmark-output.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Write FastAPI benchmark conclusion + if: always() + run: | + { + echo "## Python 3.14 / postgres 18 FastAPI Benchmark Conclusion" + echo + echo "- Python: 3.14" + echo "- postgres: 18" + echo "- Scope: testsuite FastAPI database benchmarks" + echo "- Size: small" + echo "- Result: benchmark job fails only when FastAPI benchmark execution or correctness checks fail." + echo + echo '```text' + grep -A 120 "benchmark:" fastapi-benchmark-output.txt || cat fastapi-benchmark-output.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + test: name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} runs-on: ubuntu-latest @@ -65,22 +240,27 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - pip install -r requirements.txt - pip install -r requirements-dev.txt + if [ "${{ matrix.python-version }}" == "3.8" ]; then + pip install -r requirements-3.8.txt + pip install -r requirements-dev-3.8.txt + else + pip install -r requirements.txt + pip install -r requirements-dev.txt + fi - - name: Install activerecord and testsuite from PyPI + - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev26 - pip install rhosocial-activerecord-testsuite==1.0.0.dev13 + pip install rhosocial-activerecord==1.0.0.dev27 + pip install rhosocial-activerecord-testsuite==1.0.0.dev14 - name: Create postgres scenario config run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index f88dfd9e..1ed0595c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [v1.0.0.dev14] - 2026-05-30 + +### Changed + +- Changed PostgresDialect to default to version=None instead of fixed version, requiring introspect_and_adapt() or explicit version before version-dependent features can be used. ([#37](https://github.com/rhosocial/python-activerecord-postgres/issues/37)) + + ## [v1.0.0.dev13] - 2026-05-15 ### Security diff --git a/docs/examples/chapter_12_named_procedure/diagram_demo.py b/docs/examples/chapter_12_named_procedure/diagram_demo.py index d301bdb1..e223777a 100644 --- a/docs/examples/chapter_12_named_procedure/diagram_demo.py +++ b/docs/examples/chapter_12_named_procedure/diagram_demo.py @@ -7,7 +7,7 @@ 2. 执行过程后生成实例图 3. 将图嵌入文档 """ -from rhosocial.activerecord.backend.named_query import ( +from rhosocial.activerecord.backend.named_expression import ( Procedure, ProcedureContext, ProcedureRunner, diff --git a/docs/examples/chapter_12_named_procedure/order_workflow.py b/docs/examples/chapter_12_named_procedure/order_workflow.py index 6a8bdf38..b6b511a3 100644 --- a/docs/examples/chapter_12_named_procedure/order_workflow.py +++ b/docs/examples/chapter_12_named_procedure/order_workflow.py @@ -9,7 +9,7 @@ - 静态图和实例图生成 - PostgreSQL 特有的 NOWAIT, RETURNING 等特性 """ -from rhosocial.activerecord.backend.named_query import ( +from rhosocial.activerecord.backend.named_expression import ( Procedure, ProcedureContext, ParallelStep, @@ -192,7 +192,7 @@ async def run(self, ctx: ProcedureContext) -> None: if __name__ == "__main__": - from rhosocial.activerecord.backend.named_query import ProcedureDiagram + from rhosocial.activerecord.backend.named_expression import ProcedureDiagram print("=== 静态流程图 ===") print(OrderProcessingProcedure.static_diagram("flowchart")) diff --git a/pyproject.toml b/pyproject.toml index f26a0a17..337f192c 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.dev13" +version = "1.0.0.dev14" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } @@ -49,6 +49,8 @@ test = [ "pytest>=7.0.0", "coverage>=7.0.0", "pytest-mock>=3.0.0", + "fastapi>=0.110", + "httpx>=0.27", ] dev = [ "black>=23.0.0", @@ -86,6 +88,7 @@ markers = [ "requires_protocol: marks tests requiring specific backend protocol support", "requires_functions: marks tests requiring specific database functions", "requires_inner_join: marks tests requiring INNER JOIN support", + "benchmark_fastapi: marks FastAPI benchmark tests", ] [tool.coverage.run] diff --git a/requirements-3.8.txt b/requirements-3.8.txt new file mode 100644 index 00000000..b9c2fd97 --- /dev/null +++ b/requirements-3.8.txt @@ -0,0 +1,2 @@ +psycopg[binary] +rhosocial-activerecord diff --git a/requirements-dev-3.8.txt b/requirements-dev-3.8.txt new file mode 100644 index 00000000..c6ed77b9 --- /dev/null +++ b/requirements-dev-3.8.txt @@ -0,0 +1,35 @@ +# requirements-dev-3.8.txt +# Testing framework for unit and integration tests +# Version ~=8.3.5 is the latest pytest version compatible with Python 3.8 +pytest~=8.3.5 +pytest-asyncio + +# Pytest plugin for performance benchmarking +# Provides benchmark fixtures and marker registration used during collection +pytest-benchmark + +# Pytest plugin for mocking +pytest-mock + +# Async SQLite support and async file operations for tests +aiosqlite>=0.19.0 +aiofiles>=23.0.0 + +# Parallel test execution plugin for pytest +pytest-xdist + +# Code coverage measurement tool and pytest integration +coverage +pytest-cov>=4.0.0 + +# Pydantic with email validation support +pydantic[email] + +# YAML file processing library +pyyaml + +# Rich terminal output library +rich + +# Test timeout plugin for long-running database integration tests +pytest-timeout diff --git a/requirements-dev.txt b/requirements-dev.txt index 0773a3c8..0966b148 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,55 @@ -pytest -pyyaml -rhosocial-activerecord-testsuite +# requirements-dev.txt +# Testing framework for unit and integration tests +# Core framework for running test suites and fixtures +# Version ~=8.4.2 provides stable testing capabilities +pytest~=8.4.2 pytest-asyncio + +# Pytest plugin for performance benchmarking +# Provides benchmark fixtures and benchmark command-line options +pytest-benchmark + +# Pytest plugin for mocking +# Provides the 'mocker' fixture for patching and mocking objects in tests +pytest-mock + +# Async SQLite support (test only) +aiosqlite>=0.19.0 + +# Async file operations for tests +aiofiles>=23.0.0 + +# Parallel test execution plugin for pytest +# Enables faster test execution through parallel processing +# Version ~=3.8.0 provides stable parallel test execution +pytest-xdist~=3.8.0 + +# Code coverage measurement tool +# Measures test coverage and generates coverage reports +# Version ~=7.10.7 provides comprehensive coverage analysis +coverage~=7.10.7 + +# Coverage integration with pytest +# Combines pytest and coverage for seamless test coverage reporting +pytest-cov>=4.0.0 + +# Pydantic with email validation support +# Adds email field validation capabilities to the core framework +# Required for testing email field validation in user models pydantic[email] + +# FastAPI benchmark dependencies +fastapi>=0.110 +httpx>=0.27 + +# YAML file processing library +# Used for configuration file handling and test data +# Version ~=6.0.3 provides safe YAML parsing and generation +pyyaml~=6.0.3 + +# Rich terminal output library +# Enhanced output formatting for test results and debugging +rich + +# Test timeout plugin for long-running database integration tests pytest-timeout -pytest-mock -pytest-xdist -pytest-cov \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py index 03517f70..c63d7180 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py @@ -7,14 +7,14 @@ import json from enum import Enum as PythonEnum -from typing import Any, Dict, List, Type, Union, Optional, Set +from typing import Any, Dict, List, Type, Union, Optional, Set, get_args, get_origin from psycopg.types.json import Jsonb -from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter -class PostgresListAdapter(SQLTypeAdapter): +class PostgresListAdapter(BaseSQLTypeAdapter): """ Adapts Python list to PostgreSQL array types. @@ -22,19 +22,15 @@ class PostgresListAdapter(SQLTypeAdapter): Python lists natively for PostgreSQL array types. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: - return {list: [list]} + def __init__(self): + super().__init__() + self._register_type(list, list) - def to_database(self, value: list, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: list, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: # psycopg handles list natively for PostgreSQL arrays return value - def from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> list: - if value is None: - return None + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> list: if isinstance(value, list): return value raise TypeError(f"Cannot convert {type(value).__name__} to list") @@ -73,7 +69,7 @@ def from_database_batch( return values -class PostgresJSONBAdapter(SQLTypeAdapter): +class PostgresJSONBAdapter(BaseSQLTypeAdapter): """ Adapts Python dict to PostgreSQL JSONB and vice-versa. @@ -82,26 +78,22 @@ class PostgresJSONBAdapter(SQLTypeAdapter): we need to serialize back to JSON string. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: - return {dict: [Jsonb]} + def __init__(self): + super().__init__() + self._register_type(dict, Jsonb) - def to_database(self, value: Union[dict, list], target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: return Jsonb(value) - def from_database( + def _do_from_database( self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None - ) -> Union[dict, list]: - if value is None: - return None + ) -> Union[dict, list, str]: # For string target type, serialize dict/list back to JSON string # This is needed because psycopg auto-deserializes JSON to dict, - # but model fields may be defined as str type + # but model fields may be defined as Optional[str] type if target_type is str: if isinstance(value, (dict, list)): - return json.dumps(value) + return json.dumps(value, ensure_ascii=False) return value # For dict/list target types, return as-is if isinstance(value, dict): @@ -114,19 +106,7 @@ def from_database( def to_database_batch( self, values: List[Any], target_type: Type, options: Optional[Dict[str, Any]] = None ) -> List[Any]: - """Optimized batch JSON conversion to database format. - - Creates Jsonb wrappers for all values in a single pass, - reducing function call overhead compared to individual calls. - - Args: - values: List of values to convert - target_type: Target type (Jsonb) - options: Optional conversion options - - Returns: - List of Jsonb-wrapped values - """ + """Optimized batch JSON conversion to database format.""" result = [] for value in values: if value is None: @@ -138,67 +118,47 @@ def to_database_batch( def from_database_batch( self, values: List[Any], target_type: Type, options: Optional[Dict[str, Any]] = None ) -> List[Any]: - """Optimized batch JSON deserialization from database. - - Args: - values: List of values from database - target_type: Target Python type (dict, list, or str) - options: Optional conversion options - - Returns: - List of converted values - """ + """Optimized batch JSON deserialization from database.""" result = [] for value in values: if value is None: result.append(None) elif target_type is str: if isinstance(value, (dict, list)): - result.append(json.dumps(value)) + result.append(json.dumps(value, ensure_ascii=False)) else: result.append(value) else: - # For dict/list target types, return as-is result.append(value) return result -class PostgresNetworkAddressAdapter(SQLTypeAdapter): +class PostgresNetworkAddressAdapter(BaseSQLTypeAdapter): """ Adapts Python ipaddress objects to PostgreSQL network types. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: + def __init__(self): + super().__init__() try: import ipaddress - - return { - ipaddress.IPv4Address: [str], - ipaddress.IPv6Address: [str], - ipaddress.IPv4Network: [str], - ipaddress.IPv6Network: [str], - } + self._register_type(ipaddress.IPv4Address, str) + self._register_type(ipaddress.IPv6Address, str) + self._register_type(ipaddress.IPv4Network, str) + self._register_type(ipaddress.IPv6Network, str) except ImportError: - return {} + pass - def to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: return str(value) - def from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: try: import ipaddress - return ipaddress.ip_address(value) except (ImportError, ValueError): try: - # try network import ipaddress - return ipaddress.ip_network(value) except (ImportError, ValueError): return value @@ -256,7 +216,7 @@ def from_database_batch( return result -class PostgresEnumAdapter(SQLTypeAdapter): +class PostgresEnumAdapter(BaseSQLTypeAdapter): """PostgreSQL ENUM type adapter. This adapter handles conversion between Python values and PostgreSQL enum values. @@ -267,26 +227,11 @@ class PostgresEnumAdapter(SQLTypeAdapter): - None (NULL) """ - @property - def supported_types(self) -> Dict[Type, Set[Type]]: - """Return supported type mappings.""" - return {str: {str}} - - def to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: - """Convert Python value to PostgreSQL enum value. - - Args: - value: String, Python Enum, or None - target_type: Target type (not used for enums) - options: Optional conversion options - - 'enum_type': PostgresEnumType for validation - - Returns: - Enum value string, or None - """ - if value is None: - return None + def __init__(self): + super().__init__() + self._register_type(str, str) + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: # Handle Python Enum if isinstance(value, PythonEnum): result = value.name @@ -306,21 +251,7 @@ def to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, return result - def from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: - """Convert PostgreSQL enum value to Python string. - - Args: - value: Enum value from database - target_type: Target Python type - options: Optional conversion options - - 'enum_class': Python Enum class to convert to - - Returns: - String value, Python Enum instance, or None - """ - if value is None: - return None - + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: # If already a string, check if we should convert to Python Enum if isinstance(value, str): if options and "enum_class" in options: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py index a33b5c3b..670b8d8f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/hstore.py @@ -9,13 +9,13 @@ PostgreSQL Documentation: https://www.postgresql.org/docs/current/hstore.html """ -from typing import Dict, Optional, Type, Union, List +from typing import Any, Dict, Optional, Type, Union, List -from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter from rhosocial.activerecord.backend.impl.postgres.types.hstore import PostgresHstore -class PostgresHstoreAdapter(SQLTypeAdapter): +class PostgresHstoreAdapter(BaseSQLTypeAdapter): """Adapter for converting between PostgresHstore and database values. Supports conversion from: @@ -30,34 +30,17 @@ class PostgresHstoreAdapter(SQLTypeAdapter): - None → None """ - @property - def supported_types(self) -> Dict[Type, Type]: - """Return mapping of Python types to their database representation types.""" - return { - PostgresHstore: str, - dict: str, - } + def __init__(self): + super().__init__() + self._register_type(PostgresHstore, str) + self._register_type(dict, str) - def to_database( + def _do_to_database( self, - value: Union[PostgresHstore, Dict[str, Optional[str]], str, None], + value: Union[PostgresHstore, Dict[str, Optional[str]], str], target_type: Type, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: - """Convert a Python value to a database-compatible format. - - Args: - value: The Python value to convert - target_type: The target database type - - Returns: - hstore literal string or None - - Raises: - TypeError: If value type is not supported - """ - if value is None: - return None - if isinstance(value, PostgresHstore): return value.to_postgres_string() @@ -73,26 +56,12 @@ def to_database( f"Expected PostgresHstore, dict, str, or None." ) - def from_database( + def _do_from_database( self, - value: Union[str, PostgresHstore, Dict, None], - source_type: Type, + value: Union[str, PostgresHstore, Dict], + target_type: Type, + options: Optional[Dict[str, Any]] = None, ) -> Optional[PostgresHstore]: - """Convert a database value to a Python PostgresHstore object. - - Args: - value: The database value to convert - source_type: The source database type - - Returns: - PostgresHstore instance or None - - Raises: - TypeError: If value type is not supported - """ - if value is None: - return None - if isinstance(value, PostgresHstore): return value diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py index ac4be88c..c92f154f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py @@ -16,16 +16,16 @@ """ import json -from typing import Any, Dict, List, Optional, Set, Type, Union +from typing import Any, Dict, List, Optional, Set, Type, Union, get_args, get_origin from psycopg.types.json import Jsonb -from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter from ..types.json import PostgresJsonPath -class PostgresJSONBAdapter(SQLTypeAdapter): +class PostgresJSONBAdapter(BaseSQLTypeAdapter): """ Adapts Python dict to PostgreSQL JSONB and vice-versa. @@ -34,26 +34,22 @@ class PostgresJSONBAdapter(SQLTypeAdapter): we need to serialize back to JSON string. """ - @property - def supported_types(self) -> Dict[Type, List[Any]]: - return {dict: [Jsonb]} + def __init__(self): + super().__init__() + self._register_type(dict, Jsonb) - def to_database(self, value: Union[dict, list], target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - if value is None: - return None + def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: return Jsonb(value) - def from_database( + def _do_from_database( self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None - ) -> Union[dict, list]: - if value is None: - return None + ) -> Union[dict, list, str]: # For string target type, serialize dict/list back to JSON string # This is needed because psycopg auto-deserializes JSON to dict, - # but model fields may be defined as str type + # but model fields may be defined as Optional[str] type if target_type is str: if isinstance(value, (dict, list)): - return json.dumps(value) + return json.dumps(value, ensure_ascii=False) return value # For dict/list target types, return as-is if isinstance(value, dict): 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 a3e8cc38..b4008f99 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -138,8 +138,8 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Initialize PostgreSQL-specific components - # Use default version (9.6.0) - actual version detected via introspect_and_adapt() - self._dialect = PostgresDialect((9, 6, 0)) + # Version is None until introspect_and_adapt() is called + self._dialect = PostgresDialect() # Register PostgreSQL-specific type adapters (same as sync backend) # Note: XML adapter is NOT registered by default due to potential conflicts. @@ -250,7 +250,7 @@ async def connect(self): ) except PsycopgError as e: self.log(logging.ERROR, f"Failed to connect to PostgreSQL database: {str(e)}") - raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from None + raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from e async def disconnect(self): """Close async connection to PostgreSQL database.""" @@ -265,7 +265,7 @@ async def disconnect(self): self.log(logging.INFO, "Disconnected from PostgreSQL database") except PsycopgError as e: self.log(logging.ERROR, f"Error during disconnection: {str(e)}") - raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from None + raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from e async def _get_cursor(self): """Get a database cursor, ensuring connection is active. @@ -319,13 +319,13 @@ async def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: except PsycopgIntegrityError as e: self.log(logging.ERROR, f"Integrity error in batch: {str(e)}") - raise IntegrityError(str(e)) from None + raise IntegrityError(str(e)) from e except PsycopgError as e: self.log(logging.ERROR, f"PostgreSQL error in batch: {str(e)}") - raise DatabaseError(str(e)) from None + raise DatabaseError(str(e)) from e except Exception as e: self.log(logging.ERROR, f"Unexpected error during batch execution: {str(e)}") - raise QueryError(str(e)) from None + raise QueryError(str(e)) from e finally: if cursor: await cursor.close() @@ -576,27 +576,27 @@ async def _handle_error(self, error: Exception) -> None: if isinstance(error, PsycopgIntegrityError): if "duplicate key value violates unique constraint" in error_msg.lower(): self.log(logging.ERROR, f"Unique constraint violation: {error_msg}") - raise IntegrityError(f"Unique constraint violation: {error_msg}") + raise IntegrityError(f"Unique constraint violation: {error_msg}") from error elif "violates foreign key constraint" in error_msg.lower(): self.log(logging.ERROR, f"Foreign key constraint violation: {error_msg}") - raise IntegrityError(f"Foreign key constraint violation: {error_msg}") + raise IntegrityError(f"Foreign key constraint violation: {error_msg}") from error self.log(logging.ERROR, f"Integrity error: {error_msg}") - raise IntegrityError(error_msg) + raise IntegrityError(error_msg) from error elif isinstance(error, PsycopgOperationalError): if "deadlock detected" in error_msg.lower(): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error self.log(logging.ERROR, f"Operational error: {error_msg}") - raise OperationalError(error_msg) + raise OperationalError(error_msg) from error elif isinstance(error, PsycopgProgrammingError): self.log(logging.ERROR, f"Programming error: {error_msg}") - raise QueryError(error_msg) + raise QueryError(error_msg) from error elif isinstance(error, PsycopgDeadlockError): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error elif isinstance(error, PsycopgError): self.log(logging.ERROR, f"PostgreSQL error: {error_msg}") - raise DatabaseError(error_msg) + raise DatabaseError(error_msg) from error else: self.log(logging.ERROR, f"Unexpected error: {error_msg}") raise error diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index cf9b6d61..7071a2e4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -138,8 +138,8 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Initialize PostgreSQL-specific components - # Use default version (9.6.0) - actual version detected via introspect_and_adapt() - self._dialect = PostgresDialect((9, 6, 0)) + # Version is None until introspect_and_adapt() is called + self._dialect = PostgresDialect() # Register PostgreSQL-specific type adapters (same as async backend) # Note: XML adapter is NOT registered by default due to potential conflicts. @@ -251,7 +251,7 @@ def connect(self): ) except PsycopgError as e: self.log(logging.ERROR, f"Failed to connect to PostgreSQL database: {str(e)}") - raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from None + raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from e def disconnect(self): """Close connection to PostgreSQL database.""" @@ -266,7 +266,7 @@ def disconnect(self): self.log(logging.INFO, "Disconnected from PostgreSQL database") except PsycopgError as e: self.log(logging.ERROR, f"Error during disconnection: {str(e)}") - raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from None + raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from e def _get_cursor(self): """Get a database cursor, ensuring connection is active. @@ -320,13 +320,13 @@ def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: except PsycopgIntegrityError as e: self.log(logging.ERROR, f"Integrity error in batch: {str(e)}") - raise IntegrityError(str(e)) from None + raise IntegrityError(str(e)) from e except PsycopgError as e: self.log(logging.ERROR, f"PostgreSQL error in batch: {str(e)}") - raise DatabaseError(str(e)) from None + raise DatabaseError(str(e)) from e except Exception as e: self.log(logging.ERROR, f"Unexpected error during batch execution: {str(e)}") - raise QueryError(str(e)) from None + raise QueryError(str(e)) from e finally: if cursor: cursor.close() @@ -577,27 +577,27 @@ def _handle_error(self, error: Exception) -> None: if isinstance(error, PsycopgIntegrityError): if "duplicate key value violates unique constraint" in error_msg.lower(): self.log(logging.ERROR, f"Unique constraint violation: {error_msg}") - raise IntegrityError(f"Unique constraint violation: {error_msg}") + raise IntegrityError(f"Unique constraint violation: {error_msg}") from error elif "violates foreign key constraint" in error_msg.lower(): self.log(logging.ERROR, f"Foreign key constraint violation: {error_msg}") - raise IntegrityError(f"Foreign key constraint violation: {error_msg}") + raise IntegrityError(f"Foreign key constraint violation: {error_msg}") from error self.log(logging.ERROR, f"Integrity error: {error_msg}") - raise IntegrityError(error_msg) + raise IntegrityError(error_msg) from error elif isinstance(error, PsycopgOperationalError): if "deadlock detected" in error_msg.lower(): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error self.log(logging.ERROR, f"Operational error: {error_msg}") - raise OperationalError(error_msg) + raise OperationalError(error_msg) from error elif isinstance(error, PsycopgProgrammingError): self.log(logging.ERROR, f"Programming error: {error_msg}") - raise QueryError(error_msg) + raise QueryError(error_msg) from error elif isinstance(error, PsycopgDeadlockError): self.log(logging.ERROR, f"Deadlock error: {error_msg}") - raise DeadlockError(error_msg) + raise DeadlockError(error_msg) from error elif isinstance(error, PsycopgError): self.log(logging.ERROR, f"PostgreSQL error: {error_msg}") - raise DatabaseError(error_msg) + raise DatabaseError(error_msg) from error else: self.log(logging.ERROR, f"Unexpected error: {error_msg}") raise error diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py index 999b3d97..54045e8f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py @@ -5,7 +5,7 @@ COMMAND_NAMES = [ 'info', 'query', 'introspect', 'status', - 'named-query', 'named-procedure', 'named-connection', + 'named-expression', 'named-procedure', 'named-procedure-graph', 'named-connection', ] @@ -15,8 +15,9 @@ def register_commands(subparsers): from .query import create_parser as query_parser from .introspect import create_parser as introspect_parser from .status import create_parser as status_parser - from .named_query import create_parser as nq_parser + from .named_expression import create_parser as nq_parser from .named_procedure import create_parser as np_parser + from .named_procedure_graph import create_parser as npg_parser from .named_connection import create_parser as nc_parser info_parser(subparsers) query_parser(subparsers) @@ -24,6 +25,7 @@ def register_commands(subparsers): status_parser(subparsers) nq_parser(subparsers) np_parser(subparsers) + npg_parser(subparsers) nc_parser(subparsers) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py index 2f400421..06dada96 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py @@ -70,7 +70,7 @@ def add_version_arg(parser): def create_connection_parent_parser(): """Create a parent parser with connection and output arguments. - Used by shared CLI helpers (named-query, named-procedure) that + Used by shared CLI helpers (named-expression, named-procedure) that require a parent_parser containing connection parameters. """ import argparse diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py index 9a40e897..f010b88e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_connection.py @@ -26,7 +26,12 @@ def handle(args): """Handle the named-connection subcommand.""" from rhosocial.activerecord.backend.named_connection.cli import handle_named_connection as handle_nc + from .output import create_provider + output_fmt = getattr(args, "output", "table") + ascii_borders = getattr(args, "rich_ascii", False) + provider = create_provider(output_fmt, ascii_borders=ascii_borders) + def named_connection_resolver_factory(name): return NamedConnectionResolver(name) - handle_nc(args, named_connection_resolver_factory) + handle_nc(args, named_connection_resolver_factory, provider) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py similarity index 61% rename from src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py rename to src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py index 966b4aeb..93f3d166 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py @@ -1,7 +1,7 @@ -# src/rhosocial/activerecord/backend/impl/postgres/cli/named_query.py -"""named-query subcommand - Adapter for shared CLI helper. +# src/rhosocial/activerecord/backend/impl/postgres/cli/named_expression.py +"""named-expression subcommand - Adapter for shared CLI helper. -named-query requires connection arguments and output arguments. +named-expression requires connection arguments and output arguments. """ from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, AsyncPostgresBackend @@ -12,44 +12,38 @@ def create_parser(subparsers): - """Create the named-query subcommand parser. + """Create the named-expression subcommand parser. - Reuses the shared create_named_query_parser, passing a parent parser + Reuses the shared create_named_expression_parser, passing a parent parser containing only connection and output arguments. """ - from rhosocial.activerecord.backend.named_query.cli import create_named_query_parser + from rhosocial.activerecord.backend.named_expression.cli import create_named_expression_parser local_parent = create_connection_parent_parser() + parser = create_named_expression_parser(subparsers, local_parent) + parser.add_argument( + "--dialect-version", + default=None, + help="PostgreSQL dialect version for capability probing (e.g., 15.0.0, 14.0.0).", + ) + return parser - nq_epilog = """Examples: - # Execute named query with connection parameters - %(prog)s myapp.queries.orders.high_value_pending --host localhost --database mydb --user postgres --password secret - - # Override parameters - %(prog)s myapp.queries.orders.high_value_pending --host localhost --database mydb \\ - --param threshold=5000 --param days=7 - - # Show signature without executing - %(prog)s myapp.queries.orders.high_value_pending --describe - - # Preview SQL without executing - %(prog)s myapp.queries.orders.orders_by_status \\ - --database mydb --param status=pending --dry-run - # List all named queries in a module - %(prog)s myapp.queries.orders --list +def handle(args): + """Handle the named-expression subcommand.""" + from rhosocial.activerecord.backend.named_expression.cli import handle_named_expression as handle_ne - # Using environment variables - export POSTGRES_HOST=localhost POSTGRES_DATABASE=mydb POSTGRES_USER=postgres POSTGRES_PASSWORD=secret - %(prog)s myapp.queries.orders --list -""" - return create_named_query_parser(subparsers, local_parent, epilog=nq_epilog) + provider = create_provider(args.output, ascii_borders=args.rich_ascii) + from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -def handle(args): - """Handle the named-query subcommand.""" - from rhosocial.activerecord.backend.named_query.cli import handle_named_query as handle_nq + def _get_requested_version(): + ver = getattr(args, "dialect_version", None) + if ver: + return tuple(int(p) for p in ver.split(".")) + return None - provider = create_provider(args.output, ascii_borders=args.rich_ascii) + def create_dialect(): + return PostgresDialect(version=_get_requested_version()) backend = None @@ -59,6 +53,9 @@ def backend_factory(): backend = PostgresBackend(connection_config=config) backend.connect() backend.introspect_and_adapt() + req_ver = _get_requested_version() + if req_ver: + backend._dialect.version = req_ver return backend def get_dialect(b): @@ -91,7 +88,7 @@ async def disconnect_async(): if async_backend and async_backend._connection: await async_backend.disconnect() - handle_nq( + handle_ne( args, provider, backend_factory=backend_factory, @@ -102,14 +99,16 @@ async def disconnect_async(): get_dialect_async=get_dialect_async, execute_query_async=execute_query_async, disconnect_async=disconnect_async, + create_dialect=create_dialect, ) return - handle_nq( + handle_ne( args, provider, backend_factory=backend_factory, get_dialect=get_dialect, execute_query=execute_query, disconnect=disconnect, + create_dialect=create_dialect, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py index 877be6d1..2df72470 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure.py @@ -16,7 +16,7 @@ def create_parser(subparsers): Reuses the shared create_named_procedure_parser, passing a parent parser containing only connection and output arguments. """ - from rhosocial.activerecord.backend.named_query.cli_procedure import create_named_procedure_parser + from rhosocial.activerecord.backend.named_expression.cli_procedure import create_named_procedure_parser local_parent = create_connection_parent_parser() np_epilog = """Examples: @@ -43,7 +43,7 @@ def create_parser(subparsers): def handle(args): """Handle the named-procedure subcommand.""" - from rhosocial.activerecord.backend.named_query.cli_procedure import handle_named_procedure as handle_np + from rhosocial.activerecord.backend.named_expression.cli_procedure import handle_named_procedure as handle_np provider = create_provider(args.output, ascii_borders=args.rich_ascii) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py new file mode 100644 index 00000000..c3686f7e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py @@ -0,0 +1,92 @@ +# src/rhosocial/activerecord/backend/impl/postgres/cli/named_procedure_graph.py +"""named-procedure-graph subcommand - Adapter for shared CLI helper. + +named-procedure-graph requires connection arguments, output arguments, and --rich-ascii. + +Usage: + $ python -m rhosocial.activerecord.backend.impl.postgres named-procedure-graph run \ + myapp.npg.monthly_report \ + --params '{"month": "2026-04"}' + + $ python -m rhosocial.activerecord.backend.impl.postgres named-procedure-graph list \ + myapp.npg + + $ python -m rhosocial.activerecord.backend.impl.postgres named-procedure-graph validate \ + myapp.npg.monthly_report +""" + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend + +from .connection import create_connection_parent_parser, resolve_connection_config_from_args +from .output import create_provider + + +def create_parser(subparsers): + """Create the named-procedure-graph subcommand parser. + + Reuses the shared create_named_procedure_graph_parser, passing a parent parser + containing only connection and output arguments. + """ + from rhosocial.activerecord.backend.named_expression.cli_procedure_graph import ( + create_named_procedure_graph_parser, + ) + + local_parent = create_connection_parent_parser() + return create_named_procedure_graph_parser(subparsers, local_parent) + + +def handle(args): + """Handle the named-procedure-graph subcommand.""" + from rhosocial.activerecord.backend.named_expression.cli_procedure_graph import ( + handle_named_procedure_graph as handle_npg, + ) + + provider = create_provider(args.output, ascii_borders=args.rich_ascii) + + backend = None + + def backend_factory(): + nonlocal backend + config = resolve_connection_config_from_args(args) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + return backend + + def disconnect(): + if backend and backend._connection: + backend.disconnect() + + is_async = getattr(args, "is_async", False) + + if is_async: + async_backend = None + + def backend_async_factory(): + nonlocal async_backend + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + config = resolve_connection_config_from_args(args) + async_backend = AsyncPostgresBackend(connection_config=config) + return async_backend + + async def disconnect_async(): + if async_backend and async_backend._connection: + await async_backend.disconnect() + + handle_npg( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + backend_async_factory=backend_async_factory, + disconnect_async=disconnect_async, + ) + return + + handle_npg( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/config.py b/src/rhosocial/activerecord/backend/impl/postgres/config.py index e43ee8e8..251b3306 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/config.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/config.py @@ -124,6 +124,10 @@ def to_dict(self) -> Dict[str, Any]: def to_connection_string(self) -> str: """Convert configuration to postgres connection URI. + Warning: + The returned connection string includes the password in plaintext. + Avoid logging or storing this string. + Returns: postgres connection URI string diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 487b4b12..2e5a24f5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -427,15 +427,19 @@ class PostgresDialect( Use introspect_and_adapt() to detect installed extensions automatically. """ - def __init__(self, version: Tuple[int, int, int] = (13, 0, 0)): + def __init__(self, version: Optional[Tuple[int, int, int]] = None): """ Initialize PostgreSQL dialect with specific version. Args: - version: PostgreSQL version tuple (major, minor, patch) + version: PostgreSQL version tuple (major, minor, patch). + If None, the dialect must be adapted via + backend.introspect_and_adapt() before version-dependent + features can be used. """ - self.version = version super().__init__() + if version is not None: + self.version = version @staticmethod def _validate_data_type(data_type: str) -> bool: @@ -472,9 +476,17 @@ def supports_materialized_cte(self) -> bool: """MATERIALIZED hint is supported since PostgreSQL 12.""" return self.version >= (12, 0, 0) - def supports_returning_clause(self) -> bool: - """RETURNING clause is supported since PostgreSQL 8.2.""" - return True # Supported in all modern versions + def supports_returning_insert(self) -> bool: + """RETURNING clause is supported for INSERT since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_update(self) -> bool: + """RETURNING clause is supported for UPDATE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) + + def supports_returning_delete(self) -> bool: + """RETURNING clause is supported for DELETE since PostgreSQL 8.2.""" + return self.version >= (8, 2, 0) def supports_window_functions(self) -> bool: """Window functions are supported since PostgreSQL 8.4.""" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh new file mode 100644 index 00000000..e9f2d368 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh @@ -0,0 +1,299 @@ +#!/bin/bash +# cli_commands.sh - Complete PostgreSQL CLI command examples +# +# This script demonstrates all available CLI commands in the PostgreSQL backend. +# Run this script to see examples of each command category. +# +# Usage: +# ./cli_commands.sh [COMMAND] [OPTIONS] +# +# Commands: +# info - Display PostgreSQL environment information +# query - Execute SQL queries +# introspect - Database introspection +# status - Display server status +# named-expression - Execute named expressions +# named-procedure - Execute named procedures +# named-procedure-graph - Execute procedure graphs +# named-connection - Manage named connections +# all - Run all command examples (default) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +export PYTHONSAFEPATH=1 + +# Default connection parameters (override via environment) +POSTGRES_HOST="${POSTGRES_HOST:-localhost}" +POSTGRES_PORT="${POSTGRES_PORT:-5432}" +POSTGRES_DATABASE="${POSTGRES_DATABASE:-test}" +POSTGRES_USER="${POSTGRES_USER:-postgres}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" + +export POSTGRES_HOST POSTGRES_PORT POSTGRES_DATABASE POSTGRES_USER POSTGRES_PASSWORD + +PYTHON_CMD="python -m rhosocial.activerecord.backend.impl.postgres" +CONN_ARGS="--host $POSTGRES_HOST --port $POSTGRES_PORT --database $POSTGRES_DATABASE --user $POSTGRES_USER --password $POSTGRES_PASSWORD" + +# Command: info +run_info() { + echo "" + echo "==========================================" + echo "Command: info" + echo "==========================================" + echo "" + + echo "--- Basic info ---" + $PYTHON_CMD info + + echo "" + echo "--- Verbose info (protocol families) ---" + $PYTHON_CMD info -v + + echo "" + echo "--- Detailed verbose (all details) ---" + $PYTHON_CMD info -vv + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD info -o json +} + +# Command: query +run_query() { + echo "" + echo "==========================================" + echo "Command: query" + echo "==========================================" + echo "" + + echo "--- Simple SELECT ---" + $PYTHON_CMD query $CONN_ARGS "SELECT 1 as test, 'hello' as greeting" 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- SELECT with WHERE ---" + $PYTHON_CMD query $CONN_ARGS "SELECT * FROM users WHERE id = 1" 2>/dev/null || echo "(Requires running PostgreSQL server with users table)" + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD query $CONN_ARGS "SELECT 1 as test" -o json 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- CSV output ---" + $PYTHON_CMD query $CONN_ARGS "SELECT 1 as test" -o csv 2>/dev/null || echo "(Requires running PostgreSQL server)" +} + +# Command: introspect +run_introspect() { + echo "" + echo "==========================================" + echo "Command: introspect" + echo "==========================================" + echo "" + + echo "--- List all tables ---" + $PYTHON_CMD introspect $CONN_ARGS tables 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- List all views ---" + $PYTHON_CMD introspect $CONN_ARGS views 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get table details ---" + $PYTHON_CMD introspect $CONN_ARGS table users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get column details ---" + $PYTHON_CMD introspect $CONN_ARGS columns users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get indexes ---" + $PYTHON_CMD introspect $CONN_ARGS indexes users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get foreign keys ---" + $PYTHON_CMD introspect $CONN_ARGS foreign-keys users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get triggers ---" + $PYTHON_CMD introspect $CONN_ARGS triggers users 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Get database info ---" + $PYTHON_CMD introspect $CONN_ARGS database 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD introspect $CONN_ARGS tables -o json 2>/dev/null || echo "(Requires running PostgreSQL server)" +} + +# Command: status +run_status() { + echo "" + echo "==========================================" + echo "Command: status" + echo "==========================================" + echo "" + + echo "--- All status ---" + $PYTHON_CMD status $CONN_ARGS all 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Config status ---" + $PYTHON_CMD status $CONN_ARGS config 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Performance status ---" + $PYTHON_CMD status $CONN_ARGS performance 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Storage status ---" + $PYTHON_CMD status $CONN_ARGS storage 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Databases status ---" + $PYTHON_CMD status $CONN_ARGS databases 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- Verbose output ---" + $PYTHON_CMD status $CONN_ARGS all -v 2>/dev/null || echo "(Requires running PostgreSQL server)" + + echo "" + echo "--- JSON output ---" + $PYTHON_CMD status $CONN_ARGS all -o json 2>/dev/null || echo "(Requires running PostgreSQL server)" +} + +# Command: named-expression +run_named_expression() { + echo "" + echo "==========================================" + echo "Command: named-expression" + echo "==========================================" + echo "" + + echo "--- List named expressions (module may not exist) ---" + $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions 2>/dev/null || echo "(No named expression examples found)" + + echo "" + echo "--- List with dialect version ---" + $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions --dialect-version 15.0.0 2>/dev/null || echo "(No named expression examples found)" + + echo "" + echo "--- Describe expression ---" + $PYTHON_CMD named-expression --describe rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.some_expression 2>/dev/null || echo "(No named expression examples found)" +} + +# Command: named-procedure +run_named_procedure() { + echo "" + echo "==========================================" + echo "Command: named-procedure" + echo "==========================================" + echo "" + + echo "--- List named procedures (module may not exist) ---" + $PYTHON_CMD named-procedure --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedures 2>/dev/null || echo "(No named procedure examples found)" + + echo "" + echo "--- Describe procedure ---" + $PYTHON_CMD named-procedure --describe rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.SomeProcedure 2>/dev/null || echo "(No named procedure examples found)" + + echo "" + echo "--- Dry run procedure ---" + $PYTHON_CMD named-procedure rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.SomeProcedure --dry-run 2>/dev/null || echo "(No named procedure examples found)" +} + +# Command: named-procedure-graph +run_named_procedure_graph() { + echo "" + echo "==========================================" + echo "Command: named-procedure-graph" + echo "==========================================" + echo "" + + echo "--- List procedure graphs (module may not exist) ---" + $PYTHON_CMD named-procedure-graph --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedure_graph 2>/dev/null || echo "(No named procedure graph examples found)" + + echo "" + echo "--- Validate graph ---" + $PYTHON_CMD named-procedure-graph rhosocial.activerecord.backend.impl.postgres.examples.named_procedure_graph.some_graph --validate 2>/dev/null || echo "(No named procedure graph examples found)" + + echo "" + echo "--- Show wave decomposition ---" + $PYTHON_CMD named-procedure-graph rhosocial.activerecord.backend.impl.postgres.examples.named_procedure_graph.some_graph --waves 2>/dev/null || echo "(No named procedure graph examples found)" +} + +# Command: named-connection +run_named_connection() { + echo "" + echo "==========================================" + echo "Command: named-connection" + echo "==========================================" + echo "" + + MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_connections" + + echo "--- List connections in module ---" + $PYTHON_CMD named-connection --list "$MODULE" + + echo "" + echo "--- Show connection ---" + $PYTHON_CMD named-connection --show "$MODULE.local_dev" || true + + echo "" + echo "--- Describe connection ---" + $PYTHON_CMD named-connection --describe "$MODULE.local_dev" || true +} + +# Main +COMMAND="${1:-all}" + +case "$COMMAND" in + info) + run_info + ;; + query) + run_query + ;; + introspect) + run_introspect + ;; + status) + run_status + ;; + named-expression) + run_named_expression + ;; + named-procedure) + run_named_procedure + ;; + named-procedure-graph) + run_named_procedure_graph + ;; + named-connection) + run_named_connection + ;; + all) + run_info + run_query + run_introspect + run_status + run_named_expression + run_named_procedure + run_named_procedure_graph + run_named_connection + echo "" + echo "==========================================" + echo "All examples completed!" + echo "==========================================" + ;; + *) + echo "Unknown command: $COMMAND" + echo "Available commands: info, query, introspect, status, named-expression, named-procedure, named-procedure-graph, named-connection, all" + exit 1 + ;; +esac + +echo "" +echo "Done." diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_query.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh similarity index 67% rename from src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_query.sh rename to src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh index 833f1226..ff246bd9 100755 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_query.sh +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh @@ -1,5 +1,5 @@ #!/bin/bash -# named_query.sh - PostgreSQL CLI named-query command example +# named_expression.sh - PostgreSQL CLI named-expression command example set -e @@ -14,8 +14,8 @@ export POSTGRES_HOST POSTGRES_PORT POSTGRES_DATABASE POSTGRES_USER POSTGRES_PASS PYTHON_CMD="python -m rhosocial.activerecord.backend.impl.postgres" echo "==========================================" -echo "PostgreSQL CLI - named-query command examples" +echo "PostgreSQL CLI - named-expression command examples" echo "==========================================" -$PYTHON_CMD named-query --list rhosocial.activerecord.backend.impl.postgres.examples.named_queries 2>/dev/null || echo "(No named queries found)" +$PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions 2>/dev/null || echo "(No named expressions found)" $PYTHON_CMD named-connection --list rhosocial.activerecord.backend.impl.postgres.examples.named_connections \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py index 3557ad04..1f322e55 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -135,8 +135,8 @@ def _format_partition_value(self, value: Any) -> str: upper_val = value.upper() if upper_val == "MAXVALUE" or upper_val == "MINVALUE": return upper_val - # Add quotes around string values - return f"'{value}'" + # Add quotes around string values, escaping internal single quotes + return f"'{value.replace(chr(39), chr(39)+chr(39))}'" # escape ' to '' else: return str(value) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py index fcd09e4e..1df9610e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py @@ -104,20 +104,6 @@ def get_supported_introspection_scopes(self) -> List[IntrospectionScope]: # ========== Helper Methods ========== - def _quote_identifier(self, name: str) -> str: - """Quote an identifier for PostgreSQL. - - PostgreSQL uses double quotes for identifiers. - - Args: - name: The identifier name to quote. - - Returns: - The quoted identifier. - """ - escaped = name.replace('"', '""') - return f'"{escaped}"' - def _get_default_schema(self) -> str: """Get default schema name. diff --git a/tests/benchmark/__init__.py b/tests/benchmark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmark/backend/__init__.py b/tests/benchmark/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmark/backend/conftest.py b/tests/benchmark/backend/conftest.py new file mode 100644 index 00000000..543e4860 --- /dev/null +++ b/tests/benchmark/backend/conftest.py @@ -0,0 +1,212 @@ +"""PostgreSQL direct backend benchmark fixtures.""" + +import asyncio +from dataclasses import dataclass +from typing import Any, Callable, List + +import pytest + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( + make_user_payloads, + payload_count_for_size, +) + +from providers.scenarios import get_enabled_scenarios, get_scenario + + +@dataclass +class BackendBenchmarkContext: + scenario: str + size: str + backend: Any + payloads: List[dict] + record_ids: List[Any] + sql: dict + params_factory: Callable[..., Any] + backend_namespace: str + backend_name: str + + +DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL, process_result_set=True) +DDL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DDL) +RETURNING_ID_OPTIONS = ExecutionOptions(stmt_type=StatementType.DML, process_result_set=True) + +SCENARIO_PARAMS = list(get_enabled_scenarios().keys()) or [ + pytest.param( + "default", + marks=pytest.mark.skip(reason="No PostgreSQL benchmark scenarios found"), + ) +] + + +def pytest_addoption(parser): + parser.addoption( + "--benchmark-size", + action="store", + default="small", + choices=("small", "medium", "large"), + help="Data size for PostgreSQL backend benchmark scenarios.", + ) + + +@pytest.fixture(scope="function") +def benchmark_size(request): + return request.config.getoption("--benchmark-size") + + +def pytest_configure(config): + config.addinivalue_line("markers", "benchmark: Mark tests as performance benchmarks") + config.addinivalue_line("markers", "benchmark_sync: Mark synchronous benchmark tests") + config.addinivalue_line("markers", "benchmark_async: Mark asynchronous benchmark tests") + config.addinivalue_line("markers", "benchmark_read: Mark read-oriented benchmark tests") + config.addinivalue_line("markers", "benchmark_write: Mark write-oriented benchmark tests") + config.addinivalue_line("markers", "benchmark_backend: Mark backend direct benchmark tests") + + +@pytest.fixture(scope="function", params=SCENARIO_PARAMS) +def postgres_backend_sync_context(request, benchmark_size): + from rhosocial.activerecord.backend.impl.postgres import PostgresBackend + + scenario = request.param + _, config = get_scenario(scenario) + backend = PostgresBackend(connection_config=config) + _initialize_schema(backend) + payloads = make_user_payloads(payload_count_for_size(benchmark_size)) + record_ids = _seed_sync(backend, payloads) + try: + yield BackendBenchmarkContext( + scenario=scenario, + size=benchmark_size, + backend=backend, + payloads=payloads, + record_ids=record_ids, + sql=_sql_templates(), + params_factory=_params_factory, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + finally: + backend.disconnect() + + +@pytest.fixture(scope="function", params=SCENARIO_PARAMS) +def postgres_backend_async_context(request, benchmark_size): + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + scenario = request.param + _, config = get_scenario(scenario) + loop = asyncio.new_event_loop() + backend = AsyncPostgresBackend(connection_config=config) + try: + loop.run_until_complete(_initialize_schema_async(backend)) + payloads = make_user_payloads(payload_count_for_size(benchmark_size)) + record_ids = loop.run_until_complete(_seed_async(backend, payloads)) + yield ( + BackendBenchmarkContext( + scenario=scenario, + size=benchmark_size, + backend=backend, + payloads=payloads, + record_ids=record_ids, + sql=_sql_templates(), + params_factory=_params_factory, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ), + loop.run_until_complete, + ) + finally: + loop.run_until_complete(backend.disconnect()) + loop.close() + + +def _initialize_schema(backend): + backend.execute("DROP TABLE IF EXISTS benchmark_users", options=DDL_OPTIONS) + backend.execute(_schema_sql(), options=DDL_OPTIONS) + + +async def _initialize_schema_async(backend): + await backend.execute("DROP TABLE IF EXISTS benchmark_users", options=DDL_OPTIONS) + await backend.execute(_schema_sql(), options=DDL_OPTIONS) + + +def _seed_sync(backend, payloads): + record_ids = [] + for payload in payloads: + result = backend.execute( + _sql_templates()["insert"], + _params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("failed to seed sync PostgreSQL backend benchmark row") + record_ids.append(result.data[0]["id"]) + return record_ids + + +async def _seed_async(backend, payloads): + record_ids = [] + for payload in payloads: + result = await backend.execute( + _sql_templates()["insert"], + _params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("failed to seed async PostgreSQL backend benchmark row") + record_ids.append(result.data[0]["id"]) + return record_ids + + +def _sql_templates(): + return { + "insert": """ +INSERT INTO benchmark_users ( + username, email, age, balance, notes, is_active, created_at, updated_at +) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING id +""", + "insert_many": """ +INSERT INTO benchmark_users ( + username, email, age, balance, notes, is_active, created_at, updated_at +) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +""", + "find_one": "SELECT * FROM benchmark_users WHERE id = %s", + "update": ( + "UPDATE benchmark_users " + "SET username = %s, updated_at = CURRENT_TIMESTAMP " + "WHERE id = %s" + ), + "delete": "DELETE FROM benchmark_users WHERE id = %s", + } + + +def _params_factory(operation, payload): + if operation not in {"insert", "insert_many"}: + raise ValueError(f"unsupported backend benchmark operation: {operation}") + return ( + payload["username"], + payload["email"], + payload["age"], + payload["balance"], + payload["notes"], + payload["is_active"], + ) + + +def _schema_sql(): + return """ +CREATE TABLE benchmark_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + age INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +""" diff --git a/tests/benchmark/backend/test_execute_crud_async.py b/tests/benchmark/backend/test_execute_crud_async.py new file mode 100644 index 00000000..5165c33c --- /dev/null +++ b/tests/benchmark/backend/test_execute_crud_async.py @@ -0,0 +1,89 @@ +"""Asynchronous PostgreSQL direct backend CRUD benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import ( + DQL_OPTIONS, + execute_delete_one_async, + execute_find_one_async, + execute_insert_one_async, + execute_update_one_async, +) + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_insert_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + counter = {"value": 0} + + async def insert_row(): + counter["value"] += 1 + payload = make_user_payload(len(context.payloads) + counter["value"]) + return await execute_insert_one_async(context, payload) + + inserted_id = benchmark(lambda: run(insert_row())) + row = run(execute_find_one_async(context, inserted_id)) + assert row["username"] == f"bench_user_{len(context.payloads) + counter['value']}" + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_read +def test_postgres_backend_execute_find_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + record_id = context.record_ids[len(context.record_ids) // 2] + row = benchmark(lambda: run(execute_find_one_async(context, record_id))) + assert row["id"] == record_id + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_update_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + counter = {"value": 0} + + async def update_row(): + counter["value"] += 1 + payload = make_user_payload(len(context.payloads) + counter["value"]) + record_id = await execute_insert_one_async(context, payload) + username = f"backend_updated_{counter['value']}" + await execute_update_one_async(context, record_id, username) + return record_id, username + + record_id, username = benchmark(lambda: run(update_row())) + row = run(execute_find_one_async(context, record_id)) + assert row["username"] == username + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_delete_one_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + counter = {"value": 0} + + async def delete_row(): + counter["value"] += 1 + payload = make_user_payload(len(context.payloads) + counter["value"]) + record_id = await execute_insert_one_async(context, payload) + await execute_delete_one_async(context, record_id) + return record_id + + record_id = benchmark(lambda: run(delete_row())) + result = run( + context.backend.execute( + context.sql["find_one"], + (record_id,), + options=DQL_OPTIONS, + ) + ) + assert not result.data diff --git a/tests/benchmark/backend/test_execute_crud_sync.py b/tests/benchmark/backend/test_execute_crud_sync.py new file mode 100644 index 00000000..0c8f5714 --- /dev/null +++ b/tests/benchmark/backend/test_execute_crud_sync.py @@ -0,0 +1,83 @@ +"""Synchronous PostgreSQL direct backend CRUD benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import ( + DQL_OPTIONS, + execute_delete_one, + execute_find_one, + execute_insert_one, + execute_update_one, +) + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_insert_one_sync(benchmark, postgres_backend_sync_context): + counter = {"value": 0} + + def insert_row(): + counter["value"] += 1 + payload = make_user_payload(len(postgres_backend_sync_context.payloads) + counter["value"]) + return execute_insert_one(postgres_backend_sync_context, payload) + + inserted_id = benchmark(insert_row) + row = execute_find_one(postgres_backend_sync_context, inserted_id) + assert row["username"] == f"bench_user_{len(postgres_backend_sync_context.payloads) + counter['value']}" + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_read +def test_postgres_backend_execute_find_one_sync(benchmark, postgres_backend_sync_context): + record_id = postgres_backend_sync_context.record_ids[len(postgres_backend_sync_context.record_ids) // 2] + row = benchmark(execute_find_one, postgres_backend_sync_context, record_id) + assert row["id"] == record_id + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_update_one_sync(benchmark, postgres_backend_sync_context): + counter = {"value": 0} + + def update_row(): + counter["value"] += 1 + payload = make_user_payload(len(postgres_backend_sync_context.payloads) + counter["value"]) + record_id = execute_insert_one(postgres_backend_sync_context, payload) + username = f"backend_updated_{counter['value']}" + execute_update_one(postgres_backend_sync_context, record_id, username) + return record_id, username + + record_id, username = benchmark(update_row) + row = execute_find_one(postgres_backend_sync_context, record_id) + assert row["username"] == username + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_delete_one_sync(benchmark, postgres_backend_sync_context): + counter = {"value": 0} + + def delete_row(): + counter["value"] += 1 + payload = make_user_payload(len(postgres_backend_sync_context.payloads) + counter["value"]) + record_id = execute_insert_one(postgres_backend_sync_context, payload) + execute_delete_one(postgres_backend_sync_context, record_id) + return record_id + + record_id = benchmark(delete_row) + result = postgres_backend_sync_context.backend.execute( + postgres_backend_sync_context.sql["find_one"], + (record_id,), + options=DQL_OPTIONS, + ) + assert not result.data diff --git a/tests/benchmark/backend/test_execute_many_async.py b/tests/benchmark/backend/test_execute_many_async.py new file mode 100644 index 00000000..746787a8 --- /dev/null +++ b/tests/benchmark/backend/test_execute_many_async.py @@ -0,0 +1,19 @@ +"""Asynchronous PostgreSQL direct backend batch benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import execute_many_insert_async + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_async +@pytest.mark.benchmark_write +def test_postgres_backend_execute_many_insert_async(benchmark, postgres_backend_async_context): + context, run = postgres_backend_async_context + start = len(context.payloads) + 1 + payloads = [make_user_payload(start + index) for index in range(100)] + affected_rows = benchmark(lambda: run(execute_many_insert_async(context, payloads))) + assert affected_rows == len(payloads) diff --git a/tests/benchmark/backend/test_execute_many_sync.py b/tests/benchmark/backend/test_execute_many_sync.py new file mode 100644 index 00000000..d62a7290 --- /dev/null +++ b/tests/benchmark/backend/test_execute_many_sync.py @@ -0,0 +1,18 @@ +"""Synchronous PostgreSQL direct backend batch benchmarks.""" + +import pytest + +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import make_user_payload + +from .workloads import execute_many_insert + + +@pytest.mark.benchmark +@pytest.mark.benchmark_backend +@pytest.mark.benchmark_sync +@pytest.mark.benchmark_write +def test_postgres_backend_execute_many_insert_sync(benchmark, postgres_backend_sync_context): + start = len(postgres_backend_sync_context.payloads) + 1 + payloads = [make_user_payload(start + index) for index in range(100)] + affected_rows = benchmark(execute_many_insert, postgres_backend_sync_context, payloads) + assert affected_rows == len(payloads) diff --git a/tests/benchmark/backend/workloads.py b/tests/benchmark/backend/workloads.py new file mode 100644 index 00000000..9f841bb5 --- /dev/null +++ b/tests/benchmark/backend/workloads.py @@ -0,0 +1,94 @@ +"""PostgreSQL direct backend benchmark workloads.""" + +from typing import Any, Dict, List + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL, process_result_set=True) +DML_OPTIONS = ExecutionOptions(stmt_type=StatementType.DML, process_result_set=False) +RETURNING_ID_OPTIONS = ExecutionOptions(stmt_type=StatementType.DML, process_result_set=True) + + +def execute_insert_one(context: Any, payload: Dict[str, object]) -> Any: + result = context.backend.execute( + context.sql["insert"], + context.params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("PostgreSQL insert benchmark did not return inserted id") + return result.data[0]["id"] + + +async def execute_insert_one_async(context: Any, payload: Dict[str, object]) -> Any: + result = await context.backend.execute( + context.sql["insert"], + context.params_factory("insert", payload), + options=RETURNING_ID_OPTIONS, + ) + if result.affected_rows != 1 or not result.data: + raise AssertionError("async PostgreSQL insert benchmark did not return inserted id") + return result.data[0]["id"] + + +def execute_find_one(context: Any, record_id: Any) -> Dict[str, object]: + result = context.backend.execute(context.sql["find_one"], (record_id,), options=DQL_OPTIONS) + if len(result.data) != 1 or result.data[0]["id"] != record_id: + raise AssertionError("PostgreSQL find benchmark returned an unexpected row") + return result.data[0] + + +async def execute_find_one_async(context: Any, record_id: Any) -> Dict[str, object]: + result = await context.backend.execute(context.sql["find_one"], (record_id,), options=DQL_OPTIONS) + if len(result.data) != 1 or result.data[0]["id"] != record_id: + raise AssertionError("async PostgreSQL find benchmark returned an unexpected row") + return result.data[0] + + +def execute_update_one(context: Any, record_id: Any, username: str) -> None: + result = context.backend.execute( + context.sql["update"], + (username, record_id), + options=DML_OPTIONS, + ) + if result.affected_rows != 1: + raise AssertionError("PostgreSQL update benchmark did not affect exactly one row") + + +async def execute_update_one_async(context: Any, record_id: Any, username: str) -> None: + result = await context.backend.execute( + context.sql["update"], + (username, record_id), + options=DML_OPTIONS, + ) + if result.affected_rows != 1: + raise AssertionError("async PostgreSQL update benchmark did not affect exactly one row") + + +def execute_delete_one(context: Any, record_id: Any) -> None: + result = context.backend.execute(context.sql["delete"], (record_id,), options=DML_OPTIONS) + if result.affected_rows != 1: + raise AssertionError("PostgreSQL delete benchmark did not affect exactly one row") + + +async def execute_delete_one_async(context: Any, record_id: Any) -> None: + result = await context.backend.execute(context.sql["delete"], (record_id,), options=DML_OPTIONS) + if result.affected_rows != 1: + raise AssertionError("async PostgreSQL delete benchmark did not affect exactly one row") + + +def execute_many_insert(context: Any, payloads: List[Dict[str, object]]) -> int: + params_list = [context.params_factory("insert_many", payload) for payload in payloads] + result = context.backend.execute_many(context.sql["insert_many"], params_list) + if result.affected_rows != len(payloads): + raise AssertionError("PostgreSQL execute_many insert affected unexpected row count") + return result.affected_rows + + +async def execute_many_insert_async(context: Any, payloads: List[Dict[str, object]]) -> int: + params_list = [context.params_factory("insert_many", payload) for payload in payloads] + result = await context.backend.execute_many(context.sql["insert_many"], params_list) + if result.affected_rows != len(payloads): + raise AssertionError("async PostgreSQL execute_many insert affected unexpected row count") + return result.affected_rows diff --git a/tests/providers/basic.py b/tests/providers/basic.py index ee1eb06b..52ecf7db 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -30,6 +30,7 @@ User as UserBase, TypeCase as TypeCaseBase, ValidatedFieldUser as ValidatedFieldUserBase, TypeTestModel as TypeTestModelBase, ValidatedUser as ValidatedUserBase, TypeAdapterTest as TypeAdapterTestBase, YesOrNoBooleanAdapter, + PydanticValidatedModel as PydanticValidatedModelBase, MappedUser as MappedUserBase, MappedPost as MappedPostBase, MappedComment as MappedCommentBase, ColumnMappingModel as ColumnMappingModelBase, MixedAnnotationModel as MixedAnnotationModelBase ) @@ -38,6 +39,7 @@ AsyncUser as AsyncUserBase, AsyncTypeCase as AsyncTypeCaseBase, AsyncValidatedUser as AsyncValidatedUserBase, AsyncValidatedFieldUser as AsyncValidatedFieldUserBase, AsyncTypeTestModel as AsyncTypeTestModelBase, AsyncTypeAdapterTest as AsyncTypeAdapterTestBase, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModelBase, AsyncMappedUser as AsyncMappedUserBase, AsyncMappedPost as AsyncMappedPostBase, AsyncMappedComment as AsyncMappedCommentBase, AsyncColumnMappingModel as AsyncColumnMappingModelBase, AsyncMixedAnnotationModel as AsyncMixedAnnotationModelBase @@ -45,10 +47,10 @@ # Conditionally import Python 3.10+ models User310 = TypeCase310 = ValidatedFieldUser310 = TypeTestModel310 = ValidatedUser310 = None -TypeAdapterTest310 = MappedUser310 = MappedPost310 = MappedComment310 = None +TypeAdapterTest310 = PydanticValidatedModel310 = MappedUser310 = MappedPost310 = MappedComment310 = None ColumnMappingModel310 = MixedAnnotationModel310 = None AsyncUser310 = AsyncTypeCase310 = AsyncValidatedFieldUser310 = AsyncTypeTestModel310 = None -AsyncValidatedUser310 = AsyncTypeAdapterTest310 = AsyncMappedUser310 = AsyncMappedPost310 = None +AsyncValidatedUser310 = AsyncTypeAdapterTest310 = AsyncPydanticValidatedModel310 = AsyncMappedUser310 = AsyncMappedPost310 = None AsyncMappedComment310 = AsyncColumnMappingModel310 = AsyncMixedAnnotationModel310 = None if sys.version_info >= (3, 10): @@ -56,7 +58,7 @@ from rhosocial.activerecord.testsuite.feature.basic.fixtures.models_py310 import ( User as User310, TypeCase as TypeCase310, ValidatedFieldUser as ValidatedFieldUser310, TypeTestModel as TypeTestModel310, ValidatedUser as ValidatedUser310, - TypeAdapterTest as TypeAdapterTest310, + TypeAdapterTest as TypeAdapterTest310, PydanticValidatedModel as PydanticValidatedModel310, MappedUser as MappedUser310, MappedPost as MappedPost310, MappedComment as MappedComment310, ColumnMappingModel as ColumnMappingModel310, MixedAnnotationModel as MixedAnnotationModel310 ) @@ -64,6 +66,7 @@ AsyncUser as AsyncUser310, AsyncTypeCase as AsyncTypeCase310, AsyncValidatedUser as AsyncValidatedUser310, AsyncValidatedFieldUser as AsyncValidatedFieldUser310, AsyncTypeTestModel as AsyncTypeTestModel310, AsyncTypeAdapterTest as AsyncTypeAdapterTest310, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModel310, AsyncMappedUser as AsyncMappedUser310, AsyncMappedPost as AsyncMappedPost310, AsyncMappedComment as AsyncMappedComment310, AsyncColumnMappingModel as AsyncColumnMappingModel310, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel310 @@ -73,10 +76,10 @@ # Conditionally import Python 3.11+ models User311 = TypeCase311 = ValidatedFieldUser311 = TypeTestModel311 = ValidatedUser311 = None -TypeAdapterTest311 = MappedUser311 = MappedPost311 = MappedComment311 = None +TypeAdapterTest311 = PydanticValidatedModel311 = MappedUser311 = MappedPost311 = MappedComment311 = None ColumnMappingModel311 = MixedAnnotationModel311 = None AsyncUser311 = AsyncTypeCase311 = AsyncValidatedFieldUser311 = AsyncTypeTestModel311 = None -AsyncValidatedUser311 = AsyncTypeAdapterTest311 = AsyncMappedUser311 = AsyncMappedPost311 = None +AsyncValidatedUser311 = AsyncTypeAdapterTest311 = AsyncPydanticValidatedModel311 = AsyncMappedUser311 = AsyncMappedPost311 = None AsyncMappedComment311 = AsyncColumnMappingModel311 = AsyncMixedAnnotationModel311 = None if sys.version_info >= (3, 11): @@ -84,7 +87,7 @@ from rhosocial.activerecord.testsuite.feature.basic.fixtures.models_py311 import ( User as User311, TypeCase as TypeCase311, ValidatedFieldUser as ValidatedFieldUser311, TypeTestModel as TypeTestModel311, ValidatedUser as ValidatedUser311, - TypeAdapterTest as TypeAdapterTest311, + TypeAdapterTest as TypeAdapterTest311, PydanticValidatedModel as PydanticValidatedModel311, MappedUser as MappedUser311, MappedPost as MappedPost311, MappedComment as MappedComment311, ColumnMappingModel as ColumnMappingModel311, MixedAnnotationModel as MixedAnnotationModel311 ) @@ -92,6 +95,7 @@ AsyncUser as AsyncUser311, AsyncTypeCase as AsyncTypeCase311, AsyncValidatedUser as AsyncValidatedUser311, AsyncValidatedFieldUser as AsyncValidatedFieldUser311, AsyncTypeTestModel as AsyncTypeTestModel311, AsyncTypeAdapterTest as AsyncTypeAdapterTest311, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModel311, AsyncMappedUser as AsyncMappedUser311, AsyncMappedPost as AsyncMappedPost311, AsyncMappedComment as AsyncMappedComment311, AsyncColumnMappingModel as AsyncColumnMappingModel311, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel311 @@ -101,10 +105,10 @@ # Conditionally import Python 3.12+ models User312 = TypeCase312 = ValidatedFieldUser312 = TypeTestModel312 = ValidatedUser312 = None -TypeAdapterTest312 = MappedUser312 = MappedPost312 = MappedComment312 = None +TypeAdapterTest312 = PydanticValidatedModel312 = MappedUser312 = MappedPost312 = MappedComment312 = None ColumnMappingModel312 = MixedAnnotationModel312 = None AsyncUser312 = AsyncTypeCase312 = AsyncValidatedFieldUser312 = AsyncTypeTestModel312 = None -AsyncValidatedUser312 = AsyncTypeAdapterTest312 = AsyncMappedUser312 = AsyncMappedPost312 = None +AsyncValidatedUser312 = AsyncTypeAdapterTest312 = AsyncPydanticValidatedModel312 = AsyncMappedUser312 = AsyncMappedPost312 = None AsyncMappedComment312 = AsyncColumnMappingModel312 = AsyncMixedAnnotationModel312 = None if sys.version_info >= (3, 12): @@ -112,7 +116,7 @@ from rhosocial.activerecord.testsuite.feature.basic.fixtures.models_py312 import ( User as User312, TypeCase as TypeCase312, ValidatedFieldUser as ValidatedFieldUser312, TypeTestModel as TypeTestModel312, ValidatedUser as ValidatedUser312, - TypeAdapterTest as TypeAdapterTest312, + TypeAdapterTest as TypeAdapterTest312, PydanticValidatedModel as PydanticValidatedModel312, MappedUser as MappedUser312, MappedPost as MappedPost312, MappedComment as MappedComment312, ColumnMappingModel as ColumnMappingModel312, MixedAnnotationModel as MixedAnnotationModel312 ) @@ -120,6 +124,7 @@ AsyncUser as AsyncUser312, AsyncTypeCase as AsyncTypeCase312, AsyncValidatedUser as AsyncValidatedUser312, AsyncValidatedFieldUser as AsyncValidatedFieldUser312, AsyncTypeTestModel as AsyncTypeTestModel312, AsyncTypeAdapterTest as AsyncTypeAdapterTest312, + AsyncPydanticValidatedModel as AsyncPydanticValidatedModel312, AsyncMappedUser as AsyncMappedUser312, AsyncMappedPost as AsyncMappedPost312, AsyncMappedComment as AsyncMappedComment312, AsyncColumnMappingModel as AsyncColumnMappingModel312, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel312 @@ -144,6 +149,7 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s TypeTestModel = _select_model_class(TypeTestModelBase, TypeTestModel312, TypeTestModel311, TypeTestModel310, "TypeTestModel") ValidatedUser = _select_model_class(ValidatedUserBase, ValidatedUser312, ValidatedUser311, ValidatedUser310, "ValidatedUser") TypeAdapterTest = _select_model_class(TypeAdapterTestBase, TypeAdapterTest312, TypeAdapterTest311, TypeAdapterTest310, "TypeAdapterTest") +PydanticValidatedModel = _select_model_class(PydanticValidatedModelBase, PydanticValidatedModel312, PydanticValidatedModel311, PydanticValidatedModel310, "PydanticValidatedModel") MappedUser = _select_model_class(MappedUserBase, MappedUser312, MappedUser311, MappedUser310, "MappedUser") MappedPost = _select_model_class(MappedPostBase, MappedPost312, MappedPost311, MappedPost310, "MappedPost") MappedComment = _select_model_class(MappedCommentBase, MappedComment312, MappedComment311, MappedComment310, "MappedComment") @@ -157,6 +163,7 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s AsyncTypeTestModel = _select_model_class(AsyncTypeTestModelBase, AsyncTypeTestModel312, AsyncTypeTestModel311, AsyncTypeTestModel310, "AsyncTypeTestModel") AsyncValidatedUser = _select_model_class(AsyncValidatedUserBase, AsyncValidatedUser312, AsyncValidatedUser311, AsyncValidatedUser310, "AsyncValidatedUser") AsyncTypeAdapterTest = _select_model_class(AsyncTypeAdapterTestBase, AsyncTypeAdapterTest312, AsyncTypeAdapterTest311, AsyncTypeAdapterTest310, "AsyncTypeAdapterTest") +AsyncPydanticValidatedModel = _select_model_class(AsyncPydanticValidatedModelBase, AsyncPydanticValidatedModel312, AsyncPydanticValidatedModel311, AsyncPydanticValidatedModel310, "AsyncPydanticValidatedModel") AsyncMappedUser = _select_model_class(AsyncMappedUserBase, AsyncMappedUser312, AsyncMappedUser311, AsyncMappedUser310, "AsyncMappedUser") AsyncMappedPost = _select_model_class(AsyncMappedPostBase, AsyncMappedPost312, AsyncMappedPost311, AsyncMappedPost310, "AsyncMappedPost") AsyncMappedComment = _select_model_class(AsyncMappedCommentBase, AsyncMappedComment312, AsyncMappedComment311, AsyncMappedComment310, "AsyncMappedComment") @@ -299,6 +306,10 @@ def setup_validated_user_model(self, scenario_name: str) -> Type[ActiveRecord]: """Sets up the database for validated user model tests.""" return self._setup_model(ValidatedUser, scenario_name, "validated_users") + def setup_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecord]: + """Sets up the database for Pydantic native validation tests.""" + return self._setup_model(PydanticValidatedModel, scenario_name, "pydantic_validated_models") + def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: """Sets up the database for MappedUser, MappedPost, and MappedComment models.""" return self._setup_multiple_models([ @@ -353,6 +364,10 @@ async def setup_async_validated_user_model(self, scenario_name: str) -> Type[Act """Sets up the database for async validated user model tests.""" return await self._setup_async_model(AsyncValidatedUser, scenario_name, "validated_users") + async def setup_async_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecord]: + """Sets up the database for async Pydantic native validation tests.""" + return await self._setup_async_model(AsyncPydanticValidatedModel, scenario_name, "pydantic_validated_models") + async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: """Sets up the database for AsyncMappedUser, AsyncMappedPost, and AsyncMappedComment models.""" return await self._setup_multiple_models_async([ @@ -375,8 +390,8 @@ async def cleanup_after_test_async(self, scenario_name: str): """ tables_to_drop = [ 'users', 'type_cases', 'type_tests', 'validated_field_users', - 'validated_users', 'type_adapter_tests', 'posts', 'comments', - 'column_mapping_items', 'mixed_annotation_items' + 'validated_users', 'pydantic_validated_models', 'type_adapter_tests', + 'posts', 'comments', 'column_mapping_items', 'mixed_annotation_items' ] for backend_instance in self._active_async_backends: try: @@ -409,8 +424,8 @@ def cleanup_after_test(self, scenario_name: str): """ tables_to_drop = [ 'users', 'type_cases', 'type_tests', 'validated_field_users', - 'validated_users', 'type_adapter_tests', 'posts', 'comments', - 'column_mapping_items', 'mixed_annotation_items' + 'validated_users', 'pydantic_validated_models', 'type_adapter_tests', + 'posts', 'comments', 'column_mapping_items', 'mixed_annotation_items' ] for backend_instance in self._active_backends: try: @@ -466,6 +481,7 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = N 'type_test': 'type_tests', 'validated_field_user': 'validated_field_users', 'validated_user': 'validated_users', + 'pydantic_validated_model': 'pydantic_validated_models', 'type_adapter_test': 'type_adapter_tests', } table_name = table_map.get(base_type, 'users') diff --git a/tests/providers/crud_benchmark.py b/tests/providers/crud_benchmark.py new file mode 100644 index 00000000..1c35af46 --- /dev/null +++ b/tests/providers/crud_benchmark.py @@ -0,0 +1,179 @@ +"""PostgreSQL provider for CRUD benchmark tests.""" + +import sys +from typing import Type + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( + make_user_payloads, + payload_count_for_size, +) +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models import ( + AsyncBenchmarkUser as AsyncBenchmarkUserBase, + BenchmarkUser as BenchmarkUserBase, +) +from rhosocial.activerecord.testsuite.benchmark.crud.interfaces import CrudBenchmarkContext +from rhosocial.activerecord.testsuite.utils import select_fixture + +from .scenarios import get_enabled_scenarios, get_scenario + +BenchmarkUser310 = AsyncBenchmarkUser310 = None +BenchmarkUser311 = AsyncBenchmarkUser311 = None +BenchmarkUser312 = AsyncBenchmarkUser312 = None + +if sys.version_info >= (3, 10): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py310 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser310, + BenchmarkUser as BenchmarkUser310, + ) + +if sys.version_info >= (3, 11): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py311 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser311, + BenchmarkUser as BenchmarkUser311, + ) + +if sys.version_info >= (3, 12): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py312 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser312, + BenchmarkUser as BenchmarkUser312, + ) + + +BenchmarkUser = select_fixture( + *[ + candidate + for candidate in (BenchmarkUser312, BenchmarkUser311, BenchmarkUser310, BenchmarkUserBase) + if candidate + ] +) +AsyncBenchmarkUser = select_fixture( + *[ + candidate + for candidate in ( + AsyncBenchmarkUser312, + AsyncBenchmarkUser311, + AsyncBenchmarkUser310, + AsyncBenchmarkUserBase, + ) + if candidate + ] +) + + +class CrudBenchmarkProvider: + def __init__(self): + self._active_backends = [] + self._active_async_backends = [] + + def get_benchmark_scenarios(self): + return list(get_enabled_scenarios().keys()) + + def setup_benchmark_sync(self, scenario: str, size: str): + model_class = self._setup_model(BenchmarkUser, scenario) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = [] + for payload in payloads: + instance = model_class(**payload) + rows = instance.save() + if rows != 1 or instance.id is None: + raise AssertionError("failed to seed sync CRUD benchmark row") + record_ids.append(instance.id) + return CrudBenchmarkContext( + scenario=scenario, + size=size, + model_class=model_class, + payloads=payloads, + record_ids=record_ids, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + + def teardown_benchmark_sync(self, scenario: str, context: CrudBenchmarkContext) -> None: + self._cleanup_sync() + + async def setup_benchmark_async(self, scenario: str, size: str): + model_class = await self._setup_async_model(AsyncBenchmarkUser, scenario) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = [] + for payload in payloads: + instance = model_class(**payload) + rows = await instance.save() + if rows != 1 or instance.id is None: + raise AssertionError("failed to seed async CRUD benchmark row") + record_ids.append(instance.id) + return CrudBenchmarkContext( + scenario=scenario, + size=size, + model_class=model_class, + payloads=payloads, + record_ids=record_ids, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + ) + + async def teardown_benchmark_async(self, scenario: str, context: CrudBenchmarkContext) -> None: + await self._cleanup_async() + + def _setup_model(self, model_class: Type[ActiveRecord], scenario: str) -> Type[ActiveRecord]: + backend_class, config = get_scenario(scenario) + model_class.configure(config, backend_class) + self._initialize_schema(model_class.__backend__) + if model_class.__backend__ not in self._active_backends: + self._active_backends.append(model_class.__backend__) + return model_class + + async def _setup_async_model( + self, model_class: Type[ActiveRecord], scenario: str + ) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + _, config = get_scenario(scenario) + await model_class.configure(config, AsyncPostgresBackend) + await self._initialize_schema_async(model_class.__backend__) + if model_class.__backend__ not in self._active_async_backends: + self._active_async_backends.append(model_class.__backend__) + return model_class + + def _initialize_schema(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) + backend.execute(self._schema_sql(), options=options) + + async def _initialize_schema_async(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + await backend.execute("DROP TABLE IF EXISTS benchmark_users", options=options) + await backend.execute(self._schema_sql(), options=options) + + def _schema_sql(self): + return """ +CREATE TABLE benchmark_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + age INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +""" + + def _cleanup_sync(self): + for backend in self._active_backends: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + + async def _cleanup_async(self): + for backend in self._active_async_backends: + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() diff --git a/tests/providers/fastapi_benchmark.py b/tests/providers/fastapi_benchmark.py new file mode 100644 index 00000000..db0ae072 --- /dev/null +++ b/tests/providers/fastapi_benchmark.py @@ -0,0 +1,236 @@ +"""PostgreSQL provider for FastAPI benchmark tests.""" + +import sys +from contextlib import asynccontextmanager +from typing import Type + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.data import ( + make_user_payloads, + payload_count_for_size, +) +from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models import ( + AsyncBenchmarkUser as AsyncBenchmarkUserBase, +) +from rhosocial.activerecord.testsuite.benchmark.fastapi.interfaces import ( + FASTAPI_CONNECTION_STRATEGIES, + FASTAPI_CONTEXT_STRATEGY, + FASTAPI_RUNTIME_CONFIGS, + FastAPIBenchmarkContext, +) +from rhosocial.activerecord.testsuite.utils import select_fixture + +from .scenarios import get_enabled_scenarios, get_scenario + +AsyncBenchmarkUser310 = None +AsyncBenchmarkUser311 = None +AsyncBenchmarkUser312 = None + +if sys.version_info >= (3, 10): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py310 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser310, + ) + +if sys.version_info >= (3, 11): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py311 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser311, + ) + +if sys.version_info >= (3, 12): + from rhosocial.activerecord.testsuite.benchmark.crud.fixtures.models_py312 import ( + AsyncBenchmarkUser as AsyncBenchmarkUser312, + ) + +AsyncBenchmarkUser = select_fixture( + *[ + candidate + for candidate in ( + AsyncBenchmarkUser312, + AsyncBenchmarkUser311, + AsyncBenchmarkUser310, + AsyncBenchmarkUserBase, + ) + if candidate + ] +) + + +class FastAPIBenchmarkProvider: + def __init__(self): + self._active_async_backends = [] + self._active_async_pools = [] + self._active_clients = [] + + def get_benchmark_scenarios(self): + return list(get_enabled_scenarios().keys()) + + def get_connection_strategies(self): + return FASTAPI_CONNECTION_STRATEGIES + + async def setup_benchmark_async( + self, + scenario: str, + size: str, + connection_strategy: str = "context", + ): + import httpx + from rhosocial.activerecord.testsuite.benchmark.fastapi.app import ( + create_fastapi_benchmark_app, + ) + + model_class, config = await self._setup_async_model(AsyncBenchmarkUser, scenario) + payloads = make_user_payloads(payload_count_for_size(size)) + record_ids = [] + async with model_class.__backend__.context(): + for payload in payloads: + instance = model_class(**payload) + rows = await instance.save() + if rows != 1 or instance.id is None: + raise AssertionError("failed to seed async FastAPI benchmark row") + record_ids.append(instance.id) + + runtime_config = self._fastapi_runtime_config(connection_strategy) + pool = None + pool_config = None + backend_context_factory = self._create_async_context_factory(config) + if connection_strategy != FASTAPI_CONTEXT_STRATEGY: + pool, pool_config = await self._create_async_pool(config, runtime_config) + backend_context_factory = pool.connection + + app = create_fastapi_benchmark_app( + model_class=model_class, + backend_context_factory=backend_context_factory, + backend_name="postgres", + scenario=scenario, + ) + transport = httpx.ASGITransport(app=app) + client = httpx.AsyncClient(transport=transport, base_url="http://testserver") + self._active_clients.append(client) + + return FastAPIBenchmarkContext( + scenario=scenario, + size=size, + app=app, + client=client, + model_class=model_class, + payloads=payloads, + record_ids=record_ids, + backend_namespace="rhosocial.activerecord.backend.impl.postgres", + backend_name="postgres", + connection_strategy=connection_strategy, + concurrency=runtime_config["concurrency"], + repeat=runtime_config["repeat"], + pool_config={ + "min_size": pool_config.min_size, + "max_size": pool_config.max_size, + "connection_mode": pool_config.connection_mode, + } + if pool_config + else None, + pool_connection_mode=pool.connection_mode if pool else None, + pool_stats=pool.get_stats if pool else None, + ) + + async def teardown_benchmark_async( + self, + scenario: str, + context: FastAPIBenchmarkContext, + ) -> None: + await self._cleanup_async() + + async def _setup_async_model( + self, model_class: Type[ActiveRecord], scenario: str + ) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + _, config = get_scenario(scenario) + await model_class.configure(config, AsyncPostgresBackend) + await self._initialize_schema_async(model_class.__backend__) + if model_class.__backend__ not in self._active_async_backends: + self._active_async_backends.append(model_class.__backend__) + return model_class, config + + async def _initialize_schema_async(self, backend): + options = ExecutionOptions(stmt_type=StatementType.DDL) + await self._drop_benchmark_table(backend, options) + await backend.execute(self._schema_sql(), options=options) + + def _create_async_context_factory(self, config): + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + + @asynccontextmanager + async def context_factory(): + backend = AsyncPostgresBackend(connection_config=config) + async with backend.context(): + yield backend + + return context_factory + + def _fastapi_runtime_config(self, connection_strategy): + try: + return FASTAPI_RUNTIME_CONFIGS[connection_strategy] + except KeyError as exc: + raise ValueError( + f"unsupported FastAPI benchmark connection strategy: {connection_strategy}" + ) from exc + + async def _create_async_pool(self, config, runtime_config): + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + from rhosocial.activerecord.connection.pool import AsyncBackendPool, PoolConfig + + pool_config = PoolConfig( + min_size=runtime_config["pool_min_size"], + max_size=runtime_config["pool_max_size"], + connection_mode="auto", + backend_factory=lambda: AsyncPostgresBackend(connection_config=config), + ) + pool = await AsyncBackendPool.create(pool_config) + self._active_async_pools.append(pool) + return pool, pool_config + + def _schema_sql(self): + return """ +CREATE TABLE benchmark_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + age INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +""" + + async def _cleanup_async(self): + for client in self._active_clients: + try: + await client.aclose() + except Exception: + pass + self._active_clients.clear() + + for pool in self._active_async_pools: + try: + await pool.close(force=True) + except Exception: + pass + self._active_async_pools.clear() + + for backend in self._active_async_backends: + try: + options = ExecutionOptions(stmt_type=StatementType.DDL) + await self._drop_benchmark_table(backend, options) + except Exception: + pass + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() + + async def _drop_benchmark_table(self, backend, options): + await backend.execute('DROP TABLE IF EXISTS "benchmark_users" CASCADE', options=options) diff --git a/tests/providers/mixin_benchmark.py b/tests/providers/mixin_benchmark.py new file mode 100644 index 00000000..0e47555e --- /dev/null +++ b/tests/providers/mixin_benchmark.py @@ -0,0 +1,15 @@ +"""PostgreSQL provider for mixin benchmark tests.""" + +from rhosocial.activerecord.testsuite.benchmark.mixin.interfaces import MixinBenchmarkContext + +from .crud_benchmark import CrudBenchmarkProvider + + +class MixinBenchmarkProvider(CrudBenchmarkProvider): + def setup_benchmark_sync(self, scenario: str, size: str): + context = super().setup_benchmark_sync(scenario, size) + return MixinBenchmarkContext(**context.__dict__) + + async def setup_benchmark_async(self, scenario: str, size: str): + context = await super().setup_benchmark_async(scenario, size) + return MixinBenchmarkContext(**context.__dict__) diff --git a/tests/providers/query.py b/tests/providers/query.py index 77687471..8d93134a 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -220,7 +220,20 @@ def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], T return self._setup_multiple_models(models_and_tables, scenario_name) def setup_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the JSON user model.""" + """Sets up the database for the JSON user model. + + Registers PostgresJSONBAdapter for JSON fields so that psycopg3's + automatic JSONB deserialization (dict/list) is converted back to str + before Pydantic validation, matching the model's Optional[str] type. + """ + from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter + + jsonb_adapter = PostgresJSONBAdapter() + json_fields = ["settings", "tags", "profile", "roles", "scores", "subscription", "preferences"] + for field_name in json_fields: + if field_name not in JsonUser.__field_adapters__: + JsonUser.__field_adapters__[field_name] = (jsonb_adapter, str) + models_and_tables = [ (JsonUser, "json_users"), ] @@ -289,8 +302,21 @@ async def setup_async_blog_fixtures(self, scenario_name: str) -> Tuple[Type[Acti ], scenario_name) async def setup_async_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the async JSON user model.""" + """Sets up the database for the async JSON user model. + + Registers PostgresJSONBAdapter for JSON fields so that psycopg3's + automatic JSONB deserialization (dict/list) is converted back to str + before Pydantic validation, matching the model's Optional[str] type. + """ from rhosocial.activerecord.testsuite.feature.query.fixtures.async_json_models import AsyncJsonUser + from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter + + jsonb_adapter = PostgresJSONBAdapter() + json_fields = ["settings", "tags", "profile", "roles", "scores", "subscription", "preferences"] + for field_name in json_fields: + if field_name not in AsyncJsonUser.__field_adapters__: + AsyncJsonUser.__field_adapters__[field_name] = (jsonb_adapter, str) + return await self._setup_multiple_models_async([ (AsyncJsonUser, "json_users"), ], scenario_name) diff --git a/tests/providers/query_benchmark.py b/tests/providers/query_benchmark.py new file mode 100644 index 00000000..cca4f5c6 --- /dev/null +++ b/tests/providers/query_benchmark.py @@ -0,0 +1,15 @@ +"""PostgreSQL provider for query benchmark tests.""" + +from rhosocial.activerecord.testsuite.benchmark.query.interfaces import QueryBenchmarkContext + +from .crud_benchmark import CrudBenchmarkProvider + + +class QueryBenchmarkProvider(CrudBenchmarkProvider): + def setup_benchmark_sync(self, scenario: str, size: str): + context = super().setup_benchmark_sync(scenario, size) + return QueryBenchmarkContext(**context.__dict__) + + async def setup_benchmark_async(self, scenario: str, size: str): + context = await super().setup_benchmark_async(scenario, size) + return QueryBenchmarkContext(**context.__dict__) diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 69a1c98f..b6ab3719 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -12,6 +12,11 @@ from .query import QueryProvider from .basic_connection import BasicConnectionProvider from .query_connection import QueryConnectionProvider +from .crud_benchmark import CrudBenchmarkProvider +from .fastapi_benchmark import FastAPIBenchmarkProvider +from .mixin_benchmark import MixinBenchmarkProvider +from .query_benchmark import QueryBenchmarkProvider +from .transaction_benchmark import TransactionBenchmarkProvider # Create a single, global instance of the ProviderRegistry for this backend. provider_registry = ProviderRegistry() @@ -24,5 +29,21 @@ 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) +provider_registry.register( + "feature.basic.connection.IBasicConnectionProvider", + BasicConnectionProvider, +) +provider_registry.register( + "feature.query.connection.IQueryConnectionProvider", + QueryConnectionProvider, +) + +# Register benchmark providers. +provider_registry.register("benchmark.crud.ICrudBenchmarkProvider", CrudBenchmarkProvider) +provider_registry.register("benchmark.query.IQueryBenchmarkProvider", QueryBenchmarkProvider) +provider_registry.register( + "benchmark.transaction.ITransactionBenchmarkProvider", + TransactionBenchmarkProvider, +) +provider_registry.register("benchmark.mixin.IMixinBenchmarkProvider", MixinBenchmarkProvider) +provider_registry.register("benchmark.fastapi.IFastAPIBenchmarkProvider", FastAPIBenchmarkProvider) diff --git a/tests/providers/transaction_benchmark.py b/tests/providers/transaction_benchmark.py new file mode 100644 index 00000000..3330de2e --- /dev/null +++ b/tests/providers/transaction_benchmark.py @@ -0,0 +1,17 @@ +"""PostgreSQL provider for transaction benchmark tests.""" + +from rhosocial.activerecord.testsuite.benchmark.transaction.interfaces import ( + TransactionBenchmarkContext, +) + +from .crud_benchmark import CrudBenchmarkProvider + + +class TransactionBenchmarkProvider(CrudBenchmarkProvider): + def setup_benchmark_sync(self, scenario: str, size: str): + context = super().setup_benchmark_sync(scenario, size) + return TransactionBenchmarkContext(**context.__dict__) + + async def setup_benchmark_async(self, scenario: str, size: str): + context = await super().setup_benchmark_async(scenario, size) + return TransactionBenchmarkContext(**context.__dict__) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py index 6dc8ac6f..34ce6b4b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py @@ -12,7 +12,8 @@ def test_jsonb_adapter_supported_types(): adapter = PostgresJSONBAdapter() supported = adapter.supported_types - assert supported[dict] == [Jsonb] + assert dict in supported + assert Jsonb in supported[dict] def test_jsonb_to_database(): @@ -57,18 +58,16 @@ def test_jsonb_from_database(): def test_network_adapter_supported_types(): adapter = PostgresNetworkAddressAdapter() supported = adapter.supported_types - assert supported[ipaddress.IPv4Address] == [str] - assert supported[ipaddress.IPv6Address] == [str] - assert supported[ipaddress.IPv4Network] == [str] - assert supported[ipaddress.IPv6Network] == [str] + assert ipaddress.IPv4Address in supported + assert str in supported[ipaddress.IPv4Address] + assert ipaddress.IPv6Address in supported + assert str in supported[ipaddress.IPv6Address] + assert ipaddress.IPv4Network in supported + assert str in supported[ipaddress.IPv4Network] + assert ipaddress.IPv6Network in supported + assert str in supported[ipaddress.IPv6Network] -def test_network_adapter_supported_types_no_ipaddress(mocker): - """Test that it returns empty dict if ipaddress module is missing.""" - mocker.patch('builtins.__import__', side_effect=ImportError) - adapter = PostgresNetworkAddressAdapter() - assert adapter.supported_types == {} - @pytest.mark.parametrize("ip_obj, expected_str", [ (ipaddress.ip_address("192.168.1.1"), "192.168.1.1"), @@ -112,13 +111,6 @@ def test_network_from_database_none(): adapter = PostgresNetworkAddressAdapter() assert adapter.from_database(None, ipaddress.IPv4Address) is None -def test_network_from_database_no_ipaddress(mocker): - """Test that it returns the original value if ipaddress module is missing.""" - mocker.patch('builtins.__import__', side_effect=ImportError) - adapter = PostgresNetworkAddressAdapter() - ip_str = "192.168.1.1" - assert adapter.from_database(ip_str, str) == ip_str - # ============================================================================= # Batch Operation Tests diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py new file mode 100644 index 00000000..39dc4279 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_backend_initialization.py @@ -0,0 +1,49 @@ +from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend, PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.transaction import ( + AsyncPostgresTransactionManager, + PostgresTransactionManager, +) + + +class TestPostgresBackendInitialization: + def test_sync_backend_builds_default_config_from_kwargs(self): + backend = PostgresBackend(database="app_db", username="app_user", application_name="tests") + + assert backend.config.host == "localhost" + assert backend.config.port == 5432 + assert backend.config.database == "app_db" + assert backend.config.username == "app_user" + assert backend.config.application_name == "tests" + assert isinstance(backend.dialect, PostgresDialect) + assert isinstance(backend.transaction_manager, PostgresTransactionManager) + + def test_sync_backend_preserves_explicit_connection_config(self): + config = PostgresConnectionConfig(host="db.local", port=15432, database="app_db") + backend = PostgresBackend(connection_config=config) + + assert backend.config is config + assert isinstance(backend.dialect, PostgresDialect) + + def test_async_backend_builds_default_config_from_kwargs(self): + backend = AsyncPostgresBackend( + database="app_db", + username="app_user", + application_name="tests", + ) + + assert backend.config.host == "localhost" + assert backend.config.port == 5432 + assert backend.config.database == "app_db" + assert backend.config.username == "app_user" + assert backend.config.application_name == "tests" + assert isinstance(backend.dialect, PostgresDialect) + assert isinstance(backend.transaction_manager, AsyncPostgresTransactionManager) + + def test_async_backend_preserves_explicit_connection_config(self): + config = PostgresConnectionConfig(host="db.local", port=15432, database="app_db") + backend = AsyncPostgresBackend(connection_config=config) + + assert backend.config is config + assert isinstance(backend.dialect, PostgresDialect) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py index 9e6e2b65..e0530a7a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py @@ -2,81 +2,217 @@ """ Tests for PostgreSQL backend CLI. -Tests argument parsing for named-query and named-procedure subcommands. +Tests argument parsing for named-expression and named-procedure subcommands. """ -import pytest +import asyncio import sys -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch -class TestCLINamedQueryArgs: - """Tests for named-query subcommand argument parsing.""" +class TestCLINamedExpressionArgs: + """Tests for named-expression subcommand argument parsing.""" - def test_parse_args_named_query_basic(self): - """Test basic named-query parsing.""" + def test_parse_args_named_expression_basic(self): + """Test basic named-expression parsing.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.qualified_name == 'myapp.queries.test' - def test_parse_args_named_query_with_params(self): - """Test named-query with parameters.""" + def test_parse_args_named_expression_with_params(self): + """Test named-expression with parameters.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args with patch.object(sys, 'argv', [ - 'postgres', 'named-query', 'myapp.queries.test', + 'postgres', 'named-expression', 'myapp.queries.test', '--param', 'limit=50', '--param', 'status=active', ]): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.params == ['limit=50', 'status=active'] - def test_parse_args_named_query_dry_run(self): - """Test named-query with --dry-run.""" + def test_parse_args_named_expression_dry_run(self): + """Test named-expression with --dry-run.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test', '--dry-run']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test', '--dry-run']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.dry_run is True - def test_parse_args_named_query_describe(self): - """Test named-query with --describe.""" + def test_parse_args_named_expression_describe(self): + """Test named-expression with --describe.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test', '--describe']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test', '--describe']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.describe is True - def test_parse_args_named_query_list(self): - """Test named-query with --list.""" + def test_parse_args_named_expression_list(self): + """Test named-expression with --list.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries', '--list']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries', '--list']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.list_queries is True - def test_parse_args_named_query_async(self): - """Test named-query with --async.""" + def test_parse_args_named_expression_async(self): + """Test named-expression with --async.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-query', 'myapp.queries.test', '--async']): + with patch.object(sys, 'argv', ['postgres', 'named-expression', 'myapp.queries.test', '--async']): args = parse_args() - assert args.command == 'named-query' + assert args.command == 'named-expression' assert args.is_async is True + def test_parse_args_named_expression_dialect_version(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-expression', 'myapp.queries.test', + '--dialect-version', '15.2.1', + ]): + args = parse_args() + + assert args.command == 'named-expression' + assert args.dialect_version == '15.2.1' + + +class TestCLINamedExpressionAdapter: + def _args(self, **overrides): + values = { + 'output': 'table', + 'rich_ascii': False, + 'is_async': False, + 'dialect_version': None, + 'host': 'localhost', + 'port': 5432, + 'database': 'test_db', + 'user': 'postgres', + 'password': '', + 'named_connection': None, + 'connection_params': [], + } + values.update(overrides) + return SimpleNamespace(**values) + + def test_handle_sync_wires_backend_provider_and_dialect_version(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_expression + + captured = {} + backend = MagicMock() + backend._connection = object() + backend.dialect = object() + + def fake_handle(args, provider, **kwargs): + captured['args'] = args + captured['provider'] = provider + captured.update(kwargs) + + args = self._args(dialect_version='15.2.1') + + with patch.object( + named_expression, 'create_provider', return_value='provider' + ) as create_provider, patch.object( + named_expression, + 'resolve_connection_config_from_args', + return_value='config', + ) as resolve_config, patch.object( + named_expression, 'PostgresBackend', return_value=backend + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli.handle_named_expression', + side_effect=fake_handle, + ): + named_expression.handle(args) + create_provider.assert_called_once_with('table', ascii_borders=False) + resolved_backend = captured['backend_factory']() + assert resolved_backend is backend + resolve_config.assert_called_once_with(args) + backend_cls.assert_called_once_with(connection_config='config') + backend.connect.assert_called_once_with() + backend.introspect_and_adapt.assert_called_once_with() + assert backend._dialect.version == (15, 2, 1) + assert captured['get_dialect'](backend) is backend.dialect + + captured['execute_query']('SELECT 1', (), 'dql') + backend.execute.assert_called_once() + + captured['disconnect']() + backend.disconnect.assert_called_once_with() + + def test_handle_sync_creates_standalone_dialect_for_dry_run_paths(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_expression + + captured = {} + + def fake_handle(args, provider, **kwargs): + captured.update(kwargs) + + args = self._args(dialect_version='14.0.0') + + with patch.object( + named_expression, 'create_provider', return_value='provider' + ), patch( + 'rhosocial.activerecord.backend.named_expression.cli.handle_named_expression', + side_effect=fake_handle, + ): + named_expression.handle(args) + + assert captured['create_dialect']().version == (14, 0, 0) + + def test_handle_async_wires_async_callbacks(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_expression + + captured = {} + async_backend = MagicMock() + async_backend._connection = object() + async_backend.dialect = object() + async_backend.execute = AsyncMock(return_value='result') + async_backend.disconnect = AsyncMock() + + def fake_handle(args, provider, **kwargs): + captured.update(kwargs) + + args = self._args(is_async=True) + + with patch.object( + named_expression, 'create_provider', return_value='provider' + ), patch.object( + named_expression, + 'resolve_connection_config_from_args', + return_value='config', + ), patch.object( + named_expression, 'AsyncPostgresBackend', return_value=async_backend + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli.handle_named_expression', + side_effect=fake_handle, + ): + named_expression.handle(args) + resolved_backend = captured['backend_async_factory']() + assert resolved_backend is async_backend + backend_cls.assert_called_once_with(connection_config='config') + dialect = asyncio.run(captured['get_dialect_async'](async_backend)) + assert dialect is async_backend.dialect + result = asyncio.run( + captured['execute_query_async']('SELECT 1', (), 'dql') + ) + assert result == 'result' + asyncio.run(captured['disconnect_async']()) + async_backend.disconnect.assert_awaited_once_with() + class TestCLINamedProcedureArgs: """Tests for named-procedure subcommand argument parsing.""" @@ -173,3 +309,135 @@ def test_parse_args_named_procedure_async(self): assert args.command == 'named-procedure' assert args.is_async is True + + +class TestCLINamedProcedureGraphArgs: + def test_parse_args_named_procedure_graph_run(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-procedure-graph', 'myapp.graphs.monthly_report', + '--params', '{"month":"2026-04"}', + ]): + args = parse_args() + + assert args.command == 'named-procedure-graph' + assert args.qualified_name == 'myapp.graphs.monthly_report' + assert args.params_json == '{"month":"2026-04"}' + + def test_parse_args_named_procedure_graph_list(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-procedure-graph', 'myapp.graphs', '--list', + ]): + args = parse_args() + + assert args.command == 'named-procedure-graph' + assert args.qualified_name == 'myapp.graphs' + assert args.list_procedure_graphs is True + + def test_parse_args_named_procedure_graph_validate_async(self): + from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args + + with patch.object(sys, 'argv', [ + 'postgres', 'named-procedure-graph', 'myapp.graphs.monthly_report', + '--validate', '--async', + ]): + args = parse_args() + + assert args.command == 'named-procedure-graph' + assert args.qualified_name == 'myapp.graphs.monthly_report' + assert args.validate is True + assert args.is_async is True + + +class TestCLINamedProcedureGraphAdapter: + def _args(self, **overrides): + values = { + 'output': 'table', + 'rich_ascii': True, + 'is_async': False, + 'host': 'localhost', + 'port': 5432, + 'database': 'test_db', + 'user': 'postgres', + 'password': '', + 'named_connection': None, + 'connection_params': [], + } + values.update(overrides) + return SimpleNamespace(**values) + + def test_handle_sync_wires_backend_and_disconnect(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_procedure_graph + + captured = {} + backend = MagicMock() + backend._connection = object() + + def fake_handle(args, provider, **kwargs): + captured['args'] = args + captured['provider'] = provider + captured.update(kwargs) + + args = self._args() + + with patch.object( + named_procedure_graph, 'create_provider', return_value='provider' + ) as create_provider, patch.object( + named_procedure_graph, + 'resolve_connection_config_from_args', + return_value='config', + ) as resolve_config, patch.object( + named_procedure_graph, 'PostgresBackend', return_value=backend + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli_procedure_graph.' + 'handle_named_procedure_graph', + side_effect=fake_handle, + ): + named_procedure_graph.handle(args) + create_provider.assert_called_once_with('table', ascii_borders=True) + resolved_backend = captured['backend_factory']() + assert resolved_backend is backend + resolve_config.assert_called_once_with(args) + backend_cls.assert_called_once_with(connection_config='config') + backend.connect.assert_called_once_with() + backend.introspect_and_adapt.assert_called_once_with() + + captured['disconnect']() + backend.disconnect.assert_called_once_with() + + def test_handle_async_wires_async_backend_and_disconnect(self): + from rhosocial.activerecord.backend.impl.postgres.cli import named_procedure_graph + + captured = {} + async_backend = MagicMock() + async_backend._connection = object() + async_backend.disconnect = AsyncMock() + + def fake_handle(args, provider, **kwargs): + captured.update(kwargs) + + args = self._args(is_async=True) + + with patch.object( + named_procedure_graph, 'create_provider', return_value='provider' + ), patch.object( + named_procedure_graph, + 'resolve_connection_config_from_args', + return_value='config', + ), patch( + 'rhosocial.activerecord.backend.impl.postgres.AsyncPostgresBackend', + return_value=async_backend, + ) as backend_cls, patch( + 'rhosocial.activerecord.backend.named_expression.cli_procedure_graph.' + 'handle_named_procedure_graph', + side_effect=fake_handle, + ): + named_procedure_graph.handle(args) + resolved_backend = captured['backend_async_factory']() + assert resolved_backend is async_backend + backend_cls.assert_called_once_with(connection_config='config') + asyncio.run(captured['disconnect_async']()) + async_backend.disconnect.assert_awaited_once_with() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py index da6d08ea..84813be1 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py @@ -15,21 +15,21 @@ class TestPostgreSQLFunctionSupportBasic: def test_supports_functions_returns_dict(self): """Test that supports_functions returns a dictionary.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() assert isinstance(result, dict) assert len(result) > 0 def test_supports_functions_all_values_are_support_info(self): """Test that all values in the returned dict are FunctionSupportInfo.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() for func_name, info in result.items(): assert isinstance(info, FunctionSupportInfo), f"Value for {func_name} is not FunctionSupportInfo" def test_core_functions_always_supported(self): """Test that core functions are marked as supported.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() core_functions = ["count", "sum_", "avg", "min_", "max_", "coalesce", "nullif"] for func in core_functions: @@ -231,7 +231,7 @@ def test_macaddr8_set7bit_requires_pg_10(self): def test_always_available_functions(self): """Test functions that are available in all PostgreSQL versions.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() always_available = [ @@ -249,7 +249,7 @@ class TestPostgreSQLFunctionSupportPrivateMethod: def test_unknown_function_returns_true(self): """Test that unknown functions return True (no restriction).""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect._is_postgres_function_supported("unknown_function_xyz") assert result is True @@ -277,7 +277,7 @@ class TestPostgreSQLFunctionSupportIntegration: def test_function_dict_contains_both_core_and_backend_functions(self): """Test that the result contains both core and PostgreSQL-specific functions.""" - dialect = PostgresDialect() + dialect = PostgresDialect((13, 0, 0)) result = dialect.supports_functions() assert any(func in result for func in ["count", "sum", "avg"]) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index d4d6fab9..bf683660 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -23,7 +23,7 @@ @pytest.fixture def dialect(): """Create a PostgreSQL test dialect.""" - return PostgresDialect() + return PostgresDialect((13, 0, 0)) def test_postgres_escape_sql_string(dialect): @@ -380,4 +380,111 @@ def test_drop_statistics_name_quoted(self, dialect): sql, params = dialect.format_drop_statistics_statement(expr) - assert '"my_stats"' in sql \ No newline at end of file + assert '"my_stats"' in sql + + +# ============================================================ +# _format_partition_value — single-quote escaping +# ============================================================ + +def test_partition_value_none(dialect): + """None partition value returns NULL.""" + result = dialect._format_partition_value(None) + assert result == "NULL" + + +def test_partition_value_maxvalue(dialect): + """MAXVALUE is returned as-is (case-insensitive).""" + result = dialect._format_partition_value("MAXVALUE") + assert result == "MAXVALUE" + + +def test_partition_value_minvalue(dialect): + """MINVALUE is returned as-is (case-insensitive).""" + result = dialect._format_partition_value("minvalue") + assert result == "MINVALUE" + + +def test_partition_value_normal_string(dialect): + """Normal string value is single-quoted.""" + result = dialect._format_partition_value("2024-01-01") + assert result == "'2024-01-01'" + + +def test_partition_value_escaped_single_quote(dialect): + """String value with single quote is properly escaped.""" + result = dialect._format_partition_value("it's") + assert result == "'it''s'" + assert "'; DROP" not in result + + +def test_partition_value_injection_blocked(dialect): + """SQL injection in partition value is safely escaped (inside quotes).""" + result = dialect._format_partition_value("x'; DROP TABLE users--") + assert result.count("'") % 2 == 0 + assert result.startswith("'") + assert result.endswith("'") + + +def test_partition_value_integer(dialect): + """Integer partition value is returned as str().""" + result = dialect._format_partition_value(42) + assert result == "42" + + +# ============================================================ +# format_identifier — identifier quoting equivalence and injection immunity +# ============================================================ + +def test_format_identifier_normal(dialect): + """Normal identifier is double-quoted.""" + result = dialect.format_identifier("users") + assert result == '"users"' + + +def test_format_identifier_with_quote(dialect): + """Identifier with embedded double-quote is properly escaped.""" + result = dialect.format_identifier('table"name') + assert result == '"table""name"' + + +def test_format_identifier_injection_payload(dialect): + """Identifier with injection payload is safely contained (balanced quotes).""" + payload = 'users"; DROP TABLE users--' + result = dialect.format_identifier(payload) + assert result.count('"') % 2 == 0, f"Unbalanced quotes: {result}" + assert result == '"users""; DROP TABLE users--"' + + +def test_format_identifier_naive_vs_proper_safe(dialect): + """For safe input, naive and proper quoting produce same structure.""" + names = ["users", "orders", "products", "table_1", "camelCase"] + for name in names: + naive = f'"{name}"' + proper = dialect.format_identifier(name) + assert naive == proper, f"Mismatch for '{name}': naive={naive}, proper={proper}" + + +def test_format_identifier_naive_vs_proper_malicious(dialect): + """For malicious input, proper quoting prevents breakout that naive allows.""" + payloads = [ + 'x"; DROP TABLE users--', + 'y"; DELETE FROM t--', + 'z"; UPDATE t SET a=1--', + ] + for payload in payloads: + naive = f'"{payload}"' + proper = dialect.format_identifier(payload) + + # Naive produces odd quote count => breakout + assert naive.count('"') % 2 != 0, \ + f"Naive quoting should unbalance quotes for '{payload}': {naive}" + + # Proper produces even quote count => contained + assert proper.count('"') % 2 == 0, \ + f"Proper quoting should balance quotes for '{payload}': {proper}" + + +def test_format_identifier_empty_string(dialect): + """Empty identifier produces empty double quotes.""" + assert dialect.format_identifier("") == '""' \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql new file mode 100644 index 00000000..53e07498 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql @@ -0,0 +1,13 @@ +-- tests/rhosocial/activerecord_postgres_test/feature/basic/schema/pydantic_validated_models.sql +CREATE TABLE pydantic_validated_models ( + id SERIAL PRIMARY KEY, + code VARCHAR(32), + quantity INTEGER, + step_count INTEGER, + price NUMERIC(10, 2), + start_at TIMESTAMP, + end_at TIMESTAMP, + status VARCHAR(32), + normalized_name VARCHAR(50), + created_token VARCHAR(255) +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py new file mode 100644 index 00000000..a1581ecb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py @@ -0,0 +1,16 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py +""" +Bridge file for Pydantic native validation tests from the shared testsuite. + +This file imports the reusable testsuite tests so pytest discovers and runs them +against the PostgreSQL providers. +""" + +# IMPORTANT: These imports are required for pytest discovery of testsuite +# fixtures and shared test classes. Do not remove them as unused imports. +from rhosocial.activerecord.testsuite.feature.basic.conftest import ( + async_pydantic_validated_model, + pydantic_validated_model, +) + +from rhosocial.activerecord.testsuite.feature.basic.test_pydantic_native_validation import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py new file mode 100644 index 00000000..f3ac80e4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py +""" +Bridge file for logging data summarization tests from the testsuite. + +This file imports the generic tests from the testsuite package and makes them +discoverable by pytest in this project's test run. +""" +from rhosocial.activerecord.testsuite.feature.query.test_logging_data_summarization import *