Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions .gemini/testing.md
Original file line number Diff line number Diff line change
@@ -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/
```
9 changes: 3 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 1 addition & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
psycopg>=3.2.12
psycopg-binary
psycopg-pool
psycopg[binary]
rhosocial-activerecord
12 changes: 12 additions & 0 deletions src/rhosocial/activerecord/backend/impl/postgres/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,17 +422,23 @@ 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.

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
Expand Down Expand Up @@ -617,17 +623,23 @@ 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.

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
)

Expand Down
45 changes: 43 additions & 2 deletions tests/providers/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
Loading