diff --git a/.gemini/testing.md b/.gemini/testing.md new file mode 100644 index 00000000..42328085 --- /dev/null +++ b/.gemini/testing.md @@ -0,0 +1,203 @@ +> **Important**: This document is a supplement to the main **Testing Architecture and Execution Guide** ([local](../../python-activerecord/.gemini/testing.md) | [github](https://github.com/Rhosocial/python-activerecord/blob/main/.gemini/testing.md)). Please read the core documentation first to understand the overall testing architecture, provider patterns, and capability negotiation. This guide focuses only on details specific to the PostgreSQL backend. + +# PostgreSQL Backend Testing Guide + +## 1. Overview + +This guide provides instructions for setting up and running tests for the `rhosocial-activerecord-postgres` backend, covering PostgreSQL-specific configuration, testing patterns, and troubleshooting. + +## 2. PostgreSQL Test Environment Setup + +### 2.1. Dependencies + +In addition to core testing dependencies, the PostgreSQL backend requires `psycopg`. For easier installation, `psycopg-binary` is also included. + +```toml +# pyproject.toml +[project] +dependencies = [ + "psycopg>=3.2.12", + "psycopg-binary>=3.2.12" +] + +[project.optional-dependencies] +test = [ + # ... other dependencies + "rhosocial-activerecord-testsuite", +] +``` + +### 2.2. Test Scenario Configuration + +All database configurations for testing are managed through **test scenarios**. The primary method for defining these scenarios is the `tests/config/postgres_scenarios.yaml` file. This file is git-ignored, so you must create it locally. + +The test framework, specifically `config_manager.py` and `providers/scenarios.py`, reads this file to understand which database environments are available for testing. + +**Example `tests/config/postgres_scenarios.yaml`:** + +This example shows how to configure connections for different versions of PostgreSQL. You should adapt the host, port, and credential details to your local environment. + +```yaml +scenarios: + # Example for a PostgreSQL 16 server + postgres_16: + host: "localhost" + port: 5432 + username: "test_user" + password: "your_password" # Use ${PG_PASSWORD} to load from env var + database: "test_activerecord_pg16" + sslmode: "prefer" + + # Example for a PostgreSQL 15 server (commented out by default) + # postgres_15: + # host: "localhost" + # port: 5433 + # username: "test_user" + # password: "your_password" + # database: "test_activerecord_pg15" +``` + +Alternatively, for CI/CD environments, you can configure scenarios using environment variables, but the YAML file is recommended for local development. + +### 2.3. Setting Up Test Databases with Docker + +For a consistent and isolated testing environment, you can use Docker to run PostgreSQL instances. + +```bash +# Start a PostgreSQL 16 container for the 'postgres_16' scenario +docker run -d \ + --name postgres-test-16 \ + -e POSTGRES_USER=test_user \ + -e POSTGRES_PASSWORD=your_password \ + -e POSTGRES_DB=test_activerecord_pg16 \ + -p 5432:5432 \ + postgres:16 +``` + +## 3. Testing Against the Shared Test Suite + +Integrating the PostgreSQL backend with `rhosocial-activerecord-testsuite` involves implementing a set of provider classes that adapt the generic tests to the PostgreSQL environment. + +### 3.1. Implement Provider Interfaces by Feature + +For each feature category defined in the test suite (e.g., `basic`, `query`), a corresponding provider must be implemented in the `tests/providers/` directory. + +Each provider is responsible for: +1. **Setting up the database connection** for its specific test scenario. +2. **Creating the necessary schema**. This involves converting SQLite-based schemas from the test suite to PostgreSQL-compatible SQL (e.g., changing `AUTOINCREMENT` to `SERIAL` or `IDENTITY`). +3. **Configuring and returning ActiveRecord models**. +4. **Handling cleanup** (teardown). + +The `providers/registry.py` file discovers and registers these provider implementations. + +### 3.2. Import and Execute Shared Tests + +With providers in place, tests are triggered by files in `tests/rhosocial/activerecord_postgres_test/feature/`. + +To ensure maximum compatibility, **it is strongly recommended to import and run the entire suite of tests without omissions.** This provides a comprehensive validation of the backend's implementation. + +These test files import test classes from `rhosocial-activerecord-testsuite`, and pytest fixtures invoke the providers to run the tests against the PostgreSQL backend. + +```bash +# Ensure the test suite is installed +pip install rhosocial-activerecord-testsuite +``` + +## 4. Capability Declaration + +Capabilities are declared once, during the **backend's instantiation phase**, not during testing. The `PostgreSQLBackend` class's `_initialize_capabilities` method is called on creation to build a `DatabaseCapabilities` object based on the database version. + +**Example: Declaring Capabilities in `PostgreSQLBackend`:** +```python +# src/rhosocial/activerecord/backend/impl/postgres/backend.py +from rhosocial.activerecord.backend.capabilities import ( + DatabaseCapabilities, + CTECapability, + JSONCapability +) + +class PostgreSQLBackend(StorageBackend): + def _initialize_capabilities(self): + """Initializes and returns the backend's capability descriptor.""" + capabilities = DatabaseCapabilities() + version = self.get_server_version() # Returns a tuple like (16, 1, 0) + + # PostgreSQL has robust JSONB support + if version >= (9, 4, 0): + capabilities.add_json_operation([ + JSONCapability.BASIC_JSON, + JSONCapability.JSON_EXTRACT, + JSONCapability.JSONB, # PostgreSQL specific + ]) + + # RETURNING clause is well-supported + capabilities.add_dml_returning(True) + + return capabilities +``` +The test suite automatically uses this information to skip unsupported tests. For backend-specific tests, you are responsible for managing capability checks. + +## 5. Writing PostgreSQL-Specific Tests + +Dedicated tests are needed for PostgreSQL-specific features. These can be organized in a separate directory or interspersed within the `tests/rhosocial/activerecord_postgres_test/feature/` directories. + +### Example: Testing JSONB Contains Operator + +```python +# tests/feature/query/test_postgres_jsonb.py +def test_postgres_jsonb_contains_operator(pg_backend): + """Tests PostgreSQL's JSONB '@>' contains operator.""" + pg_backend.execute("CREATE TABLE users (id SERIAL, data JSONB)") + + class User(ActiveRecord): + # ... + + User(data={'tags': ['python', 'orm']}).save() + + # Use the @> operator for JSONB containment + found_user = User.where("data @> %s", [{'tags': ['python']}]).first() + + assert found_user is not None +``` + +## 6. Troubleshooting + +* **Issue**: `psycopg.OperationalError: connection failed` + * **Solution**: + 1. Check if your PostgreSQL service is running (e.g., via `docker ps`). + 2. Confirm that the details in your `postgres_scenarios.yaml` match your running database instance. + 3. Try connecting manually (e.g., with `psql`) to verify credentials and permissions. + +* **Issue**: PostgreSQL syntax errors during shared tests. + * **Solution**: Check the schema conversion logic in your providers. It may need to be updated to handle more SQLite-to-PostgreSQL syntax differences (e.g., `AUTOINCREMENT` to `SERIAL`). + +* **Issue**: Tests skipped due to unsupported capabilities. + * **Solution**: Check the `_initialize_capabilities` method in your backend implementation. Ensure it correctly declares all supported features based on the PostgreSQL version. + +## 7. Quick Command Reference + +**Crucial Prerequisite**: Before running any tests, `PYTHONPATH` **must** be set. + +```bash +# Set PYTHONPATH (Linux/macOS) +export PYTHONPATH=src + +# Set PYTHONPATH (Windows PowerShell) +$env:PYTHONPATH="src" + +# --- + +# --- Test Execution --- + +# Run all PostgreSQL backend tests +pytest tests/ + +# Run tests for a specific feature +pytest tests/rhosocial/activerecord_postgres_test/feature/basic/ + +# Run specific tests by name +pytest -k "jsonb" + +# View coverage +pytest --cov=rhosocial.activerecord.backend.impl.postgres tests/ +``` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 000b957b..a24f0620 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,13 +65,10 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Install activerecord from git + - name: Install activerecord and testsuite from git run: | - pip install rhosocial-activerecord==1.0.0.dev13 - - - name: Install testsuite package from git - run: | - pip install rhosocial-activerecord-testsuite==1.0.0.dev3 + pip install rhosocial-activerecord==1.0.0.dev14 + pip install rhosocial-activerecord-testsuite==1.0.0.dev4 - name: Create postgres scenario config run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f51efa2..ceb174a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [1.0.0.dev2] - 2025-12-11 + +### Added + +- Added support for new mapping and type adapter tests, implemented annotated adapter query tests and PostgreSQL provider for type adapter tests. Added column mapping tests and a comprehensive testing guide. ([#2](https://github.com/rhosocial/python-activerecord-postgres/issues/2)) + + + +### Fixed + +- Adjusted schema for timezone-aware datetime in tests. ([#2](https://github.com/rhosocial/python-activerecord-postgres/issues/2)) + + ## [1.0.0.dev1] - 2025-11-29 ### Added diff --git a/pyproject.toml b/pyproject.toml index f85c78b0..093e2cef 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.dev1" +version = "1.0.0.dev2" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } @@ -35,9 +35,8 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "rhosocial-activerecord>=0.9.0,<2.0.0", - "psycopg>=3.2.12", - "psycopg-binary>=3.2.12" + "psycopg[binary]", + "rhosocial-activerecord" ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 49c9eb0e..e90bb622 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ -psycopg>=3.2.12 -psycopg-binary -psycopg-pool +psycopg[binary] rhosocial-activerecord \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend.py index 81722133..649bb001 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend.py @@ -422,6 +422,7 @@ def insert(self, data: Dict, returning: Optional[Union[bool, List[str], ReturningOptions]] = None, column_adapters: Optional[Dict] = None, + column_mapping: Optional[Dict[str, str]] = None, auto_commit: Optional[bool] = True, primary_key: Optional[str] = None) -> QueryResult: """Insert record and force returning the primary key. @@ -429,10 +430,15 @@ def insert(self, Overrides the base implementation to ignore the incorrect `returning=False` passed from the base model class. """ + # psycopg3 doesn't handle None for SERIAL columns, so we pop the pk if it's None. + if primary_key and data.get(primary_key) is None: + data.pop(primary_key, None) + # Force returning the primary key, overriding the faulty value from the base class final_returning = [primary_key] if primary_key else None return super().insert( table, data, returning=final_returning, column_adapters=column_adapters, + column_mapping=column_mapping, auto_commit=auto_commit, primary_key=primary_key ) @property @@ -617,6 +623,7 @@ async def insert(self, data: Dict, returning: Optional[Union[bool, List[str], ReturningOptions]] = None, column_adapters: Optional[Dict] = None, + column_mapping: Optional[Dict[str, str]] = None, auto_commit: Optional[bool] = True, primary_key: Optional[str] = None) -> QueryResult: """Insert record and force returning the primary key asynchronously. @@ -624,10 +631,15 @@ async def insert(self, Overrides the base implementation to ignore the incorrect `returning=False` passed from the base model class. """ + # psycopg3 doesn't handle None for SERIAL columns, so we pop the pk if it's None. + if primary_key and data.get(primary_key) is None: + data.pop(primary_key, None) + # Force returning the primary key, overriding the faulty value from the base class final_returning = [primary_key] if primary_key else None return await super().insert( table, data, returning=final_returning, column_adapters=column_adapters, + column_mapping=column_mapping, auto_commit=auto_commit, primary_key=primary_key ) diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 909ebef5..730011fd 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -11,9 +11,10 @@ 3. Cleaning up any resources after a test runs. """ import os -from typing import Type, List +from typing import Type, List, Tuple from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter from rhosocial.activerecord.testsuite.feature.basic.interfaces import IBasicProvider # ...and the scenarios are defined specifically for this backend. from .scenarios import get_enabled_scenarios, get_scenario @@ -63,6 +64,14 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl return model_class + def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + """Helper to set up multiple related models for a test.""" + result = [] + for model_class, table_name in model_classes: + configured_model = self._setup_model(model_class, scenario_name, table_name) + result.append(configured_model) + return tuple(result) + # --- Implementation of the IBasicProvider interface --- def setup_user_model(self, scenario_name: str) -> Type[ActiveRecord]: @@ -90,6 +99,33 @@ def setup_validated_user_model(self, scenario_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ValidatedUser return self._setup_model(ValidatedUser, scenario_name, "validated_users") + 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.""" + from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import MappedUser, MappedPost, MappedComment + return self._setup_multiple_models([ + (MappedUser, "users"), + (MappedPost, "posts"), + (MappedComment, "comments") + ], scenario_name) + + def setup_mixed_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + """Sets up the database for ColumnMappingModel and MixedAnnotationModel.""" + from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ColumnMappingModel, MixedAnnotationModel + return self._setup_multiple_models([ + (ColumnMappingModel, "column_mapping_items"), + (MixedAnnotationModel, "mixed_annotation_items") + ], scenario_name) + + def setup_type_adapter_model_and_schema(self, scenario_name: str) -> Type[ActiveRecord]: + """Sets up the database for the `TypeAdapterTest` model tests.""" + from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import TypeAdapterTest + return self._setup_model(TypeAdapterTest, scenario_name, "type_adapter_tests") + + def get_yes_no_adapter(self) -> 'BaseSQLTypeAdapter': + """Returns an instance of the YesOrNoBooleanAdapter.""" + from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import YesOrNoBooleanAdapter + return YesOrNoBooleanAdapter() + def _load_postgres_schema(self, filename: str) -> str: """Helper to load a SQL schema file from this project's fixtures.""" # Schemas are stored in the centralized location for basic feature. @@ -104,10 +140,15 @@ def cleanup_after_test(self, scenario_name: str): Performs cleanup after a test. This now iterates through the backends that were created during setup, drops tables, and explicitly disconnects them. """ + tables_to_drop = [ + 'users', 'type_cases', 'type_tests', 'validated_field_users', + 'validated_users', 'type_adapter_tests', 'posts', 'comments', + 'column_mapping_items', 'mixed_annotation_items' + ] for backend_instance in self._active_backends: try: # Drop all tables that might have been created for basic tests - for table_name in ['users', 'type_cases', 'type_tests', 'validated_field_users', 'validated_users']: + for table_name in tables_to_drop: try: backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') except Exception: diff --git a/tests/providers/query.py b/tests/providers/query.py index 958f010b..beeeaff6 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -144,6 +144,23 @@ def setup_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord ] return self._setup_multiple_models(models_and_tables, scenario_name) + def setup_annotated_query_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + """Sets up the database for the SearchableItem model tests.""" + from rhosocial.activerecord.testsuite.feature.query.fixtures.annotated_adapter_models import SearchableItem + return self._setup_multiple_models([ + (SearchableItem, "searchable_items"), + ], scenario_name) + + 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.""" + from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import MappedUser, MappedPost, MappedComment + return self._setup_multiple_models([ + (MappedUser, "users"), + (MappedPost, "posts"), + (MappedComment, "comments") + ], scenario_name) + + def _load_postgres_schema(self, filename: str) -> str: """Helper to load a SQL schema file from this project's fixtures.""" # Schemas are stored in the centralized location for query feature. @@ -158,11 +175,15 @@ def cleanup_after_test(self, scenario_name: str): Performs cleanup after a test. This uses the same backend instance that was used for setup to ensure proper cleanup with foreign key constraints. """ + tables_to_drop = [ + 'users', 'orders', 'order_items', 'posts', 'comments', 'json_users', 'nodes', + 'extended_orders', 'extended_order_items', 'searchable_items' + ] for backend_instance in self._active_backends: try: # Drop all tables that might have been created for query tests # Use CASCADE to handle foreign key dependencies - for table_name in ['users', 'orders', 'order_items', 'posts', 'comments', 'json_users', 'nodes', 'extended_orders', 'extended_order_items']: + for table_name in tables_to_drop: try: backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') except Exception: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py new file mode 100644 index 00000000..c72e1f9d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py @@ -0,0 +1,136 @@ +# /Users/vistart/PycharmProjects/rhosocial/python-activerecord-postgres/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py +import pytest +import pytest_asyncio +from datetime import datetime +import uuid + +from rhosocial.activerecord.backend.type_adapter import UUIDAdapter, BooleanAdapter + + +@pytest_asyncio.fixture +async def setup_mapped_users_table(async_postgres_backend): + """Async fixture to create and drop a 'mapped_users' table for PostgreSQL.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS mapped_users") + await async_postgres_backend.execute(""" + CREATE TABLE mapped_users ( + user_id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + user_uuid UUID, + is_active BOOLEAN + ) + """) + yield + await async_postgres_backend.execute("DROP TABLE IF EXISTS mapped_users") + + +@pytest.mark.asyncio +async def test_async_insert_and_returning_with_mapping(async_postgres_backend, setup_mapped_users_table): + """ + Tests that async execute() with an INSERT and RETURNING clause correctly applies column_mapping. + """ + backend = async_postgres_backend + now = datetime.now() + test_uuid = uuid.uuid4() + + column_to_field_mapping = { + "user_id": "pk", + "name": "full_name", + "user_uuid": "uuid", + "is_active": "active", + "created_at": "creation_time" + } + + sql = "INSERT INTO mapped_users (name, email, created_at, user_uuid, is_active) VALUES (%s, %s, %s, %s, %s)" + params = ("Async PG User", "asyncpg@example.com", now, test_uuid, True) + + result = await backend.execute( + sql=sql, + params=params, + returning=True, + column_mapping=column_to_field_mapping + ) + + assert result.data is not None + assert len(result.data) == 1 + returned_row = result.data[0] + + assert "pk" in returned_row + assert "full_name" in returned_row + assert "uuid" in returned_row + assert "active" in returned_row + assert returned_row["pk"] == 1 + assert returned_row["full_name"] == "Async PG User" + assert returned_row["uuid"] == test_uuid + assert returned_row["active"] is True + + +@pytest.mark.asyncio +async def test_async_update_with_backend(async_postgres_backend, setup_mapped_users_table): + """ + Tests that an async update operation via execute() works correctly for PostgreSQL. + """ + backend = async_postgres_backend + + await backend.execute("INSERT INTO mapped_users (name, email, created_at, is_active) VALUES (%s, %s, %s, %s)", + ("Async Jane PG", "async.jane.pg@example.com", datetime.now(), True)) + + sql = "UPDATE mapped_users SET name = %s WHERE user_id = %s" + params = ("Async Jane Smith PG", 1) + result = await backend.execute(sql, params) + + assert result.affected_rows == 1 + + fetch_result = await backend.execute("SELECT name FROM mapped_users WHERE user_id = 1") + fetched_row = fetch_result.data[0] if fetch_result.data else None + assert fetched_row is not None + assert fetched_row["name"] == "Async Jane Smith PG" + + +@pytest.mark.asyncio +async def test_async_fetch_with_combined_mapping_and_adapters(async_postgres_backend, setup_mapped_users_table): + """ + Tests that async execute() correctly applies both column_mapping and column_adapters for PostgreSQL. + """ + backend = async_postgres_backend + now = datetime.now() + test_uuid = uuid.uuid4() + + column_to_field_mapping = { + "user_id": "pk", + "name": "full_name", + "user_uuid": "uuid", + "is_active": "active" + } + + column_adapters = { + "user_uuid": (UUIDAdapter(), uuid.UUID), + "is_active": (BooleanAdapter(), bool) + } + + await backend.execute( + "INSERT INTO mapped_users (name, email, created_at, user_uuid, is_active) VALUES (%s, %s, %s, %s, %s)", + ("Async PG Combined", "asyncpgcombined@example.com", now, test_uuid, True) + ) + + result = await backend.execute( + "SELECT * FROM mapped_users WHERE user_id = 1", + column_mapping=column_to_field_mapping, + column_adapters=column_adapters + ) + + fetched_row = result.data[0] if result.data else None + assert fetched_row is not None + + # 1. Assert keys are the MAPPED FIELD NAMES + assert "full_name" in fetched_row + assert "uuid" in fetched_row + assert "active" in fetched_row + + # 2. Assert values are the CORRECT PYTHON TYPES + assert fetched_row["full_name"] == "Async PG Combined" + assert isinstance(fetched_row["uuid"], uuid.UUID) + assert fetched_row["uuid"] == test_uuid + assert isinstance(fetched_row["active"], bool) + assert fetched_row["active"] is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py new file mode 100644 index 00000000..eecc7ddd --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py @@ -0,0 +1,136 @@ +# /Users/vistart/PycharmProjects/rhosocial/python-activerecord-postgres/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py +import pytest +from datetime import datetime +import uuid + +from rhosocial.activerecord.backend.type_adapter import UUIDAdapter, BooleanAdapter + + +@pytest.fixture +def setup_mapped_users_table(postgres_backend): + """Fixture to create and drop a 'mapped_users' table for PostgreSQL.""" + postgres_backend.execute("DROP TABLE IF EXISTS mapped_users") + postgres_backend.execute(""" + CREATE TABLE mapped_users ( + user_id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + user_uuid UUID, + is_active BOOLEAN + ) + """) + yield + postgres_backend.execute("DROP TABLE IF EXISTS mapped_users") + + +def test_insert_and_returning_with_mapping(postgres_backend, setup_mapped_users_table): + """ + Tests that execute() with an INSERT and RETURNING clause correctly applies column_mapping. + """ + backend = postgres_backend + now = datetime.now() + test_uuid = uuid.uuid4() + + column_to_field_mapping = { + "user_id": "pk", + "name": "full_name", + "user_uuid": "uuid", + "is_active": "active", + "created_at": "creation_time" + } + + sql = "INSERT INTO mapped_users (name, email, created_at, user_uuid, is_active) VALUES (%s, %s, %s, %s, %s)" + params = ("Postgres User", "pg@example.com", now, test_uuid, True) + + result = backend.execute( + sql=sql, + params=params, + returning=True, + column_mapping=column_to_field_mapping + ) + + assert result.data is not None + assert len(result.data) == 1 + returned_row = result.data[0] + + assert "pk" in returned_row + assert "full_name" in returned_row + assert "uuid" in returned_row + assert "active" in returned_row + assert returned_row["pk"] == 1 + assert returned_row["full_name"] == "Postgres User" + assert returned_row["uuid"] == test_uuid + assert returned_row["active"] is True + + +def test_update_with_backend(postgres_backend, setup_mapped_users_table): + """ + Tests that an update operation via execute() works correctly for PostgreSQL. + """ + backend = postgres_backend + + backend.execute("INSERT INTO mapped_users (name, email, created_at, is_active) VALUES (%s, %s, %s, %s)", + ("Jane PG", "jane.pg@example.com", datetime.now(), True)) + + sql = "UPDATE mapped_users SET name = %s WHERE user_id = %s" + params = ("Jane Smith PG", 1) + result = backend.execute(sql, params) + + assert result.affected_rows == 1 + + fetch_result = backend.execute("SELECT name FROM mapped_users WHERE user_id = 1") + fetched_row = fetch_result.data[0] if fetch_result.data else None + assert fetched_row is not None + assert fetched_row["name"] == "Jane Smith PG" + + +def test_fetch_with_combined_mapping_and_adapters(postgres_backend, setup_mapped_users_table): + """ + Tests that execute() correctly applies both column_mapping and column_adapters for PostgreSQL. + PostgreSQL's driver (psycopg) often handles common types like UUID and bool automatically, + so this test primarily validates that the adapter mechanism works if an adapter is provided, + and that mapping is applied correctly. + """ + backend = postgres_backend + now = datetime.now() + test_uuid = uuid.uuid4() + + column_to_field_mapping = { + "user_id": "pk", + "name": "full_name", + "user_uuid": "uuid", + "is_active": "active" + } + + # Even if the driver handles it, we can test that providing an adapter still works. + column_adapters = { + "user_uuid": (UUIDAdapter(), uuid.UUID), + "is_active": (BooleanAdapter(), bool) + } + + backend.execute( + "INSERT INTO mapped_users (name, email, created_at, user_uuid, is_active) VALUES (%s, %s, %s, %s, %s)", + ("PG Combined", "pgcombined@example.com", now, test_uuid, True) + ) + + result = backend.execute( + "SELECT * FROM mapped_users WHERE user_id = 1", + column_mapping=column_to_field_mapping, + column_adapters=column_adapters + ) + + fetched_row = result.data[0] if result.data else None + assert fetched_row is not None + + # 1. Assert keys are the MAPPED FIELD NAMES + assert "full_name" in fetched_row + assert "uuid" in fetched_row + assert "active" in fetched_row + + # 2. Assert values are the CORRECT PYTHON TYPES + assert fetched_row["full_name"] == "PG Combined" + assert isinstance(fetched_row["uuid"], uuid.UUID) + assert fetched_row["uuid"] == test_uuid + assert isinstance(fetched_row["active"], bool) + assert fetched_row["active"] is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/column_mapping_items.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/column_mapping_items.sql new file mode 100644 index 00000000..9ee802ee --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/column_mapping_items.sql @@ -0,0 +1,6 @@ +CREATE TABLE "column_mapping_items" ( + "id" SERIAL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "item_total" INT NOT NULL, + "remarks" INT +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/comments.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/comments.sql new file mode 100644 index 00000000..18a1eb1b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/comments.sql @@ -0,0 +1,11 @@ +CREATE TABLE "comments" ( + "id" SERIAL PRIMARY KEY, + "post_ref" INT NOT NULL, + "author" INT NOT NULL, + "text" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL, + "updated_at" TIMESTAMP, + "approved" BOOLEAN DEFAULT false, + FOREIGN KEY ("post_ref") REFERENCES "posts"("id") ON DELETE CASCADE, + FOREIGN KEY ("author") REFERENCES "users"("id") ON DELETE CASCADE +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/mixed_annotation_items.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/mixed_annotation_items.sql new file mode 100644 index 00000000..e824b264 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/mixed_annotation_items.sql @@ -0,0 +1,8 @@ +CREATE TABLE "mixed_annotation_items" ( + "id" INT PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "tags" TEXT, + "meta" TEXT, + "description" TEXT, + "status" TEXT +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/posts.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/posts.sql new file mode 100644 index 00000000..a57be435 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/posts.sql @@ -0,0 +1,11 @@ +CREATE TABLE "posts" ( + "id" SERIAL PRIMARY KEY, + "author" INT NOT NULL, + "title" VARCHAR(255) NOT NULL, + "content" TEXT, + "published_at" TIMESTAMP, + "published" BOOLEAN DEFAULT false, + "created_at" TIMESTAMP, + "updated_at" TIMESTAMP, + FOREIGN KEY ("author") REFERENCES "users"("id") ON DELETE CASCADE +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/type_adapter_tests.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/type_adapter_tests.sql new file mode 100644 index 00000000..aaf0b8a9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/type_adapter_tests.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS type_adapter_tests ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + optional_name VARCHAR(255), + optional_age INTEGER, + last_login TIMESTAMP WITH TIME ZONE, + is_premium BOOLEAN, + unsupported_union VARCHAR(255), + custom_bool VARCHAR(3), + optional_custom_bool VARCHAR(3) +); \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py new file mode 100644 index 00000000..ef61fd01 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py @@ -0,0 +1,18 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py +""" +This module serves as a bridge to execute the generic basic mapped models tests +defined in the `rhosocial-activerecord-testsuite` package against the PostgreSQL backend. + +The tests are dynamically parametrized with PostgreSQL-specific fixtures. +""" + +# IMPORTANT: +# The following imports are essential for the test suite to discover and run the tests. +# The fixtures are defined in the `conftest.py` of the testsuite and are implemented +# by the provider in the current backend (`tests/providers/basic.py`). + +# The `basic_model_fixtures` is a fixture that provides the necessary setup for the tests. +from rhosocial.activerecord.testsuite.feature.basic.conftest import mapped_models_fixtures + +# The `TestBasicMappedModels` class contains the actual test cases. +from rhosocial.activerecord.testsuite.feature.basic.test_example_basic_fixtures import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py new file mode 100644 index 00000000..35f27e39 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py @@ -0,0 +1,18 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py +""" +This module serves as a bridge to execute the generic field-to-column mapping tests +defined in the `rhosocial-activerecord-testsuite` package against the PostgreSQL backend. + +The tests are dynamically parametrized with PostgreSQL-specific fixtures. +""" + +# IMPORTANT: +# The following imports are essential for the test suite to discover and run the tests. +# The fixtures are defined in the `conftest.py` of the testsuite and are implemented +# by the provider in the current backend (`tests/providers/basic.py`). + +# The `mapped_models_fixtures` is a fixture that provides the necessary setup for the tests. +from rhosocial.activerecord.testsuite.feature.basic.conftest import mapped_models_fixtures + +# The `TestMappedModels` class contains the actual test cases. +from rhosocial.activerecord.testsuite.feature.basic.test_field_column_mapping import TestMappedModels diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py new file mode 100644 index 00000000..3adffc2b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py @@ -0,0 +1,15 @@ +""" +This is a "bridge" file for the basic features test group, specifically for +type adapter tests. + +Its purpose is to import the generic tests from the `rhosocial-activerecord-testsuite` +package and make them discoverable by `pytest` within this project's test run. +""" + +# Import the fixture that provides the configured model and backend for type adapter tests. +from rhosocial.activerecord.testsuite.feature.basic.conftest import ( + type_adapter_fixtures, +) + +# Import all tests from the generic testsuite file. +from rhosocial.activerecord.testsuite.feature.basic.test_type_adapter import * \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/schema/searchable_items.sql b/tests/rhosocial/activerecord_postgres_test/feature/query/schema/searchable_items.sql new file mode 100644 index 00000000..4a3b59bc --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/schema/searchable_items.sql @@ -0,0 +1,9 @@ +-- tests/rhosocial/activerecord_postgres_test/feature/query/schema/searchable_items.sql + +CREATE TABLE "searchable_items" ( + "id" SERIAL PRIMARY KEY, + "name" VARCHAR(255), + "tags" TEXT, + "created_at" TIMESTAMP, + "updated_at" TIMESTAMP +); \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_annotated_adapter_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_annotated_adapter_queries.py new file mode 100644 index 00000000..aca81338 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_annotated_adapter_queries.py @@ -0,0 +1,14 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_annotated_adapter_queries.py +""" +This is a "bridge" file for the query features test group, specifically for +tests related to Annotated type adapters. + +Its purpose is to import the generic tests from the `rhosocial-activerecord-testsuite` +package and make them discoverable by `pytest` within this project's test run. +""" + +from rhosocial.activerecord.testsuite.feature.query.conftest import ( + annotated_query_fixtures, +) + +from rhosocial.activerecord.testsuite.feature.query.test_annotated_adapter_queries import * \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_mapped_models.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_mapped_models.py new file mode 100644 index 00000000..eb3856bb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_mapped_models.py @@ -0,0 +1,25 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_query_mapped_models.py +# File path: tests/rhosocial/activerecord_postgres_test/feature/query/test_query_mapped_models.py + +""" +This bridge file imports the `mapped_models_fixtures` from the testsuite's query feature +conftest and then wildcard imports all test cases related to mapped models +from the testsuite. + +IMPORTANT: +- Do NOT add any test logic directly in this file. +- This file is solely responsible for wiring up the testsuite's generic tests + with the backend's specific fixtures and configuration. +""" +import pytest +from rhosocial.activerecord.testsuite.feature.query.conftest import mapped_models_fixtures + +# Wildcard import all test cases from the testsuite's test file. +from rhosocial.activerecord.testsuite.feature.query.test_example_query_fixtures import * + +# Define markers to categorize these tests +pytest.mark.query_mapped_models = pytest.mark.mark(name="query_mapped_models", description="Tests for models with custom column name mappings in query feature.") + +# You can optionally add a fixture here if needed to ensure specific setup/teardown +# for ALL query_mapped_models tests in this backend, but generally this should be handled +# by the provider and the testsuite's conftest.