Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
534309d
refactor(postgres): split types into type_values and expression names…
vistart Sep 27, 2026
37db58c
test(postgres): scope the namespace guardrail to backend-defined classes
vistart Sep 27, 2026
32e308c
perf(postgres): resolve backend exports lazily so expressions import …
vistart Sep 27, 2026
635e4ce
refactor(backend): make impl.<backend> an open package
vistart Sep 28, 2026
5157d66
refactor(backend): keep both backend classes in impl.<backend>.backend
vistart Sep 28, 2026
eaf96f0
fix(backend): correct relative import depth inside backend/
vistart Sep 28, 2026
f9688c2
refactor(backend): re-export the async class from the backend package
vistart Sep 28, 2026
43c4d39
fix(test): assert the storage-parameter enum on its owning module
vistart Sep 28, 2026
1ac9220
fix(providers): point backend_module at the backend package
vistart Sep 28, 2026
bfc5a12
fix(test): patch the async backend on the module the CLI reads
vistart Sep 28, 2026
73a0339
fix(postgres): drop the stale backend_base module and point at backen…
vistart Sep 28, 2026
b461c79
fix(postgres): remove the last stale top-level async_backend
vistart Sep 28, 2026
979088a
chore: correct the path comment at the top of the file
vistart Sep 28, 2026
6da699f
docs: rewrite the README and correct the .claude/ knowledge base
vistart Sep 29, 2026
3708a10
fix(postgres): split the psycopg floor by Python version
vistart Sep 29, 2026
57ed25c
fix(postgres): forward SSL/TLS config to psycopg
vistart Sep 29, 2026
8e4804b
chore: rename changelog fragment to PR #60
vistart Sep 29, 2026
b359b53
ci: unify the changelog fragment rules
vistart Sep 29, 2026
3dda125
ci: reject feature/fix branches that target main directly
vistart Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
109 changes: 61 additions & 48 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,26 +104,25 @@ Clear distinction between Backend and ActiveRecord, avoiding tight coupling betw

**Implementation**: The `SQLDialectBase` (and its concrete implementations like `SQLiteDialect`) provides dedicated methods for this:
- `format_identifier(self, identifier: str) -> str`: Formats and quotes identifiers.
- `format_string_literal(self, value: str) -> str`: Formats and quotes string literals.
- `format_literal(self, value: Any) -> str`: Formats and quotes string literals.

All query building components (e.g., mixins, `SQLExpression` subclasses) are designed to route identifier and literal formatting through these dialect methods, ensuring a single, centralized point of control for SQL syntax generation.

```python
# In backend/dialect.py
# In backend/dialect/base.py
class SQLDialectBase(ABC):
@abstractmethod
def format_identifier(self, identifier: str) -> str:
"""Format identifier (table name, column name)."""
pass

@abstractmethod
def format_string_literal(self, value: str) -> str:
"""Format string literal."""
def format_literal(self, value: Any) -> str:
"""Format a Python value as a SQL literal."""
pass

# Usage in query builder (conceptual)
# formatted_column = self.model_class.backend().dialect.format_identifier(column_name)
# formatted_string = self.model_class.backend().dialect.format_string_literal(string_value)
# formatted_string = self.model_class.backend().dialect.format_literal(string_value)
```

## Package Architecture
Expand Down Expand Up @@ -290,7 +289,10 @@ class ActiveRecord(
```python
# backend/base.py
class StorageBackend(
# ...composed from LoggingMixin, CapabilityMixin, TypeAdaptionMixin, SQLBuildingMixin, etc.
# ...composed from LoggingMixin, TypeAdaptionMixin, SQLBuildingMixin,
# ReturningClauseMixin, ResultProcessingMixin, SQLOperationsMixin, ExecutionMixin,
# BatchExecutionMixin, ExecutionHooksMixin, ConnectionMixin,
# TransactionManagementMixin. There is no `CapabilityMixin`.
ABC
):
"""
Expand Down Expand Up @@ -404,14 +406,14 @@ class Article(TimestampMixin, SoftDeleteMixin, ActiveRecord):

```python
# The project's query builder is ActiveQuery, composed from specialized mixins.
from rhosocial.activerecord.query import ActiveQuery, BaseQueryMixin, AggregateQueryMixin, CTEQueryMixin, JoinQueryMixin, RangeQueryMixin, RelationalQueryMixin
from rhosocial.activerecord.query import ActiveQuery, BaseQueryMixin, AggregateQueryMixin, CTEQuery, JoinQueryMixin, RangeQueryMixin, RelationalQueryMixin

class ActiveQuery(
CTEQueryMixin,
CTEQuery,
JoinQueryMixin,
RelationalQueryMixin,
RangeQueryMixin,
# BaseQueryMixin and AggregateQueryMixin are inherited through CTEQueryMixin
# BaseQueryMixin and AggregateQueryMixin are inherited through CTEQuery
):
"""
Complete ActiveQuery implementation, combining all query mixins.
Expand Down Expand Up @@ -495,7 +497,7 @@ def create_backend(backend_type: str, **config) -> StorageBackend:
backends = {
'sqlite': SQLiteBackend,
'mysql': MySQLBackend,
'postgresql': PostgreSQLBackend,
'postgres': PostgresBackend,
}

backend_class = backends.get(backend_type)
Expand All @@ -511,8 +513,10 @@ def create_backend(backend_type: str, **config) -> StorageBackend:

```
BaseModel (Pydantic)
└── IActiveRecord (Interface - implemented by BaseActiveRecord)
└── BaseActiveRecord (Core implementation, implements IActiveRecord)
└── BaseActiveRecord (Core CRUD implementation, a BaseModel subclass)
# NOTE: BaseActiveRecord does NOT inherit from IActiveRecord.
# IActiveRecord is a Protocol the QUERY mixins implement
# (e.g. `class QueryMixin(IActiveRecord)` in interface/query.py).
# ActiveRecord is composed from these mixins and BaseActiveRecord
└── QueryMixin
└── RelationManagementMixin
Expand All @@ -530,7 +534,7 @@ StorageBackendBase (ABC)
└── StorageBackend (ABC)
└── SQLiteBackend # Concrete implementation
# └── MySQLBackend (if implemented)
# └── PostgreSQLBackend (if implemented)
# └── PostgresBackend (if implemented)

StorageBackendBase (ABC)
# Composed from mixins for async operations
Expand All @@ -546,7 +550,7 @@ StorageBackendBase (ABC)
```python
# 1. User configures model
from rhosocial.activerecord.model import ActiveRecord
from rhosocial.activerecord.backend.impl.sqlite import SQLiteBackend
from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend

class User(ActiveRecord):
__table_name__ = "users"
Expand Down Expand Up @@ -589,8 +593,10 @@ The project maintains **minimal core dependencies** by design:
```python
# Minimal core dependencies - Pydantic only
dependencies = [
"pydantic>=2.0.0", # Data validation and model definition
"typing_extensions>=4.0.0", # Backported typing features for Python 3.8
"pydantic==2.10.6; python_version < '3.9'", # Data validation and model definition
"pydantic>=2.12.0; python_version >= '3.9'",
"typing-extensions>=4.12.0; python_version < '3.9'", # Backported typing for 3.8
"backports.zoneinfo; python_version < '3.9'",
]
```

Expand All @@ -599,9 +605,11 @@ dependencies = [
### Optional Dependencies

```python
# The core ships NO driver extras. Each extra points at the separate backend
# distribution, which is what pulls the driver in transitively.
extras_require = {
"mysql": ["mysql-connector-python>=8.0.0"], # MySQL backend
"postgresql": ["psycopg[binary]>=3.2.13"], # PostgreSQL backend
"mysql": ["rhosocial-activerecord-mysql>=1.0.0,<2.0.0"],
"postgres": ["rhosocial-activerecord-postgres>=1.0.0,<2.0.0"],
"dev": ["pytest", "black", "mypy"], # Development tools
}
```
Expand All @@ -616,14 +624,14 @@ def discover_backends():

# Check for installed backends (each uses only native drivers)
try:
from rhosocial.activerecord.backend.impl.mysql import MySQLBackend
from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend
backends['mysql'] = MySQLBackend # Uses mysql-connector-python directly
except ImportError:
pass

try:
from rhosocial.activerecord.backend.impl.postgresql import PostgreSQLBackend
backends['postgresql'] = PostgreSQLBackend # Uses psycopg 3 directly
from rhosocial.activerecord.backend.impl.postgres.backend import PostgresBackend
backends['postgres'] = PostgresBackend # Uses psycopg 3 directly
except ImportError:
pass

Expand All @@ -635,16 +643,18 @@ def discover_backends():
### 1. Custom Fields

```python
class EncryptedField(Field):
"""Custom encrypted field type."""

def __set__(self, instance, value):
encrypted = encrypt(value)
super().__set__(instance, encrypted)

def __get__(self, instance, owner):
value = super().__get__(instance, owner)
return decrypt(value) if value else None
# There is no `Field` base class to subclass. `base/fields.py` holds only
# annotation markers (DDLAnnotation, UseColumn, UseAdapter, UseSqlType,
# UseIndex, UseConstraint, UseColumnAttributes, UseComment, UseGeneratedColumn,
# DerivedField). For transparent encryption use a pydantic field_validator, or
# a SQLTypeAdapter for the column type.
class EncryptedModel(ActiveRecord):
settings: str

@field_validator("settings", mode="before")
@classmethod
def _decrypt(cls, v):
return decrypt(v) if v else None
```

### 2. Custom Validators
Expand Down Expand Up @@ -681,14 +691,16 @@ class User(UserQueryMixin, ActiveRecord):
### 4. Event Hooks

```python
class User(ActiveRecord):
def before_save(self):
"""Called before saving."""
self.updated_at = datetime.now()
# `before_save` / `after_save` are NEVER called -- no such hook exists and
# nothing dispatches to them, so code written this way is silently dead.
# Register against the ModelEvent enum instead.
from rhosocial.activerecord.interface.base import ModelEvent

user = User(name="Alice")
user.on(ModelEvent.AFTER_INSERT, lambda rec: cache.invalidate(f"user:{rec.id}"))

def after_save(self):
"""Called after saving."""
cache.invalidate(f"user:{self.id}")
# Events: BEFORE_VALIDATE, AFTER_VALIDATE, BEFORE_INSERT, AFTER_INSERT,
# BEFORE_UPDATE, AFTER_UPDATE, BEFORE_DELETE, AFTER_DELETE.
```

## Performance Optimization
Expand Down Expand Up @@ -791,17 +803,18 @@ class ThreadSafeDict(Dict[K, V]):
### Exception Hierarchy

```python
class ActiveRecordError(Exception):
# backend/errors.py -- there is NO `ActiveRecordError` class. `DatabaseError`
# is the actual root, and everything below derives from it.
class DatabaseError(Exception):
"""Base exception for all ActiveRecord errors."""

class DatabaseError(ActiveRecordError):
"""Database operation errors."""

class ValidationError(ActiveRecordError):
"""Data validation errors."""

class RecordNotFound(DatabaseError):
"""Record not found in database."""
class ConnectionError(DatabaseError): ...
class TransactionError(DatabaseError): ...
class QueryError(DatabaseError): ...
class ValidationError(DatabaseError): ...
class IntegrityError(DatabaseError): ...
class LockError(DatabaseError): ...
class RecordNotFound(DatabaseError): ...
```

### Error Propagation
Expand Down
54 changes: 19 additions & 35 deletions .claude/backend_development.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ rhosocial-activerecord-{backend}/
│ └── impl/
│ └── {backend}/
│ ├── __init__.py
│ ├── backend.py # Main backend implementation
│ ├── backend/ # sync + async backend classes
│ ├── adapters.py # Backend-specific type adapters
│ ├── config.py # Connection configuration
│ ├── dialect.py # SQL dialect handling
Expand Down Expand Up @@ -114,7 +114,7 @@ Expression.to_sql() -> Dialect.format_*() -> SQL string and parameters
- `operators.py`: SQL operations (binary, unary, arithmetic expressions)
- `predicates.py`: SQL predicate expressions (WHERE clause conditions)
- `query_parts.py`: SQL query clauses (WHERE, GROUP BY, HAVING, ORDER BY, etc.)
- `statements.py`: DML/DQL/DDL statements (SELECT, INSERT, UPDATE, DELETE, etc.)
- `statements/`: DML/DQL/DDL statements (a package, not a module) (SELECT, INSERT, UPDATE, DELETE, etc.)
- `functions.py`: Standalone factory functions for creating SQL expressions
- `aggregates.py`: SQL aggregation expressions and functions
- `advanced_functions.py`: Advanced SQL functions (CASE, CAST, EXISTS, window functions)
Expand Down Expand Up @@ -149,8 +149,9 @@ Every backend must implement the `StorageBackend` abstract base class. This is t
```python
# backend.py
from typing import Any, Dict, List, Optional, Tuple, Type
from ...base import StorageBackend, ConnectionConfig
from ...type_adapter import SQLTypeAdapter
from rhosocial.activerecord.backend.base import StorageBackend
from rhosocial.activerecord.backend.config import ConnectionConfig
from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter

class MyBackend(StorageBackend):
"""Implementation of MyDatabase backend using native driver."""
Expand All @@ -175,7 +176,8 @@ class MyBackend(StorageBackend):
"""Check if connection is valid."""
pass

def execute(self, sql: str, params: Optional[Tuple] = None, returning: Optional[Union[bool, List[str], ReturningOptions]] = None, column_adapters: Optional[Dict[str, Tuple[SQLTypeAdapter, Type]]] = None) -> QueryResult:
def execute(self, sql: str, params: Optional[Tuple] = None, *,
options: Optional[ExecutionOptions] = None) -> QueryResult:
"""Executes a SQL query."""
pass

Expand All @@ -197,10 +199,6 @@ class MyBackend(StorageBackend):
"""
pass

def _initialize_capabilities(self) -> DatabaseCapabilities:
"""Declare all features supported by this backend."""
pass

def _handle_error(self, error: Exception) -> None:
"""Catch driver-specific exceptions and raise standard errors."""
pass
Expand Down Expand Up @@ -241,10 +239,10 @@ Define an immutable configuration class for your backend.
# config.py
from dataclasses import dataclass, field
from typing import Optional, Dict, Any
from rhosocial.activerecord.backend.config import BaseConfig
from rhosocial.activerecord.backend.config import ConnectionConfig

@dataclass(frozen=True)
class MyDatabaseConfig(BaseConfig):
@dataclass
class MyDatabaseConfig(ConnectionConfig):
host: str = "localhost"
port: int = 5432
database: str
Expand Down Expand Up @@ -366,34 +364,20 @@ Every dialect inherits from:

```python
# dialect.py
from rhosocial.activerecord.backend.dialect import (
SQLDialect, ReturningClauseHandler, TypeMapping
)
from typing import Dict, List, Optional
from rhosocial.activerecord.backend.dialect.base import SQLDialectBase
from typing import Optional, Tuple

class MyDatabaseDialect(SQLDialect):
class MyDatabaseDialect(SQLDialectBase):
def __init__(self):
super().__init__()
self.returning_handler = MyDatabaseReturningHandler()

def quote_identifier(self, identifier: str) -> str: return f'"{identifier}"'
def get_placeholder(self, name: str = None) -> str: return "?"

def format_limit_offset(self, sql: str, limit: Optional[int], offset: Optional[int]) -> str:
if limit is not None: sql += f" LIMIT {limit}"
if offset is not None: sql += f" OFFSET {offset}"
return sql

def get_type_mappings(self) -> Dict[str, TypeMapping]:
return {"INTEGER": TypeMapping("INTEGER"), "TEXT": TypeMapping("TEXT"), ...}
def get_parameter_placeholder(self, position: int = 0) -> str:
return "?"

class MyDatabaseReturningHandler(ReturningClauseHandler):
@property
def is_supported(self) -> bool: return True

def format_clause(self, columns: Optional[List[str]] = None) -> str:
cols = "*" if not columns else ', '.join(self.dialect.quote_identifier(c) for c in columns)
return f"RETURNING {cols}"
# Identifier quoting is inherited from SQLDialectBase.format_identifier;
# LIMIT/OFFSET come from the DQL mixins; type mapping is not a dialect
# method but a set of SQLTypeAdapter registrations on the backend.
```

### 4. The Type Adaptation System
Expand Down Expand Up @@ -565,7 +549,7 @@ This checklist summarizes all the required and recommended steps for creating a
- [ ] Implement `get_default_adapter_suggestions` to define the conversion strategy.
- [ ] Implement `Transaction Management`.
- [ ] Implement `Error Handling` to map driver exceptions.
- [ ] Implement `Feature Detection` to declare capabilities.
- [ ] Implement the `supports_*` capability switches your dialect needs.
- [ ] If providing an `AsyncStorageBackend`, ensure it has **Functional Equivalence** with the sync version.

### Required Tests
Expand Down
Loading
Loading