diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 10f0dcca..d0e66429 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -65,10 +65,10 @@ jobs:
pip install -r requirements.txt
pip install -r requirements-dev.txt
- - name: Install activerecord and testsuite from PyPI
+ - name: Install activerecord and testsuite from Git
run: |
- pip install rhosocial-activerecord==1.0.0.dev22
- pip install rhosocial-activerecord-testsuite==1.0.0.dev9
+ pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev23
+ pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev10
- name: Create postgres scenario config
run: |
diff --git a/.gitignore b/.gitignore
index 82e1c780..f2be2d67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -175,3 +175,7 @@ cython_debug/
*.crt
.env*coverage*.json
htmlcov/
+
+# Claude Code temporary files (local only)
+.claude/tmp/
+.claude/plans/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63d531a0..db40748f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+## [v1.0.0.dev10] - 2026-04-13
+
+### Added
+
+- Added PostgreSQL row-level lock strength support: FOR SHARE, FOR NO KEY UPDATE, and FOR KEY SHARE. These lock strengths provide finer control over row-level locking in PostgreSQL 9.0+ (KEY SHARE requires 9.3+). ([#23](https://github.com/rhosocial/python-activerecord-postgres/issues/23))
+- Added enhanced mathematical functions to PostgreSQL backend, including round_, pow, power, sqrt, mod, ceil, floor, trunc, max_, min_, and avg, with proper naming to avoid conflicts with Python builtins. ([#25](https://github.com/rhosocial/python-activerecord-postgres/issues/25))
+- Added comprehensive PostgreSQL function support: array (18), network address (16), UUID (8), hstore (33), and range constructors (6). ([#26](https://github.com/rhosocial/python-activerecord-postgres/issues/26))
+- Add PostgreSQL constraint support protocol with EXCLUDE and NOT VALID options ([#27](https://github.com/rhosocial/python-activerecord-postgres/issues/27))
+
+
+
+### Fixed
+
+- Refactored SQL function generation utilities by migrating them from types/ to functions/ directory for better module responsibility separation. ([#24](https://github.com/rhosocial/python-activerecord-postgres/issues/24))
+
+
## [v1.0.0.dev9] - 2026-04-08
### Added
diff --git a/docs/en_US/README.md b/docs/en_US/README.md
index 10326350..55e644f7 100644
--- a/docs/en_US/README.md
+++ b/docs/en_US/README.md
@@ -26,29 +26,36 @@
* **[Advanced Indexing](postgres_specific_features/indexing.md)**: GIN, GiST, BRIN index types
* **[RETURNING Clause](postgres_specific_features/returning.md)**: INSERT/UPDATE/DELETE RETURNING support
-4. **[Transaction Support](transaction_support/README.md)**
+4. **[DDL Operations](ddl/README.md)**
+ * **[PostgreSQL DDL Overview](ddl/README.md)**: CREATE TABLE, ALTER TABLE, DROP TABLE
+ * **[Example Code](../examples/chapter_04_ddl/ddl.py)**: DDL example
+
+5. **[Transaction Support](transaction_support/README.md)**
* **[Transaction Isolation Levels](transaction_support/isolation_level.md)**: READ COMMITTED, REPEATABLE READ, SERIALIZABLE
* **[Savepoint Support](transaction_support/savepoint.md)**: nested transactions
* **[DEFERRABLE Mode](transaction_support/deferrable.md)**: deferred constraint checking
* **[Deadlock Handling](transaction_support/deadlock.md)**: failure retry mechanism
-5. **[Type Adapters](type_adapters/README.md)**
+6. **[Type Adapters](type_adapters/README.md)**
* **[PostgreSQL to Python Type Mapping](type_adapters/mapping.md)**: type conversion rules
* **[Custom Type Adapters](type_adapters/custom.md)**: extending type support
* **[Timezone Handling](type_adapters/timezone.md)**: TIMESTAMP WITH TIME ZONE
* **[Array Type Handling](type_adapters/arrays.md)**: PostgreSQL array support
-6. **[Testing](testing/README.md)**
+7. **[Testing](testing/README.md)**
* **[Test Configuration](testing/configuration.md)**: test environment setup
* **[Using testsuite for Testing](testing/testsuite.md)**: test suite usage
* **[Local PostgreSQL Testing](testing/local.md)**: local database testing
-7. **[Troubleshooting](troubleshooting/README.md)**
+8. **[Troubleshooting](troubleshooting/README.md)**
* **[Common Connection Errors](troubleshooting/connection.md)**: connection issue diagnosis
* **[Performance Issues](troubleshooting/performance.md)**: performance bottleneck analysis
* **[SQL Standard Compliance](troubleshooting/sql_standard.md)**: strict SQL compliance handling
-8. **[Scenarios](scenarios/README.md)**
+9. **[Scenarios](scenarios/README.md)**
* **[Parallel Worker Processing](scenarios/parallel_workers.md)**: correct usage in multi-process/async concurrent scenarios
+10. **[Worker Isolation Experiment](examples/chapter_05_worker_isolation/README.md)**
+ * **[Worker Connection Isolation](examples/chapter_05_worker_isolation/README.md)**: worker process isolation experiment
+
> ๐ **Core Library Documentation**: To learn about the complete functionality of the ActiveRecord framework, please refer to [rhosocial-activerecord documentation](https://github.com/rhosocial/python-activerecord/tree/main/docs/en_US).
diff --git a/docs/en_US/ddl/README.md b/docs/en_US/ddl/README.md
new file mode 100644
index 00000000..9db1f4d7
--- /dev/null
+++ b/docs/en_US/ddl/README.md
@@ -0,0 +1,77 @@
+# PostgreSQL DDL Operations
+
+The PostgreSQL backend supports the same type-safe DDL expressions as the core library, with PostgreSQL-specific extensions.
+
+## Supported Operations
+
+| Operation | PostgreSQL Support | Notes |
+|----------|-------------------|-------|
+| `CreateTableExpression` | โ
Full | PRIMARY KEY, NOT NULL, UNIQUE, etc. |
+| `DropTableExpression` | โ
Full | IF EXISTS, CASCADE, RESTRICT |
+| `AlterTableExpression` | โ
Full | ADD/DROP COLUMN, ALTER COLUMN |
+| `CreateIndexExpression` | โ
Full | Index types (BTREE, HASH, GIN, GiST, BRIN) |
+| `DropIndexExpression` | โ
Full | |
+| `CreateViewExpression` | โ
Full | Materialized views support |
+| `DropViewExpression` | โ
Full | |
+
+## PostgreSQL-Specific Features
+
+### Index Types
+
+PostgreSQL supports various index types:
+
+```python
+create_idx = CreateIndexExpression(
+ dialect,
+ index_name="idx_users_name",
+ table_name="users",
+ columns=["name"],
+ index_type="GIN" # GIN, GiST, BRIN, BTREE, HASH
+)
+```
+
+### Partial Indexes
+
+PostgreSQL supports partial indexes with WHERE clause:
+
+```python
+from rhosocial.activerecord.backend.expression import Column, Literal
+
+create_idx = CreateIndexExpression(
+ dialect,
+ index_name="idx_active_users",
+ table_name="users",
+ columns=["email"],
+ where=Column(dialect, "status") == Literal(dialect, "active")
+)
+```
+
+### Schema Support
+
+PostgreSQL supports schemas:
+
+```python
+create_table = CreateTableExpression(
+ dialect,
+ table_name="schema_name.users",
+ columns=columns
+)
+```
+
+## Running the Example
+
+```bash
+cd python-activerecord-postgres
+source .venv3.8/bin/activate
+PYTHONPATH=src python docs/examples/chapter_04_ddl/ddl.py
+```
+
+The example tests:
+1. Create table with constraints
+2. Create table with IF NOT EXISTS
+3. Alter table - add column
+4. Alter table - drop column
+5. Drop table with IF EXISTS
+6. Introspection to verify schema changes
+
+> **Note**: PostgreSQL has more powerful DDL support than SQLite. For full PostgreSQL DDL capabilities, refer to [PostgreSQL 16 Documentation](https://www.postgresql.org/docs/16/sql-createtable.html).
\ No newline at end of file
diff --git a/docs/en_US/installation_and_configuration/backend_group.md b/docs/en_US/installation_and_configuration/backend_group.md
new file mode 100644
index 00000000..5fa0b2f3
--- /dev/null
+++ b/docs/en_US/installation_and_configuration/backend_group.md
@@ -0,0 +1,101 @@
+# BackendGroup and BackendManager (PostgreSQL)
+
+This document describes how to use `BackendGroup` and `BackendManager` with the PostgreSQL backend. For detailed API documentation, refer to the [core library documentation](../../../rhosocial-activerecord/docs/en_US/connection/connection_management.md).
+
+## Quick Example
+
+```python
+from rhosocial.activerecord.connection import BackendGroup
+from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, PostgresConnectionConfig
+from rhosocial.activerecord.model import ActiveRecord
+
+
+class User(ActiveRecord):
+ name: str
+ email: str
+
+
+# Using context manager
+with BackendGroup(
+ name="main",
+ models=[User],
+ config=PostgresConnectionConfig(
+ host="localhost",
+ port=5432,
+ database="myapp",
+ username="app",
+ password="secret",
+ ),
+ backend_class=PostgresBackend,
+) as group:
+ user = User(name="John", email="john@example.com")
+ user.save()
+
+# Using with multiple groups via BackendManager
+from rhosocial.activerecord.connection import BackendManager
+
+manager = BackendManager()
+manager.create_group(
+ name="main",
+ models=[User],
+ config=PostgresConnectionConfig(host="localhost", database="main_db"),
+ backend_class=PostgresBackend,
+)
+manager.create_group(
+ name="stats",
+ config=PostgresConnectionConfig(host="localhost", database="stats_db"),
+ backend_class=PostgresBackend,
+)
+
+main_backend = manager.get_group("main").get_backend()
+stats_backend = manager.get_group("stats").get_backend()
+```
+
+## PostgreSQL-Specific Features
+
+### SSL/TLS Configuration
+
+```python
+config = PostgresConnectionConfig(
+ host="localhost",
+ port=5432,
+ database="myapp",
+ username="app",
+ password="secret",
+ sslmode="require",
+ sslrootcert="/path/to/ca.pem",
+ sslcert="/path/to/client.pem",
+ sslkey="/path/to/client.key",
+)
+```
+
+### Connection Pool
+
+PostgreSQL backend supports connection pool configuration (via `psycopg` pool):
+
+```python
+config = PostgresConnectionConfig(
+ host="localhost",
+ port=5432,
+ database="myapp",
+ username="app",
+ password="secret",
+ min_pool_size=5,
+ max_pool_size=20,
+)
+```
+
+### Search Path
+
+Set default schema search path:
+
+```python
+config = PostgresConnectionConfig(
+ host="localhost",
+ port=5432,
+ database="myapp",
+ username="app",
+ password="secret",
+ options="-c search_path=public,extensions",
+)
+```
\ No newline at end of file
diff --git a/docs/en_US/scenarios/parallel_workers.md b/docs/en_US/scenarios/parallel_workers.md
index abbbbc51..cfe2f873 100644
--- a/docs/en_US/scenarios/parallel_workers.md
+++ b/docs/en_US/scenarios/parallel_workers.md
@@ -634,3 +634,241 @@ def transfer_task(from_id: int, to_id: int, amount: float, conn_params: dict):
- Test bridge files: `tests/rhosocial/activerecord_postgres_test/feature/basic/worker/`
- Provider implementations: `tests/providers/basic.py`, `tests/providers/query.py`
- WorkerPool implementation: `rhosocial.activerecord.worker.pool`
+
+---
+
+## 9. Test Verification Conclusions
+
+### 9.1 Test Environment
+
+Tests verified across multiple environments:
+
+| Platform | Operating System | Python Version | pytest Version | PostgreSQL Version |
+|----------|------------------|----------------|----------------|-------------------|
+| macOS | macOS Tahoe 26.4.1 (Build 25E253) arm64 | 3.8.10 | 8.3.5 | 9.6.24 |
+| Windows | Windows 11 Pro 25H2 (Build 26200) | 3.8.10 / 3.14.3 | 8.3.5 / 8.4.2 | 17.5 |
+
+### 9.2 Test Results Summary
+
+#### Multiprocess Parallel Testing (exp1)
+
+| Platform | Serial Time | Sync Multiprocess | Async Multiprocess | Speedup |
+|----------|-------------|-------------------|--------------------|---------|
+| macOS (Python 3.8.10) | 0.727s | 0.700s (1.0x) | 0.894s (0.8x) | 1.0x |
+| Windows | 0.910s | 2.998s (0.3x) | 3.163s (0.3x) | 0.3x |
+
+> **Note**: Process startup overhead may exceed parallel benefits for small datasets. Larger datasets show better speedup. macOS arm64 sync multiprocess shows stable performance.
+
+#### Async Feature Testing (exp2)
+
+| Platform | Same-process Sync Serial | Same-process Async Sequential | Multiprocess Sync | Multiprocess Async |
+|----------|--------------------------|-------------------------------|-------------------|--------------------|
+| macOS (Python 3.8.10) | 0.651s | N/A (deadlock triggered) | N/A | N/A |
+| Windows | 0.204s | 0.218s | 1.133s | 1.264s |
+
+> **Note**: macOS triggered PostgreSQL deadlock detection (SQLSTATE 40P01) during async concurrent testing, related to small dataset and higher conflict probability.
+
+#### Deadlock Detection Testing (exp3)
+
+All platforms successfully triggered PostgreSQL deadlock detection:
+- Deadlock automatically detected (SQLSTATE 40P01)
+- Lower-cost transaction rolled back
+- Uncaught exceptions result in lost work from rolled-back transactions
+- macOS: 2 Workers succeeded, 2 Workers deadlocked and rolled back, total time 2.584s
+- Windows: 2 Workers succeeded, 2 Workers deadlocked and rolled back
+
+#### Correct Solution Testing (exp4)
+
+| Solution | macOS Time | Windows Time | Verification |
+|----------|------------|--------------|--------------|
+| A: Data Partitioning (Sync) | 0.804s | 1.170s | โ No duplicates |
+| A: Data Partitioning (Async) | 0.724s | 1.263s | โ No duplicates |
+| B: Atomic Claiming (Sync) | 1.071s | 1.812s | โ No duplicates |
+| B: Atomic Claiming (Async) | 1.218s | 1.446s | โ No duplicates |
+| C: Atomic + Retry (Sync) | 1.193s | 2.501s | โ No duplicates |
+
+#### Multithread Warning Testing (exp5)
+
+All platforms verified multithreaded connection sharing is unsafe:
+- Shared `__backend__`: Cursor state corruption
+- Per-thread `configure()`: Class attributes overwritten, still share same instance
+
+### 9.3 Platform-Specific Notes
+
+#### Windows Configuration
+
+On Windows, `WindowsSelectorEventLoopPolicy` must be set:
+
+```python
+import asyncio
+import sys
+
+def worker_async(post_ids: list) -> int:
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+ return asyncio.run(async_worker_main(post_ids))
+
+# Also required in main program
+def main():
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+ # ... other code
+```
+
+**Reason**: Windows uses `ProactorEventLoop` by default, but `psycopg` async backend requires `SelectorEventLoop`.
+
+> **Important**: Without the correct event loop policy, you will receive:
+> ```
+> Psycopg cannot use the 'ProactorEventLoop' to run in async mode.
+> Please use a compatible event loop, for instance by setting
+> 'asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())'
+> ```
+
+#### macOS / Linux
+
+No special configuration needed; default event loop works correctly.
+
+### 9.4 FastAPI Async Application Best Practices
+
+#### Problem Scenario
+
+In FastAPI + PostgreSQL + async backend scenario:
+- Each HTTP request is handled in a separate coroutine
+- Multiple coroutines may execute concurrently
+- PostgreSQL's `threadsafety=2`, connection pool is supported
+- Need efficient connection reuse mechanism
+
+#### Recommended Solution: BackendPool Connection Pooling
+
+**Core Principles**:
+1. Create global `AsyncBackendPool` at application startup
+2. Each request acquires connection from pool
+3. Return connection to pool after use
+4. Close connection pool at application shutdown
+
+**Complete Example**:
+
+```python
+# database.py - Connection pool manager
+from rhosocial.activerecord.connection.pool import PoolConfig, AsyncBackendPool
+from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend
+
+# Global connection pool (created at application startup)
+_pool: AsyncBackendPool = None
+
+async def init_pool():
+ """Initialize connection pool"""
+ global _pool
+
+ pool_config = PoolConfig(
+ min_size=2, # Minimum connections
+ max_size=10, # Maximum connections
+ backend_factory=lambda: AsyncPostgresBackend(connection_config=config)
+ )
+
+ _pool = AsyncBackendPool(pool_config)
+
+async def close_pool():
+ """Close connection pool"""
+ global _pool
+ if _pool:
+ await _pool.close()
+ _pool = None
+
+@asynccontextmanager
+async def get_request_db():
+ """Request-level connection manager"""
+ global _pool
+
+ # Acquire connection from pool
+ async with _pool.connection() as backend:
+ # Bind models to current connection
+ AsyncUser.__backend__ = backend
+ AsyncPost.__backend__ = backend
+ AsyncComment.__backend__ = backend
+
+ yield backend
+ # Connection automatically returned to pool
+
+
+# app.py - FastAPI application
+from fastapi import FastAPI, Depends
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_database()
+ await init_pool() # Initialize pool at startup
+ yield
+ await close_pool() # Cleanup pool at shutdown
+
+app = FastAPI(lifespan=lifespan)
+
+@app.get("/users/{user_id}")
+async def get_user(user_id: int, db=Depends(get_request_db)):
+ user = await AsyncUser.find_one(user_id)
+ return {"user": user.to_dict()}
+```
+
+#### Solution Analysis
+
+| Feature | Description |
+|---------|-------------|
+| **Connection Reuse** | Pooled connections, reduced creation overhead |
+| **Concurrency Safety** | Each request gets independent connection |
+| **Resource Management** | Pool automatically manages connection lifecycle |
+| **High Performance** | Suitable for high-concurrency scenarios |
+| **Transaction Support** | Transaction support within same connection |
+
+#### MySQL vs PostgreSQL Approach Comparison
+
+| Aspect | MySQL | PostgreSQL |
+|--------|-------|------------|
+| threadsafety | 1 | 2 |
+| BackendPool | โ Not supported | โ
Recommended |
+| Recommended approach | Request-level BackendGroup | BackendPool |
+| Connection reuse | Per-request (create/disconnect) | Pool-managed reuse |
+| Performance | Acceptable overhead | Optimized for pooling |
+
+#### Key Design Points
+
+1. **Global Connection Pool** (created at application startup)
+ - Initialize connection pool in `lifespan`
+ - Configure appropriate min_size and max_size
+
+2. **Request-Level Connection Acquisition**
+ - Use dependency injection to get connection
+ - Automatically bind models to current connection
+
+3. **Automatic Connection Return**
+ - Use `async with` to ensure connection return
+ - Properly returns even on exceptions
+
+4. **Cleanup at Application Shutdown**
+ - Close connection pool after `yield` in `lifespan`
+
+#### Suitable Scenarios
+
+| Scenario | Recommendation | Notes |
+|----------|----------------|-------|
+| Web API (high concurrency) | โญโญโญ Recommended | Connection pooling, efficient reuse |
+| Microservices | โญโญโญ Recommended | Pool management, suitable for distributed |
+| Real-time applications | โญโญโญ Recommended | High performance, WebSocket support |
+| Batch processing | โญโญ Acceptable | Configure appropriate pool size |
+| Background tasks | โญโญ Acceptable | Share application connection pool |
+
+#### Complete Example Code
+
+See `docs/examples/chapter_10_fastapi/` directory.
+
+### 9.5 Conclusions
+
+1. **Multiprocessing is the correct approach for parallel workers**: Verified on all platforms
+2. **Sync backend is more stable**: Async backend requires extra configuration on Windows
+3. **Deadlock retry recommended for production**: Doesn't rely on data partitionability, automatically handles deadlocks
+4. **Data partitioning is most efficient**: MVCC has no lock contention, suitable for partitionable scenarios
+5. **PostgreSQL recommends BackendPool for multithreading**: threadsafety=2 supports connection pooling
+6. **BackendPool provides true thread isolation**: Each thread gets independent connection, no race conditions
+7. **FastAPI recommends using BackendPool**: Efficient connection reuse, suitable for high-concurrency scenarios
+5. **PostgreSQL vs MySQL deadlock error codes**:
+ - PostgreSQL: SQLSTATE 40P01
+ - MySQL: errno 1213
diff --git a/docs/examples/chapter_04_ddl/ddl.py b/docs/examples/chapter_04_ddl/ddl.py
new file mode 100644
index 00000000..7e0d6d2b
--- /dev/null
+++ b/docs/examples/chapter_04_ddl/ddl.py
@@ -0,0 +1,202 @@
+"""
+DDL Test Script for PostgreSQL Backend
+Tests CreateTableExpression, DropTableExpression, AlterTableExpression, etc.
+"""
+
+import sys
+import os
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src'))
+
+from typing import ClassVar
+from rhosocial.activerecord.model import ActiveRecord
+from rhosocial.activerecord.base import FieldProxy
+from rhosocial.activerecord.backend.impl.postgres import PostgresBackend
+from rhosocial.activerecord.backend.expression import (
+ ColumnDefinition,
+ CreateTableExpression,
+ DropTableExpression,
+ AlterTableExpression,
+ AddColumn,
+ DropColumn,
+)
+from rhosocial.activerecord.backend.expression.statements import (
+ ColumnConstraint,
+ ColumnConstraintType,
+)
+
+
+class User(ActiveRecord):
+ """User Model for DDL test"""
+ name: str
+ email: str
+
+ c: ClassVar[FieldProxy] = FieldProxy()
+
+ @classmethod
+ def table_name(cls) -> str:
+ return "ddl_test_users"
+
+
+def main():
+ # Configure with PostgreSQL backend
+ from rhosocial.activerecord.backend.impl.postgres import PostgresConnectionConfig
+
+ config = PostgresConnectionConfig(
+ host="db-dev-1-n.rho.im",
+ port=15432,
+ database="test_db",
+ username="root",
+ password="password",
+ sslmode="prefer",
+ )
+ User.configure(config, PostgresBackend)
+
+ dialect = User.__backend__.dialect
+ backend = User.__backend__
+ introspector = backend.introspector
+
+ print(f"Connected to PostgreSQL: {dialect.get_server_version()}")
+ print()
+
+ # ============================================================
+ # Test 1: Create Table
+ # ============================================================
+ print("=== Test 1: Create Table ===")
+ columns = [
+ ColumnDefinition(
+ "id",
+ "INTEGER",
+ constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]
+ ),
+ ColumnDefinition(
+ "name",
+ "VARCHAR(100)",
+ constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]
+ ),
+ ColumnDefinition(
+ "email",
+ "VARCHAR(255)",
+ constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]
+ ),
+ ]
+
+ create = CreateTableExpression(
+ dialect=dialect,
+ table_name="ddl_test_users",
+ columns=columns,
+ )
+
+ sql, params = create.to_sql()
+ print(f"SQL: {sql}")
+ print()
+
+ sql_str, params_str = create.to_sql()
+ backend.execute(sql_str, params_str)
+
+ # Introspection
+ tables = introspector.list_tables()
+ print(f"Tables after create: {[t.name for t in tables]}")
+ print()
+
+ # ============================================================
+ # Test 2: Create Table with IF NOT EXISTS
+ # ============================================================
+ print("=== Test 2: Create Table with IF NOT EXISTS ===")
+ create2 = CreateTableExpression(
+ dialect=dialect,
+ table_name="ddl_test_users",
+ columns=columns,
+ if_not_exists=True,
+ )
+
+ sql, params = create2.to_sql()
+ print(f"SQL: {sql}")
+ print()
+
+ sql_str, params_str = create2.to_sql()
+ backend.execute(sql_str, params_str)
+ print("Created with IF NOT EXISTS successfully")
+ print()
+
+ # ============================================================
+ # Test 3: Alter Table - Add Column
+ # ============================================================
+ print("=== Test 3: Alter Table - Add Column ===")
+ alter = AlterTableExpression(
+ dialect,
+ table_name="ddl_test_users",
+ actions=[
+ AddColumn(ColumnDefinition("phone", "VARCHAR(20)"))
+ ]
+ )
+
+ sql, params = alter.to_sql()
+ print(f"SQL: {sql}")
+ print()
+
+ sql_str, params_str = alter.to_sql()
+ backend.execute(sql_str, params_str)
+
+ # Verify column added
+ table_info = introspector.get_table_info("ddl_test_users")
+ if table_info and table_info.columns:
+ print(f"Columns: {[c.name for c in table_info.columns]}")
+ print()
+
+ # ============================================================
+ # Test 4: Alter Table - Drop Column
+ # ============================================================
+ print("=== Test 4: Alter Table - Drop Column ===")
+ # First add a column to drop
+ alter_add = AlterTableExpression(
+ dialect,
+ table_name="ddl_test_users",
+ actions=[AddColumn(ColumnDefinition("temp_field", "TEXT"))]
+ )
+ sql_str, params_str = alter_add.to_sql()
+ backend.execute(sql_str, params_str)
+
+ alter_drop = AlterTableExpression(
+ dialect,
+ table_name="ddl_test_users",
+ actions=[DropColumn("temp_field")]
+ )
+
+ sql, params = alter_drop.to_sql()
+ print(f"SQL: {sql}")
+ print()
+
+ sql_str, params_str = alter_drop.to_sql()
+ backend.execute(sql_str, params_str)
+
+ table_info = introspector.get_table_info("ddl_test_users")
+ if table_info and table_info.columns:
+ print(f"Columns after drop: {[c.name for c in table_info.columns]}")
+ print()
+
+ # ============================================================
+ # Test 5: Drop Table
+ # ============================================================
+ print("=== Test 5: Drop Table ===")
+ drop = DropTableExpression(
+ dialect,
+ table_name="ddl_test_users",
+ if_exists=True,
+ )
+
+ sql, params = drop.to_sql()
+ print(f"SQL: {sql}")
+ print()
+
+ sql_str, params_str = drop.to_sql()
+ backend.execute(sql_str, params_str)
+
+ tables = introspector.list_tables()
+ print(f"Tables after drop: {[t.name for t in tables]}")
+ print()
+
+ print("=== All PostgreSQL DDL tests completed! ===")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/docs/examples/chapter_05_worker_isolation/README.md b/docs/examples/chapter_05_worker_isolation/README.md
new file mode 100644
index 00000000..d6f1c078
--- /dev/null
+++ b/docs/examples/chapter_05_worker_isolation/README.md
@@ -0,0 +1,197 @@
+# Worker Isolation Experiment (PostgreSQL ็)
+
+ๆฌๅฎ้ชๆจๅจ้ช่ฏ Worker ่ฟ็จ้็ฆป็ธๅฏนไบๅ
จๅฑๅ
ฑไบซ่ฟๆฅ็ๆๆๆง๏ผPostgreSQL ็ๆฌ๏ผใ
+
+## ๅฎ้ช็ฎ็
+
+1. **ๅฏนๆฏไธค็ง่ฟๆฅ็ฎก็ๆจกๅผ**๏ผ
+ - ๅ
จๅฑๅ
ฑไบซ่ฟๆฅ๏ผ้่ฏฏๆจกๅผ๏ผ
+ - Worker ่ฟ็จๅ
็ฎก็่ฟๆฅ๏ผๆญฃ็กฎๆจกๅผ๏ผ
+
+2. **้ช่ฏๅ
ๅฎนๅ
ๆฌ**๏ผ
+ - ่ฟๆฅ็จณๅฎๆง
+ - ้่ฏฏ็
+ - ๆง่ฝ่กจ็ฐ
+ - ้ฟๆถ้ด่ฟ่ก็่ไน
ๆง
+
+## ไพ่ตๆจกๅ
+
+ๆฌๅฎ้ชไฝฟ็จ `rhosocial.activerecord.worker_pool` ๆ ธๅฟๆจกๅ่ฟ่ก่ฟ็จ้็ฆป็ไปปๅกๆง่ก๏ผ
+
+```python
+from rhosocial.activerecord.worker_pool import (
+ WorkerPool,
+ TaskDefinition,
+ TaskResult,
+ TaskMode,
+ TaskPriority,
+ register_handler,
+)
+```
+
+> **ๆณจๆ**๏ผ`worker_pool` ๆจกๅๅทฒ้ๆๅฐ `rhosocial.activerecord` ๆ ธๅฟๅ
ไธญ๏ผๆจ่ๅจๆๆๅนถ่กๅบๆฏไธไฝฟ็จใ
+
+## ๆไปถ็ปๆ
+
+```
+chapter_05_worker_isolation/
+โโโ models.py # ActiveRecord ๆจกๅๅฎไน
+โโโ config.py # ้
็ฝฎๅ ่ฝฝๅๆฐๆฎๅบ่ฎพ็ฝฎ
+โโโ exp1_shared_connection.py # ๅฎ้ช1๏ผๅ
ฑไบซ่ฟๆฅ๏ผๅๆจกๅผ๏ผ
+โโโ exp2_isolated_connection.py # ๅฎ้ช2๏ผ้็ฆป่ฟๆฅ๏ผๆญฃ็กฎๆจกๅผ๏ผ
+โโโ exp3_endurance_test.py # ๅฎ้ช3๏ผ่ไน
ๆงๆต่ฏ
+โโโ README.md # ๆฌๆไปถ
+```
+
+## ่ฟ่กๅฎ้ช
+
+### ๅฎ้ช1๏ผๅ
ฑไบซ่ฟๆฅ๏ผๅๆจกๅผ๏ผ
+
+```bash
+cd python-activerecord-mysql/docs/examples/chapter_05_worker_isolation
+python exp1_shared_connection.py
+```
+worker_isolation_experiment/
+โโโ models.py # ActiveRecord ๆจกๅๅฎไน
+โโโ config.py # ้
็ฝฎๅ ่ฝฝๅๆฐๆฎๅบ่ฎพ็ฝฎ
+โโโ exp1_shared_connection.py # ๅฎ้ช1๏ผๅ
ฑไบซ่ฟๆฅ๏ผๅๆจกๅผ๏ผ
+โโโ exp2_isolated_connection.py # ๅฎ้ช2๏ผ้็ฆป่ฟๆฅ๏ผๆญฃ็กฎๆจกๅผ๏ผ
+โโโ exp3_endurance_test.py # ๅฎ้ช3๏ผ่ไน
ๆงๆต่ฏ
+โโโ README.md # ๆฌๆไปถ
+```
+
+## ๅ็ฝฎๆกไปถ
+
+1. **ๅฎ่ฃ
ไพ่ต**๏ผ
+ ```bash
+ pip install rhosocial-activerecord rhosocial-activerecord-mysql pyyaml
+ ```
+
+2. **้
็ฝฎๆฐๆฎๅบ่ฟๆฅ**๏ผ
+ ```bash
+ # ่ฎพ็ฝฎๅบๆฏ้
็ฝฎๆไปถ่ทฏๅพ
+ export MYSQL_SCENARIOS_CONFIG_PATH=/path/to/mysql_scenarios.yaml
+
+ # ้ๆฉ่ฆไฝฟ็จ็ๅบๆฏ
+ export MYSQL_SCENARIO=mysql_80
+ ```
+
+3. **็กฎไฟ MySQL ๆฐๆฎๅบๅฏ่ฎฟ้ฎ**
+
+## ่ฟ่กๅฎ้ช
+
+### ๅฎ้ช1๏ผๅ
ฑไบซ่ฟๆฅ๏ผๅๆจกๅผ๏ผ
+
+```bash
+cd python-activerecord-mysql/docs/examples/worker_isolation_experiment
+python exp1_shared_connection.py
+```
+
+**้ขๆ็ปๆ**๏ผ
+- ๅฏ่ฝๅบ็ฐ่ฟๆฅ้่ฏฏ
+- ๆไฝๅคฑ่ดฅ
+- ๆฐๆฎไธไธ่ด
+
+### ๅฎ้ช2๏ผ้็ฆป่ฟๆฅ๏ผๆญฃ็กฎๆจกๅผ๏ผ
+
+```bash
+python exp2_isolated_connection.py
+```
+
+**้ขๆ็ปๆ**๏ผ
+- ๆๆๆไฝๆๅๅฎๆ
+- ๆ ่ฟๆฅ็ธๅ
ณ้่ฏฏ
+- ็จณๅฎ็ๆง่ฝ่กจ็ฐ
+
+### ๅฎ้ช3๏ผ่ไน
ๆงๆต่ฏ
+
+```bash
+# ไป
ๆต่ฏ้็ฆปๆจกๅผ
+python exp3_endurance_test.py --pattern isolated --rounds 20
+
+# ไป
ๆต่ฏๅ
ฑไบซๆจกๅผ
+python exp3_endurance_test.py --pattern shared --rounds 20
+
+# ๅๆถๆต่ฏไธค็งๆจกๅผ่ฟ่กๅฏนๆฏ
+python exp3_endurance_test.py --pattern both --rounds 20 --workers 8 --ops-per-round 200
+```
+
+**ๅๆฐ่ฏดๆ**๏ผ
+- `--pattern`: ๆต่ฏๆจกๅผ (isolated/shared/both)
+- `--workers`: Worker ่ฟ็จๆฐ
+- `--ops-per-round`: ๆฏไธช Worker ๆฏ่ฝฎ็ๆไฝๆฐ
+- `--rounds`: ๆต่ฏ่ฝฎๆฐ
+
+## ็ปๆ่งฃ่ฏป
+
+### ๅ
ณ้ฎๆๆ
+
+| ๆๆ | ๅ
ฑไบซ่ฟๆฅ๏ผๅๆจกๅผ๏ผ | ้็ฆป่ฟๆฅ๏ผๆญฃ็กฎๆจกๅผ๏ผ |
+|------|-------------------|---------------------|
+| **ๆป้่ฏฏๆฐ** | ้ซ | ไฝ/้ถ |
+| **่ฟๆฅ้ฎ้ขๆฐ** | ้ซ | ้ถ |
+| **ๆๅ็** | ไธ็จณๅฎ | ๆฅ่ฟ100% |
+| **ๆง่ฝ่ถๅฟ** | ๅฏ่ฝไธ้ | ็จณๅฎ |
+
+### ๅธธ่ง้่ฏฏ็ฑปๅ
+
+ๅ
ฑไบซ่ฟๆฅๆจกๅผๅฏ่ฝๅบ็ฐ๏ผ
+
+1. **Connection errors**๏ผ่ฟๆฅ่ขซๅ
ณ้ญๆ้็ฝฎ
+2. **Cursor errors**๏ผๆธธๆ ็ถๆๆททไนฑ
+3. **Timeout errors**๏ผๆไฝ่ถ
ๆถ
+4. **Lock/Deadlock errors**๏ผ้็ซไบ้ฎ้ข
+
+## ๅฎ้ชๅ็
+
+### ๅ
ฑไบซ่ฟๆฅ็้ฎ้ข
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ Main Process โ
+โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โ โ User.__backend__ (ๅ
ฑไบซ) โ โ
+โ โ โ โ โ
+โ โ MySQL Connection โ โ
+โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ fork() โ fork() โ fork()
+ โโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโ
+ โ Worker 1 โ โ Worker 2 โ โ Worker 3 โ
+ โ ๅผ็จๅไธ โ โ ๅผ็จๅไธ โ โ ๅผ็จๅไธ โ
+ โ ่ฟๆฅๅฏน่ฑก โ โ ่ฟๆฅๅฏน่ฑก โ โ ่ฟๆฅๅฏน่ฑก โ
+ โโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโ
+ โ โ โ
+ ็ซไบๅไธ่ฟๆฅ โ ๆธธๆ ๆททไนฑใๆฐๆฎๆๅใ่ฟๆฅๅคฑๆ
+```
+
+### ้็ฆป่ฟๆฅ็ๆญฃ็กฎๆง
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ Main Process โ
+โ (ๆ ๅ
ฑไบซ่ฟๆฅ) โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ fork() โ fork() โ fork()
+ โโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโ
+ โ Worker 1 โ โ Worker 2 โ โ Worker 3 โ
+ โ Connect() โ โ Connect() โ โ Connect() โ
+ โ โ โ โ โ โ โ โ โ
+ โ MySQL 1 โ โ MySQL 2 โ โ MySQL 3 โ
+ โ (็ฌ็ซ) โ โ (็ฌ็ซ) โ โ (็ฌ็ซ) โ
+ โโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโ
+ โ โ โ
+ ๅ่ช็ฌ็ซๆไฝ โ ๆ ็ซไบใๆ ๅฒ็ชใ็จณๅฎๅฏ้
+```
+
+## ๆ ธๅฟๅๅ
+
+1. **่ฟ็จ้็ฆป**๏ผWorker ๅฟ
้กปๆฏ็ฌ็ซ่ฟ็จ
+2. **่ฟๆฅ็ฌ็ซ**๏ผๆฏไธช่ฟ็จๅๅปบ่ชๅทฑ็่ฟๆฅ
+3. **็ๅฝๅจๆ็ฎก็**๏ผ่ฟๅ
ฅๆถ็ปๅฎ๏ผ้ๅบๆถ้ๆพ
+4. **ไบๅก่พน็**๏ผๆฏไธชไปปๅกไธไธชไบๅก
+
+## ๅ่
+
+- [Worker Pool ็ฌ็ซๆจกๅไฝฟ็จๆๅ](../../../docs/zh_CN/scenarios/worker_pool.md)
+- [ๅนถ่ก Worker ๅบๆฏ](../../../docs/zh_CN/scenarios/parallel_workers.md)
diff --git a/docs/examples/chapter_05_worker_isolation/config.py b/docs/examples/chapter_05_worker_isolation/config.py
new file mode 100644
index 00000000..e6a56e13
--- /dev/null
+++ b/docs/examples/chapter_05_worker_isolation/config.py
@@ -0,0 +1,119 @@
+# docs/examples/chapter_05_worker_isolation/config.py
+"""
+Configuration loader for Worker isolation experiment (PostgreSQL version).
+"""
+
+import os
+import yaml
+from typing import Optional
+
+from rhosocial.activerecord.backend.impl.postgres import PostgresBackend
+from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig
+
+
+SCHEMA_SQL = {
+ "users": """
+ CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(50) NOT NULL UNIQUE,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ age INTEGER,
+ balance DECIMAL(10, 2) NOT NULL DEFAULT 0.0,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+ "orders": """
+ CREATE TABLE IF NOT EXISTS orders (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ order_number VARCHAR(50) NOT NULL,
+ total_amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+ "posts": """
+ CREATE TABLE IF NOT EXISTS posts (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title VARCHAR(200) NOT NULL,
+ content TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+ "comments": """
+ CREATE TABLE IF NOT EXISTS comments (
+ id SERIAL PRIMARY KEY,
+ post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ content TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+}
+
+
+def load_scenario_config(scenario_name: str = None) -> PostgresConnectionConfig:
+ """Load PostgreSQL connection config from the test scenarios YAML file."""
+ env_config_path = os.getenv("POSTGRES_SCENARIOS_CONFIG_PATH")
+ if env_config_path and os.path.exists(env_config_path):
+ config_path = env_config_path
+ else:
+ config_path = os.path.join(
+ os.path.dirname(__file__),
+ "..", "..", "..", "tests", "config", "postgres_scenarios.yaml"
+ )
+
+ if not os.path.exists(config_path):
+ raise FileNotFoundError(
+ f"PostgreSQL scenarios config not found at {config_path}. "
+ f"Please create the config file or set POSTGRES_SCENARIOS_CONFIG_PATH."
+ )
+
+ with open(config_path, "r") as f:
+ config_data = yaml.safe_load(f)
+
+ scenarios = config_data.get("scenarios", {})
+ if not scenarios:
+ raise ValueError("No scenarios found in config file")
+
+ if scenario_name is None:
+ scenario_name = os.getenv("POSTGRES_SCENARIO")
+ if scenario_name is None:
+ scenario_name = list(scenarios.keys())[0]
+
+ if scenario_name not in scenarios:
+ raise ValueError(
+ f"Scenario '{scenario_name}' not found. "
+ f"Available: {list(scenarios.keys())}"
+ )
+
+ scenario = scenarios[scenario_name]
+ return PostgresConnectionConfig(
+ host=scenario.get("host", "localhost"),
+ port=scenario.get("port", 5432),
+ database=scenario.get("database", "test_db"),
+ username=scenario.get("username", "root"),
+ password=scenario.get("password", ""),
+ sslmode=scenario.get("sslmode", "prefer"),
+ )
+
+
+def get_backend_class():
+ """Return the PostgreSQL backend class."""
+ return PostgresBackend
+
+
+def setup_database(backend: PostgresBackend, drop_existing: bool = True) -> None:
+ """Set up database tables for PostgreSQL."""
+ if drop_existing:
+ for table in reversed(list(SCHEMA_SQL.keys())):
+ backend.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
+
+ for table, sql in SCHEMA_SQL.items():
+ backend.execute(sql)
\ No newline at end of file
diff --git a/docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py b/docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py
new file mode 100644
index 00000000..66aff9bf
--- /dev/null
+++ b/docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+# docs/examples/worker_isolation_experiment/exp1_shared_connection.py
+"""
+Experiment 1: Shared Global Connection (Anti-pattern)
+
+This experiment demonstrates the DANGERS of sharing a single database connection
+across multiple Worker processes. This is an ANTI-PATTERN that should NEVER be
+used in production.
+
+Symptoms you may observe:
+- Connection corruption
+- Inconsistent query results
+- Deadlocks and timeouts
+- Data corruption
+- Random crashes
+
+The purpose of this experiment is to DEMONSTRATE why process isolation is necessary.
+"""
+
+from __future__ import annotations
+
+import multiprocessing
+import os
+import random
+import sys
+import time
+import threading
+from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
+from dataclasses import dataclass
+from typing import List, Dict, Any, Optional
+from decimal import Decimal
+
+# Add parent directories to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
+
+from config import load_scenario_config, get_backend_class, setup_database
+from models import User, Order, Post, Comment, ALL_MODELS
+
+
+@dataclass
+class TaskResult:
+ """Result of a worker task."""
+ worker_id: int
+ success: bool
+ operations: int
+ errors: List[str]
+ duration: float
+ connection_issues: int
+
+
+def worker_task_shared_connection(worker_id: int, num_operations: int, config_dict: Dict[str, Any]) -> TaskResult:
+ """
+ Worker task using SHARED global connection (ANTI-PATTERN).
+
+ This demonstrates what happens when multiple processes try to share
+ the same database connection through class-level __backend__.
+
+ WARNING: This will cause problems!
+ """
+ from rhosocial.activerecord.backend.impl.postgres import PostgresBackend
+ from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig
+
+ for model in ALL_MODELS:
+ model.configure(PostgresConnectionConfig(**config_dict), PostgresBackend)
+
+ start_time = time.time()
+ errors = []
+ operations = 0
+ connection_issues = 0
+
+ try:
+ # Each worker tries to use the globally configured backend
+ # This is the PROBLEM - they're all sharing the same connection!
+
+ for i in range(num_operations):
+ try:
+ # Random operation type
+ op_type = random.choice(['create', 'read', 'update', 'delete', 'transaction'])
+
+ if op_type == 'create':
+ # Create a new user
+ username = f"worker{worker_id}_user{i}_{int(time.time()*1000)}"
+ email = f"{username}@test.com"
+ user = User(username=username, email=email, balance=random.uniform(0, 1000))
+ user.save()
+ operations += 1
+
+ elif op_type == 'read':
+ # Read users
+ users = User.query().limit(10).all()
+ operations += 1
+
+ elif op_type == 'update':
+ # Update a random user's balance
+ users = User.query().limit(1).all()
+ if users:
+ users[0].balance += random.uniform(-10, 10)
+ users[0].save()
+ operations += 1
+
+ elif op_type == 'delete':
+ # Delete a user (if exists)
+ users = User.query().where(User.c.username.like(f"worker{worker_id}%")).limit(1).all()
+ if users:
+ users[0].delete()
+ operations += 1
+
+ elif op_type == 'transaction':
+ # Transaction with multiple operations
+ backend = User.backend()
+ with backend.transaction():
+ user = User(username=f"tx_worker{worker_id}_{i}", email=f"tx{i}@test.com")
+ user.save()
+ order = Order(user_id=user.id, order_number=f"ORD-{int(time.time()*1000)}", total_amount=Decimal('99.99'))
+ order.save()
+ operations += 1
+
+ # Small delay to simulate real work
+ time.sleep(random.uniform(0.001, 0.01))
+
+ except Exception as e:
+ error_msg = str(e)
+ errors.append(error_msg)
+
+ # Track connection-related issues
+ if any(keyword in error_msg.lower() for keyword in
+ ['connection', 'closed', 'timeout', 'lock', 'deadlock', 'cursor']):
+ connection_issues += 1
+
+ # Continue trying other operations
+ continue
+
+ except Exception as e:
+ errors.append(f"Worker crashed: {str(e)}")
+
+ duration = time.time() - start_time
+
+ return TaskResult(
+ worker_id=worker_id,
+ success=len(errors) == 0,
+ operations=operations,
+ errors=errors[:10], # Limit error list size
+ duration=duration,
+ connection_issues=connection_issues
+ )
+
+
+def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker: int) -> Dict[str, Any]:
+ """
+ Run the shared connection experiment.
+
+ This demonstrates the anti-pattern of configuring ActiveRecord globally
+ and letting all worker processes share the same connection.
+ """
+ print(f"\n{'='*70}")
+ print(f"Experiment 1: Shared Global Connection (ANTI-PATTERN)")
+ print(f"{'='*70}")
+ print(f"Workers: {num_workers}")
+ print(f"Operations per worker: {ops_per_worker}")
+ print(f"{'='*70}\n")
+
+ # Configure globally ONCE (this is the anti-pattern)
+ print("Step 1: Configuring ActiveRecord globally (shared connection)...")
+ from rhosocial.activerecord.backend.impl.postgres import PostgresBackend
+ from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig
+
+ config = PostgresConnectionConfig(**config_dict)
+ for model in ALL_MODELS:
+ model.configure(config, PostgresBackend)
+
+ # Verify all models share the same backend
+ backends = [model.__backend__ for model in ALL_MODELS]
+ print(f" All models share backend: {all(b is backends[0] for b in backends)}")
+ print(f" Backend ID: {id(backends[0])}")
+
+ # Setup database
+ print("\nStep 2: Setting up database...")
+ backend = User.backend()
+ setup_database(backend, drop_existing=True)
+
+ # Seed initial data
+ print(" Seeding initial data...")
+ for i in range(10):
+ user = User(username=f"seed_user_{i}", email=f"seed{i}@test.com", balance=100.0)
+ user.save()
+ print(f" Created 10 seed users")
+
+ # Run workers with shared connection
+ print(f"\nStep 3: Running {num_workers} workers with SHARED connection...")
+
+ start_time = time.time()
+ results: List[TaskResult] = []
+
+ # Use ProcessPoolExecutor - each process will try to use the shared connection
+ with ProcessPoolExecutor(max_workers=num_workers) as executor:
+ futures = [
+ executor.submit(worker_task_shared_connection, i, ops_per_worker, config_dict)
+ for i in range(num_workers)
+ ]
+
+ for future in as_completed(futures):
+ try:
+ result = future.result(timeout=60)
+ results.append(result)
+ status = "โ
" if result.success else "โ"
+ print(f" Worker {result.worker_id}: {status} "
+ f"{result.operations} ops in {result.duration:.2f}s "
+ f"(connection issues: {result.connection_issues})")
+ except Exception as e:
+ print(f" Worker failed with exception: {e}")
+
+ total_duration = time.time() - start_time
+
+ # Aggregate results
+ total_ops = sum(r.operations for r in results)
+ total_errors = sum(len(r.errors) for r in results)
+ total_conn_issues = sum(r.connection_issues for r in results)
+ successful_workers = sum(1 for r in results if r.success)
+
+ print(f"\n{'='*70}")
+ print("Results Summary:")
+ print(f"{'='*70}")
+ print(f" Total duration: {total_duration:.2f}s")
+ print(f" Successful workers: {successful_workers}/{num_workers}")
+ print(f" Total operations completed: {total_ops}")
+ print(f" Total errors: {total_errors}")
+ print(f" Connection-related issues: {total_conn_issues}")
+ print(f" Operations per second: {total_ops/total_duration:.2f}")
+
+ # Show sample errors
+ all_errors = [e for r in results for e in r.errors]
+ if all_errors:
+ print(f"\n Sample errors (first 5):")
+ for err in all_errors[:5]:
+ print(f" - {err[:100]}...")
+
+ print(f"{'='*70}\n")
+
+ # Cleanup
+ print("Cleaning up...")
+ try:
+ backend.execute("SET FOREIGN_KEY_CHECKS = 0")
+ for table in ['comments', 'orders', 'posts', 'users']:
+ backend.execute(f"DROP TABLE IF EXISTS `{table}`")
+ backend.execute("SET FOREIGN_KEY_CHECKS = 1")
+ except:
+ pass
+
+ backend.disconnect()
+
+ return {
+ "total_duration": total_duration,
+ "successful_workers": successful_workers,
+ "total_workers": num_workers,
+ "total_operations": total_ops,
+ "total_errors": total_errors,
+ "connection_issues": total_conn_issues,
+ "ops_per_second": total_ops/total_duration if total_duration > 0 else 0
+ }
+
+
+def main():
+ """Main entry point."""
+ print("\n" + "="*70)
+ print("Worker Isolation Experiment - Exp 1: Shared Connection (ANTI-PATTERN)")
+ print("="*70)
+ print("\nโ ๏ธ WARNING: This experiment demonstrates an ANTI-PATTERN!")
+ print(" Do NOT use this approach in production!")
+ print(" You may observe: connection errors, data corruption, deadlocks")
+ print("="*70)
+
+ # Load config
+ try:
+ config = load_scenario_config()
+ print(f"\nUsing scenario: {os.getenv('MYSQL_SCENARIO', 'default')}")
+ except Exception as e:
+ print(f"Error loading config: {e}")
+ print("Set MYSQL_SCENARIOS_CONFIG_PATH or MYSQL_SCENARIO environment variable")
+ return
+
+ # Convert config to dict for serialization
+ config_dict = {
+ "host": config.host,
+ "port": config.port,
+ "database": config.database,
+ "username": config.username,
+ "password": config.password,
+ "sslmode": config.sslmode,
+ }
+
+ # Run experiment
+ run_experiment(
+ config_dict=config_dict,
+ num_workers=4,
+ ops_per_worker=50
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py b/docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py
new file mode 100644
index 00000000..b04d7308
--- /dev/null
+++ b/docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py
@@ -0,0 +1,354 @@
+#!/usr/bin/env python3
+# docs/examples/worker_isolation_experiment/exp2_isolated_connection.py
+"""
+Experiment 2: Process-Isolated Connections (Correct Pattern)
+
+This experiment demonstrates the CORRECT way to handle database connections
+in a Worker pool environment. Each worker process:
+
+1. Creates its own database connection
+2. Binds models to that connection on entry
+3. Releases the connection on exit
+
+This ensures:
+- No connection sharing between processes
+- Thread-safe operations within each process
+- Proper resource cleanup
+- Predictable behavior
+"""
+
+from __future__ import annotations
+
+import multiprocessing
+import os
+import random
+import sys
+import time
+from concurrent.futures import ProcessPoolExecutor, as_completed
+from dataclasses import dataclass
+from typing import List, Dict, Any, Optional
+from decimal import Decimal
+
+# Add parent directories to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
+
+from config import load_scenario_config, get_backend_class, SCHEMA_SQL
+
+
+@dataclass
+class TaskResult:
+ """Result of a worker task."""
+ worker_id: int
+ success: bool
+ operations: int
+ errors: List[str]
+ duration: float
+ connection_issues: int
+
+
+def _ensure_tables_exist(backend) -> None:
+ """Ensure all required tables exist."""
+ backend.execute("SET FOREIGN_KEY_CHECKS = 0")
+ try:
+ # Just create if not exists - don't drop
+ for table, sql in SCHEMA_SQL.items():
+ # Modify to CREATE IF NOT EXISTS
+ sql_modified = sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE IF NOT EXISTS")
+ try:
+ backend.execute(sql_modified)
+ except Exception:
+ pass # Table might already exist
+ finally:
+ backend.execute("SET FOREIGN_KEY_CHECKS = 1")
+
+
+def worker_task_isolated_connection(
+ worker_id: int,
+ num_operations: int,
+ config_dict: Dict[str, Any]
+) -> TaskResult:
+ """
+ Worker task with ISOLATED connection per process (CORRECT PATTERN).
+
+ Each worker:
+ 1. Creates its own MySQL backend instance
+ 2. Connects to the database
+ 3. Binds models to its connection
+ 4. Performs operations
+ 5. Disconnects on exit (guaranteed by finally)
+
+ This is the correct approach for process-based parallelism.
+ """
+ from rhosocial.activerecord.backend.impl.mysql import MySQLBackend
+ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig
+ from models import User, Order, Post, Comment, ALL_MODELS
+
+ start_time = time.time()
+ errors = []
+ operations = 0
+ connection_issues = 0
+ backend = None
+
+ try:
+ # === Phase 1: Create isolated connection ===
+ config = MySQLConnectionConfig(**config_dict)
+ backend = MySQLBackend(config)
+ backend.connect()
+
+ # Bind models to this process's backend
+ for model in ALL_MODELS:
+ model.__backend__ = backend
+
+ # Ensure tables exist (idempotent)
+ _ensure_tables_exist(backend)
+
+ # === Phase 2: Perform operations ===
+ for i in range(num_operations):
+ try:
+ op_type = random.choice(['create', 'read', 'update', 'delete', 'transaction', 'batch'])
+
+ if op_type == 'create':
+ username = f"worker{worker_id}_user{i}_{int(time.time()*1000)}"
+ email = f"{username}@test.com"
+ user = User(username=username, email=email, balance=random.uniform(0, 1000))
+ user.save()
+ operations += 1
+
+ elif op_type == 'read':
+ users = User.query().limit(10).all()
+ operations += 1
+
+ elif op_type == 'update':
+ users = User.query().limit(1).all()
+ if users:
+ users[0].balance += random.uniform(-10, 10)
+ users[0].save()
+ operations += 1
+
+ elif op_type == 'delete':
+ users = User.query().where(User.c.username.like(f"worker{worker_id}%")).limit(1).all()
+ if users:
+ users[0].delete()
+ operations += 1
+
+ elif op_type == 'transaction':
+ with backend.transaction():
+ user = User(username=f"tx_worker{worker_id}_{i}", email=f"tx{i}@test.com")
+ user.save()
+ order = Order(
+ user_id=user.id,
+ order_number=f"ORD-{int(time.time()*1000)}",
+ total_amount=Decimal('99.99')
+ )
+ order.save()
+ operations += 1
+
+ elif op_type == 'batch':
+ # Batch create users
+ batch_users = []
+ for j in range(5):
+ batch_users.append(User(
+ username=f"batch_w{worker_id}_{i}_{j}",
+ email=f"batch_{i}_{j}@test.com",
+ balance=random.uniform(0, 100)
+ ))
+ # Save each (could use bulk insert if available)
+ for u in batch_users:
+ u.save()
+ operations += 5
+
+ time.sleep(random.uniform(0.001, 0.01))
+
+ except Exception as e:
+ error_msg = str(e)
+ errors.append(error_msg)
+
+ if any(keyword in error_msg.lower() for keyword in
+ ['connection', 'closed', 'timeout', 'lock', 'deadlock', 'cursor']):
+ connection_issues += 1
+
+ continue
+
+ except Exception as e:
+ errors.append(f"Worker setup/teardown error: {str(e)}")
+
+ finally:
+ # === Phase 3: Cleanup (guaranteed) ===
+ if backend:
+ try:
+ # Unbind models
+ for model in ALL_MODELS:
+ model.__backend__ = None
+ # Disconnect
+ backend.disconnect()
+ except:
+ pass
+
+ duration = time.time() - start_time
+
+ return TaskResult(
+ worker_id=worker_id,
+ success=len(errors) == 0,
+ operations=operations,
+ errors=errors[:10],
+ duration=duration,
+ connection_issues=connection_issues
+ )
+
+
+def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker: int) -> Dict[str, Any]:
+ """
+ Run the isolated connection experiment.
+
+ Each worker process creates its own connection and manages its lifecycle.
+ """
+ print(f"\n{'='*70}")
+ print(f"Experiment 2: Process-Isolated Connections (CORRECT PATTERN)")
+ print(f"{'='*70}")
+ print(f"Workers: {num_workers}")
+ print(f"Operations per worker: {ops_per_worker}")
+ print(f"{'='*70}\n")
+
+ # Setup initial database state (in main process)
+ print("Step 1: Setting up database...")
+ from rhosocial.activerecord.backend.impl.mysql import MySQLBackend
+ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig
+ from models import User, ALL_MODELS
+ from config import setup_database
+
+ config = MySQLConnectionConfig(**config_dict)
+ setup_backend = MySQLBackend(config)
+ setup_backend.connect()
+ setup_database(setup_backend, drop_existing=True)
+
+ # Seed initial data
+ print(" Seeding initial data...")
+ for model in ALL_MODELS:
+ model.__backend__ = setup_backend
+
+ for i in range(10):
+ user = User(username=f"seed_user_{i}", email=f"seed{i}@test.com", balance=100.0)
+ user.save()
+
+ for model in ALL_MODELS:
+ model.__backend__ = None
+ setup_backend.disconnect()
+ print(f" Created 10 seed users")
+
+ # Run workers with isolated connections
+ print(f"\nStep 2: Running {num_workers} workers with ISOLATED connections...")
+ print(f" Each worker creates its own database connection")
+
+ start_time = time.time()
+ results: List[TaskResult] = []
+
+ with ProcessPoolExecutor(max_workers=num_workers) as executor:
+ futures = [
+ executor.submit(worker_task_isolated_connection, i, ops_per_worker, config_dict)
+ for i in range(num_workers)
+ ]
+
+ for future in as_completed(futures):
+ try:
+ result = future.result(timeout=120)
+ results.append(result)
+ status = "โ
" if result.success else "โ"
+ print(f" Worker {result.worker_id}: {status} "
+ f"{result.operations} ops in {result.duration:.2f}s "
+ f"(connection issues: {result.connection_issues})")
+ except Exception as e:
+ print(f" Worker failed with exception: {e}")
+
+ total_duration = time.time() - start_time
+
+ # Aggregate results
+ total_ops = sum(r.operations for r in results)
+ total_errors = sum(len(r.errors) for r in results)
+ total_conn_issues = sum(r.connection_issues for r in results)
+ successful_workers = sum(1 for r in results if r.success)
+
+ print(f"\n{'='*70}")
+ print("Results Summary:")
+ print(f"{'='*70}")
+ print(f" Total duration: {total_duration:.2f}s")
+ print(f" Successful workers: {successful_workers}/{num_workers}")
+ print(f" Total operations completed: {total_ops}")
+ print(f" Total errors: {total_errors}")
+ print(f" Connection-related issues: {total_conn_issues}")
+ print(f" Operations per second: {total_ops/total_duration:.2f}")
+
+ # Show sample errors
+ all_errors = [e for r in results for e in r.errors]
+ if all_errors:
+ print(f"\n Sample errors (first 5):")
+ for err in all_errors[:5]:
+ print(f" - {err[:100]}...")
+ else:
+ print(f"\n โ
No errors detected!")
+
+ print(f"{'='*70}\n")
+
+ # Cleanup
+ print("Cleaning up...")
+ try:
+ cleanup_backend = MySQLBackend(config)
+ cleanup_backend.connect()
+ cleanup_backend.execute("SET FOREIGN_KEY_CHECKS = 0")
+ for table in ['comments', 'orders', 'posts', 'users']:
+ cleanup_backend.execute(f"DROP TABLE IF EXISTS `{table}`")
+ cleanup_backend.execute("SET FOREIGN_KEY_CHECKS = 1")
+ cleanup_backend.disconnect()
+ except:
+ pass
+
+ return {
+ "total_duration": total_duration,
+ "successful_workers": successful_workers,
+ "total_workers": num_workers,
+ "total_operations": total_ops,
+ "total_errors": total_errors,
+ "connection_issues": total_conn_issues,
+ "ops_per_second": total_ops/total_duration if total_duration > 0 else 0
+ }
+
+
+def main():
+ """Main entry point."""
+ print("\n" + "="*70)
+ print("Worker Isolation Experiment - Exp 2: Isolated Connection (CORRECT)")
+ print("="*70)
+ print("\nโ
This experiment demonstrates the CORRECT pattern!")
+ print(" Each worker process has its own isolated database connection.")
+ print(" This ensures: no connection conflicts, proper cleanup, stability")
+ print("="*70)
+
+ # Load config
+ try:
+ config = load_scenario_config()
+ print(f"\nUsing scenario: {os.getenv('MYSQL_SCENARIO', 'default')}")
+ except Exception as e:
+ print(f"Error loading config: {e}")
+ print("Set MYSQL_SCENARIOS_CONFIG_PATH or MYSQL_SCENARIO environment variable")
+ return
+
+ # Convert config to dict for serialization
+ config_dict = {
+ "host": config.host,
+ "port": config.port,
+ "database": config.database,
+ "username": config.username,
+ "password": config.password,
+ "charset": config.charset,
+ "autocommit": config.autocommit,
+ }
+
+ # Run experiment
+ run_experiment(
+ config_dict=config_dict,
+ num_workers=4,
+ ops_per_worker=50
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py b/docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py
new file mode 100644
index 00000000..2ed78e35
--- /dev/null
+++ b/docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py
@@ -0,0 +1,460 @@
+#!/usr/bin/env python3
+# docs/examples/worker_isolation_experiment/exp3_endurance_test.py
+"""
+Experiment 3: Endurance Test (Durability Verification)
+
+This experiment runs for an extended period to verify the stability of
+process-isolated connections vs shared connections.
+
+It performs continuous CRUD operations with multiple workers to:
+1. Detect memory leaks
+2. Identify connection exhaustion issues
+3. Verify consistent behavior over time
+4. Measure performance degradation (if any)
+
+Run this experiment for at least 10-30 minutes to observe behavior.
+
+Note: This experiment uses the rhosocial.activerecord.worker_pool module
+ from the core package for process-isolated task execution.
+"""
+
+from __future__ import annotations
+
+import argparse
+import gc
+import multiprocessing
+import os
+import random
+import sys
+import time
+import traceback
+from concurrent.futures import ProcessPoolExecutor, as_completed
+from dataclasses import dataclass, field
+from typing import List, Dict, Any, Optional
+from decimal import Decimal
+import threading
+
+# Add parent directories to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
+
+from config import load_scenario_config, get_backend_class, SCHEMA_SQL
+
+# Import from core package worker_pool module
+from rhosocial.activerecord.worker_pool import (
+ TaskDefinition,
+ TaskResult,
+ TaskMode,
+ TaskPriority,
+ register_handler,
+ WorkerPool,
+)
+
+
+@dataclass
+class WorkerStats:
+ """Statistics from a single worker run."""
+ worker_id: int
+ round_num: int
+ operations: int
+ errors: int
+ duration: float
+ success: bool
+ error_types: Dict[str, int] = field(default_factory=dict)
+
+
+@dataclass
+class ExperimentStats:
+ """Aggregated experiment statistics."""
+ total_operations: int = 0
+ total_errors: int = 0
+ total_duration: float = 0.0
+ rounds_completed: int = 0
+ connection_issues: int = 0
+ error_breakdown: Dict[str, int] = field(default_factory=dict)
+ ops_per_round: List[int] = field(default_factory=list)
+ errors_per_round: List[int] = field(default_factory=list)
+
+
+def worker_task(
+ worker_id: int,
+ round_num: int,
+ num_operations: int,
+ config_dict: Dict[str, Any],
+ use_isolated: bool = True
+) -> WorkerStats:
+ """
+ Worker task for endurance testing.
+
+ Args:
+ worker_id: Worker identifier
+ round_num: Current round number
+ num_operations: Number of operations to perform
+ config_dict: Database configuration
+ use_isolated: If True, use isolated connection (correct pattern)
+ If False, use shared connection (anti-pattern)
+ """
+ from rhosocial.activerecord.backend.impl.mysql import MySQLBackend
+ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig
+ from models import User, Order, Post, Comment, ALL_MODELS
+
+ start_time = time.time()
+ operations = 0
+ errors = 0
+ error_types: Dict[str, int] = {}
+ backend = None
+
+ def record_error(error_msg: str):
+ nonlocal errors
+ errors += 1
+ # Categorize error
+ error_category = "unknown"
+ lower_msg = error_msg.lower()
+ if "connection" in lower_msg or "closed" in lower_msg:
+ error_category = "connection"
+ elif "timeout" in lower_msg:
+ error_category = "timeout"
+ elif "deadlock" in lower_msg or "lock" in lower_msg:
+ error_category = "lock"
+ elif "cursor" in lower_msg:
+ error_category = "cursor"
+ error_types[error_category] = error_types.get(error_category, 0) + 1
+
+ try:
+ if use_isolated:
+ # Correct pattern: Create isolated connection
+ config = MySQLConnectionConfig(**config_dict)
+ backend = MySQLBackend(config)
+ backend.connect()
+ for model in ALL_MODELS:
+ model.__backend__ = backend
+ else:
+ # Anti-pattern: Try to use shared connection
+ # This will fail in ProcessPoolExecutor since each process
+ # has its own memory space
+ pass
+
+ # Perform operations
+ for i in range(num_operations):
+ try:
+ op_type = random.choices(
+ ['create', 'read', 'update', 'delete', 'transaction'],
+ weights=[0.2, 0.4, 0.2, 0.1, 0.1]
+ )[0]
+
+ if op_type == 'create':
+ username = f"endurance_w{worker_id}_r{round_num}_{i}"
+ user = User(username=username, email=f"{username}@test.com")
+ user.save()
+ operations += 1
+
+ elif op_type == 'read':
+ users = User.query().order_by(User.c.id.desc()).limit(10).all()
+ operations += 1
+
+ elif op_type == 'update':
+ users = User.query().limit(1).all()
+ if users:
+ users[0].balance = random.uniform(0, 1000)
+ users[0].save()
+ operations += 1
+
+ elif op_type == 'delete':
+ # Delete random user (not seed users)
+ users = User.query().where(User.c.username.like("endurance_%")).limit(1).all()
+ if users:
+ users[0].delete()
+ operations += 1
+
+ elif op_type == 'transaction':
+ if backend:
+ with backend.transaction():
+ user = User(
+ username=f"tx_w{worker_id}_r{round_num}_{i}",
+ email=f"tx_{i}@test.com"
+ )
+ user.save()
+ order = Order(
+ user_id=user.id,
+ order_number=f"TX-{int(time.time()*1000000)}",
+ total_amount=Decimal(str(random.uniform(10, 100)))
+ )
+ order.save()
+ operations += 1
+
+ # Variable delay
+ time.sleep(random.uniform(0.001, 0.005))
+
+ except Exception as e:
+ record_error(str(e))
+
+ except Exception as e:
+ record_error(f"Worker error: {str(e)}")
+
+ finally:
+ if use_isolated and backend:
+ try:
+ for model in ALL_MODELS:
+ model.__backend__ = None
+ backend.disconnect()
+ except:
+ pass
+
+ duration = time.time() - start_time
+
+ return WorkerStats(
+ worker_id=worker_id,
+ round_num=round_num,
+ operations=operations,
+ errors=errors,
+ duration=duration,
+ success=(errors == 0),
+ error_types=error_types
+ )
+
+
+def run_endurance_test(
+ config_dict: Dict[str, Any],
+ num_workers: int,
+ ops_per_round: int,
+ num_rounds: int,
+ use_isolated: bool
+) -> ExperimentStats:
+ """Run the endurance test."""
+
+ pattern_name = "ISOLATED (Correct)" if use_isolated else "SHARED (Anti-pattern)"
+ print(f"\n{'='*70}")
+ print(f"Endurance Test: {pattern_name}")
+ print(f"{'='*70}")
+ print(f"Workers: {num_workers}")
+ print(f"Operations per round: {ops_per_round}")
+ print(f"Rounds: {num_rounds}")
+ print(f"Total expected operations: {num_workers * ops_per_round * num_rounds}")
+ print(f"{'='*70}\n")
+
+ stats = ExperimentStats()
+
+ # Setup database
+ from rhosocial.activerecord.backend.impl.mysql import MySQLBackend
+ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig
+ from models import User, ALL_MODELS
+ from config import setup_database
+
+ config = MySQLConnectionConfig(**config_dict)
+ setup_backend = MySQLBackend(config)
+ setup_backend.connect()
+ setup_database(setup_backend, drop_existing=True)
+
+ # Seed data
+ for model in ALL_MODELS:
+ model.__backend__ = setup_backend
+ for i in range(20):
+ User(username=f"seed_{i}", email=f"seed{i}@test.com", balance=100.0).save()
+ for model in ALL_MODELS:
+ model.__backend__ = None
+ setup_backend.disconnect()
+
+ print("Starting endurance test...")
+ start_time = time.time()
+
+ for round_num in range(1, num_rounds + 1):
+ round_start = time.time()
+
+ with ProcessPoolExecutor(max_workers=num_workers) as executor:
+ futures = [
+ executor.submit(
+ worker_task,
+ i, round_num, ops_per_round, config_dict, use_isolated
+ )
+ for i in range(num_workers)
+ ]
+
+ round_ops = 0
+ round_errors = 0
+
+ for future in as_completed(futures):
+ try:
+ result = future.result(timeout=120)
+ stats.total_operations += result.operations
+ stats.total_errors += result.errors
+ round_ops += result.operations
+ round_errors += result.errors
+
+ for error_type, count in result.error_types.items():
+ stats.error_breakdown[error_type] = stats.error_breakdown.get(error_type, 0) + count
+ if error_type == "connection":
+ stats.connection_issues += count
+
+ except Exception as e:
+ stats.total_errors += 1
+ stats.error_breakdown["executor"] = stats.error_breakdown.get("executor", 0) + 1
+
+ round_duration = time.time() - round_start
+ stats.rounds_completed = round_num
+ stats.ops_per_round.append(round_ops)
+ stats.errors_per_round.append(round_errors)
+
+ # Progress report
+ if round_num % 5 == 0 or round_num == num_rounds:
+ elapsed = time.time() - start_time
+ avg_ops = stats.total_operations / round_num if round_num > 0 else 0
+ print(f" Round {round_num}/{num_rounds}: "
+ f"{round_ops} ops, {round_errors} errors, "
+ f"{round_duration:.2f}s, "
+ f"Total: {stats.total_operations} ops, {stats.total_errors} errors, "
+ f"Elapsed: {elapsed:.1f}s")
+
+ stats.total_duration = time.time() - start_time
+
+ # Print summary
+ print(f"\n{'='*70}")
+ print("Endurance Test Results:")
+ print(f"{'='*70}")
+ print(f" Pattern: {pattern_name}")
+ print(f" Total duration: {stats.total_duration:.2f}s")
+ print(f" Rounds completed: {stats.rounds_completed}")
+ print(f" Total operations: {stats.total_operations}")
+ print(f" Total errors: {stats.total_errors}")
+ print(f" Connection issues: {stats.connection_issues}")
+ print(f" Operations per second: {stats.total_operations/stats.total_duration:.2f}")
+ print(f" Success rate: {(1 - stats.total_errors/(stats.total_operations+1))*100:.2f}%")
+
+ if stats.error_breakdown:
+ print(f"\n Error breakdown:")
+ for error_type, count in sorted(stats.error_breakdown.items()):
+ print(f" {error_type}: {count}")
+
+ # Performance trend
+ if len(stats.ops_per_round) > 1:
+ first_half = stats.ops_per_round[:len(stats.ops_per_round)//2]
+ second_half = stats.ops_per_round[len(stats.ops_per_round)//2:]
+ avg_first = sum(first_half) / len(first_half)
+ avg_second = sum(second_half) / len(second_half)
+ trend = "stable" if abs(avg_second - avg_first) / avg_first < 0.1 else \
+ "degrading" if avg_second < avg_first else "improving"
+ print(f"\n Performance trend: {trend}")
+ print(f" First half avg: {avg_first:.0f} ops/round")
+ print(f" Second half avg: {avg_second:.0f} ops/round")
+
+ print(f"{'='*70}\n")
+
+ # Cleanup
+ try:
+ cleanup_backend = MySQLBackend(config)
+ cleanup_backend.connect()
+ cleanup_backend.execute("SET FOREIGN_KEY_CHECKS = 0")
+ for table in ['comments', 'orders', 'posts', 'users']:
+ cleanup_backend.execute(f"DROP TABLE IF EXISTS `{table}`")
+ cleanup_backend.execute("SET FOREIGN_KEY_CHECKS = 1")
+ cleanup_backend.disconnect()
+ except:
+ pass
+
+ return stats
+
+
+def main():
+ """Main entry point."""
+ parser = argparse.ArgumentParser(description="Worker Isolation Endurance Test")
+ parser.add_argument(
+ "--pattern",
+ choices=["isolated", "shared", "both"],
+ default="isolated",
+ help="Connection pattern to test"
+ )
+ parser.add_argument(
+ "--workers",
+ type=int,
+ default=4,
+ help="Number of worker processes"
+ )
+ parser.add_argument(
+ "--ops-per-round",
+ type=int,
+ default=100,
+ help="Operations per worker per round"
+ )
+ parser.add_argument(
+ "--rounds",
+ type=int,
+ default=10,
+ help="Number of rounds to run"
+ )
+ args = parser.parse_args()
+
+ print("\n" + "="*70)
+ print("Worker Isolation Experiment - Endurance Test")
+ print("="*70)
+ print(f"\nConfiguration:")
+ print(f" Pattern: {args.pattern}")
+ print(f" Workers: {args.workers}")
+ print(f" Operations per round: {args.ops_per_round}")
+ print(f" Rounds: {args.rounds}")
+ print("="*70)
+
+ # Load config
+ try:
+ config = load_scenario_config()
+ print(f"\nUsing scenario: {os.getenv('MYSQL_SCENARIO', 'default')}")
+ except Exception as e:
+ print(f"Error loading config: {e}")
+ return
+
+ config_dict = {
+ "host": config.host,
+ "port": config.port,
+ "database": config.database,
+ "username": config.username,
+ "password": config.password,
+ "charset": config.charset,
+ "autocommit": config.autocommit,
+ }
+
+ results = {}
+
+ if args.pattern in ["isolated", "both"]:
+ print("\n" + "="*70)
+ print("Running ISOLATED connection test...")
+ print("="*70)
+ results["isolated"] = run_endurance_test(
+ config_dict, args.workers, args.ops_per_round, args.rounds,
+ use_isolated=True
+ )
+
+ if args.pattern in ["shared", "both"]:
+ print("\n" + "="*70)
+ print("Running SHARED connection test (anti-pattern)...")
+ print("="*70)
+ results["shared"] = run_endurance_test(
+ config_dict, args.workers, args.ops_per_round, args.rounds,
+ use_isolated=False
+ )
+
+ # Comparison
+ if len(results) == 2:
+ print("\n" + "="*70)
+ print("Comparison Summary:")
+ print("="*70)
+ iso = results["isolated"]
+ shr = results["shared"]
+
+ print(f"\n{'Metric':<30} {'Isolated':>15} {'Shared':>15}")
+ print("-" * 60)
+ print(f"{'Total operations':<30} {iso.total_operations:>15} {shr.total_operations:>15}")
+ print(f"{'Total errors':<30} {iso.total_errors:>15} {shr.total_errors:>15}")
+ print(f"{'Connection issues':<30} {iso.connection_issues:>15} {shr.connection_issues:>15}")
+ print(f"{'Success rate':<30} {(1-iso.total_errors/(iso.total_operations+1))*100:>14.2f}% {(1-shr.total_errors/(shr.total_operations+1))*100:>14.2f}%")
+ print(f"{'Ops/second':<30} {iso.total_operations/iso.total_duration:>15.2f} {shr.total_operations/shr.total_duration:>15.2f}")
+
+ print("\nConclusion:")
+ if iso.total_errors < shr.total_errors:
+ print(" โ
Isolated connections are MORE STABLE than shared connections")
+ elif iso.total_errors > shr.total_errors:
+ print(" โ ๏ธ Unexpected: Shared connections performed better (investigate!)")
+ else:
+ print(" โ๏ธ Both patterns showed similar error rates")
+
+ print("="*70)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/examples/chapter_05_worker_isolation/models.py b/docs/examples/chapter_05_worker_isolation/models.py
new file mode 100644
index 00000000..6fd8ec7f
--- /dev/null
+++ b/docs/examples/chapter_05_worker_isolation/models.py
@@ -0,0 +1,82 @@
+# docs/examples/worker_isolation_experiment/models.py
+"""
+Model definitions for Worker isolation experiment.
+
+These models are designed to be compatible with the MySQL schema files
+used in the testsuite.
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import ClassVar, Optional
+
+from pydantic import Field, EmailStr
+
+from rhosocial.activerecord.model import ActiveRecord
+from rhosocial.activerecord.base.field_proxy import FieldProxy
+from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin
+from rhosocial.activerecord.relation import HasMany, BelongsTo
+
+
+class User(IntegerPKMixin, TimestampMixin, ActiveRecord):
+ """User model for isolation experiment."""
+ c: ClassVar[FieldProxy] = FieldProxy()
+ __table_name__ = "users"
+
+ id: Optional[int] = None
+ username: str
+ email: EmailStr
+ age: Optional[int] = Field(None, ge=0, le=100)
+ balance: float = 0.0
+ is_active: bool = True
+
+ orders: ClassVar[HasMany['Order']] = HasMany(foreign_key='user_id', inverse_of='user')
+ posts: ClassVar[HasMany['Post']] = HasMany(foreign_key='user_id', inverse_of='user')
+
+
+class Order(IntegerPKMixin, TimestampMixin, ActiveRecord):
+ """Order model for isolation experiment."""
+ c: ClassVar[FieldProxy] = FieldProxy()
+ __table_name__ = "orders"
+
+ id: Optional[int] = None
+ user_id: int
+ order_number: str
+ total_amount: Decimal = Field(default=Decimal('0'))
+ status: str = 'pending'
+
+ user: ClassVar[BelongsTo['User']] = BelongsTo(foreign_key='user_id', inverse_of='orders')
+
+
+class Post(IntegerPKMixin, TimestampMixin, ActiveRecord):
+ """Post model for isolation experiment."""
+ c: ClassVar[FieldProxy] = FieldProxy()
+ __table_name__ = "posts"
+
+ id: Optional[int] = None
+ user_id: int
+ title: str
+ content: str = ''
+ status: str = 'published'
+
+ user: ClassVar[BelongsTo['User']] = BelongsTo(foreign_key='user_id', inverse_of='posts')
+
+
+class Comment(IntegerPKMixin, TimestampMixin, ActiveRecord):
+ """Comment model for isolation experiment."""
+ c: ClassVar[FieldProxy] = FieldProxy()
+ __table_name__ = "comments"
+
+ id: Optional[int] = None
+ user_id: int
+ post_id: int
+ content: str
+ is_hidden: bool = False
+
+ user: ClassVar[BelongsTo['User']] = BelongsTo(foreign_key='user_id', inverse_of='comments')
+ post: ClassVar[BelongsTo['Post']] = BelongsTo(foreign_key='post_id', inverse_of='comments')
+
+
+# Model list for easy iteration
+ALL_MODELS = [User, Order, Post, Comment]
diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/README.md b/docs/examples/chapter_08_scenarios/parallel_workers/README.md
index f1083949..e886a43a 100644
--- a/docs/examples/chapter_08_scenarios/parallel_workers/README.md
+++ b/docs/examples/chapter_08_scenarios/parallel_workers/README.md
@@ -220,3 +220,103 @@ A: ไธไธๅฎใๆญป้ไพ่ตๆถๅบ๏ผ`sleep(0.01)` ๅทฒๅขๅคง็ชๅฃ๏ผๅค่ฟ่กๅ
**Q: ไธบไปไน psycopg ไธๆฏๆ free-threaded Python๏ผ**
A: `psycopg` ็ๆฌ 3 ็ C ๆฉๅฑๆจกๅๅฐๆช้้
Python 3.13t/3.14t ็ free-threaded ๆๅปบใๅฆ้ free-threaded ๆฏๆ๏ผ่ฏทไฝฟ็จ MySQL ๆ SQLite ๅ็ซฏใ
+
+## Windows ๆต่ฏ็ปๆ
+
+ไปฅไธๆต่ฏ็ปๆๅจ Windows 11 ไธ้ช่ฏ้่ฟ๏ผ
+
+### ๆต่ฏ็ฏๅข
+
+| ็ปไปถ | ็ๆฌ |
+|------|------|
+| ๆไฝ็ณป็ป | Windows 11 Pro 25H2 (Build 26200) |
+| Python | 3.8.10 / 3.14.3 |
+| pytest | 8.3.5 (Python 3.8) / 8.4.2 (Python 3.14) |
+| PostgreSQL | 17.5 |
+
+### exp1: ๅบ็กๅค่ฟ็จ็จๆณ
+
+```
+=== Experiment A: Serial Processing (Single Process, Synchronous ActiveRecord) ===
+Serial completed 20 posts, time: 0.910s
+
+=== Experiment B: 4 Processes Parallel (Synchronous ActiveRecord) ===
+Parallel completed 20 posts, time: 2.998s
+Speedup: 0.3x (theoretical max 4x)
+
+=== Experiment C: 4 Processes Parallel (Asynchronous ActiveRecord) ===
+Parallel completed 20 posts, time: 3.163s
+Speedup: 0.3x (theoretical max 4x)
+```
+
+### exp2: PostgreSQL ๅผๆญฅ็น็นไธๅค่ฟ็จๅนถๅๅๅ
ฅ
+
+```
+Part 1: Same process sync serial vs async sequential (single-connection limitation)
+Sync serial: 0.204s, async sequential: 0.218s
+
+Part 2: Multiprocess concurrent writes (PostgreSQL MVCC)
+Sync multiprocess (4 processes): 1.133s
+Async multiprocess (4 processes): 1.264s
+```
+
+### exp3: ๆญป้ๆผ็คบ
+
+```
+Results: 2 Workers succeeded, 2 deadlocked, 0 other errors
+โ ๏ธ Found 2 Workers rolled back due to deadlock by PostgreSQL!
+Deadlock error code: SQLSTATE 40P01
+```
+
+### exp4: ๆญฃ็กฎ็ๅนถ่กๆนๆก
+
+```
+Solution A: Data Partitioning
+Sync: 1.170s, Async: 1.263s โ No duplicates
+
+Solution B: Atomic Claiming
+Sync: 1.812s, Async: 1.446s โ No duplicates
+
+Solution C: Atomic + Deadlock Retry (Production Recommended)
+Sync: 2.501s โ No duplicates
+```
+
+### exp5: ๅค็บฟ็จ่ญฆๅ
+
+```
+Scenario 1: Shared __backend__
+โ ๏ธ Undefined behavior detected
+
+Scenario 2: Each thread calls configure()
+Actual backend instance count: 1 (Expected 4)
+โ All threads share the same backend instance
+```
+
+### Windows ็นๆฎ่ฏดๆ
+
+ๅจ Windows ไธ่ฟ่กๅผๆญฅๆต่ฏๆถ๏ผ้่ฆ่ฎพ็ฝฎ `WindowsSelectorEventLoopPolicy`๏ผ
+
+```python
+import asyncio
+import sys
+
+def worker_async(post_ids: list) -> int:
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+ return asyncio.run(async_worker_main(post_ids))
+
+# ไธป็จๅบไธญไน้่ฆ่ฎพ็ฝฎ๏ผๅฆๆๅจไธป่ฟ็จไธญไฝฟ็จๅผๆญฅ๏ผ
+def main():
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+ # ... ๅ
ถไปไปฃ็
+```
+
+่ฟๆฏๅ ไธบ Windows ้ป่ฎคไฝฟ็จ `ProactorEventLoop`๏ผ่ `psycopg` ็ๅผๆญฅๅ็ซฏ้่ฆ `SelectorEventLoop`ใ
+
+> **้่ฆ**: ๅฆๆๆช่ฎพ็ฝฎๆญฃ็กฎ็ event loop policy๏ผๅฐๆถๅฐไปฅไธ้่ฏฏ๏ผ
+> ```
+> Psycopg cannot use the 'ProactorEventLoop' to run in async mode.
+> Please use a compatible event loop, for instance by setting
+> 'asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())'
+> ```
diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/exp1_basic_multiprocess.py b/docs/examples/chapter_08_scenarios/parallel_workers/exp1_basic_multiprocess.py
index f4eb3425..a89b3e52 100644
--- a/docs/examples/chapter_08_scenarios/parallel_workers/exp1_basic_multiprocess.py
+++ b/docs/examples/chapter_08_scenarios/parallel_workers/exp1_basic_multiprocess.py
@@ -214,6 +214,8 @@ async def async_worker_main(post_ids: list) -> int:
def worker_async(post_ids: list) -> int:
"""Child process entry: Each process independently creates its own event loop"""
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
return asyncio.run(async_worker_main(post_ids))
diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/exp2_postgres_async_advantage.py b/docs/examples/chapter_08_scenarios/parallel_workers/exp2_postgres_async_advantage.py
index 000a1dc7..c5beca4b 100644
--- a/docs/examples/chapter_08_scenarios/parallel_workers/exp2_postgres_async_advantage.py
+++ b/docs/examples/chapter_08_scenarios/parallel_workers/exp2_postgres_async_advantage.py
@@ -167,6 +167,8 @@ async def _async_worker_main(post_ids: list) -> tuple:
def async_worker(post_ids: list) -> tuple:
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
return asyncio.run(_async_worker_main(post_ids))
@@ -176,6 +178,8 @@ def async_worker(post_ids: list) -> tuple:
def main() -> None:
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
all_ids = fetch_all_post_ids()
if not all_ids:
print("โ ๏ธ No post data, please run setup_db.py first")
diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/exp4_partition_correct.py b/docs/examples/chapter_08_scenarios/parallel_workers/exp4_partition_correct.py
index 2dbe53e8..872e4afc 100644
--- a/docs/examples/chapter_08_scenarios/parallel_workers/exp4_partition_correct.py
+++ b/docs/examples/chapter_08_scenarios/parallel_workers/exp4_partition_correct.py
@@ -158,6 +158,8 @@ async def _async_partition_main(user_ids: list) -> int:
def worker_partition_async(user_ids: list) -> int:
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
return asyncio.run(_async_partition_main(user_ids))
@@ -251,6 +253,8 @@ async def _async_atomic_main(worker_id: int) -> int:
def worker_atomic_async(worker_id: int) -> int:
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
return asyncio.run(_async_atomic_main(worker_id))
diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py b/docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py
new file mode 100644
index 00000000..689bfda4
--- /dev/null
+++ b/docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py
@@ -0,0 +1,361 @@
+# exp6_backend_group_pool.py - BackendGroup and BackendPool for PostgreSQL
+# docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py
+"""
+Experiment objective: Compare BackendGroup + backend.context() vs BackendPool
+for thread-safe PostgreSQL operations.
+
+Key points:
+- PostgreSQL (psycopg) has threadsafety=2 (connections can be shared across threads)
+- BackendPool is SUITABLE for PostgreSQL - recommended for thread-safe operations
+- BackendGroup + backend.context() also works, but has race condition limitations
+
+PostgreSQL-specific notes:
+- psycopg's connections are thread-safe (threadsafety=2)
+- BackendPool can be used for connection pooling
+- Each thread can acquire its own connection from the pool
+
+How to run:
+python setup_db.py # Initialize database first
+python exp6_backend_group_pool.py
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import time
+from concurrent.futures import ThreadPoolExecutor
+
+_src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "src"))
+if _src not in sys.path:
+ sys.path.insert(0, _src)
+
+from rhosocial.activerecord.connection import BackendGroup
+from rhosocial.activerecord.connection.pool import PoolConfig, BackendPool
+from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, PostgresConnectionConfig
+
+from config_loader import load_config
+from models import Comment, Post, User
+
+NUM_THREADS = 4
+
+
+def init_test_data() -> None:
+ """Create test tables and data if not exists"""
+ config = load_config()
+ backend = PostgresBackend(connection_config=config)
+ backend.connect()
+
+ backend.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(64) NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ backend.execute("""
+ CREATE TABLE IF NOT EXISTS posts (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ title VARCHAR(255) NOT NULL,
+ body TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'published',
+ view_count INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ backend.execute("""
+ CREATE TABLE IF NOT EXISTS comments (
+ id SERIAL PRIMARY KEY,
+ post_id INTEGER NOT NULL REFERENCES posts(id),
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ body TEXT,
+ is_approved BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ result = backend.execute("SELECT COUNT(*) as cnt FROM users WHERE username = 'bot'")
+ if result.data[0]['cnt'] == 0:
+ backend.execute("""
+ INSERT INTO users (username, email, is_active)
+ VALUES ('bot', 'bot@example.com', TRUE)
+ """)
+
+ result = backend.execute("SELECT COUNT(*) as cnt FROM posts")
+ if result.data[0]['cnt'] < 20:
+ user_result = backend.execute("SELECT id FROM users LIMIT 1")
+ user_id = user_result.data[0]['id'] if user_result.data else 1
+ for i in range(20):
+ backend.execute(f"""
+ INSERT INTO posts (user_id, title, body, status)
+ VALUES ({user_id}, 'Test Post {i}', 'Body of test post {i}', 'published')
+ """)
+
+ backend.disconnect()
+
+
+def reset_exp_data() -> None:
+ """Delete comments inserted by this experiment"""
+ config = load_config()
+ try:
+ Comment.configure(config, PostgresBackend)
+ to_delete = Comment.query().where(Comment.c.body.like("[exp6]%")).all()
+ for c in to_delete:
+ c.delete()
+ Comment.backend().disconnect()
+ except Exception:
+ pass
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Scenario 1: BackendPool (โ
Thread-safe, RECOMMENDED for PostgreSQL)
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def thread_worker_pool(pool: BackendPool, thread_id: int, post_ids: list) -> dict:
+ """
+ Thread-safe worker using BackendPool.
+
+ Each thread:
+ 1. Acquires a connection from the pool
+ 2. Performs database operations
+ 3. Releases connection back to pool
+ """
+ results = {"thread_id": thread_id, "success": 0, "errors": []}
+
+ try:
+ with pool.connection() as backend:
+ User.__backend__ = backend
+ Post.__backend__ = backend
+ Comment.__backend__ = backend
+
+ bot = User.query().order_by(User.c.id).one()
+ if bot is None:
+ results["errors"].append("No bot user found")
+ return results
+
+ for post_id in post_ids:
+ try:
+ post = Post.find_one(post_id)
+ if post is None:
+ continue
+
+ comment = Comment(
+ post_id=post.id,
+ user_id=bot.id,
+ body=f"[exp6-pool] Thread {thread_id} comment on post {post_id}",
+ is_approved=True
+ )
+ comment.save()
+ results["success"] += 1
+
+ except Exception as e:
+ results["errors"].append(f"Post {post_id}: {str(e)}")
+
+ except Exception as e:
+ results["errors"].append(f"Pool error: {str(e)}")
+
+ return results
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Scenario 2: BackendGroup + backend.context() (โ ๏ธ Has race conditions)
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def thread_worker_group(group: BackendGroup, thread_id: int, post_ids: list) -> dict:
+ """
+ Worker using BackendGroup + backend.context().
+
+ Note: This has race conditions because all threads share the same backend instance,
+ and context() modifies the same connection state.
+ """
+ backend = group.get_backend()
+ results = {"thread_id": thread_id, "success": 0, "errors": []}
+
+ try:
+ with backend.context():
+ bot = User.query().order_by(User.c.id).one()
+ if bot is None:
+ results["errors"].append("No bot user found")
+ return results
+
+ for post_id in post_ids:
+ try:
+ post = Post.find_one(post_id)
+ if post is None:
+ continue
+
+ comment = Comment(
+ post_id=post.id,
+ user_id=bot.id,
+ body=f"[exp6-group] Thread {thread_id} comment on post {post_id}",
+ is_approved=True
+ )
+ comment.save()
+ results["success"] += 1
+
+ except Exception as e:
+ results["errors"].append(f"Post {post_id}: {str(e)}")
+
+ except Exception as e:
+ results["errors"].append(f"Context error: {str(e)}")
+
+ return results
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Main
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def main():
+ print("=" * 60)
+ print("BackendGroup vs BackendPool Thread Safety Demo (PostgreSQL)")
+ print("=" * 60)
+
+ print("\nInitializing test data...")
+ init_test_data()
+
+ config = load_config()
+
+ print("Resetting experiment data...")
+ reset_exp_data()
+
+ # Get post IDs
+ Post.configure(config, PostgresBackend)
+ posts = Post.query().limit(20).all()
+ all_post_ids = [p.id for p in posts if p.id is not None]
+ Post.backend().disconnect()
+
+ chunk_size = max(1, len(all_post_ids) // NUM_THREADS)
+ chunks = [all_post_ids[i:i + chunk_size] for i in range(0, len(all_post_ids), chunk_size)]
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # Scenario 1: BackendPool
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ print("\n" + "=" * 60)
+ print("Scenario 1: BackendPool (โ
Thread-safe, RECOMMENDED)")
+ print("=" * 60)
+
+ pool_config = PoolConfig(
+ min_size=2,
+ max_size=10,
+ backend_factory=lambda: PostgresBackend(connection_config=config)
+ )
+ pool = BackendPool(pool_config)
+
+ start_time = time.time()
+ results_pool = []
+
+ with ThreadPoolExecutor(max_workers=NUM_THREADS) as executor:
+ futures = []
+ for i, chunk in enumerate(chunks[:NUM_THREADS]):
+ future = executor.submit(thread_worker_pool, pool, i, chunk)
+ futures.append(future)
+
+ for future in futures:
+ results_pool.append(future.result())
+
+ pool_time = time.time() - start_time
+ pool.close()
+
+ print(f"\nResults (BackendPool):")
+ total_success = sum(r["success"] for r in results_pool)
+ total_errors = sum(len(r["errors"]) for r in results_pool)
+ for r in results_pool:
+ if r["errors"]:
+ print(f" Thread {r['thread_id']}: {r['success']} success, {len(r['errors'])} errors")
+ else:
+ print(f" Thread {r['thread_id']}: {r['success']} success โ")
+
+ print(f"\nTotal: {total_success} comments created, {total_errors} errors")
+ print(f"Time: {pool_time:.3f}s")
+
+ # Reset for next scenario
+ reset_exp_data()
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # Scenario 2: BackendGroup + backend.context()
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ print("\n" + "=" * 60)
+ print("Scenario 2: BackendGroup + backend.context() (โ ๏ธ Race conditions)")
+ print("=" * 60)
+
+ group = BackendGroup(
+ name="exp6_group",
+ models=[User, Post, Comment],
+ config=config,
+ backend_class=PostgresBackend
+ )
+ group.configure()
+
+ start_time = time.time()
+ results_group = []
+
+ with ThreadPoolExecutor(max_workers=NUM_THREADS) as executor:
+ futures = []
+ for i, chunk in enumerate(chunks[:NUM_THREADS]):
+ future = executor.submit(thread_worker_group, group, i, chunk)
+ futures.append(future)
+
+ for future in futures:
+ results_group.append(future.result())
+
+ group_time = time.time() - start_time
+ group.disconnect()
+
+ print(f"\nResults (BackendGroup):")
+ total_success = sum(r["success"] for r in results_group)
+ total_errors = sum(len(r["errors"]) for r in results_group)
+ for r in results_group:
+ if r["errors"]:
+ print(f" Thread {r['thread_id']}: {r['success']} success, {len(r['errors'])} errors")
+ else:
+ print(f" Thread {r['thread_id']}: {r['success']} success")
+
+ print(f"\nTotal: {total_success} comments created, {total_errors} errors")
+ print(f"Time: {group_time:.3f}s")
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # Summary
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ print("\n" + "=" * 60)
+ print("Summary")
+ print("=" * 60)
+ print(f"BackendPool: {sum(r['success'] for r in results_pool)} success, {sum(len(r['errors']) for r in results_pool)} errors")
+ print(f"BackendGroup: {sum(r['success'] for r in results_group)} success, {sum(len(r['errors']) for r in results_group)} errors")
+
+ print("\n" + "=" * 60)
+ print("Conclusion:")
+ print("=" * 60)
+ print("""
+PostgreSQL (psycopg) has threadsafety=2 - BackendPool IS RECOMMENDED:
+- Each thread acquires its own connection from the pool
+- Connection pool manages connection lifecycle efficiently
+- True thread-safe concurrent database operations
+
+BackendGroup + backend.context() has limitations:
+- All threads share the same backend instance
+- context() modifies the same connection state
+- Race conditions occur when multiple threads call context() simultaneously
+
+Key principle:
+- For PostgreSQL: Use BackendPool for thread-safe operations
+- For MySQL (threadsafety=1): Use multiprocessing, NOT threading
+- '้็จ้่ฟใ็จๅฎๅณๆญ' (Connect on demand, disconnect after use)
+ - BackendPool: Pool manages connection lifecycle
+ - BackendGroup: Each thread needs its own group instance for true isolation
+""")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/examples/chapter_10_fastapi/README.md b/docs/examples/chapter_10_fastapi/README.md
new file mode 100644
index 00000000..c4deffc7
--- /dev/null
+++ b/docs/examples/chapter_10_fastapi/README.md
@@ -0,0 +1,143 @@
+# FastAPI + PostgreSQL + Async Backend Example
+# docs/examples/chapter_10_fastapi/README.md
+
+This example demonstrates connection pooling in FastAPI async applications using BackendPool.
+
+## Core Principle
+
+**Connection pooling with BackendPool** - PostgreSQL supports `threadsafety=2`, making BackendPool the recommended approach for thread-safe connection management.
+
+## Why BackendPool for PostgreSQL?
+
+### PostgreSQL's Advantage
+- `psycopg` has `threadsafety=2`
+- Connections CAN be shared across threads
+- BackendPool provides efficient connection reuse
+- Reduced connection creation overhead
+
+### Connection Pool Benefits
+- Connection reuse across requests (efficient)
+- Pool manages connection lifecycle automatically
+- Suitable for high-concurrency scenarios
+- True thread-safe connection management
+
+## File Structure
+
+```
+chapter_10_fastapi/
+โโโ README.md # This file
+โโโ config_loader.py # Database configuration
+โโโ models.py # Async model definitions
+โโโ database.py # Connection pool manager
+โโโ app.py # FastAPI application
+```
+
+## Key Components
+
+### 1. Connection Pool Initialization (`database.py`)
+
+```python
+# Global connection pool (created at startup)
+_pool: AsyncBackendPool = None
+
+async def init_pool():
+ global _pool
+
+ pool_config = PoolConfig(
+ min_size=2, # Minimum connections
+ max_size=10, # Maximum connections
+ backend_factory=lambda: AsyncPostgresBackend(connection_config=config)
+ )
+
+ _pool = AsyncBackendPool(pool_config)
+```
+
+### 2. Request-Level Connection Acquisition (`database.py`)
+
+```python
+@asynccontextmanager
+async def get_request_db():
+ # Acquire connection from pool
+ async with _pool.connection() as backend:
+ # Bind models to this connection
+ AsyncUser.__backend__ = backend
+ AsyncPost.__backend__ = backend
+ AsyncComment.__backend__ = backend
+
+ yield backend
+ # Connection automatically returned to pool
+```
+
+### 3. Dependency Injection (`app.py`)
+
+```python
+@app.get("/users/{user_id}")
+async def get_user(user_id: int, db=Depends(get_request_db)):
+ user = await AsyncUser.find_one(user_id)
+ return {"user": user.to_dict()}
+```
+
+## Running
+
+```bash
+cd docs/examples/chapter_10_fastapi
+uvicorn app:app --reload
+```
+
+## Testing
+
+```bash
+# List users
+curl http://localhost:8000/users
+
+# Create user
+curl -X POST http://localhost:8000/users \
+ -H "Content-Type: application/json" \
+ -d '{"username": "test", "email": "test@example.com"}'
+
+# Get user
+curl http://localhost:8000/users/1
+
+# Get user's posts
+curl http://localhost:8000/users/1/posts
+
+# Create post
+curl -X POST http://localhost:8000/posts \
+ -H "Content-Type: application/json" \
+ -d '{"user_id": 1, "title": "My First Post", "body": "Hello World"}'
+
+# Add comment
+curl -X POST http://localhost:8000/posts/1/comments \
+ -H "Content-Type: application/json" \
+ -d '{"user_id": 1, "body": "Great post!"}'
+```
+
+## Comparison: MySQL vs PostgreSQL
+
+| Aspect | MySQL | PostgreSQL |
+|--------|-------|------------|
+| threadsafety | 1 | 2 |
+| BackendPool | โ Not supported | โ
Recommended |
+| Recommended approach | Request-level BackendGroup | BackendPool |
+| Connection reuse | Per-request (create/disconnect) | Pool-managed reuse |
+| Performance | Acceptable overhead | Optimized for pooling |
+
+## Use Cases
+
+### Suitable
+- Web API applications (high concurrency)
+- Real-time applications
+- Microservices
+- Any scenario requiring connection pooling
+
+### Also Suitable
+- WebSocket connections (with proper pool sizing)
+- Batch processing (with connection pooling)
+- Background tasks (with pool access)
+
+## Performance
+
+- Connection reuse reduces creation overhead
+- Pool manages connection health automatically
+- Configurable pool size for different workloads
+- Optimal for high-concurrency scenarios
diff --git a/docs/examples/chapter_10_fastapi/app.py b/docs/examples/chapter_10_fastapi/app.py
new file mode 100644
index 00000000..a3396506
--- /dev/null
+++ b/docs/examples/chapter_10_fastapi/app.py
@@ -0,0 +1,325 @@
+# app.py - FastAPI Application with PostgreSQL Async Backend
+# docs/examples/chapter_10_fastapi/app.py
+"""
+FastAPI application example: Connection pooling with BackendPool.
+
+This example demonstrates thread-safe connection management in FastAPI
+using PostgreSQL's connection pool.
+
+Key differences from MySQL example:
+- PostgreSQL (psycopg) has threadsafety=2
+- BackendPool IS suitable for PostgreSQL
+- Connection pooling for efficient resource reuse
+- Recommended for high-concurrency scenarios
+
+Run:
+ cd docs/examples/chapter_10_fastapi
+ uvicorn app:app --reload
+
+Test:
+ # List users
+ curl http://localhost:8000/users
+
+ # Create user
+ curl -X POST http://localhost:8000/users \
+ -H "Content-Type: application/json" \
+ -d '{"username": "test", "email": "test@example.com"}'
+
+ # Get single user
+ curl http://localhost:8000/users/1
+
+ # Get user's posts
+ curl http://localhost:8000/users/1/posts
+
+ # Create post
+ curl -X POST http://localhost:8000/posts \
+ -H "Content-Type: application/json" \
+ -d '{"user_id": 1, "title": "My First Post", "body": "Hello World"}'
+
+ # Get post details
+ curl http://localhost:8000/posts/1
+
+ # Add comment
+ curl -X POST http://localhost:8000/posts/1/comments \
+ -H "Content-Type: application/json" \
+ -d '{"user_id": 1, "body": "Great post!"}'
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+
+_src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "src"))
+if _src not in sys.path:
+ sys.path.insert(0, _src)
+
+from contextlib import asynccontextmanager
+from typing import List, Optional
+
+from fastapi import FastAPI, Depends, HTTPException, status
+from fastapi.encoders import jsonable_encoder
+from pydantic import BaseModel, EmailStr
+
+from config_loader import load_config
+from database import get_request_db, init_database, init_pool, close_pool
+from models import AsyncUser, AsyncPost, AsyncComment
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Pydantic Schemas (Request/Response Models)
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+class UserCreate(BaseModel):
+ username: str
+ email: EmailStr
+
+
+class UserResponse(BaseModel):
+ id: int
+ username: str
+ email: str
+ is_active: bool
+
+
+class PostCreate(BaseModel):
+ user_id: int
+ title: str
+ body: str = ""
+
+
+class PostResponse(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ body: str
+ status: str
+ view_count: int
+
+
+class CommentCreate(BaseModel):
+ user_id: int
+ body: str
+
+
+class CommentResponse(BaseModel):
+ id: int
+ post_id: int
+ user_id: int
+ body: str
+ is_approved: bool
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# FastAPI Application
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """Application lifecycle management."""
+ # Startup: Initialize database and connection pool
+ await init_database()
+ await init_pool()
+ yield
+ # Shutdown: Close connection pool
+ await close_pool()
+
+
+app = FastAPI(
+ title="PostgreSQL + FastAPI + Async ActiveRecord Example",
+ description="Connection pooling example with BackendPool",
+ version="1.0.0",
+ lifespan=lifespan
+)
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# User Endpoints
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.get("/users", response_model=List[UserResponse])
+async def list_users(db=Depends(get_request_db)):
+ """List all users."""
+ users = await AsyncUser.query().all()
+ return [UserResponse(
+ id=u.id,
+ username=u.username,
+ email=u.email,
+ is_active=u.is_active
+ ) for u in users]
+
+
+@app.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+async def create_user(user_data: UserCreate, db=Depends(get_request_db)):
+ """Create a new user."""
+ user = AsyncUser(
+ username=user_data.username,
+ email=user_data.email,
+ is_active=True
+ )
+ await user.save()
+
+ return UserResponse(
+ id=user.id,
+ username=user.username,
+ email=user.email,
+ is_active=user.is_active
+ )
+
+
+@app.get("/users/{user_id}", response_model=UserResponse)
+async def get_user(user_id: int, db=Depends(get_request_db)):
+ """Get a single user by ID."""
+ user = await AsyncUser.find_one(user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ return UserResponse(
+ id=user.id,
+ username=user.username,
+ email=user.email,
+ is_active=user.is_active
+ )
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Post Endpoints
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.post("/posts", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
+async def create_post(post_data: PostCreate, db=Depends(get_request_db)):
+ """Create a new post."""
+ # Validate user exists
+ user = await AsyncUser.find_one(post_data.user_id)
+ if not user:
+ raise HTTPException(status_code=400, detail="User not found")
+
+ post = AsyncPost(
+ user_id=post_data.user_id,
+ title=post_data.title,
+ body=post_data.body,
+ status="draft"
+ )
+ await post.save()
+
+ return PostResponse(
+ id=post.id,
+ user_id=post.user_id,
+ title=post.title,
+ body=post.body,
+ status=post.status,
+ view_count=post.view_count
+ )
+
+
+@app.get("/posts", response_model=List[PostResponse])
+async def list_posts(db=Depends(get_request_db)):
+ """List all posts."""
+ posts = await AsyncPost.query().all()
+ return [PostResponse(
+ id=p.id,
+ user_id=p.user_id,
+ title=p.title,
+ body=p.body,
+ status=p.status,
+ view_count=p.view_count
+ ) for p in posts]
+
+
+@app.get("/posts/{post_id}", response_model=PostResponse)
+async def get_post(post_id: int, db=Depends(get_request_db)):
+ """Get a single post by ID."""
+ post = await AsyncPost.find_one(post_id)
+ if not post:
+ raise HTTPException(status_code=404, detail="Post not found")
+
+ return PostResponse(
+ id=post.id,
+ user_id=post.user_id,
+ title=post.title,
+ body=post.body,
+ status=post.status,
+ view_count=post.view_count
+ )
+
+
+@app.get("/users/{user_id}/posts", response_model=List[PostResponse])
+async def get_user_posts(user_id: int, db=Depends(get_request_db)):
+ """Get all posts by a user."""
+ user = await AsyncUser.find_one(user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Use relationship query
+ posts = await user.posts
+
+ return [PostResponse(
+ id=p.id,
+ user_id=p.user_id,
+ title=p.title,
+ body=p.body,
+ status=p.status,
+ view_count=p.view_count
+ ) for p in posts]
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Comment Endpoints
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.post("/posts/{post_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
+async def create_comment(post_id: int, comment_data: CommentCreate, db=Depends(get_request_db)):
+ """Add a comment to a post."""
+ # Validate post exists
+ post = await AsyncPost.find_one(post_id)
+ if not post:
+ raise HTTPException(status_code=404, detail="Post not found")
+
+ # Validate user exists
+ user = await AsyncUser.find_one(comment_data.user_id)
+ if not user:
+ raise HTTPException(status_code=400, detail="User not found")
+
+ comment = AsyncComment(
+ post_id=post_id,
+ user_id=comment_data.user_id,
+ body=comment_data.body,
+ is_approved=False
+ )
+ await comment.save()
+
+ return CommentResponse(
+ id=comment.id,
+ post_id=comment.post_id,
+ user_id=comment.user_id,
+ body=comment.body,
+ is_approved=comment.is_approved
+ )
+
+
+@app.get("/posts/{post_id}/comments", response_model=List[CommentResponse])
+async def get_post_comments(post_id: int, db=Depends(get_request_db)):
+ """Get all comments for a post."""
+ post = await AsyncPost.find_one(post_id)
+ if not post:
+ raise HTTPException(status_code=404, detail="Post not found")
+
+ # Use relationship query
+ comments = await post.comments
+
+ return [CommentResponse(
+ id=c.id,
+ post_id=c.post_id,
+ user_id=c.user_id,
+ body=c.body,
+ is_approved=c.is_approved
+ ) for c in comments]
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Run
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/docs/examples/chapter_10_fastapi/config_loader.py b/docs/examples/chapter_10_fastapi/config_loader.py
new file mode 100644
index 00000000..8b2b3ada
--- /dev/null
+++ b/docs/examples/chapter_10_fastapi/config_loader.py
@@ -0,0 +1,24 @@
+# config_loader.py - PostgreSQL Connection Configuration for FastAPI
+# docs/examples/chapter_10_fastapi/config_loader.py
+
+from __future__ import annotations
+
+import os
+import sys
+
+_src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "src"))
+if _src not in sys.path:
+ sys.path.insert(0, _src)
+
+from rhosocial.activerecord.backend.impl.postgres import PostgresConnectionConfig
+
+
+def load_config() -> PostgresConnectionConfig:
+ """Load PostgreSQL connection configuration from environment or defaults."""
+ return PostgresConnectionConfig(
+ host=os.environ.get("POSTGRES_HOST", "localhost"),
+ port=int(os.environ.get("POSTGRES_PORT", "5432")),
+ database=os.environ.get("POSTGRES_DATABASE", "test_db"),
+ username=os.environ.get("POSTGRES_USER", "postgres"),
+ password=os.environ.get("POSTGRES_PASSWORD", ""),
+ )
diff --git a/docs/examples/chapter_10_fastapi/database.py b/docs/examples/chapter_10_fastapi/database.py
new file mode 100644
index 00000000..58d671ac
--- /dev/null
+++ b/docs/examples/chapter_10_fastapi/database.py
@@ -0,0 +1,163 @@
+# database.py - Request-level Connection Management with BackendPool
+# docs/examples/chapter_10_fastapi/database.py
+"""
+Request-level database connection manager using BackendPool.
+
+Core principles:
+1. Use BackendPool for connection pooling (PostgreSQL supports threadsafety=2)
+2. Each request acquires connection from pool
+3. Connection returned to pool after use
+4. Efficient connection reuse across requests
+
+Design rationale:
+- PostgreSQL (psycopg) has threadsafety=2, connections CAN be shared across threads
+- BackendPool provides true thread-safe connection management
+- Connection pooling reduces connection creation overhead
+- Recommended approach for PostgreSQL + FastAPI
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from contextlib import asynccontextmanager
+
+_src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "src"))
+if _src not in sys.path:
+ sys.path.insert(0, _src)
+
+from fastapi import Request
+from rhosocial.activerecord.connection.pool import PoolConfig, AsyncBackendPool
+from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend
+
+from config_loader import load_config
+from models import AsyncUser, AsyncPost, AsyncComment
+
+
+# Global connection pool (created at startup)
+_pool: AsyncBackendPool = None
+
+
+async def init_pool():
+ """
+ Initialize connection pool at application startup.
+
+ Should be called once during application initialization.
+ """
+ global _pool
+
+ config = load_config()
+
+ pool_config = PoolConfig(
+ min_size=2, # Minimum connections in pool
+ max_size=10, # Maximum connections in pool
+ backend_factory=lambda: AsyncPostgresBackend(connection_config=config)
+ )
+
+ _pool = AsyncBackendPool(pool_config)
+ print(f"Connection pool initialized (min={pool_config.min_size}, max={pool_config.max_size})")
+
+
+async def close_pool():
+ """
+ Close connection pool at application shutdown.
+
+ Should be called once during application cleanup.
+ """
+ global _pool
+
+ if _pool:
+ await _pool.close()
+ _pool = None
+ print("Connection pool closed.")
+
+
+@asynccontextmanager
+async def get_request_db():
+ """
+ Request-level database connection manager using BackendPool.
+
+ Usage:
+ @app.get("/users/{user_id}")
+ async def get_user(user_id: int, db=Depends(get_request_db)):
+ user = await AsyncUser.find_one(user_id)
+ return {"user": user.to_dict()}
+
+ Mechanism:
+ 1. Acquire connection from pool
+ 2. Bind models to acquired connection
+ 3. Execute database operations
+ 4. Return connection to pool (automatic)
+
+ Performance considerations:
+ - Connection reuse across requests (efficient)
+ - Pool manages connection lifecycle
+ - Suitable for high-concurrency scenarios
+ """
+ global _pool
+
+ if _pool is None:
+ raise RuntimeError("Connection pool not initialized. Call init_pool() at startup.")
+
+ # Acquire connection from pool
+ async with _pool.connection() as backend:
+ # Bind models to this connection
+ AsyncUser.__backend__ = backend
+ AsyncPost.__backend__ = backend
+ AsyncComment.__backend__ = backend
+
+ yield backend
+ # Connection automatically returned to pool
+
+
+async def init_database():
+ """
+ Initialize database tables.
+
+ Called at application startup to create necessary tables.
+ """
+ config = load_config()
+ backend = AsyncPostgresBackend(connection_config=config)
+ await backend.connect()
+
+ # Create users table
+ await backend.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(64) NOT NULL UNIQUE,
+ email VARCHAR(255) NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ # Create posts table
+ await backend.execute("""
+ CREATE TABLE IF NOT EXISTS posts (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ title VARCHAR(255) NOT NULL,
+ body TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'draft',
+ view_count INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ # Create comments table
+ await backend.execute("""
+ CREATE TABLE IF NOT EXISTS comments (
+ id SERIAL PRIMARY KEY,
+ post_id INTEGER NOT NULL REFERENCES posts(id),
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ body TEXT,
+ is_approved BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ await backend.disconnect()
+ print("Database tables created successfully.")
diff --git a/docs/examples/chapter_10_fastapi/models.py b/docs/examples/chapter_10_fastapi/models.py
new file mode 100644
index 00000000..f2a07346
--- /dev/null
+++ b/docs/examples/chapter_10_fastapi/models.py
@@ -0,0 +1,73 @@
+# models.py - Async Model Definitions
+# docs/examples/chapter_10_fastapi/models.py
+
+from __future__ import annotations
+
+import os
+import sys
+
+_src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "src"))
+if _src not in sys.path:
+ sys.path.insert(0, _src)
+
+from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin
+from rhosocial.activerecord.model import AsyncActiveRecord
+from rhosocial.activerecord.relation import BelongsTo, HasMany
+from rhosocial.activerecord.relation.async_descriptors import (
+ AsyncHasManyDescriptor,
+ AsyncBelongsToDescriptor,
+)
+
+
+class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord):
+ """Async User model."""
+ __table_name__ = "users"
+
+ username: str
+ email: str
+ is_active: bool = True
+
+ # Relationships
+ posts: AsyncHasManyDescriptor = HasMany(
+ "AsyncPost", foreign_key="user_id", back_populates="author"
+ )
+ comments: AsyncHasManyDescriptor = HasMany(
+ "AsyncComment", foreign_key="user_id", back_populates="author"
+ )
+
+
+class AsyncPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord):
+ """Async Post model."""
+ __table_name__ = "posts"
+
+ user_id: int
+ title: str
+ body: str = ""
+ status: str = "draft"
+ view_count: int = 0
+
+ # Relationships
+ author: AsyncBelongsToDescriptor = BelongsTo(
+ "AsyncUser", foreign_key="user_id", back_populates="posts"
+ )
+ comments: AsyncHasManyDescriptor = HasMany(
+ "AsyncComment", foreign_key="post_id", back_populates="post"
+ )
+
+
+class AsyncComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord):
+ """Async Comment model."""
+ __table_name__ = "comments"
+
+ post_id: int
+ user_id: int
+ body: str
+ is_approved: bool = False
+
+ # Relationships
+ post: AsyncBelongsToDescriptor = BelongsTo(
+ "AsyncPost", foreign_key="post_id", back_populates="comments"
+ )
+ author: AsyncBelongsToDescriptor = BelongsTo(
+ "AsyncUser", foreign_key="user_id", back_populates="comments"
+ )
diff --git a/docs/zh_CN/README.md b/docs/zh_CN/README.md
index b2a2e767..ab1a61a3 100644
--- a/docs/zh_CN/README.md
+++ b/docs/zh_CN/README.md
@@ -26,27 +26,34 @@
* **[้ซ็บง็ดขๅผ](postgres_specific_features/indexing.md)**: GIN, GiST, BRIN ็ดขๅผ็ฑปๅ
* **[RETURNING ๅญๅฅ](postgres_specific_features/returning.md)**: INSERT/UPDATE/DELETE RETURNING ๆฏๆ
-4. **[ไบๅกๆฏๆ (Transaction Support)](transaction_support/README.md)**
+4. **[DDL ๆไฝ (DDL Operations)](ddl/README.md)**
+ * **[PostgreSQL DDL ๆฆ่ง](ddl/README.md)**: CREATE TABLE, ALTER TABLE, DROP TABLE
+ * **[็คบไพไปฃ็ ](../examples/chapter_04_ddl/ddl.py)**: DDL ็คบไพ
+
+5. **[ไบๅกๆฏๆ (Transaction Support)](transaction_support/README.md)**
* **[ไบๅก้็ฆป็บงๅซ](transaction_support/isolation_level.md)**: READ COMMITTED, REPEATABLE READ, SERIALIZABLE
* **[Savepoint ๆฏๆ](transaction_support/savepoint.md)**: ๅตๅฅไบๅก
* **[DEFERRABLE ๆจกๅผ](transaction_support/deferrable.md)**: ๅปถ่ฟ็บฆๆๆฃๆฅ
* **[ๆญป้ๅค็](transaction_support/deadlock.md)**: ๅคฑ่ดฅ้่ฏๆบๅถ
-5. **[็ฑปๅ้้
ๅจ (Type Adapters)](type_adapters/README.md)**
+6. **[็ฑปๅ้้
ๅจ (Type Adapters)](type_adapters/README.md)**
* **[PostgreSQL ๅฐ Python ็ฑปๅๆ ๅฐ](type_adapters/mapping.md)**: ็ฑปๅ่ฝฌๆข่งๅ
* **[่ชๅฎไน็ฑปๅ้้
ๅจ](type_adapters/custom.md)**: ๆฉๅฑ็ฑปๅๆฏๆ
* **[ๆถๅบๅค็](type_adapters/timezone.md)**: TIMESTAMP WITH TIME ZONE
* **[ๆฐ็ป็ฑปๅๅค็](type_adapters/arrays.md)**: PostgreSQL ๆฐ็ปๆฏๆ
-6. **[ๆต่ฏ (Testing)](testing/README.md)**
+7. **[ๆต่ฏ (Testing)](testing/README.md)**
* **[ๆต่ฏ้
็ฝฎ](testing/configuration.md)**: ๆต่ฏ็ฏๅข่ฎพ็ฝฎ
* **[ๆฌๅฐ PostgreSQL ๆต่ฏ](testing/local.md)**: ๆฌๅฐๆฐๆฎๅบๆต่ฏ
-7. **[ๆ
้ๆ้ค (Troubleshooting)](troubleshooting/README.md)**
+8. **[ๆ
้ๆ้ค (Troubleshooting)](troubleshooting/README.md)**
* **[ๅธธ่ง่ฟๆฅ้่ฏฏ](troubleshooting/connection.md)**: ่ฟๆฅ้ฎ้ข่ฏๆญ
* **[ๆง่ฝ้ฎ้ข](troubleshooting/performance.md)**: ๆง่ฝ็ถ้ขๅๆ
-8. **[ๅบๆฏๅฎๆ (Scenarios)](scenarios/README.md)**
+9. **[ๅบๆฏๅฎๆ (Scenarios)](scenarios/README.md)**
* **[ๅนถ่ก Worker ๅค็](scenarios/parallel_workers.md)**: ๅค่ฟ็จ/ๅผๆญฅๅนถๅๅบๆฏ็ๆญฃ็กฎ็จๆณ
+10. **[Worker ้็ฆปๅฎ้ช](examples/chapter_05_worker_isolation/README.md)**
+ * **[Worker ่ฟๆฅ้็ฆปๅฎ้ช](examples/chapter_05_worker_isolation/README.md)**: Worker ่ฟ็จ้็ฆปๅฎ้ช
+
> ๐ **ๆ ธๅฟๅบๆๆกฃ**๏ผ่ฆไบ่งฃ ActiveRecord ๆกๆถ็ๅฎๆดๅ่ฝ๏ผ่ฏทๅ่ [rhosocial-activerecord ๆๆกฃ](https://github.com/Rhosocial/python-activerecord/tree/main/docs/zh_CN)ใ
diff --git a/docs/zh_CN/ddl/README.md b/docs/zh_CN/ddl/README.md
new file mode 100644
index 00000000..063547e5
--- /dev/null
+++ b/docs/zh_CN/ddl/README.md
@@ -0,0 +1,77 @@
+# PostgreSQL DDL ๆไฝ
+
+PostgreSQL ๅ็ซฏๆฏๆไธๆ ธๅฟๅบ็ธๅ็็ฑปๅๅฎๅ
จ DDL ่กจ่พพๅผ๏ผๅนถๅ
ทๆ PostgreSQL ็นๅฎๆฉๅฑใ
+
+## ๆฏๆ็ๆไฝ
+
+| ๆไฝ | PostgreSQL ๆฏๆ | ๅคๆณจ |
+|----------|-------------------|-------|
+| `CreateTableExpression` | โ
ๅฎๆด | PRIMARY KEY, NOT NULL, UNIQUE ็ญ |
+| `DropTableExpression` | โ
ๅฎๆด | IF EXISTS, CASCADE, RESTRICT |
+| `AlterTableExpression` | โ
ๅฎๆด | ADD/DROP COLUMN, ALTER COLUMN |
+| `CreateIndexExpression` | โ
ๅฎๆด | ็ดขๅผ็ฑปๅ (BTREE, HASH, GIN, GiST, BRIN) |
+| `DropIndexExpression` | โ
ๅฎๆด | |
+| `CreateViewExpression` | โ
ๅฎๆด | ็ฉๅ่งๅพๆฏๆ |
+| `DropViewExpression` | โ
ๅฎๆด | |
+
+## PostgreSQL ็นๆง
+
+### ็ดขๅผ็ฑปๅ
+
+PostgreSQL ๆฏๆๅค็ง็ดขๅผ็ฑปๅ๏ผ
+
+```python
+create_idx = CreateIndexExpression(
+ dialect,
+ index_name="idx_users_name",
+ table_name="users",
+ columns=["name"],
+ index_type="GIN" # GIN, GiST, BRIN, BTREE, HASH
+)
+```
+
+### ๅฑ้จ็ดขๅผ
+
+PostgreSQL ๆฏๆๅธฆ WHERE ๅญๅฅ็ๅฑ้จ็ดขๅผ๏ผ
+
+```python
+from rhosocial.activerecord.backend.expression import Column, Literal
+
+create_idx = CreateIndexExpression(
+ dialect,
+ index_name="idx_active_users",
+ table_name="users",
+ columns=["email"],
+ where=Column(dialect, "status") == Literal(dialect, "active")
+)
+```
+
+### Schema ๆฏๆ
+
+PostgreSQL ๆฏๆ schema๏ผ
+
+```python
+create_table = CreateTableExpression(
+ dialect,
+ table_name="schema_name.users",
+ columns=columns
+)
+```
+
+## ่ฟ่ก็คบไพ
+
+```bash
+cd python-activerecord-postgres
+source .venv3.8/bin/activate
+PYTHONPATH=src python docs/examples/chapter_04_ddl/ddl.py
+```
+
+็คบไพๆต่ฏ๏ผ
+1. ๅๅปบๅธฆ็บฆๆ็่กจ
+2. ไฝฟ็จ IF NOT EXISTS ๅๅปบ่กจ
+3. ALTER TABLE - ๆทปๅ ๅ
+4. ALTER TABLE - ๅ ้คๅ
+5. ไฝฟ็จ IF EXISTS ๅ ้ค่กจ
+6. ๅ
็้ช่ฏๆถๆๅๅ
+
+> **ๆณจๆ**๏ผPostgreSQL ๅ
ทๆๆฏ SQLite ๆดๅผบๅคง็ DDL ๆฏๆใๅฎๆด็ PostgreSQL DDL ๅ่ฝ่ฏทๅ่ [PostgreSQL 16 ๆๆกฃ](https://www.postgresql.org/docs/16/sql-createtable.html)ใ
\ No newline at end of file
diff --git a/docs/zh_CN/scenarios/parallel_workers.md b/docs/zh_CN/scenarios/parallel_workers.md
index ec7d9311..2301406f 100644
--- a/docs/zh_CN/scenarios/parallel_workers.md
+++ b/docs/zh_CN/scenarios/parallel_workers.md
@@ -480,6 +480,7 @@ async def batch_update_correct(post_ids: list[int]):
| [`exp3_deadlock_wrong.py`](../../examples/chapter_08_scenarios/parallel_workers/exp3_deadlock_wrong.py) | ่ก้้กบๅบๅฒ็ชๅฏผ่ด PostgreSQL ๆญป้๏ผๅ้ขๆๆ๏ผ | ยง4.1 |
| [`exp4_partition_correct.py`](../../examples/chapter_08_scenarios/parallel_workers/exp4_partition_correct.py) | ๆฐๆฎๅๅบ + ๅๅญ้ขๅ + ๆญป้้่ฏ๏ผๅๆญฅ/ๅผๆญฅๅๆนๆก๏ผ | ยง4.2โ4.4 |
| [`exp5_multithread_warning.py`](../../examples/chapter_08_scenarios/parallel_workers/exp5_multithread_warning.py) | ๅค็บฟ็จๅ
ฑไบซ่ฟๆฅ็้ฎ้ข๏ผๅ้ขๆๆ๏ผ | ยง1.2 |
+| [`exp6_backend_group_pool.py`](../../examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py) | BackendGroup vs BackendPool ็บฟ็จๅฎๅ
จๆงๅฏนๆฏ | ยง8.5 |
> **่ฏดๆ**๏ผๆๆ็คบไพๆไปถๅ็ดๆฅไฝฟ็จ `rhosocial-activerecord` ORM๏ผๆจกๅไฝ็ณปไธบ `User โ Post โ Comment`๏ผๅนถไฝ็ฐ `HasMany` / `BelongsTo` ๅ
ณ่ๅ
ณ็ณป็ๅๆญฅไธๅผๆญฅๅฏน็ญ็จๆณใ
@@ -651,3 +652,317 @@ def transfer_task(from_id: int, to_id: int, amount: float, conn_params: dict):
- ๆต่ฏๆกฅๆฅๆไปถ๏ผ`tests/rhosocial/activerecord_postgres_test/feature/basic/worker/`
- Provider ๅฎ็ฐ๏ผ`tests/providers/basic.py`ใ`tests/providers/query.py`
- WorkerPool ๅฎ็ฐ๏ผ`rhosocial.activerecord.worker.pool`
+
+---
+
+## 9. ๆต่ฏ้ช่ฏ็ป่ฎบ
+
+### 9.1 ๆต่ฏ็ฏๅข
+
+ไปฅไธๆต่ฏๅจๅค็ง็ฏๅขไธ้ช่ฏ้่ฟ๏ผ
+
+| ๅนณๅฐ | ๆไฝ็ณป็ป | Python ็ๆฌ | pytest ็ๆฌ | PostgreSQL ็ๆฌ |
+|------|----------|-------------|-------------|-----------------|
+| macOS | macOS Tahoe 26.4.1 (Build 25E253) arm64 | 3.8.10 | 8.3.5 | 9.6.24 |
+| Windows | Windows 11 Pro 25H2 (Build 26200) | 3.8.10 / 3.14.3 | 8.3.5 / 8.4.2 | 17.5 |
+
+### 9.2 ๆต่ฏ็ปๆๆฑๆป
+
+#### ๅค่ฟ็จๅนถ่กๆต่ฏ (exp1)
+
+| ๅนณๅฐ | ไธฒ่ก่ๆถ | ๅๆญฅๅค่ฟ็จ | ๅผๆญฅๅค่ฟ็จ | ๅ ้ๆฏ |
+|------|----------|------------|------------|--------|
+| macOS (Python 3.8.10) | 0.727s | 0.700s (1.0x) | 0.894s (0.8x) | 1.0x |
+| Windows | 0.910s | 2.998s (0.3x) | 3.163s (0.3x) | 0.3x |
+
+> **่ฏดๆ**: ๅค่ฟ็จๅฏๅจๅผ้ๅจๅฐๆฐๆฎ้ไธๅฏ่ฝ่ถ
่ฟๅนถ่กๆถ็๏ผๅคงๆฐๆฎ้ๆถๅ ้ๆๆๆดๆๆพใmacOS arm64 ๅนณๅฐๅๆญฅๅค่ฟ็จๆง่ฝ่กจ็ฐ็จณๅฎใ
+
+#### ๅผๆญฅ็นๆงๆต่ฏ (exp2)
+
+| ๅนณๅฐ | ๅ่ฟ็จๅๆญฅไธฒ่ก | ๅ่ฟ็จๅผๆญฅ้กบๅบ | ๅค่ฟ็จๅๆญฅ | ๅค่ฟ็จๅผๆญฅ |
+|------|----------------|----------------|------------|------------|
+| macOS (Python 3.8.10) | 0.651s | N/A๏ผๆญป้่งฆๅ๏ผ | N/A | N/A |
+| Windows | 0.204s | 0.218s | 1.133s | 1.264s |
+
+> **่ฏดๆ**: macOS ๅนณๅฐๅจๅผๆญฅๅนถๅๆต่ฏไธญ่งฆๅไบ PostgreSQL ๆญป้ๆฃๆต๏ผSQLSTATE 40P01๏ผ๏ผ่ฟไธๆต่ฏๆฐๆฎ้่พๅฐไธๅนถๅๅฒ็ชๆฆ็่พ้ซๆๅ
ณใ
+
+#### ๆญป้ๆฃๆตๆต่ฏ (exp3)
+
+ๆๆๅนณๅฐๅๆๅ่งฆๅ PostgreSQL ๆญป้ๆฃๆตๆบๅถ๏ผ
+- ๆญป้่ขซ่ชๅจๆฃๆต๏ผSQLSTATE 40P01๏ผ
+- ไปฃไปท่พๅฐ็ไบๅก่ขซๅๆป
+- ๆชๆ่ทๅผๅธธๆถ๏ผๅๆปไบๅก็ๅทฅไฝไธขๅคฑ
+- macOS: 2 Workers ๆๅ๏ผ2 Workers ๆญป้ๅๆป๏ผๆป่ๆถ 2.584s
+- Windows: 2 Workers ๆๅ๏ผ2 Workers ๆญป้ๅๆป
+
+#### ๆญฃ็กฎๆนๆกๆต่ฏ (exp4)
+
+| ๆนๆก | macOS ่ๆถ | Windows ่ๆถ | ้ช่ฏ็ปๆ |
+|------|------------|--------------|----------|
+| A: ๆฐๆฎๅๅบ๏ผๅๆญฅ๏ผ | 0.804s | 1.170s | โ ๆ ้ๅค |
+| A: ๆฐๆฎๅๅบ๏ผๅผๆญฅ๏ผ | 0.724s | 1.263s | โ ๆ ้ๅค |
+| B: ๅๅญ้ขๅ๏ผๅๆญฅ๏ผ | 1.071s | 1.812s | โ ๆ ้ๅค |
+| B: ๅๅญ้ขๅ๏ผๅผๆญฅ๏ผ | 1.218s | 1.446s | โ ๆ ้ๅค |
+| C: ๅๅญ+้่ฏ๏ผๅๆญฅ๏ผ | 1.193s | 2.501s | โ ๆ ้ๅค |
+
+#### ๅค็บฟ็จ่ญฆๅๆต่ฏ (exp5)
+
+ๆๆๅนณๅฐๅ้ช่ฏๅค็บฟ็จๅ
ฑไบซ่ฟๆฅไธๅฎๅ
จ๏ผ
+- ๅ
ฑไบซ `__backend__`: ๆธธๆ ็ถๆๆททไนฑ
+- ๆฏ็บฟ็จ `configure()`: ็ฑปๅฑๆง่ขซ่ฆ็๏ผไปๅ
ฑไบซๅไธๅฎไพ
+
+### 9.3 ๅนณๅฐๅทฎๅผ่ฏดๆ
+
+#### Windows ็นๆฎ้
็ฝฎ
+
+ๅจ Windows ไธ่ฟ่กๅผๆญฅๆต่ฏๆถ๏ผ้่ฆ่ฎพ็ฝฎ `WindowsSelectorEventLoopPolicy`๏ผ
+
+```python
+import asyncio
+import sys
+
+def worker_async(post_ids: list) -> int:
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+ return asyncio.run(async_worker_main(post_ids))
+
+# ไธป็จๅบไธญๅๆ ท้่ฆ่ฎพ็ฝฎ
+def main():
+ if sys.platform == "win32":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+ # ... ๅ
ถไปไปฃ็
+```
+
+**ๅๅ **: Windows ้ป่ฎคไฝฟ็จ `ProactorEventLoop`๏ผ่ `psycopg` ็ๅผๆญฅๅ็ซฏ้่ฆ `SelectorEventLoop`ใ
+
+> **้่ฆ**: ๅฆๆๆช่ฎพ็ฝฎๆญฃ็กฎ็ event loop policy๏ผๅฐๆถๅฐไปฅไธ้่ฏฏ๏ผ
+> ```
+> Psycopg cannot use the 'ProactorEventLoop' to run in async mode.
+> Please use a compatible event loop, for instance by setting
+> 'asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())'
+> ```
+
+#### macOS / Linux
+
+ๆ ้็นๆฎ้
็ฝฎ๏ผ้ป่ฎค event loop ๅณๅฏๆญฃๅธธๅทฅไฝใ
+
+### 9.4 BackendGroup vs BackendPool ็บฟ็จๅฎๅ
จๅฏนๆฏ (exp6)
+
+#### ๅฎ้ช็ฎ็
+
+ๅฏนๆฏ BackendGroup + backend.context() ไธ BackendPool ๅจๅค็บฟ็จๅบๆฏไธ็ๅฎๅ
จๆงใ
+
+#### ๅฎ้ช็ปๆ
+
+| ๅบๆฏ | ๆๅๆฐ | ้่ฏฏๆฐ | ่ๆถ | ็ป่ฎบ |
+|-----|--------|--------|------|------|
+| Scenario 1: BackendPool | 20 | 0 | 0.382s | โ
็บฟ็จๅฎๅ
จ๏ผๆจ่ |
+| Scenario 2: BackendGroup | 20 | 0 | 0.484s | โ ๏ธ ๅฏ็จไฝๆ็ซไบ้ฃ้ฉ |
+
+#### ๅ
ณ้ฎๅ็ฐ
+
+**PostgreSQL (psycopg) ็ threadsafety=2**๏ผ
+
+1. **่ฟๆฅๅฏน่ฑกๆฌ่บซๆฏ็บฟ็จๅฎๅ
จ็**๏ผๅค็บฟ็จๅฏไปฅๅฎๅ
จๅ
ฑไบซ่ฟๆฅๅฏน่ฑก
+2. **BackendPool ๆฏๆจ่ๆนๆก**๏ผๆฏไธช็บฟ็จไปๆฑ ไธญ่ทๅ็ฌ็ซ่ฟๆฅ
+3. **BackendGroup ๅจ PostgreSQL ไธ่ฝๅทฅไฝ**๏ผไฝ็่ฎบไธไปๆ็ซไบๆกไปถ
+
+#### BackendPool ็ไผๅฟ
+
+```python
+# โ
ๆจ่๏ผBackendPool๏ผ่ฟๆฅๆฑ ๏ผ
+from rhosocial.activerecord.connection.pool import PoolConfig, BackendPool
+
+config = PoolConfig(
+ min_size=2,
+ max_size=10,
+ backend_factory=lambda: PostgresBackend(connection_config=db_config)
+)
+pool = BackendPool(config)
+
+# ๆฏไธช็บฟ็จ่ทๅ็ฌ็ซ่ฟๆฅ
+def thread_worker(pool, thread_id):
+ with pool.connection() as backend:
+ User.__backend__ = backend
+ user = User.find_one(1)
+ # ไฝฟ็จๅฎๆฏ่ชๅจๅฝ่ฟ่ฟๆฅๆฑ
+```
+
+#### BackendGroup ็ๅฑ้
+
+```python
+# โ ๏ธ ๆ้ฃ้ฉ๏ผBackendGroup๏ผๅ
ฑไบซๅ็ซฏๅฎไพ๏ผ
+group = BackendGroup(
+ name="main",
+ models=[User, Post, Comment],
+ config=config,
+ backend_class=PostgresBackend
+)
+group.configure()
+
+# ๅคไธช็บฟ็จๅ
ฑไบซๅไธไธชๅ็ซฏๅฎไพ
+def thread_worker(group, thread_id):
+ backend = group.get_backend()
+ with backend.context(): # ็่ฎบไธๆ็ซไบๆกไปถ
+ user = User.find_one(1)
+```
+
+#### PostgreSQL ็็บฟ็จๅฎๅ
จๆนๆก
+
+| ๆนๆก | threadsafety | ๆจ่ๅบฆ | ่ฏดๆ |
+|-----|-------------|--------|------|
+| **BackendPool** | 2 | โญโญโญ ๆจ่ | ๆฏ็บฟ็จ็ฌ็ซ่ฟๆฅ๏ผ็ๆญฃ็็บฟ็จ้็ฆป |
+| BackendGroup + context() | 2 | โญโญ ๅฏ็จ | ๅ
ฑไบซๅ็ซฏๅฎไพ๏ผPostgreSQL ่ฝๅค็ไฝไธๅคไผ้
|
+| ๅค่ฟ็จ | - | โญ ๅฏ็จ | ่ฟ็จ้็ฆป๏ผไฝๅผ้่พๅคง |
+
+#### ็ป่ฎบ
+
+- **PostgreSQL ๆฏๆ BackendPool**๏ผๅ ไธบ psycopg ็ threadsafety=2
+- **BackendPool ๆฏๅค็บฟ็จ้ฆ้ๆนๆก**๏ผๆไพ็ๆญฃ็่ฟๆฅ้็ฆป
+- **BackendGroup ไธป่ฆ็จไบๅคๆจกๅๅ
ฑไบซ**๏ผไธๆฏ็บฟ็จ้็ฆปๆบๅถ
+- **"้็จ้่ฟใ็จๅฎๅณๆญ"**๏ผBackendPool ่ชๅจ็ฎก็่ฟๆฅ็ๅฝๅจๆ
+
+### 9.5 FastAPI ๅผๆญฅๅบ็จ็ๆไฝณๅฎ่ทต
+
+#### ้ฎ้ขๅบๆฏ
+
+ๅจ FastAPI + PostgreSQL + ๅผๆญฅๅ็ซฏๅบๆฏไธ๏ผ
+- ๆฏไธช HTTP ่ฏทๆฑๅจ็ฌ็ซ็ๅ็จไธญๅค็
+- ๅคไธชๅ็จๅฏ่ฝๅนถๅๆง่ก
+- PostgreSQL ็ `threadsafety=2`๏ผๆฏๆ่ฟๆฅๆฑ
+- ้่ฆ้ซๆ็่ฟๆฅๅค็จๆบๅถ
+
+#### ๆจ่ๆนๆก๏ผBackendPool ่ฟๆฅๆฑ
+
+**ๆ ธๅฟๅๅ**๏ผ
+1. ๅบ็จๅฏๅจๆถๅๅปบๅ
จๅฑ `AsyncBackendPool`
+2. ๆฏไธช่ฏทๆฑไปๆฑ ไธญ่ทๅ่ฟๆฅ
+3. ไฝฟ็จๅฎๆฏๅๅฝ่ฟ่ฟๆฅๆฑ
+4. ๅบ็จๅ
ณ้ญๆถๅ
ณ้ญ่ฟๆฅๆฑ
+
+**ๅฎๆด็คบไพ**๏ผ
+
+```python
+# database.py - ่ฟๆฅๆฑ ็ฎก็ๅจ
+from rhosocial.activerecord.connection.pool import PoolConfig, AsyncBackendPool
+from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend
+
+# ๅ
จๅฑ่ฟๆฅๆฑ ๏ผๅบ็จๅฏๅจๆถๅๅปบ๏ผ
+_pool: AsyncBackendPool = None
+
+async def init_pool():
+ """ๅๅงๅ่ฟๆฅๆฑ """
+ global _pool
+
+ pool_config = PoolConfig(
+ min_size=2, # ๆๅฐ่ฟๆฅๆฐ
+ max_size=10, # ๆๅคง่ฟๆฅๆฐ
+ backend_factory=lambda: AsyncPostgresBackend(connection_config=config)
+ )
+
+ _pool = AsyncBackendPool(pool_config)
+
+async def close_pool():
+ """ๅ
ณ้ญ่ฟๆฅๆฑ """
+ global _pool
+ if _pool:
+ await _pool.close()
+ _pool = None
+
+@asynccontextmanager
+async def get_request_db():
+ """่ฏทๆฑ็บง่ฟๆฅ็ฎก็ๅจ"""
+ global _pool
+
+ # ไปๆฑ ไธญ่ทๅ่ฟๆฅ
+ async with _pool.connection() as backend:
+ # ็ปๅฎๆจกๅๅฐๅฝๅ่ฟๆฅ
+ AsyncUser.__backend__ = backend
+ AsyncPost.__backend__ = backend
+ AsyncComment.__backend__ = backend
+
+ yield backend
+ # ่ฟๆฅ่ชๅจๅฝ่ฟๅฐๆฑ
+
+
+# app.py - FastAPI ๅบ็จ
+from fastapi import FastAPI, Depends
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_database()
+ await init_pool() # ๅฏๅจๆถๅๅงๅ่ฟๆฅๆฑ
+ yield
+ await close_pool() # ๅ
ณ้ญๆถๆธ
็่ฟๆฅๆฑ
+
+app = FastAPI(lifespan=lifespan)
+
+@app.get("/users/{user_id}")
+async def get_user(user_id: int, db=Depends(get_request_db)):
+ user = await AsyncUser.find_one(user_id)
+ return {"user": user.to_dict()}
+```
+
+#### ๆนๆกๅๆ
+
+| ็นๆง | ่ฏดๆ |
+|-----|------|
+| **่ฟๆฅๅค็จ** | ๆฑ ๅ่ฟๆฅ๏ผๅๅฐๅๅปบๅผ้ |
+| **ๅนถๅๅฎๅ
จ** | ๆฏไธช่ฏทๆฑ่ทๅ็ฌ็ซ่ฟๆฅ |
+| **่ตๆบ็ฎก็** | ๆฑ ่ชๅจ็ฎก็่ฟๆฅ็ๅฝๅจๆ |
+| **้ซๆง่ฝ** | ้ๅ้ซๅนถๅๅบๆฏ |
+| **ไบๅกๆฏๆ** | ๅไธ่ฟๆฅๅ
ๆฏๆไบๅก |
+
+#### MySQL vs PostgreSQL ๆนๆกๅฏนๆฏ
+
+| ๆน้ข | MySQL | PostgreSQL |
+|-----|-------|------------|
+| threadsafety | 1 | 2 |
+| BackendPool | โ ไธๆฏๆ | โ
ๆจ่ |
+| ๆจ่ๆนๆก | ่ฏทๆฑ็บง BackendGroup | BackendPool |
+| ่ฟๆฅๅค็จ | ๆฏ่ฏทๆฑๅๅปบ/ๆญๅผ | ๆฑ ็ฎก็ๅค็จ |
+| ๆง่ฝ | ๅฏๆฅๅๅผ้ | ไผๅ่ฟๆฅๆฑ |
+
+#### ๅ
ณ้ฎ่ฎพ่ฎก่ฆ็น
+
+1. **ๅ
จๅฑ่ฟๆฅๆฑ **๏ผๅบ็จๅฏๅจๆถๅๅปบ๏ผ
+ - ๅจ `lifespan` ไธญๅๅงๅ่ฟๆฅๆฑ
+ - ้
็ฝฎๅ้็ min_size ๅ max_size
+
+2. **่ฏทๆฑ็บง่ฟๆฅ่ทๅ**
+ - ไฝฟ็จไพ่ตๆณจๅ
ฅ่ทๅ่ฟๆฅ
+ - ่ชๅจ็ปๅฎๆจกๅๅฐๅฝๅ่ฟๆฅ
+
+3. **่ชๅจๅฝ่ฟ่ฟๆฅ**
+ - ไฝฟ็จ `async with` ็กฎไฟ่ฟๆฅๅฝ่ฟ
+ - ๅผๅธธๆถไน่ฝๆญฃ็กฎๅฝ่ฟ
+
+4. **ๅบ็จๅ
ณ้ญๆถๆธ
็**
+ - ๅจ `lifespan` ็ `yield` ๅๅ
ณ้ญ่ฟๆฅๆฑ
+
+#### ้็จๅบๆฏ
+
+| ๅบๆฏ | ๆจ่ๅบฆ | ่ฏดๆ |
+|-----|--------|------|
+| Web API๏ผ้ซๅนถๅ๏ผ | โญโญโญ ๆจ่ | ่ฟๆฅๆฑ ๏ผ้ซๆๅค็จ |
+| ๅพฎๆๅก | โญโญโญ ๆจ่ | ่ฟๆฅๆฑ ็ฎก็๏ผ้ๅๅๅธๅผ |
+| ๅฎๆถๅบ็จ | โญโญโญ ๆจ่ | ้ซๆง่ฝ๏ผๆฏๆ WebSocket |
+| ๆนๅค็ไปปๅก | โญโญ ๅฏ็จ | ้
ๅ้ๅฝ็ๆฑ ๅคงๅฐ |
+| ๅๅฐไปปๅก | โญโญ ๅฏ็จ | ๅ
ฑไบซๅบ็จ่ฟๆฅๆฑ |
+
+#### ๅฎๆด็คบไพไปฃ็
+
+ๅ่ง `docs/examples/chapter_10_fastapi/` ็ฎๅฝใ
+
+### 9.6 ็ป่ฎบ
+
+1. **ๅค่ฟ็จๆฏๅนถ่ก Worker ็ๆญฃ็กฎๆนๆก**: ๆๆๅนณๅฐๅ้ช่ฏ้่ฟ
+2. **ๅๆญฅๅ็ซฏๆด็จณๅฎ**: ๅผๆญฅๅ็ซฏๅจ Windows ไธ้่ฆ้ขๅค้
็ฝฎ
+3. **ๆญป้้่ฏๆนๆกๆจ่็จไบ็ไบง**: ไธไพ่ตๆฐๆฎๅฏๅๅบๆง๏ผ่ชๅจๅค็ๆญป้
+4. **ๆฐๆฎๅๅบๆ็ๆ้ซ**: MVCC ๆ ้็ซไบ๏ผ้ๅๅฏๅๅบๅบๆฏ
+5. **PostgreSQL ๅค็บฟ็จๆจ่ BackendPool**: threadsafety=2 ๆฏๆ่ฟๆฅๆฑ
+6. **BackendPool ๆไพ็ๆญฃ็็บฟ็จ้็ฆป**: ๆฏ็บฟ็จ็ฌ็ซ่ฟๆฅ๏ผๆ ็ซไบๆกไปถ
+7. **FastAPI ๆจ่ไฝฟ็จ BackendPool**: ้ซๆ่ฟๆฅๅค็จ๏ผ้ๅ้ซๅนถๅๅบๆฏ
+5. **PostgreSQL vs MySQL ๆญป้้่ฏฏ็ **:
+ - PostgreSQL: SQLSTATE 40P01
+ - MySQL: errno 1213
diff --git a/pyproject.toml b/pyproject.toml
index d1c14529..a5d70451 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.dev9"
+version = "1.0.0.dev10"
description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support."
readme = "README.md"
license = { file = "LICENSE" }
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py
index f53168c0..317403e1 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py
@@ -60,6 +60,8 @@
from .types.text_search import (
PostgresTsVector,
PostgresTsQuery,
+)
+from .functions.text_search import (
to_tsvector,
to_tsquery,
plainto_tsquery,
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 2daa393e..ffd65598 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py
@@ -61,7 +61,12 @@
from ..transaction import AsyncPostgresTransactionManager
-class AsyncPostgresBackend(AsyncExplainBackendMixin, IntrospectorBackendMixin, PostgresBackendMixin, AsyncStorageBackend):
+class AsyncPostgresBackend(
+ AsyncExplainBackendMixin,
+ IntrospectorBackendMixin,
+ PostgresBackendMixin,
+ AsyncStorageBackend,
+):
"""Asynchronous PostgreSQL-specific backend implementation."""
def __init__(self, **kwargs):
@@ -663,7 +668,15 @@ async def _reconnect(self) -> bool:
self.log(logging.ERROR, f"Failed to reconnect: {str(e)}")
return False
- async def execute(self, sql: str, params: Optional[Tuple] = None, *, options=None, max_retries: int = 2, **kwargs) -> QueryResult:
+ async def execute(
+ self,
+ sql: str,
+ params: Optional[Tuple] = None,
+ *,
+ options=None,
+ max_retries: int = 2,
+ **kwargs,
+ ) -> QueryResult:
"""
Execute a SQL statement with optional parameters asynchronously.
@@ -725,7 +738,7 @@ async def execute(self, sql: str, params: Optional[Tuple] = None, *, options=Non
if await self._reconnect():
continue
# If reconnection fails, break and raise the error
- self.log(logging.ERROR, f"Failed to reconnect after connection error")
+ self.log(logging.ERROR, "Failed to reconnect after connection error")
break
raise
@@ -839,5 +852,139 @@ def _parse_explain_result(self, raw_rows, sql, duration):
rows = [PostgresExplainPlanLine(line=r.get("QUERY PLAN", "")) for r in raw_rows]
return PostgresExplainResult(raw_rows=raw_rows, sql=sql, duration=duration, rows=rows)
+ # region Advisory Lock Methods
+
+ async def execute_advisory_lock(
+ self,
+ key,
+ shared: bool = False,
+ session: bool = True
+ ) -> None:
+ """
+ Acquire an advisory lock asynchronously.
+
+ This method blocks until the lock is acquired.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+
+ Raises:
+ DatabaseError: If the lock operation fails
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import AdvisoryLockExpression
+
+ expr = AdvisoryLockExpression(self.dialect, key=key, shared=shared, session=session)
+ sql, params = expr.to_sql()
+ await self.execute(sql, params)
+
+ async def execute_advisory_unlock(self, key, shared: bool = False) -> bool:
+ """
+ Release an advisory lock asynchronously.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, release shared lock; otherwise exclusive lock
+
+ Returns:
+ True if the lock was released, False if it was not held
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import AdvisoryUnlockExpression
+
+ expr = AdvisoryUnlockExpression(self.dialect, key=key, shared=shared)
+ sql, params = expr.to_sql()
+ result = await self.execute(sql, params)
+ # pg_advisory_unlock returns boolean in first column
+ if result.data and len(result.data) > 0:
+ row = result.data[0]
+ # Result is a dict with column name as key
+ return bool(list(row.values())[0])
+ return False
+
+ async def execute_advisory_unlock_all(self) -> None:
+ """
+ Release all advisory locks held by the current session asynchronously.
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import AdvisoryUnlockAllExpression
+
+ expr = AdvisoryUnlockAllExpression(self.dialect)
+ sql, params = expr.to_sql()
+ await self.execute(sql, params)
+
+ async def try_advisory_lock(
+ self,
+ key,
+ shared: bool = False,
+ session: bool = True
+ ) -> bool:
+ """
+ Non-blocking advisory lock acquisition asynchronously.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+
+ Returns:
+ True if the lock was acquired, False if it was not available
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import TryAdvisoryLockExpression
+
+ expr = TryAdvisoryLockExpression(self.dialect, key=key, shared=shared, session=session)
+ sql, params = expr.to_sql()
+ result = await self.execute(sql, params)
+ # pg_try_advisory_lock returns boolean in first column
+ if result.data and len(result.data) > 0:
+ row = result.data[0]
+ # Result is a dict with column name as key
+ return bool(list(row.values())[0])
+ return False
+
+ def advisory_lock(
+ self,
+ key,
+ shared: bool = False,
+ session: bool = True
+ ):
+ """
+ Async context manager for advisory locks.
+
+ Automatically acquires the lock on entry and releases it on exit.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+
+ Returns:
+ Async context manager that holds the lock
+
+ Example:
+ async with backend.advisory_lock(key=12345):
+ # Critical section - lock is held
+ await process_data()
+ # Lock automatically released
+
+ # Shared lock for read-only access
+ async with backend.advisory_lock(key=12345, shared=True):
+ await read_data()
+ """
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def _advisory_lock_context():
+ await self.execute_advisory_lock(key=key, shared=shared, session=session)
+ try:
+ yield
+ finally:
+ # Only release session-level locks; transaction-level are auto-released
+ if session:
+ await self.execute_advisory_unlock(key=key)
+
+ return _advisory_lock_context()
+
+ # endregion
+
__all__ = ["AsyncPostgresBackend"]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py
index 7aa5c2c8..322d42d2 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py
@@ -664,7 +664,15 @@ def _is_connection_error_message(self, error_msg: str) -> bool:
return True
return False
- def execute(self, sql: str, params: Optional[Tuple] = None, *, options=None, max_retries: int = 2, **kwargs) -> QueryResult:
+ def execute(
+ self,
+ sql: str,
+ params: Optional[Tuple] = None,
+ *,
+ options=None,
+ max_retries: int = 2,
+ **kwargs,
+ ) -> QueryResult:
"""
Execute a SQL statement with optional parameters.
@@ -726,7 +734,7 @@ def execute(self, sql: str, params: Optional[Tuple] = None, *, options=None, max
if self._reconnect():
continue
# If reconnection fails, break and raise the error
- self.log(logging.ERROR, f"Failed to reconnect after connection error")
+ self.log(logging.ERROR, "Failed to reconnect after connection error")
break
raise
@@ -840,5 +848,139 @@ def _parse_explain_result(self, raw_rows, sql, duration):
rows = [PostgresExplainPlanLine(line=r.get("QUERY PLAN", "")) for r in raw_rows]
return PostgresExplainResult(raw_rows=raw_rows, sql=sql, duration=duration, rows=rows)
+ # region Advisory Lock Methods
+
+ def execute_advisory_lock(
+ self,
+ key,
+ shared: bool = False,
+ session: bool = True
+ ) -> None:
+ """
+ Acquire an advisory lock.
+
+ This method blocks until the lock is acquired.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+
+ Raises:
+ DatabaseError: If the lock operation fails
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import AdvisoryLockExpression
+
+ expr = AdvisoryLockExpression(self.dialect, key=key, shared=shared, session=session)
+ sql, params = expr.to_sql()
+ self.execute(sql, params)
+
+ def execute_advisory_unlock(self, key, shared: bool = False) -> bool:
+ """
+ Release an advisory lock.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, release shared lock; otherwise exclusive lock
+
+ Returns:
+ True if the lock was released, False if it was not held
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import AdvisoryUnlockExpression
+
+ expr = AdvisoryUnlockExpression(self.dialect, key=key, shared=shared)
+ sql, params = expr.to_sql()
+ result = self.execute(sql, params)
+ # pg_advisory_unlock returns boolean in first column
+ if result.data and len(result.data) > 0:
+ row = result.data[0]
+ # Result is a dict with column name as key
+ return bool(list(row.values())[0])
+ return False
+
+ def execute_advisory_unlock_all(self) -> None:
+ """
+ Release all advisory locks held by the current session.
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import AdvisoryUnlockAllExpression
+
+ expr = AdvisoryUnlockAllExpression(self.dialect)
+ sql, params = expr.to_sql()
+ self.execute(sql, params)
+
+ def try_advisory_lock(
+ self,
+ key,
+ shared: bool = False,
+ session: bool = True
+ ) -> bool:
+ """
+ Non-blocking advisory lock acquisition.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+
+ Returns:
+ True if the lock was acquired, False if it was not available
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import TryAdvisoryLockExpression
+
+ expr = TryAdvisoryLockExpression(self.dialect, key=key, shared=shared, session=session)
+ sql, params = expr.to_sql()
+ result = self.execute(sql, params)
+ # pg_try_advisory_lock returns boolean in first column
+ if result.data and len(result.data) > 0:
+ row = result.data[0]
+ # Result is a dict with column name as key
+ return bool(list(row.values())[0])
+ return False
+
+ def advisory_lock(
+ self,
+ key,
+ shared: bool = False,
+ session: bool = True
+ ):
+ """
+ Context manager for advisory locks.
+
+ Automatically acquires the lock on entry and releases it on exit.
+
+ Args:
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+
+ Returns:
+ Context manager that holds the lock
+
+ Example:
+ with backend.advisory_lock(key=12345):
+ # Critical section - lock is held
+ process_data()
+ # Lock automatically released
+
+ # Shared lock for read-only access
+ with backend.advisory_lock(key=12345, shared=True):
+ read_data()
+ """
+ from contextlib import contextmanager
+
+ @contextmanager
+ def _advisory_lock_context():
+ self.execute_advisory_lock(key=key, shared=shared, session=session)
+ try:
+ yield
+ finally:
+ # Only release session-level locks; transaction-level are auto-released
+ if session:
+ self.execute_advisory_unlock(key=key)
+
+ return _advisory_lock_context()
+
+ # endregion
+
__all__ = ["PostgresBackend"]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py
index 37dfcb58..31bcd428 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py
@@ -35,6 +35,7 @@
SetOperationMixin,
TruncateMixin,
ILIKEMixin,
+ ConstraintMixin,
)
from rhosocial.activerecord.backend.dialect.protocols import (
CTESupport,
@@ -65,6 +66,7 @@
ILIKESupport,
IntrospectionSupport,
TransactionControlSupport,
+ ConstraintSupport,
)
from .mixins import (
PostgresExtensionMixin,
@@ -92,6 +94,7 @@
PostgresTriggerMixin,
PostgresCommentMixin,
PostgresTypeMixin,
+ PostgresConstraintMixin,
# Type mixins
EnumTypeMixin,
TypesDataTypeMixin,
@@ -99,6 +102,7 @@
# DDL/DML operation mixins (new)
PostgresExtendedStatisticsMixin,
PostgresStoredProcedureMixin,
+ PostgresAdvisoryLockMixin,
# Introspection capability mixin
PostgresIntrospectionCapabilityMixin,
)
@@ -130,6 +134,7 @@
PostgresTriggerSupport,
PostgresCommentSupport,
PostgresTypeSupport,
+ PostgresConstraintSupport,
# Type feature protocols
MultirangeSupport,
EnumTypeSupport,
@@ -137,6 +142,7 @@
PostgresParallelQuerySupport,
PostgresStoredProcedureSupport,
PostgresExtendedStatisticsSupport,
+ PostgresAdvisoryLockSupport,
)
if TYPE_CHECKING:
@@ -187,6 +193,7 @@ class PostgresDialect(
IndexMixin,
SequenceMixin,
TableMixin,
+ ConstraintMixin,
# Introspection capability
PostgresIntrospectionCapabilityMixin,
# PostgreSQL-specific mixins
@@ -215,6 +222,7 @@ class PostgresDialect(
PostgresTriggerMixin,
PostgresCommentMixin,
PostgresTypeMixin,
+ PostgresConstraintMixin,
# Type mixins
EnumTypeMixin,
TypesDataTypeMixin,
@@ -222,6 +230,7 @@ class PostgresDialect(
# DDL/DML operation mixins (new)
PostgresExtendedStatisticsMixin,
PostgresStoredProcedureMixin,
+ PostgresAdvisoryLockMixin,
# Protocol supports
SetOperationSupport,
TruncateSupport,
@@ -249,6 +258,7 @@ class PostgresDialect(
IndexSupport,
SequenceSupport,
TableSupport,
+ ConstraintSupport,
# Introspection protocol
IntrospectionSupport,
# Transaction control protocol
@@ -279,6 +289,7 @@ class PostgresDialect(
PostgresTriggerSupport,
PostgresCommentSupport,
PostgresTypeSupport,
+ PostgresConstraintSupport,
# Type feature protocols
MultirangeSupport,
EnumTypeSupport,
@@ -286,6 +297,7 @@ class PostgresDialect(
PostgresParallelQuerySupport,
PostgresStoredProcedureSupport,
PostgresExtendedStatisticsSupport,
+ PostgresAdvisoryLockSupport,
):
"""
PostgreSQL dialect implementation that adapts to the PostgreSQL version.
@@ -461,6 +473,46 @@ def supports_for_update_skip_locked(self) -> bool:
"""Whether FOR UPDATE SKIP LOCKED is supported."""
return self.version >= (9, 5, 0) # Supported since 9.5
+ def supports_for_share(self) -> bool:
+ """Whether FOR SHARE lock strength is supported (PostgreSQL 9.0+)."""
+ return self.version >= (9, 0, 0)
+
+ def supports_for_no_key_update(self) -> bool:
+ """Whether FOR NO KEY UPDATE lock strength is supported (PostgreSQL 9.0+)."""
+ return self.version >= (9, 0, 0)
+
+ def supports_for_key_share(self) -> bool:
+ """Whether FOR KEY SHARE lock strength is supported (PostgreSQL 9.3+)."""
+ return self.version >= (9, 3, 0)
+
+ def supports_lock_strength(self, strength) -> bool:
+ """
+ Check if a specific lock strength is supported.
+
+ PostgreSQL lock strength support by version:
+ - FOR UPDATE: All versions
+ - FOR NO KEY UPDATE: PostgreSQL 9.0+
+ - FOR SHARE: PostgreSQL 9.0+
+ - FOR KEY SHARE: PostgreSQL 9.3+
+
+ Args:
+ strength: The LockStrength enum value to check
+
+ Returns:
+ True if the lock strength is supported, False otherwise
+ """
+ from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength
+
+ if strength == LockStrength.UPDATE:
+ return True # All PostgreSQL versions support FOR UPDATE
+ elif strength == LockStrength.NO_KEY_UPDATE:
+ return self.version >= (9, 0, 0)
+ elif strength == LockStrength.SHARE:
+ return self.version >= (9, 0, 0)
+ elif strength == LockStrength.KEY_SHARE:
+ return self.version >= (9, 3, 0)
+ return False
+
def supports_merge_statement(self) -> bool:
"""Whether MERGE statement is supported."""
return self.version >= (15, 0, 0) # MERGE added in PostgreSQL 15
@@ -846,6 +898,16 @@ def supports_table_tablespace(self) -> bool:
"""Whether tablespace specification is supported."""
return True
+ # Constraint capability overrides
+
+ def supports_constraint_enforced(self) -> bool:
+ """PostgreSQL does not support ENFORCED/NOT ENFORCED (SQL:2016).
+
+ PostgreSQL uses NOT VALID as a proprietary alternative for
+ skipping validation of existing data when adding constraints.
+ """
+ return False
+
def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]:
"""
Format CREATE TABLE statement for PostgreSQL, including LIKE syntax support.
@@ -1044,6 +1106,118 @@ def format_cast_expression(
# endregion
+ # region Constraint DDL Support (PostgreSQL-specific)
+
+ def format_add_table_constraint_action(
+ self, action: "AddTableConstraint",
+ ) -> Tuple[str, tuple]:
+ """Format ADD CONSTRAINT action with PostgreSQL-specific extensions.
+
+ Extends the base class implementation with:
+ - EXCLUDE constraint support (PG-specific constraint type)
+ - NOT VALID suffix (PG-specific: skip validation of existing rows)
+ """
+ from rhosocial.activerecord.backend.expression.statements import (
+ TableConstraintType, ConstraintValidation,
+ )
+
+ # Handle EXCLUDE constraint (PG-specific, not in base class)
+ if action.constraint.constraint_type == TableConstraintType.EXCLUDE:
+ parts = []
+ exclude_sql, params = self._format_exclude_constraint(action.constraint)
+ parts.append(exclude_sql)
+
+ # NOT VALID suffix
+ if action.constraint.dialect_options:
+ validation = action.constraint.dialect_options.get('validation')
+ if validation == ConstraintValidation.NOVALIDATE:
+ parts.append("NOT VALID")
+
+ return f"ADD {' '.join(parts)}", tuple(params)
+
+ # Use base class for standard formatting (includes DEFERRABLE)
+ sql, params = super().format_add_table_constraint_action(action)
+
+ # PostgreSQL NOT VALID suffix
+ if action.constraint.dialect_options:
+ validation = action.constraint.dialect_options.get('validation')
+ if validation == ConstraintValidation.NOVALIDATE:
+ sql += " NOT VALID"
+
+ return sql, params
+
+ def _format_exclude_constraint(
+ self, constraint: "TableConstraint",
+ ) -> Tuple[str, tuple]:
+ """Format EXCLUDE constraint (PostgreSQL-specific).
+
+ EXCLUDE constraints use the dialect_options dict to specify:
+ - 'exclude_elements': List of (expression, operator) tuples
+ e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&)
+ - 'using': The index access method (default 'gist')
+ e.g., 'gist', 'btree', 'spgist'
+ - 'where': Optional predicate for partial exclusion constraints
+
+ Example:
+ TableConstraint(
+ constraint_type=TableConstraintType.EXCLUDE,
+ name='exclude_range_overlap',
+ dialect_options={
+ 'exclude_elements': [('range', '&&')],
+ 'using': 'gist',
+ }
+ )
+ # Generates: EXCLUDE USING gist (range WITH &&)
+ """
+ parts = []
+ params: list = []
+
+ if constraint.name:
+ parts.append(f"CONSTRAINT {self.format_identifier(constraint.name)}")
+
+ # USING clause
+ using = 'gist' # default
+ if constraint.dialect_options and 'using' in constraint.dialect_options:
+ using = constraint.dialect_options['using']
+ parts.append(f"EXCLUDE USING {using}")
+
+ # Elements: (expression, operator) pairs
+ exclude_elements = []
+ if constraint.dialect_options and 'exclude_elements' in constraint.dialect_options:
+ for expr, op in constraint.dialect_options['exclude_elements']:
+ if isinstance(expr, str):
+ exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}")
+ else:
+ # expr is a BaseExpression
+ expr_sql, expr_params = expr.to_sql()
+ params.extend(expr_params)
+ exclude_elements.append(f"{expr_sql} WITH {op}")
+
+ if exclude_elements:
+ parts.append(f"({', '.join(exclude_elements)})")
+
+ # WHERE clause for partial exclusion constraint
+ if constraint.dialect_options and 'where' in constraint.dialect_options:
+ where_expr = constraint.dialect_options['where']
+ where_sql, where_params = where_expr.to_sql()
+ params.extend(where_params)
+ parts.append(f"WHERE ({where_sql})")
+
+ # DEFERRABLE / NOT DEFERRABLE
+ if constraint.deferrable is True:
+ if constraint.initially_deferred is True:
+ parts.append("DEFERRABLE INITIALLY DEFERRED")
+ elif constraint.initially_deferred is False:
+ parts.append("DEFERRABLE INITIALLY IMMEDIATE")
+ else:
+ parts.append("DEFERRABLE")
+ elif constraint.deferrable is False:
+ parts.append("NOT DEFERRABLE")
+
+ return ' '.join(parts), tuple(params)
+
+ # endregion
+
# region Trigger Support (PostgreSQL-specific)
def supports_trigger(self) -> bool:
return True
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py
new file mode 100644
index 00000000..16bf2c00
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py
@@ -0,0 +1,26 @@
+# src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py
+"""
+PostgreSQL-specific expression classes.
+
+This module provides expression classes that are specific to PostgreSQL,
+such as advisory lock expressions and row-level locking support.
+"""
+
+from .advisory import (
+ AdvisoryLockExpression,
+ AdvisoryUnlockExpression,
+ AdvisoryUnlockAllExpression,
+ TryAdvisoryLockExpression,
+ AdvisoryLockType,
+)
+from .locking import LockStrength, PostgresForUpdateClause
+
+__all__ = [
+ "AdvisoryLockExpression",
+ "AdvisoryUnlockExpression",
+ "AdvisoryUnlockAllExpression",
+ "TryAdvisoryLockExpression",
+ "AdvisoryLockType",
+ "LockStrength",
+ "PostgresForUpdateClause",
+]
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/__init__.py
new file mode 100644
index 00000000..1390e904
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/__init__.py
@@ -0,0 +1,23 @@
+# src/rhosocial/activerecord/backend/expression/advisory/__init__.py
+"""
+Advisory lock expression classes.
+
+This module provides expression classes for database advisory locks,
+which are application-level locks managed by the database server.
+"""
+
+from .lock import (
+ AdvisoryLockExpression,
+ AdvisoryUnlockExpression,
+ AdvisoryUnlockAllExpression,
+ TryAdvisoryLockExpression,
+ AdvisoryLockType,
+)
+
+__all__ = [
+ "AdvisoryLockExpression",
+ "AdvisoryUnlockExpression",
+ "AdvisoryUnlockAllExpression",
+ "TryAdvisoryLockExpression",
+ "AdvisoryLockType",
+]
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py
new file mode 100644
index 00000000..e303abe2
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py
@@ -0,0 +1,215 @@
+# src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py
+"""
+Advisory lock expression classes for PostgreSQL.
+
+Advisory locks are application-level locks that provide a way for
+applications to coordinate activities across multiple sessions.
+Unlike regular locks, advisory locks are not tied to specific table rows
+and can be used for arbitrary application-defined purposes.
+
+PostgreSQL advisory locks:
+- pg_advisory_lock(key) - acquire exclusive session-level lock
+- pg_advisory_lock_shared(key) - acquire shared session-level lock
+- pg_advisory_unlock(key) - release session-level lock
+- pg_advisory_unlock_all(key) - release all session-level locks
+- pg_try_advisory_lock(key) - non-blocking acquire exclusive lock
+- pg_try_advisory_lock_shared(key) - non-blocking acquire shared lock
+- pg_advisory_xact_lock(key) - acquire exclusive transaction-level lock
+- pg_advisory_xact_lock_shared(key) - acquire shared transaction-level lock
+- pg_try_advisory_xact_lock(key) - non-blocking acquire transaction-level lock
+
+Keys can be either a single 64-bit integer or two 32-bit integers.
+"""
+
+from enum import Enum
+from typing import Tuple, Union, TYPE_CHECKING
+
+from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams
+
+if TYPE_CHECKING:
+ from rhosocial.activerecord.backend.dialect import SQLDialectBase
+
+
+class AdvisoryLockType(Enum):
+ """Type of advisory lock."""
+
+ EXCLUSIVE = "exclusive" # Only one session can hold the lock
+ SHARED = "shared" # Multiple sessions can hold the lock simultaneously
+
+
+class AdvisoryLockExpression(BaseExpression):
+ """
+ Expression for acquiring an advisory lock.
+
+ Advisory locks are application-level locks managed by the database server.
+ They are not tied to specific table rows and can be used for arbitrary
+ coordination purposes.
+
+ Usage:
+ # Acquire exclusive session-level lock
+ expr = AdvisoryLockExpression(dialect, key=12345)
+ sql, params = expr.to_sql() # SELECT pg_advisory_lock(12345)
+
+ # Acquire shared session-level lock
+ expr = AdvisoryLockExpression(dialect, key=12345, shared=True)
+ sql, params = expr.to_sql() # SELECT pg_advisory_lock_shared(12345)
+
+ # Transaction-level lock (auto-released on commit/rollback)
+ expr = AdvisoryLockExpression(dialect, key=12345, session=False)
+ sql, params = expr.to_sql() # SELECT pg_advisory_xact_lock(12345)
+
+ # Two-key variant
+ expr = AdvisoryLockExpression(dialect, key=(123, 456))
+ sql, params = expr.to_sql() # SELECT pg_advisory_lock(123, 456)
+ """
+
+ def __init__(
+ self,
+ dialect: "SQLDialectBase",
+ key: Union[int, Tuple[int, int]],
+ shared: bool = False,
+ session: bool = True,
+ ):
+ """
+ Initialize advisory lock expression.
+
+ Args:
+ dialect: The SQL dialect to use for formatting
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+ """
+ super().__init__(dialect)
+ self.key = key
+ self.shared = shared
+ self.session = session
+
+ def to_sql(self) -> SQLQueryAndParams:
+ """
+ Generate SQL for acquiring advisory lock.
+
+ Returns:
+ Tuple of (SQL string, parameters tuple)
+ """
+ return self.dialect.format_advisory_lock(self)
+
+
+class AdvisoryUnlockExpression(BaseExpression):
+ """
+ Expression for releasing an advisory lock.
+
+ Usage:
+ # Release session-level lock
+ expr = AdvisoryUnlockExpression(dialect, key=12345)
+ sql, params = expr.to_sql() # SELECT pg_advisory_unlock(12345)
+
+ # Release shared session-level lock
+ expr = AdvisoryUnlockExpression(dialect, key=12345, shared=True)
+ # Note: pg_advisory_unlock works for both exclusive and shared locks
+
+ # Two-key variant
+ expr = AdvisoryUnlockExpression(dialect, key=(123, 456))
+ sql, params = expr.to_sql() # SELECT pg_advisory_unlock(123, 456)
+ """
+
+ def __init__(
+ self,
+ dialect: "SQLDialectBase",
+ key: Union[int, Tuple[int, int]],
+ shared: bool = False,
+ ):
+ """
+ Initialize advisory unlock expression.
+
+ Args:
+ dialect: The SQL dialect to use for formatting
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, indicates this was a shared lock (informational only)
+ """
+ super().__init__(dialect)
+ self.key = key
+ self.shared = shared
+
+ def to_sql(self) -> SQLQueryAndParams:
+ """
+ Generate SQL for releasing advisory lock.
+
+ Returns:
+ Tuple of (SQL string, parameters tuple)
+ """
+ return self.dialect.format_advisory_unlock(self)
+
+
+class AdvisoryUnlockAllExpression(BaseExpression):
+ """
+ Expression for releasing all advisory locks held by the current session.
+
+ Usage:
+ expr = AdvisoryUnlockAllExpression(dialect)
+ sql, params = expr.to_sql() # SELECT pg_advisory_unlock_all()
+ """
+
+ def __init__(self, dialect: "SQLDialectBase"):
+ """Initialize advisory unlock all expression."""
+ super().__init__(dialect)
+
+ def to_sql(self) -> SQLQueryAndParams:
+ """
+ Generate SQL for releasing all advisory locks.
+
+ Returns:
+ Tuple of (SQL string, parameters tuple)
+ """
+ return self.dialect.format_advisory_unlock_all(self)
+
+
+class TryAdvisoryLockExpression(BaseExpression):
+ """
+ Expression for non-blocking advisory lock acquisition.
+
+ Returns a boolean indicating whether the lock was acquired.
+ Unlike AdvisoryLockExpression, this does not wait if the lock is unavailable.
+
+ Usage:
+ # Non-blocking acquire exclusive session-level lock
+ expr = TryAdvisoryLockExpression(dialect, key=12345)
+ sql, params = expr.to_sql() # SELECT pg_try_advisory_lock(12345)
+
+ # Non-blocking acquire shared session-level lock
+ expr = TryAdvisoryLockExpression(dialect, key=12345, shared=True)
+ sql, params = expr.to_sql() # SELECT pg_try_advisory_lock_shared(12345)
+
+ # Transaction-level variant
+ expr = TryAdvisoryLockExpression(dialect, key=12345, session=False)
+ sql, params = expr.to_sql() # SELECT pg_try_advisory_xact_lock(12345)
+ """
+
+ def __init__(
+ self,
+ dialect: "SQLDialectBase",
+ key: Union[int, Tuple[int, int]],
+ shared: bool = False,
+ session: bool = True,
+ ):
+ """
+ Initialize try advisory lock expression.
+
+ Args:
+ dialect: The SQL dialect to use for formatting
+ key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers
+ shared: If True, acquire shared lock; otherwise exclusive lock
+ session: If True (default), session-level lock; otherwise transaction-level
+ """
+ super().__init__(dialect)
+ self.key = key
+ self.shared = shared
+ self.session = session
+
+ def to_sql(self) -> SQLQueryAndParams:
+ """
+ Generate SQL for non-blocking advisory lock acquisition.
+
+ Returns:
+ Tuple of (SQL string, parameters tuple)
+ """
+ return self.dialect.format_try_advisory_lock(self)
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py
new file mode 100644
index 00000000..47e0c763
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py
@@ -0,0 +1,178 @@
+# src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py
+"""
+PostgreSQL-specific row-level locking expressions.
+
+PostgreSQL supports advanced row-level locking with multiple lock strengths
+beyond the standard FOR UPDATE:
+- FOR NO KEY UPDATE: Weaker exclusive lock that allows FOR KEY SHARE
+- FOR SHARE: Shared lock that allows other readers
+- FOR KEY SHARE: Weakest shared lock
+
+Version requirements:
+- FOR UPDATE: All PostgreSQL versions
+- FOR NO KEY UPDATE: PostgreSQL 9.0+
+- FOR SHARE: PostgreSQL 9.0+
+- FOR KEY SHARE: PostgreSQL 9.3+
+"""
+
+from enum import Enum
+from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
+
+from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause
+from rhosocial.activerecord.backend.expression import bases
+
+if TYPE_CHECKING:
+ from rhosocial.activerecord.backend.dialect import SQLDialectBase
+
+
+class LockStrength(Enum):
+ """
+ Enumeration of PostgreSQL row-level lock strength options.
+
+ These options control the type of lock acquired on selected rows.
+ Only PostgreSQL supports lock strengths beyond FOR UPDATE.
+
+ Lock strength hierarchy (PostgreSQL):
+ - UPDATE: Strongest exclusive lock, blocks all other locks
+ - NO_KEY_UPDATE: Weaker exclusive lock, allows KEY_SHARE
+ - SHARE: Shared lock, allows other SHARE and KEY_SHARE
+ - KEY_SHARE: Weakest shared lock, allows NO_KEY_UPDATE and SHARE
+
+ Version requirements (PostgreSQL):
+ - FOR UPDATE: All versions
+ - FOR NO KEY UPDATE: PostgreSQL 9.0+
+ - FOR SHARE: PostgreSQL 9.0+
+ - FOR KEY SHARE: PostgreSQL 9.3+
+ """
+
+ UPDATE = "FOR UPDATE" # Exclusive lock (strongest)
+ NO_KEY_UPDATE = "FOR NO KEY UPDATE" # Weaker exclusive lock
+ SHARE = "FOR SHARE" # Shared lock
+ KEY_SHARE = "FOR KEY SHARE" # Weakest shared lock
+
+
+class PostgresForUpdateClause(ForUpdateClause):
+ """
+ PostgreSQL-specific FOR UPDATE clause with lock strength support.
+
+ Extends the standard ForUpdateClause with PostgreSQL's advanced
+ row-level locking capabilities.
+
+ PostgreSQL supports additional lock strengths beyond FOR UPDATE:
+ - FOR NO KEY UPDATE: Weaker exclusive lock that allows FOR KEY SHARE
+ - FOR SHARE: Shared lock that allows other readers
+ - FOR KEY SHARE: Weakest shared lock
+
+ Example Usage:
+ # Basic FOR UPDATE (same as parent class)
+ for_update = PostgresForUpdateClause(dialect)
+
+ # FOR SHARE (PostgreSQL 9.0+)
+ for_update = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE)
+
+ # FOR NO KEY UPDATE (PostgreSQL 9.0+)
+ for_update = PostgresForUpdateClause(dialect, strength=LockStrength.NO_KEY_UPDATE)
+
+ # FOR KEY SHARE (PostgreSQL 9.3+)
+ for_update = PostgresForUpdateClause(dialect, strength=LockStrength.KEY_SHARE)
+
+ # FOR SHARE with NOWAIT
+ for_update = PostgresForUpdateClause(
+ dialect,
+ strength=LockStrength.SHARE,
+ nowait=True
+ )
+ """
+
+ def __init__(
+ self,
+ dialect: "SQLDialectBase",
+ strength: Optional[LockStrength] = None,
+ of_columns: Optional[List[Union[str, "bases.BaseExpression"]]] = None,
+ nowait: bool = False,
+ skip_locked: bool = False,
+ dialect_options: Optional[Dict[str, Any]] = None,
+ ):
+ """
+ Initialize PostgreSQL FOR UPDATE clause.
+
+ Args:
+ dialect: The SQL dialect to use for formatting
+ strength: Lock strength (defaults to UPDATE for backward compatibility)
+ of_columns: Columns to apply the lock to
+ nowait: If True, fail immediately if rows are locked
+ skip_locked: If True, skip locked rows instead of waiting
+ dialect_options: Additional dialect-specific options
+ """
+ super().__init__(
+ dialect,
+ of_columns=of_columns,
+ nowait=nowait,
+ skip_locked=skip_locked,
+ dialect_options=dialect_options,
+ )
+ # Default to UPDATE for backward compatibility
+ self.strength = strength if strength is not None else LockStrength.UPDATE
+
+ def to_sql(self) -> "bases.SQLQueryAndParams":
+ """
+ Generate the SQL representation of the PostgreSQL FOR UPDATE clause.
+
+ This method handles PostgreSQL-specific lock strengths (FOR SHARE,
+ FOR NO KEY UPDATE, FOR KEY SHARE) in addition to the standard
+ FOR UPDATE clause.
+
+ Returns:
+ Tuple containing:
+ - SQL string fragment for the FOR UPDATE clause
+ - Tuple of parameter values for prepared statements
+ """
+ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
+
+ all_params = []
+
+ # Check version support for lock strength
+ if self.strength == LockStrength.NO_KEY_UPDATE:
+ if not self.dialect.supports_for_no_key_update():
+ raise UnsupportedFeatureError(
+ self.dialect.name, "FOR NO KEY UPDATE (requires PostgreSQL 9.0+)"
+ )
+ elif self.strength == LockStrength.SHARE:
+ if not self.dialect.supports_for_share():
+ raise UnsupportedFeatureError(
+ self.dialect.name, "FOR SHARE (requires PostgreSQL 9.0+)"
+ )
+ elif self.strength == LockStrength.KEY_SHARE:
+ if not self.dialect.supports_for_key_share():
+ raise UnsupportedFeatureError(
+ self.dialect.name, "FOR KEY SHARE (requires PostgreSQL 9.3+)"
+ )
+
+ # Use the strength value directly (e.g., "FOR UPDATE", "FOR SHARE")
+ sql_parts = [self.strength.value]
+
+ # Handle OF columns if specified
+ if self.of_columns:
+ of_parts = []
+ for col in self.of_columns:
+ if isinstance(col, str):
+ of_parts.append(self.dialect.format_identifier(col))
+ else:
+ # BaseExpression
+ col_sql, col_params = col.to_sql()
+ of_parts.append(col_sql)
+ all_params.extend(col_params)
+ if of_parts:
+ sql_parts.append(f"OF {', '.join(of_parts)}")
+
+ # Handle NOWAIT/SKIP LOCKED options
+ if self.nowait:
+ sql_parts.append("NOWAIT")
+ elif self.skip_locked:
+ if not self.dialect.supports_for_update_skip_locked():
+ raise UnsupportedFeatureError(
+ self.dialect.name, "SKIP LOCKED (requires PostgreSQL 9.5+)"
+ )
+ sql_parts.append("SKIP LOCKED")
+
+ return " ".join(sql_parts), tuple(all_params)
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py
index dc647f92..cee5d167 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py
@@ -18,6 +18,9 @@
- enum: Enum functions - https://www.postgresql.org/docs/current/functions-enum.html
- bit_string: Bit string functions and operators - https://www.postgresql.org/docs/current/functions-bitstring.html
- json: JSON functions and operators - https://www.postgresql.org/docs/current/functions-json.html
+- text_search: Full-text search functions - https://www.postgresql.org/docs/current/functions-textsearch.html
+- xml: XML functions - https://www.postgresql.org/docs/current/functions-xml.html
+- math_enhanced: Enhanced math functions - https://www.postgresql.org/docs/current/functions-math.html
"""
# Range functions
@@ -102,6 +105,140 @@
jsonb_path_match,
)
+# Text search functions
+from .text_search import (
+ to_tsvector,
+ to_tsquery,
+ plainto_tsquery,
+ phraseto_tsquery,
+ websearch_to_tsquery,
+ ts_matches,
+ ts_matches_expr,
+ ts_rank,
+ ts_rank_cd,
+ ts_headline,
+ tsvector_concat,
+ tsvector_strip,
+ tsvector_setweight,
+ tsvector_length,
+)
+
+# XML functions
+from .xml import (
+ xmlparse,
+ xpath_query,
+ xpath_exists,
+ xml_is_well_formed,
+)
+
+# Math enhanced functions
+from .math_enhanced import (
+ round_,
+ pow,
+ power,
+ sqrt,
+ mod,
+ ceil,
+ floor,
+ trunc,
+ max_,
+ min_,
+ avg,
+)
+
+# Array functions
+from .array import (
+ array_agg,
+ array_append,
+ array_cat,
+ array_dims,
+ array_fill,
+ array_length,
+ array_lower,
+ array_ndims,
+ array_position,
+ array_positions,
+ array_prepend,
+ array_remove,
+ array_replace,
+ array_to_string,
+ array_upper,
+ unnest,
+ array_agg_distinct,
+ string_to_array,
+)
+
+# Network address functions
+from .network import (
+ inet_client_addr,
+ inet_client_port,
+ inet_server_addr,
+ inet_server_port,
+ inet_merge,
+ inet_and,
+ inet_or,
+ inetnot,
+ inet_set_mask,
+ inet_masklen,
+ inet_netmask,
+ inet_network,
+ inet_recv,
+ inet_show,
+ cidr_netmask,
+ macaddr8_set7bit,
+)
+
+# UUID functions
+from .uuid import (
+ uuid_generate_v1,
+ uuid_generate_v1mc,
+ uuid_generate_v3,
+ uuid_generate_v4,
+ uuid_generate_v5,
+ uuid_nil,
+ uuid_max,
+)
+
+# hstore functions
+from .hstore import (
+ hstore_from_record,
+ hstore_from_key_value,
+ hstore_akeys,
+ hstore_skeys,
+ hstore_avals,
+ hstore_svals,
+ hstore_each,
+ hstore_to_array,
+ hstore_to_matrix,
+ hstore_to_json,
+ hstore_to_jsonb,
+ hstore_to_json_loose,
+ hstore_to_jsonb_loose,
+ hstore_slice,
+ hstore_exist,
+ hstore_defined,
+ hstore_delete,
+ hstore_delete_keys,
+ hstore_delete_pairs,
+ hstore_populate_record,
+ hstore_get_value,
+ hstore_get_values,
+ hstore_concat,
+ hstore_key_exists,
+ hstore_all_keys_exist,
+ hstore_any_key_exists,
+ hstore_contains,
+ hstore_contained_by,
+ hstore_subtract_key,
+ hstore_subtract_keys,
+ hstore_subtract_pairs,
+ hstore_to_array_operator,
+ hstore_to_matrix_operator,
+ hstore_record_update,
+ hstore_subscript_get,
+ hstore_subscript_set,
+)
+
__all__ = [
# Range functions
@@ -171,4 +308,124 @@
"jsonb_path_query_first",
"jsonb_path_exists",
"jsonb_path_match",
+ # Text search functions
+ "to_tsvector",
+ "to_tsquery",
+ "plainto_tsquery",
+ "phraseto_tsquery",
+ "websearch_to_tsquery",
+ "ts_matches",
+ "ts_matches_expr",
+ "ts_rank",
+ "ts_rank_cd",
+ "ts_headline",
+ "tsvector_concat",
+ "tsvector_strip",
+ "tsvector_setweight",
+ "tsvector_length",
+ # XML functions
+ "xmlparse",
+ "xpath_query",
+ "xpath_exists",
+ "xml_is_well_formed",
+ # Math enhanced functions
+ "round_",
+ "pow",
+ "power",
+ "sqrt",
+ "mod",
+ "ceil",
+ "floor",
+ "trunc",
+ "max_",
+ "min_",
+ "avg",
+ # Array functions
+ "array_agg",
+ "array_append",
+ "array_cat",
+ "array_dims",
+ "array_fill",
+ "array_length",
+ "array_lower",
+ "array_ndims",
+ "array_position",
+ "array_positions",
+ "array_prepend",
+ "array_remove",
+ "array_replace",
+ "array_to_string",
+ "array_upper",
+ "unnest",
+ "array_agg_distinct",
+ "string_to_array",
+ # Network address functions
+ "inet_client_addr",
+ "inet_client_port",
+ "inet_server_addr",
+ "inet_server_port",
+ "inet_merge",
+ "inet_and",
+ "inet_or",
+ "inetnot",
+ "inet_set_mask",
+ "inet_masklen",
+ "inet_netmask",
+ "inet_network",
+ "inet_recv",
+ "inet_show",
+ "cidr_netmask",
+ "macaddr8_set7bit",
+ # UUID functions
+ "uuid_generate_v1",
+ "uuid_generate_v1mc",
+ "uuid_generate_v3",
+ "uuid_generate_v4",
+ "uuid_generate_v5",
+ "uuid_nil",
+ "uuid_max",
+ # hstore functions
+ "hstore_from_record",
+ "hstore_from_key_value",
+ "hstore_akeys",
+ "hstore_skeys",
+ "hstore_avals",
+ "hstore_svals",
+ "hstore_each",
+ "hstore_to_array",
+ "hstore_to_matrix",
+ "hstore_to_json",
+ "hstore_to_jsonb",
+ "hstore_to_json_loose",
+ "hstore_to_jsonb_loose",
+ "hstore_slice",
+ "hstore_exist",
+ "hstore_defined",
+ "hstore_delete",
+ "hstore_delete_keys",
+ "hstore_delete_pairs",
+ "hstore_populate_record",
+ "hstore_get_value",
+ "hstore_get_values",
+ "hstore_concat",
+ "hstore_key_exists",
+ "hstore_all_keys_exist",
+ "hstore_any_key_exists",
+ "hstore_contains",
+ "hstore_contained_by",
+ "hstore_subtract_key",
+ "hstore_subtract_keys",
+ "hstore_subtract_pairs",
+ "hstore_to_array_operator",
+ "hstore_to_matrix_operator",
+ "hstore_record_update",
+ "hstore_subscript_get",
+ "hstore_subscript_set",
+ # Range constructors
+ "int4range",
+ "int8range",
+ "numrange",
+ "tsrange",
+ "tstzrange",
+ "daterange",
]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py
new file mode 100644
index 00000000..45735211
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py
@@ -0,0 +1,390 @@
+# src/rhosocial/activerecord/backend/impl/postgres/functions/array.py
+"""
+PostgreSQL Array Functions.
+
+This module provides SQL expression generators for PostgreSQL array
+functions and operators.
+
+PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-array.html
+
+All functions follow the expression-dialect separation architecture:
+- First parameter is always the dialect instance
+- They return SQL expression strings
+"""
+
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rhosocial.activerecord.backend.dialect import SQLDialectBase
+
+
+def _to_sql(expr: Any) -> str:
+ """Convert an expression to its SQL string representation."""
+ if hasattr(expr, 'to_sql'):
+ return expr.to_sql()[0]
+ return str(expr)
+
+
+def array_agg(dialect: "SQLDialectBase", expression: Any) -> str:
+ """
+ Aggregate function that collects values into an array.
+
+ Args:
+ dialect: The SQL dialect instance
+ expression: The expression to aggregate
+
+ Returns:
+ SQL expression: array_agg(expression)
+
+ Example:
+ >>> array_agg(dialect, "SELECT value FROM table")
+ 'array_agg(value)'
+ """
+ return f"array_agg({_to_sql(expression)})"
+
+
+def array_append(dialect: "SQLDialectBase", array: Any, element: Any) -> str:
+ """
+ Append an element to the end of an array.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ element: The element to append
+
+ Returns:
+ SQL expression: array_append(array, element)
+
+ Example:
+ >>> array_append(dialect, "'{1,2}'", 3)
+ "array_append('{1,2}', 3)"
+ """
+ return f"array_append({_to_sql(array)}, {_to_sql(element)})"
+
+
+def array_cat(dialect: "SQLDialectBase", array1: Any, array2: Any) -> str:
+ """
+ Concatenate two arrays.
+
+ Args:
+ dialect: The SQL dialect instance
+ array1: First array
+ array2: Second array
+
+ Returns:
+ SQL expression: array_cat(array1, array2)
+
+ Example:
+ >>> array_cat(dialect, "'{1,2}'", "'{3,4}'")
+ "array_cat('{1,2}', '{3,4}')"
+ """
+ return f"array_cat({_to_sql(array1)}, {_to_sql(array2)})"
+
+
+def array_dims(dialect: "SQLDialectBase", array: Any) -> str:
+ """
+ Return the dimensions of an array as text.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+
+ Returns:
+ SQL expression: array_dims(array)
+
+ Example:
+ >>> array_dims(dialect, "'{{1,2},{3,4}}'")
+ "array_dims('{{1,2},{3,4}}')"
+ """
+ return f"array_dims({_to_sql(array)})"
+
+
+def array_fill(dialect: "SQLDialectBase", value: Any, dims: Any) -> str:
+ """
+ Return an array filled with a value for specified dimensions.
+
+ Args:
+ dialect: The SQL dialect instance
+ value: The value to fill the array with
+ dims: The array dimensions
+
+ Returns:
+ SQL expression: array_fill(value, dims)
+
+ Example:
+ >>> array_fill(dialect, 0, "'{3,3}'")
+ "array_fill(0, '{3,3}')"
+ """
+ return f"array_fill({_to_sql(value)}, {_to_sql(dims)})"
+
+
+def array_length(dialect: "SQLDialectBase", array: Any, dimension: int = 1) -> str:
+ """
+ Return the length of an array for the specified dimension.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ dimension: The dimension (1-indexed, default 1)
+
+ Returns:
+ SQL expression: array_length(array, dimension)
+
+ Example:
+ >>> array_length(dialect, "'{1,2,3}'", 1)
+ "array_length('{1,2,3}', 1)"
+ """
+ return f"array_length({_to_sql(array)}, {dimension})"
+
+
+def array_lower(dialect: "SQLDialectBase", array: Any, dimension: int = 1) -> str:
+ """
+ Return the lower bound of an array's dimension.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ dimension: The dimension (1-indexed, default 1)
+
+ Returns:
+ SQL expression: array_lower(array, dimension)
+
+ Example:
+ >>> array_lower(dialect, "'[1:3]={1,2,3}'", 1)
+ "array_lower('[1:3]={1,2,3}', 1)"
+ """
+ return f"array_lower({_to_sql(array)}, {dimension})"
+
+
+def array_ndims(dialect: "SQLDialectBase", array: Any) -> str:
+ """
+ Return the number of dimensions of an array.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+
+ Returns:
+ SQL expression: array_ndims(array)
+
+ Example:
+ >>> array_ndims(dialect, "'{{1,2},{3,4}}'")
+ "array_ndims('{{1,2},{3,4}}')"
+ """
+ return f"array_ndims({_to_sql(array)})"
+
+
+def array_position(dialect: "SQLDialectBase", array: Any, element: Any) -> str:
+ """
+ Return the position of the first occurrence of element in array.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ element: The element to find
+
+ Returns:
+ SQL expression: array_position(array, element)
+
+ Example:
+ >>> array_position(dialect, "'{a,b,c}'", "'b'")
+ "array_position('{a,b,c}', 'b')"
+ """
+ return f"array_position({_to_sql(array)}, {_to_sql(element)})"
+
+
+def array_positions(dialect: "SQLDialectBase", array: Any, element: Any) -> str:
+ """
+ Return an array of positions of element in array.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ element: The element to find
+
+ Returns:
+ SQL expression: array_positions(array, element)
+
+ Example:
+ >>> array_positions(dialect, "'[1,3,5,3]'", 3)
+ "array_positions('[1,3,5,3]', 3)"
+ """
+ return f"array_positions({_to_sql(array)}, {_to_sql(element)})"
+
+
+def array_prepend(dialect: "SQLDialectBase", element: Any, array: Any) -> str:
+ """
+ Prepend an element to the beginning of an array.
+
+ Args:
+ dialect: The SQL dialect instance
+ element: The element to prepend
+ array: The array expression
+
+ Returns:
+ SQL expression: array_prepend(element, array)
+
+ Example:
+ >>> array_prepend(dialect, 1, "'{2,3}'")
+ "array_prepend(1, '{2,3}')"
+ """
+ return f"array_prepend({_to_sql(element)}, {_to_sql(array)})"
+
+
+def array_remove(dialect: "SQLDialectBase", array: Any, element: Any) -> str:
+ """
+ Remove all elements equal to the specified value from array.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ element: The element to remove
+
+ Returns:
+ SQL expression: array_remove(array, element)
+
+ Example:
+ >>> array_remove(dialect, "'{1,2,3,2}'", 2)
+ "array_remove('{1,2,3,2}', 2)"
+ """
+ return f"array_remove({_to_sql(array)}, {_to_sql(element)})"
+
+
+def array_replace(dialect: "SQLDialectBase", array: Any, search: Any, replace: Any) -> str:
+ """
+ Replace all occurrences of search with replace in array.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ search: The value to search for
+ replace: The value to replace with
+
+ Returns:
+ SQL expression: array_replace(array, search, replace)
+
+ Example:
+ >>> array_replace(dialect, "'{1,2,3}'", 2, 4)
+ "array_replace('{1,2,3}', 2, 4)"
+ """
+ return f"array_replace({_to_sql(array)}, {_to_sql(search)}, {_to_sql(replace)})"
+
+
+def array_to_string(dialect: "SQLDialectBase", array: Any, delimiter: str) -> str:
+ """
+ Convert array to string using delimiter.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ delimiter: The delimiter string
+
+ Returns:
+ SQL expression: array_to_string(array, delimiter)
+
+ Example:
+ >>> array_to_string(dialect, "'{1,2,3}'", ',')
+ "array_to_string('{1,2,3}', ',')"
+ """
+ return f"array_to_string({_to_sql(array)}, '{delimiter}')"
+
+
+def array_upper(dialect: "SQLDialectBase", array: Any, dimension: int = 1) -> str:
+ """
+ Return the upper bound of an array's dimension.
+
+ Args:
+ dialect: The SQL dialect instance
+ array: The array expression
+ dimension: The dimension (1-indexed, default 1)
+
+ Returns:
+ SQL expression: array_upper(array, dimension)
+
+ Example:
+ >>> array_upper(dialect, "'[1:3]={1,2,3}'", 1)
+ "array_upper('[1:3]={1,2,3}', 1)"
+ """
+ return f"array_upper({_to_sql(array)}, {dimension})"
+
+
+def unnest(dialect: "SQLDialectBase", *arrays: Any) -> str:
+ """
+ Expand an array into a set of rows.
+
+ Args:
+ dialect: The SQL dialect instance
+ *arrays: One or more array expressions
+
+ Returns:
+ SQL expression: unnest(array1, array2, ...)
+
+ Example:
+ >>> unnest(dialect, "'{1,2}'", "'{a,b}'")
+ "unnest('{1,2}', '{a,b}')"
+ """
+ if not arrays:
+ return "unnest()"
+ return f"unnest({', '.join(_to_sql(a) for a in arrays)})"
+
+
+def array_agg_distinct(dialect: "SQLDialectBase", expression: Any) -> str:
+ """
+ Aggregate function that collects distinct values into an array.
+
+ Args:
+ dialect: The SQL dialect instance
+ expression: The expression to aggregate
+
+ Returns:
+ SQL expression: array_agg(DISTINCT expression)
+
+ Example:
+ >>> array_agg_distinct(dialect, "SELECT value FROM table")
+ 'array_agg(DISTINCT value)'
+ """
+ return f"array_agg(DISTINCT {_to_sql(expression)})"
+
+
+def string_to_array(dialect: "SQLDialectBase", string: Any, delimiter: str, null_string: str = None) -> str:
+ """
+ Split string into array using delimiter.
+
+ Args:
+ dialect: The SQL dialect instance
+ string: The input string
+ delimiter: The delimiter string
+ null_string: If specified, occurrences of this string in string are replaced by NULL
+
+ Returns:
+ SQL expression: string_to_array(string, delimiter[, null_string])
+
+ Example:
+ >>> string_to_array(dialect, "'a,b,c'", ',')
+ "string_to_array('a,b,c', ',')"
+ """
+ if null_string is not None:
+ return f"string_to_array({_to_sql(string)}, '{delimiter}', '{null_string}')"
+ return f"string_to_array({_to_sql(string)}, '{delimiter}')"
+
+
+__all__ = [
+ "array_agg",
+ "array_append",
+ "array_cat",
+ "array_dims",
+ "array_fill",
+ "array_length",
+ "array_lower",
+ "array_ndims",
+ "array_position",
+ "array_positions",
+ "array_prepend",
+ "array_remove",
+ "array_replace",
+ "array_to_string",
+ "array_upper",
+ "unnest",
+ "array_agg_distinct",
+ "string_to_array",
+]
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py
new file mode 100644
index 00000000..ec63b7ae
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py
@@ -0,0 +1,750 @@
+# src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py
+"""
+PostgreSQL hstore functions for SQL expression generation.
+
+This module provides utility functions for generating PostgreSQL hstore SQL expressions.
+
+hstore is a PostgreSQL extension that stores key/value pairs in a single value.
+
+PostgreSQL Documentation: https://www.postgresql.org/docs/current/hstore.html
+
+Supported functions:
+- Constructors: hstore(record), hstore(text, text)
+- Key/Value Extraction: akeys, skeys, avals, svals, each
+- Conversion: hstore_to_array, hstore_to_matrix, hstore_to_json, hstore_to_jsonb
+- Subsets: slice
+- Existence: exist, defined
+- Delete: delete
+- Record: populate_record
+
+Supported operators:
+- -> text - Access single key
+- -> text[] - Access multiple keys
+- || - Concatenate
+- ? - Key exists
+- ?& - All keys exist
+- ?| - Any key exists
+- @> - Contains
+- <@ - Contained by
+- - text - Delete by key
+- - text[] - Delete by keys
+- - hstore - Delete matching pairs
+- %% - To array
+- %# - To matrix
+- #= - Record update
+"""
+
+from typing import Any
+
+
+def _to_sql(expr: Any) -> str:
+ """Convert an expression to its SQL string representation."""
+ if hasattr(expr, 'to_sql'):
+ return expr.to_sql()[0]
+ return str(expr)
+
+
+# ============== Constructors ==============
+
+def hstore_from_record(record: str) -> str:
+ """
+ Construct hstore from a record/row.
+
+ Args:
+ record: The record expression (e.g., 'ROW(1,2)' or table column)
+
+ Returns:
+ SQL expression: hstore(record)
+
+ Example:
+ >>> hstore_from_record("ROW(1,2)")
+ "hstore(ROW(1,2))"
+ """
+ return f"hstore({record})"
+
+
+def hstore_from_key_value(key: str, value: str) -> str:
+ """
+ Construct hstore from a single key/value pair.
+
+ Args:
+ key: The key string
+ value: The value string
+
+ Returns:
+ SQL expression: hstore(key, value)
+
+ Example:
+ >>> hstore_from_key_value("'name'", "'value'")
+ "hstore('name', 'value')"
+ """
+ return f"hstore({key}, {value})"
+
+
+# ============== Key/Value Extraction ==============
+
+def hstore_akeys(hstore_expr: str) -> str:
+ """
+ Get all keys from hstore as a text array.
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: akeys(hstore)
+
+ Example:
+ >>> hstore_akeys("data")
+ "akeys(data)"
+ """
+ return f"akeys({hstore_expr})"
+
+
+def hstore_skeys(hstore_expr: str) -> str:
+ """
+ Get all keys from hstore as a set.
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: skeys(hstore)
+
+ Example:
+ >>> hstore_skeys("data")
+ "skeys(data)"
+ """
+ return f"skeys({hstore_expr})"
+
+
+def hstore_avals(hstore_expr: str) -> str:
+ """
+ Get all values from hstore as a text array.
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: avals(hstore)
+
+ Example:
+ >>> hstore_avals("data")
+ "avals(data)"
+ """
+ return f"avals({hstore_expr})"
+
+
+def hstore_svals(hstore_expr: str) -> str:
+ """
+ Get all values from hstore as a set.
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: svals(hstore)
+
+ Example:
+ >>> hstore_svals("data")
+ "svals(data)"
+ """
+ return f"svals({hstore_expr})"
+
+
+def hstore_each(hstore_expr: str) -> str:
+ """
+ Get all key/value pairs from hstore as a set of (key, value) records.
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: each(hstore)
+
+ Example:
+ >>> hstore_each("data")
+ "each(data)"
+ """
+ return f"each({hstore_expr})"
+
+
+# ============== Conversion Functions ==============
+
+def hstore_to_array(hstore_expr: str) -> str:
+ """
+ Convert hstore to text array (alternating keys and values).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: hstore_to_array(hstore)
+
+ Example:
+ >>> hstore_to_array("data")
+ "hstore_to_array(data)"
+ """
+ return f"hstore_to_array({hstore_expr})"
+
+
+def hstore_to_matrix(hstore_expr: str) -> str:
+ """
+ Convert hstore to 2D text array (key/value pairs as rows).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: hstore_to_matrix(hstore)
+
+ Example:
+ >>> hstore_to_matrix("data")
+ "hstore_to_matrix(data)"
+ """
+ return f"hstore_to_matrix({hstore_expr})"
+
+
+def hstore_to_json(hstore_expr: str) -> str:
+ """
+ Convert hstore to JSON (values as strings).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: hstore_to_json(hstore)
+
+ Example:
+ >>> hstore_to_json("data")
+ "hstore_to_json(data)"
+ """
+ return f"hstore_to_json({hstore_expr})"
+
+
+def hstore_to_jsonb(hstore_expr: str) -> str:
+ """
+ Convert hstore to JSONB (values as strings).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: hstore_to_jsonb(hstore)
+
+ Example:
+ >>> hstore_to_jsonb("data")
+ "hstore_to_jsonb(data)"
+ """
+ return f"hstore_to_jsonb({hstore_expr})"
+
+
+def hstore_to_json_loose(hstore_expr: str) -> str:
+ """
+ Convert hstore to JSON with loose type inference.
+
+ Attempts to infer proper JSON types for values (numeric, boolean, etc).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: hstore_to_json_loose(hstore)
+
+ Example:
+ >>> hstore_to_json_loose("data")
+ "hstore_to_json_loose(data)"
+ """
+ return f"hstore_to_json_loose({hstore_expr})"
+
+
+def hstore_to_jsonb_loose(hstore_expr: str) -> str:
+ """
+ Convert hstore to JSONB with loose type inference.
+
+ Attempts to infer proper JSON types for values (numeric, boolean, etc).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: hstore_to_jsonb_loose(hstore)
+
+ Example:
+ >>> hstore_to_jsonb_loose("data")
+ "hstore_to_jsonb_loose(data)"
+ """
+ return f"hstore_to_jsonb_loose({hstore_expr})"
+
+
+# ============== Subset Functions ==============
+
+def hstore_slice(hstore_expr: str, keys: str) -> str:
+ """
+ Extract subset of hstore by keys.
+
+ Args:
+ hstore_expr: The hstore expression
+ keys: Array of keys to extract (e.g., "ARRAY['a', 'b']")
+
+ Returns:
+ SQL expression: slice(hstore, keys)
+
+ Example:
+ >>> hstore_slice("data", "ARRAY['a', 'b']")
+ "slice(data, ARRAY['a', 'b'])"
+ """
+ return f"slice({hstore_expr}, {keys})"
+
+
+# ============== Existence Functions ==============
+
+def hstore_exist(hstore_expr: str, key: str) -> str:
+ """
+ Check if key exists in hstore (including NULL values).
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to check
+
+ Returns:
+ SQL expression: exist(hstore, key)
+
+ Example:
+ >>> hstore_exist("data", "'name'")
+ "exist(data, 'name')"
+ """
+ return f"exist({hstore_expr}, {key})"
+
+
+def hstore_defined(hstore_expr: str, key: str) -> str:
+ """
+ Check if key exists and has a non-NULL value.
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to check
+
+ Returns:
+ SQL expression: defined(hstore, key)
+
+ Example:
+ >>> hstore_defined("data", "'name'")
+ "defined(data, 'name')"
+ """
+ return f"defined({hstore_expr}, {key})"
+
+
+# ============== Delete Functions ==============
+
+def hstore_delete(hstore_expr: str, key: str) -> str:
+ """
+ Delete a key from hstore.
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to delete
+
+ Returns:
+ SQL expression: delete(hstore, key)
+
+ Example:
+ >>> hstore_delete("data", "'name'")
+ "delete(data, 'name')"
+ """
+ return f"delete({hstore_expr}, {key})"
+
+
+def hstore_delete_keys(hstore_expr: str, keys: str) -> str:
+ """
+ Delete multiple keys from hstore.
+
+ Args:
+ hstore_expr: The hstore expression
+ keys: Array of keys to delete (e.g., "ARRAY['a', 'b']")
+
+ Returns:
+ SQL expression: delete(hstore, keys)
+
+ Example:
+ >>> hstore_delete_keys("data", "ARRAY['name', 'age']")
+ "delete(data, ARRAY['name', 'age'])"
+ """
+ return f"delete({hstore_expr}, {keys})"
+
+
+def hstore_delete_pairs(hstore_expr: str, pairs: str) -> str:
+ """
+ Delete matching key/value pairs from hstore.
+
+ Args:
+ hstore_expr: The hstore expression
+ pairs: The hstore containing keys/values to delete
+
+ Returns:
+ SQL expression: delete(hstore, hstore)
+
+ Example:
+ >>> hstore_delete_pairs("data", "'a=>1'::hstore")
+ "delete(data, 'a=>1'::hstore)"
+ """
+ return f"delete({hstore_expr}, {pairs})"
+
+
+# ============== Record Functions ==============
+
+def hstore_populate_record(record: str, hstore_expr: str) -> str:
+ """
+ Update a record/row with values from hstore.
+
+ Args:
+ record: The record expression to update
+ hstore_expr: The hstore containing field values
+
+ Returns:
+ SQL expression: populate_record(record, hstore)
+
+ Example:
+ >>> hstore_populate_record("ROW(1,2)", "'f1=>42'::hstore")
+ "populate_record(ROW(1,2), 'f1=>42'::hstore)"
+ """
+ return f"populate_record({record}, {hstore_expr})"
+
+
+# ============== Operators ==============
+
+def hstore_get_value(hstore_expr: str, key: str) -> str:
+ """
+ Get value by key (operator ->).
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to access
+
+ Returns:
+ SQL expression: hstore -> key
+
+ Example:
+ >>> hstore_get_value("data", "'name'")
+ "data -> 'name'"
+ """
+ return f"{hstore_expr} -> {key}"
+
+
+def hstore_get_values(hstore_expr: str, keys: str) -> str:
+ """
+ Get values by multiple keys (operator -> text[]).
+
+ Args:
+ hstore_expr: The hstore expression
+ keys: Array of keys to access
+
+ Returns:
+ SQL expression: hstore -> text[]
+
+ Example:
+ >>> hstore_get_values("data", "ARRAY['name', 'age']")
+ "data -> ARRAY['name', 'age']"
+ """
+ return f"{hstore_expr} -> {keys}"
+
+
+def hstore_concat(left: str, right: str) -> str:
+ """
+ Concatenate two hstores (operator ||).
+
+ Args:
+ left: Left hstore expression
+ right: Right hstore expression
+
+ Returns:
+ SQL expression: hstore || hstore
+
+ Example:
+ >>> hstore_concat("'a=>1'::hstore", "'b=>2'::hstore")
+ "'a=>1'::hstore || 'b=>2'::hstore"
+ """
+ return f"{left} || {right}"
+
+
+def hstore_key_exists(hstore_expr: str, key: str) -> str:
+ """
+ Check if key exists (operator ?).
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to check
+
+ Returns:
+ SQL expression: hstore ? key
+
+ Example:
+ >>> hstore_key_exists("data", "'name'")
+ "data ? 'name'"
+ """
+ return f"{hstore_expr} ? {key}"
+
+
+def hstore_all_keys_exist(hstore_expr: str, keys: str) -> str:
+ """
+ Check if all keys exist (operator ?&).
+
+ Args:
+ hstore_expr: The hstore expression
+ keys: Array of keys that must all exist
+
+ Returns:
+ SQL expression: hstore ?& keys
+
+ Example:
+ >>> hstore_all_keys_exist("data", "ARRAY['a', 'b']")
+ "data ?& ARRAY['a', 'b']"
+ """
+ return f"{hstore_expr} ?& {keys}"
+
+
+def hstore_any_key_exists(hstore_expr: str, keys: str) -> str:
+ """
+ Check if any key exists (operator ?|).
+
+ Args:
+ hstore_expr: The hstore expression
+ keys: Array of keys where any must exist
+
+ Returns:
+ SQL expression: hstore ?| keys
+
+ Example:
+ >>> hstore_any_key_exists("data", "ARRAY['a', 'b']")
+ "data ?| ARRAY['a', 'b']"
+ """
+ return f"{hstore_expr} ?| {keys}"
+
+
+def hstore_contains(left: str, right: str) -> str:
+ """
+ Check if left hstore contains right (operator @>).
+
+ Args:
+ left: Left hstore expression
+ right: Right hstore expression
+
+ Returns:
+ SQL expression: hstore @> hstore
+
+ Example:
+ >>> hstore_contains("data", "'a=>1'")
+ "data @> 'a=>1'"
+ """
+ return f"{left} @> {right}"
+
+
+def hstore_contained_by(left: str, right: str) -> str:
+ """
+ Check if left hstore is contained by right (operator <@).
+
+ Args:
+ left: Left hstore expression
+ right: Right hstore expression
+
+ Returns:
+ SQL expression: hstore <@ hstore
+
+ Example:
+ >>> hstore_contained_by("'a=>1'", "data")
+ "'a=>1' <@ data"
+ """
+ return f"{left} <@ {right}"
+
+
+def hstore_subtract_key(hstore_expr: str, key: str) -> str:
+ """
+ Delete key from hstore (operator - text).
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to delete
+
+ Returns:
+ SQL expression: hstore - key
+
+ Example:
+ >>> hstore_subtract_key("data", "'name'")
+ "data - 'name'"
+ """
+ return f"{hstore_expr} - {key}"
+
+
+def hstore_subtract_keys(hstore_expr: str, keys: str) -> str:
+ """
+ Delete multiple keys from hstore (operator - text[]).
+
+ Args:
+ hstore_expr: The hstore expression
+ keys: Array of keys to delete
+
+ Returns:
+ SQL expression: hstore - text[]
+
+ Example:
+ >>> hstore_subtract_keys("data", "ARRAY['a', 'b']")
+ "data - ARRAY['a', 'b']"
+ """
+ return f"{hstore_expr} - {keys}"
+
+
+def hstore_subtract_pairs(left: str, right: str) -> str:
+ """
+ Delete matching pairs from hstore (operator - hstore).
+
+ Args:
+ left: Left hstore expression
+ right: Right hstore containing pairs to delete
+
+ Returns:
+ SQL expression: hstore - hstore
+
+ Example:
+ >>> hstore_subtract_pairs("data", "'a=>1'::hstore")
+ "data - 'a=>1'::hstore"
+ """
+ return f"{left} - {right}"
+
+
+def hstore_to_array_operator(hstore_expr: str) -> str:
+ """
+ Convert hstore to alternating key/value array (operator %%).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: %%hstore
+
+ Example:
+ >>> hstore_to_array_operator("data")
+ "%%data"
+ """
+ return f"%%{hstore_expr}"
+
+
+def hstore_to_matrix_operator(hstore_expr: str) -> str:
+ """
+ Convert hstore to 2D key/value array (operator %#).
+
+ Args:
+ hstore_expr: The hstore expression
+
+ Returns:
+ SQL expression: %#hstore
+
+ Example:
+ >>> hstore_to_matrix_operator("data")
+ "%#data"
+ """
+ return f"%#{hstore_expr}"
+
+
+def hstore_record_update(record: str, hstore_expr: str) -> str:
+ """
+ Update record fields from hstore (operator #=).
+
+ Args:
+ record: The record expression to update
+ hstore_expr: The hstore containing field values
+
+ Returns:
+ SQL expression: record #= hstore
+
+ Example:
+ >>> record_update("ROW(1,2)", "'f1=>42'::hstore")
+ "ROW(1,2) #= 'f1=>42'::hstore"
+ """
+ return f"{record} #= {hstore_expr}"
+
+
+# ============== Subscript Access ==============
+
+def hstore_subscript_get(hstore_expr: str, key: str) -> str:
+ """
+ Get value by key using subscript syntax.
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to access
+
+ Returns:
+ SQL expression: hstore[key]
+
+ Example:
+ >>> hstore_subscript_get("data", "'name'")
+ "data['name']"
+ """
+ return f"{hstore_expr}[{key}]"
+
+
+def hstore_subscript_set(hstore_expr: str, key: str, value: str) -> str:
+ """
+ Set value by key using subscript syntax.
+
+ Args:
+ hstore_expr: The hstore expression
+ key: The key to set
+ value: The value to set
+
+ Returns:
+ SQL expression: hstore[key] = value
+
+ Example:
+ >>> hstore_subscript_set("data", "'name'", "'value'")
+ "data['name'] = 'value'"
+ """
+ return f"{hstore_expr}[{key}] = {value}"
+
+
+__all__ = [
+ # Constructors
+ "hstore_from_record",
+ "hstore_from_key_value",
+ # Key/Value Extraction
+ "hstore_akeys",
+ "hstore_skeys",
+ "hstore_avals",
+ "hstore_svals",
+ "hstore_each",
+ # Conversion
+ "hstore_to_array",
+ "hstore_to_matrix",
+ "hstore_to_json",
+ "hstore_to_jsonb",
+ "hstore_to_json_loose",
+ "hstore_to_jsonb_loose",
+ # Subset
+ "hstore_slice",
+ # Existence
+ "hstore_exist",
+ "hstore_defined",
+ # Delete
+ "hstore_delete",
+ "hstore_delete_keys",
+ "hstore_delete_pairs",
+ # Record
+ "hstore_populate_record",
+ # Operators
+ "hstore_get_value",
+ "hstore_get_values",
+ "hstore_concat",
+ "hstore_key_exists",
+ "hstore_all_keys_exist",
+ "hstore_any_key_exists",
+ "hstore_contains",
+ "hstore_contained_by",
+ "hstore_subtract_key",
+ "hstore_subtract_keys",
+ "hstore_subtract_pairs",
+ "hstore_to_array_operator",
+ "hstore_to_matrix_operator",
+ "hstore_record_update",
+ # Subscript
+ "hstore_subscript_get",
+ "hstore_subscript_set",
+]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py
new file mode 100644
index 00000000..0f47c41b
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py
@@ -0,0 +1,381 @@
+# src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py
+"""
+PostgreSQL enhanced math function factories.
+
+Additional mathematical functions beyond the basic math module.
+Includes: round, pow, power, sqrt, mod, ceil, floor, trunc, max, min, avg
+
+Functions: round_, pow, power, sqrt, mod, ceil, floor, trunc, max_, min_, avg
+"""
+
+from typing import Union, TYPE_CHECKING
+
+from rhosocial.activerecord.backend.expression import bases, core
+
+if TYPE_CHECKING: # pragma: no cover
+ from rhosocial.activerecord.backend.dialect import SQLDialectBase
+
+
+def _convert_to_expression(
+ dialect: "SQLDialectBase",
+ expr: Union[str, "bases.BaseExpression"],
+) -> "bases.BaseExpression":
+ """
+ Helper function to convert an input value to an appropriate BaseExpression.
+
+ Args:
+ dialect: The SQL dialect instance
+ expr: The expression to convert
+
+ Returns:
+ A BaseExpression instance
+ """
+ if isinstance(expr, bases.BaseExpression):
+ return expr
+ elif isinstance(expr, (int, float)):
+ return core.Literal(dialect, expr)
+ elif isinstance(expr, str):
+ # Try to parse as number first
+ try:
+ return core.Literal(dialect, float(expr) if '.' in expr else int(expr))
+ except ValueError:
+ # Not a number, treat as column name
+ return core.Column(dialect, expr)
+ else:
+ return core.Column(dialect, expr)
+
+
+def round_(
+ dialect: "SQLDialectBase",
+ expr: Union[str, "bases.BaseExpression"],
+ precision: int = 0,
+) -> "core.FunctionCall":
+ """
+ Creates a ROUND function call.
+
+ Rounds a numeric value to the specified number of decimal places.
+
+ Usage:
+ - round_(dialect, Column("price")) -> ROUND("price")
+ - round_(dialect, Column("price"), 2) -> ROUND("price", 2)
+
+ Args:
+ dialect: The SQL dialect instance
+ expr: The numeric expression to round
+ precision: Number of decimal places (default 0)
+
+ Returns:
+ A FunctionCall instance representing the ROUND function
+
+ Version: All PostgreSQL versions
+ """
+ target_expr = _convert_to_expression(dialect, expr)
+ precision_expr = core.Literal(dialect, precision)
+ return core.FunctionCall(dialect, "ROUND", target_expr, precision_expr)
+
+
+def pow(
+ dialect: "SQLDialectBase",
+ base: Union[str, "bases.BaseExpression"],
+ exponent: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a POW function call.
+
+ Returns the value of base raised to the power of exponent.
+
+ Usage:
+ - pow(dialect, 2, 3) -> POW(2, 3)
+ - pow(dialect, Column("x"), 2) -> POW("x", 2)
+
+ Args:
+ dialect: The SQL dialect instance
+ base: The base value
+ exponent: The exponent value
+
+ Returns:
+ A FunctionCall instance representing the POW function
+
+ Version: All PostgreSQL versions
+ """
+ base_expr = _convert_to_expression(dialect, base)
+ exp_expr = _convert_to_expression(dialect, exponent)
+ return core.FunctionCall(dialect, "POW", base_expr, exp_expr)
+
+
+def power(
+ dialect: "SQLDialectBase",
+ base: Union[str, "bases.BaseExpression"],
+ exponent: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a POWER function call (alias for POW).
+
+ Returns the value of base raised to the power of exponent.
+
+ Usage:
+ - power(dialect, 2, 3) -> POWER(2, 3)
+ - power(dialect, Column("x"), 2) -> POWER("x", 2)
+
+ Args:
+ dialect: The SQL dialect instance
+ base: The base value
+ exponent: The exponent value
+
+ Returns:
+ A FunctionCall instance representing the POWER function
+
+ Version: All PostgreSQL versions
+ """
+ base_expr = _convert_to_expression(dialect, base)
+ exp_expr = _convert_to_expression(dialect, exponent)
+ return core.FunctionCall(dialect, "POWER", base_expr, exp_expr)
+
+
+def sqrt(
+ dialect: "SQLDialectBase",
+ expr: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a SQRT function call.
+
+ Returns the square root of the argument.
+
+ Usage:
+ - sqrt(dialect, 16) -> SQRT(16)
+ - sqrt(dialect, Column("value")) -> SQRT("value")
+
+ Args:
+ dialect: The SQL dialect instance
+ expr: The numeric expression
+
+ Returns:
+ A FunctionCall instance representing the SQRT function
+
+ Version: All PostgreSQL versions
+ """
+ target_expr = _convert_to_expression(dialect, expr)
+ return core.FunctionCall(dialect, "SQRT", target_expr)
+
+
+def mod(
+ dialect: "SQLDialectBase",
+ dividend: Union[str, "bases.BaseExpression"],
+ divisor: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a MOD function call.
+
+ Returns the remainder of dividend divided by divisor.
+
+ Usage:
+ - mod(dialect, 10, 3) -> MOD(10, 3)
+ - mod(dialect, Column("total"), Column("count")) -> MOD("total", "count")
+
+ Args:
+ dialect: The SQL dialect instance
+ dividend: The dividend value
+ divisor: The divisor value
+
+ Returns:
+ A FunctionCall instance representing the MOD function
+
+ Version: All PostgreSQL versions
+ """
+ dividend_expr = _convert_to_expression(dialect, dividend)
+ divisor_expr = _convert_to_expression(dialect, divisor)
+ return core.FunctionCall(dialect, "MOD", dividend_expr, divisor_expr)
+
+
+def ceil(
+ dialect: "SQLDialectBase",
+ expr: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a CEIL function call.
+
+ Returns the smallest integer greater than or equal to the argument.
+
+ Usage:
+ - ceil(dialect, 3.14) -> CEIL(3.14)
+ - ceil(dialect, Column("value")) -> CEIL("value")
+
+ Args:
+ dialect: The SQL dialect instance
+ expr: The numeric expression
+
+ Returns:
+ A FunctionCall instance representing the CEIL function
+
+ Version: All PostgreSQL versions
+ """
+ target_expr = _convert_to_expression(dialect, expr)
+ return core.FunctionCall(dialect, "CEIL", target_expr)
+
+
+def floor(
+ dialect: "SQLDialectBase",
+ expr: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a FLOOR function call.
+
+ Returns the largest integer less than or equal to the argument.
+
+ Usage:
+ - floor(dialect, 3.14) -> FLOOR(3.14)
+ - floor(dialect, Column("value")) -> FLOOR("value")
+
+ Args:
+ dialect: The SQL dialect instance
+ expr: The numeric expression
+
+ Returns:
+ A FunctionCall instance representing the FLOOR function
+
+ Version: All PostgreSQL versions
+ """
+ target_expr = _convert_to_expression(dialect, expr)
+ return core.FunctionCall(dialect, "FLOOR", target_expr)
+
+
+def trunc(
+ dialect: "SQLDialectBase",
+ expr: Union[str, "bases.BaseExpression"],
+ precision: int = 0,
+) -> "core.FunctionCall":
+ """
+ Creates a TRUNC function call.
+
+ Returns the value truncated to the specified number of decimal places.
+
+ Usage:
+ - trunc(dialect, 3.14) -> TRUNC(3.14)
+ - trunc(dialect, 3.14, 2) -> TRUNC(3.14, 2)
+ - trunc(dialect, Column("price"), 2) -> TRUNC("price", 2)
+
+ Args:
+ dialect: The SQL dialect instance
+ expr: The numeric expression to truncate
+ precision: Number of decimal places (default 0)
+
+ Returns:
+ A FunctionCall instance representing the TRUNC function
+
+ Version: All PostgreSQL versions
+ """
+ target_expr = _convert_to_expression(dialect, expr)
+ precision_expr = core.Literal(dialect, precision)
+ return core.FunctionCall(dialect, "TRUNC", target_expr, precision_expr)
+
+
+def max_(
+ dialect: "SQLDialectBase",
+ expr1: Union[str, "bases.BaseExpression"],
+ expr2: Union[str, "bases.BaseExpression"],
+ *more: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a GREATEST or MAX function call.
+
+ Returns the maximum value among the arguments.
+
+ Usage:
+ - max_(dialect, 1, 2) -> GREATEST(1, 2)
+ - max_(dialect, Column("a"), Column("b")) -> GREATEST("a", "b")
+
+ Note:
+ PostgreSQL's GREATEST and LEAST are scalar functions.
+ For aggregate MAX/MIN, use the single-argument form.
+
+ Args:
+ dialect: The SQL dialect instance
+ expr1: First expression
+ expr2: Second expression
+ *more: Additional expressions
+
+ Returns:
+ A FunctionCall instance representing GREATEST
+
+ Version: All PostgreSQL versions
+ """
+ exprs = [_convert_to_expression(dialect, expr1), _convert_to_expression(dialect, expr2)]
+ for m in more:
+ exprs.append(_convert_to_expression(dialect, m))
+ return core.FunctionCall(dialect, "GREATEST", *exprs)
+
+
+def min_(
+ dialect: "SQLDialectBase",
+ expr1: Union[str, "bases.BaseExpression"],
+ expr2: Union[str, "bases.BaseExpression"],
+ *more: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates a LEAST or MIN function call.
+
+ Returns the minimum value among the arguments.
+
+ Usage:
+ - min_(dialect, 1, 2) -> LEAST(1, 2)
+ - min_(dialect, Column("a"), Column("b")) -> LEAST("a", "b")
+
+ Note:
+ PostgreSQL's GREATEST and LEAST are scalar functions.
+ For aggregate MIN/MAX, use the single-argument form.
+
+ Args:
+ dialect: The SQL dialect instance
+ expr1: First expression
+ expr2: Second expression
+ *more: Additional expressions
+
+ Returns:
+ A FunctionCall instance representing LEAST
+
+ Version: All PostgreSQL versions
+ """
+ exprs = [_convert_to_expression(dialect, expr1), _convert_to_expression(dialect, expr2)]
+ for m in more:
+ exprs.append(_convert_to_expression(dialect, m))
+ return core.FunctionCall(dialect, "LEAST", *exprs)
+
+
+def avg(
+ dialect: "SQLDialectBase",
+ expr: Union[str, "bases.BaseExpression"],
+) -> "core.FunctionCall":
+ """
+ Creates an AVG aggregate function call.
+
+ Returns the average value of all non-NULL values in a group.
+
+ Usage:
+ - avg(dialect, Column("price")) -> AVG("price")
+
+ Args:
+ dialect: The SQL dialect instance
+ expr: The expression to average
+
+ Returns:
+ A FunctionCall instance representing the AVG function
+
+ Version: All PostgreSQL versions
+ """
+ target_expr = _convert_to_expression(dialect, expr)
+ return core.FunctionCall(dialect, "AVG", target_expr)
+
+
+__all__ = [
+ "round_",
+ "pow",
+ "power",
+ "sqrt",
+ "mod",
+ "ceil",
+ "floor",
+ "trunc",
+ "max_",
+ "min_",
+ "avg",
+]
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py
new file mode 100644
index 00000000..1d8966de
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py
@@ -0,0 +1,321 @@
+# src/rhosocial/activerecord/backend/impl/postgres/functions/network.py
+"""
+PostgreSQL Network Address Functions.
+
+This module provides SQL expression generators for PostgreSQL network
+address functions and operators.
+
+PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-net.html
+
+All functions follow the expression-dialect separation architecture:
+- First parameter is always the dialect instance
+- They return SQL expression strings
+"""
+
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rhosocial.activerecord.backend.dialect import SQLDialectBase
+
+
+def _to_sql(expr: Any) -> str:
+ """Convert an expression to its SQL string representation."""
+ if hasattr(expr, 'to_sql'):
+ return expr.to_sql()[0]
+ return str(expr)
+
+
+def inet_client_addr(dialect: "SQLDialectBase") -> str:
+ """
+ Return the client's IP address from the current session.
+
+ Returns:
+ SQL expression: inet_client_addr()
+
+ Example:
+ >>> inet_client_addr(dialect)
+ 'inet_client_addr()'
+ """
+ return "inet_client_addr()"
+
+
+def inet_client_port(dialect: "SQLDialectBase") -> str:
+ """
+ Return the client's port number from the current session.
+
+ Returns:
+ SQL expression: inet_client_port()
+
+ Example:
+ >>> inet_client_port(dialect)
+ 'inet_client_port()'
+ """
+ return "inet_client_port()"
+
+
+def inet_server_addr(dialect: "SQLDialectBase") -> str:
+ """
+ Return the server's IP address for the current connection.
+
+ Returns:
+ SQL expression: inet_server_addr()
+
+ Example:
+ >>> inet_server_addr(dialect)
+ 'inet_server_addr()'
+ """
+ return "inet_server_addr()"
+
+
+def inet_server_port(dialect: "SQLDialectBase") -> str:
+ """
+ Return the server's port number for the current connection.
+
+ Returns:
+ SQL expression: inet_server_port()
+
+ Example:
+ >>> inet_server_port(dialect)
+ 'inet_server_port()'
+ """
+ return "inet_server_port()"
+
+
+def inet_merge(dialect: "SQLDialectBase", net1: Any, net2: Any) -> str:
+ """
+ Compute the smallest network that includes both input networks.
+
+ Args:
+ dialect: The SQL dialect instance
+ net1: First network (inet or cidr)
+ net2: Second network (inet or cidr)
+
+ Returns:
+ SQL expression: inet_merge(net1, net2)
+
+ Example:
+ >>> inet_merge(dialect, "'192.168.1.0/24'", "'192.168.2.0/24'")
+ "inet_merge('192.168.1.0/24', '192.168.2.0/24')"
+ """
+ return f"inet_merge({_to_sql(net1)}, {_to_sql(net2)})"
+
+
+def inet_and(dialect: "SQLDialectBase", addr1: Any, addr2: Any) -> str:
+ """
+ Compute bitwise AND of two IP addresses.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr1: First IP address (inet or cidr)
+ addr2: Second IP address (inet or cidr)
+
+ Returns:
+ SQL expression: addr1 & addr2
+
+ Example:
+ >>> inet_and(dialect, "'192.168.1.1'", "'255.255.255.0'")
+ "inet_and('192.168.1.1', '255.255.255.0')"
+ """
+ return f"inet_and({_to_sql(addr1)}, {_to_sql(addr2)})"
+
+
+def inet_or(dialect: "SQLDialectBase", addr1: Any, addr2: Any) -> str:
+ """
+ Compute bitwise OR of two IP addresses.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr1: First IP address (inet or cidr)
+ addr2: Second IP address (inet or cidr)
+
+ Returns:
+ SQL expression: addr1 | addr2
+
+ Example:
+ >>> inet_or(dialect, "'192.168.1.1'", "'0.0.0.255'")
+ "inet_or('192.168.1.1', '0.0.0.255')"
+ """
+ return f"inet_or({_to_sql(addr1)}, {_to_sql(addr2)})"
+
+
+def inetnot(dialect: "SQLDialectBase", addr: Any) -> str:
+ """
+ Compute bitwise NOT of an IP address.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: IP address (inet or cidr)
+
+ Returns:
+ SQL expression: ~addr
+
+ Example:
+ >>> inetnot(dialect, "'192.168.1.1'")
+ "inetnot('192.168.1.1')"
+ """
+ return f"inetnot({_to_sql(addr)})"
+
+
+def inet_set_mask(dialect: "SQLDialectBase", addr: Any, mask_len: Any) -> str:
+ """
+ Set the mask length for an IP address.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: IP address (inet or cidr)
+ mask_len: The mask length
+
+ Returns:
+ SQL expression: set_masklen(addr, mask_len)
+
+ Example:
+ >>> inet_set_mask(dialect, "'192.168.1.1'", 24)
+ "set_masklen('192.168.1.1', 24)"
+ """
+ return f"set_masklen({_to_sql(addr)}, {_to_sql(mask_len)})"
+
+
+def inet_masklen(dialect: "SQLDialectBase", addr: Any) -> str:
+ """
+ Return the mask length of an IP address.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: IP address (inet or cidr)
+
+ Returns:
+ SQL expression: masklen(addr)
+
+ Example:
+ >>> inet_masklen(dialect, "'192.168.1.1/24'")
+ "masklen('192.168.1.1/24')"
+ """
+ return f"masklen({_to_sql(addr)})"
+
+
+def inet_netmask(dialect: "SQLDialectBase", addr: Any) -> str:
+ """
+ Return the network mask of an IP address.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: IP address (inet or cidr)
+
+ Returns:
+ SQL expression: netmask(addr)
+
+ Example:
+ >>> inet_netmask(dialect, "'192.168.1.1/24'")
+ "netmask('192.168.1.1/24')"
+ """
+ return f"netmask({_to_sql(addr)})"
+
+
+def inet_network(dialect: "SQLDialectBase", addr: Any) -> str:
+ """
+ Extract the network part of an IP address.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: IP address (inet or cidr)
+
+ Returns:
+ SQL expression: network(addr)
+
+ Example:
+ >>> inet_network(dialect, "'192.168.1.1/24'")
+ "network('192.168.1.1/24')"
+ """
+ return f"network({_to_sql(addr)})"
+
+
+def inet_recv(dialect: "SQLDialectBase", addr: Any) -> str:
+ """
+ Extract the host part of an IP address.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: IP address (inet or cidr)
+
+ Returns:
+ SQL expression: host(addr)
+
+ Example:
+ >>> inet_recv(dialect, "'192.168.1.1/24'")
+ "host('192.168.1.1/24')"
+ """
+ return f"host({_to_sql(addr)})"
+
+
+def inet_show(dialect: "SQLDialectBase", addr: Any) -> str:
+ """
+ Return the IP address as text.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: IP address (inet or cidr)
+
+ Returns:
+ SQL expression: text(addr)
+
+ Example:
+ >>> inet_show(dialect, "'192.168.1.1'")
+ "text('192.168.1.1')"
+ """
+ return f"text({_to_sql(addr)})"
+
+
+def cidr_netmask(dialect: "SQLDialectBase", addr: Any) -> str:
+ """
+ Return the broadcast address of the network.
+
+ Args:
+ dialect: The SQL dialect instance
+ addr: CIDR address
+
+ Returns:
+ SQL expression: broadcast(addr)
+
+ Example:
+ >>> cidr_netmask(dialect, "'192.168.1.0/24'")
+ "broadcast('192.168.1.0/24')"
+ """
+ return f"broadcast({_to_sql(addr)})"
+
+
+def macaddr8_set7bit(dialect: "SQLDialectBase", mac: Any) -> str:
+ """
+ Set the 7th bit of the MAC address (marking it as a multicast address).
+
+ Args:
+ dialect: The SQL dialect instance
+ mac: MAC address
+
+ Returns:
+ SQL expression: macaddr8_set7bit(mac)
+
+ Example:
+ >>> macaddr8_set7bit(dialect, "'08:00:2b:01:02:03'")
+ "macaddr8_set7bit('08:00:2b:01:02:03')"
+ """
+ return f"macaddr8_set7bit({_to_sql(mac)})"
+
+
+__all__ = [
+ "inet_client_addr",
+ "inet_client_port",
+ "inet_server_addr",
+ "inet_server_port",
+ "inet_merge",
+ "inet_and",
+ "inet_or",
+ "inetnot",
+ "inet_set_mask",
+ "inet_masklen",
+ "inet_netmask",
+ "inet_network",
+ "inet_recv",
+ "inet_show",
+ "cidr_netmask",
+ "macaddr8_set7bit",
+]
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py
index 19b6ca9a..c6bea2f9 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py
@@ -376,7 +376,209 @@ def range_upper_inf(dialect: "SQLDialectBase", range_value: Any) -> str:
return f"upper_inf({r_str})"
+# Range type constructors
+
+# Sentinel value to detect when bounds argument was not provided
+_BOUNDS_UNSET = object()
+
+
+def int4range(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str:
+ """
+ Construct an integer range.
+
+ Args:
+ dialect: The SQL dialect instance
+ lower: Lower bound (integer or None for unbounded)
+ upper: Upper bound (integer or None for unbounded)
+ bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive).
+ If not provided, defaults to inclusive '[]'.
+
+ Returns:
+ SQL expression: int4range(lower, upper[, bounds])
+
+ Example:
+ >>> int4range(dialect, 1, 10)
+ 'int4range(1, 10)'
+ >>> int4range(dialect, 1, 10, '[)')
+ "int4range(1, 10, '[)')"
+ """
+ if lower is None and upper is None:
+ return "int4range()"
+ elif lower is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"int4range(NULL, {_to_sql(upper)}{bound_str})"
+ elif upper is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"int4range({_to_sql(lower)}, NULL{bound_str})"
+ elif bounds is _BOUNDS_UNSET:
+ return f"int4range({_to_sql(lower)}, {_to_sql(upper)})"
+ return f"int4range({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')"
+
+
+def int8range(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str:
+ """
+ Construct a big integer range.
+
+ Args:
+ dialect: The SQL dialect instance
+ lower: Lower bound (bigint or None for unbounded)
+ upper: Upper bound (bigint or None for unbounded)
+ bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive).
+ If not provided, defaults to inclusive '[]'.
+
+ Returns:
+ SQL expression: int8range(lower, upper[, bounds])
+
+ Example:
+ >>> int8range(dialect, 1, 1000000)
+ 'int8range(1, 1000000)'
+ """
+ if lower is None and upper is None:
+ return "int8range()"
+ elif lower is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"int8range(NULL, {_to_sql(upper)}{bound_str})"
+ elif upper is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"int8range({_to_sql(lower)}, NULL{bound_str})"
+ elif bounds is _BOUNDS_UNSET:
+ return f"int8range({_to_sql(lower)}, {_to_sql(upper)})"
+ return f"int8range({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')"
+
+
+def numrange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str:
+ """
+ Construct a numeric range.
+
+ Args:
+ dialect: The SQL dialect instance
+ lower: Lower bound (numeric or None for unbounded)
+ upper: Upper bound (numeric or None for unbounded)
+ bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive).
+ If not provided, defaults to inclusive '[]'.
+
+ Returns:
+ SQL expression: numrange(lower, upper[, bounds])
+
+ Example:
+ >>> numrange(dialect, 1.5, 10.5)
+ 'numrange(1.5, 10.5)'
+ """
+ if lower is None and upper is None:
+ return "numrange()"
+ elif lower is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"numrange(NULL, {_to_sql(upper)}{bound_str})"
+ elif upper is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"numrange({_to_sql(lower)}, NULL{bound_str})"
+ elif bounds is _BOUNDS_UNSET:
+ return f"numrange({_to_sql(lower)}, {_to_sql(upper)})"
+ return f"numrange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')"
+
+
+def tsrange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str:
+ """
+ Construct a timestamp range (without time zone).
+
+ Args:
+ dialect: The SQL dialect instance
+ lower: Lower bound (timestamp or None for unbounded)
+ upper: Upper bound (timestamp or None for unbounded)
+ bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive).
+ If not provided, defaults to inclusive '[]'.
+
+ Returns:
+ SQL expression: tsrange(lower, upper[, bounds])
+
+ Example:
+ >>> tsrange(dialect, "'2024-01-01 00:00:00'", "'2024-12-31 23:59:59'")
+ "tsrange('2024-01-01 00:00:00', '2024-12-31 23:59:59')"
+ """
+ if lower is None and upper is None:
+ return "tsrange()"
+ elif lower is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"tsrange(NULL, {_to_sql(upper)}{bound_str})"
+ elif upper is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"tsrange({_to_sql(lower)}, NULL{bound_str})"
+ elif bounds is _BOUNDS_UNSET:
+ return f"tsrange({_to_sql(lower)}, {_to_sql(upper)})"
+ return f"tsrange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')"
+
+
+def tstzrange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str:
+ """
+ Construct a timestamp range (with time zone).
+
+ Args:
+ dialect: The SQL dialect instance
+ lower: Lower bound (timestamptz or None for unbounded)
+ upper: Upper bound (timestamptz or None for unbounded)
+ bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive).
+ If not provided, defaults to inclusive '[]'.
+
+ Returns:
+ SQL expression: tstzrange(lower, upper[, bounds])
+
+ Example:
+ >>> tstzrange(dialect, "'2024-01-01 00:00:00+00'", "'2024-12-31 23:59:59+00'")
+ "tstzrange('2024-01-01 00:00:00+00', '2024-12-31 23:59:59+00')"
+ """
+ if lower is None and upper is None:
+ return "tstzrange()"
+ elif lower is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"tstzrange(NULL, {_to_sql(upper)}{bound_str})"
+ elif upper is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"tstzrange({_to_sql(lower)}, NULL{bound_str})"
+ elif bounds is _BOUNDS_UNSET:
+ return f"tstzrange({_to_sql(lower)}, {_to_sql(upper)})"
+ return f"tstzrange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')"
+
+
+def daterange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str:
+ """
+ Construct a date range.
+
+ Args:
+ dialect: The SQL dialect instance
+ lower: Lower bound (date or None for unbounded)
+ upper: Upper bound (date or None for unbounded)
+ bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive).
+ If not provided, defaults to inclusive '[]'.
+
+ Returns:
+ SQL expression: daterange(lower, upper[, bounds])
+
+ Example:
+ >>> daterange(dialect, "'2024-01-01'", "'2024-12-31'")
+ "daterange('2024-01-01', '2024-12-31')"
+ """
+ if lower is None and upper is None:
+ return "daterange()"
+ elif lower is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"daterange(NULL, {_to_sql(upper)}{bound_str})"
+ elif upper is None:
+ bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else ""
+ return f"daterange({_to_sql(lower)}, NULL{bound_str})"
+ elif bounds is _BOUNDS_UNSET:
+ return f"daterange({_to_sql(lower)}, {_to_sql(upper)})"
+ return f"daterange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')"
+
+
+def _to_sql(expr: Any) -> str:
+ """Convert an expression to its SQL string representation."""
+ if hasattr(expr, 'to_sql'):
+ return expr.to_sql()[0]
+ return str(expr)
+
+
__all__ = [
+ # Range operators
"range_contains",
"range_contained_by",
"range_contains_range",
@@ -389,6 +591,7 @@ def range_upper_inf(dialect: "SQLDialectBase", range_value: Any) -> str:
"range_union",
"range_intersection",
"range_difference",
+ # Range functions
"range_lower",
"range_upper",
"range_is_empty",
@@ -396,4 +599,11 @@ def range_upper_inf(dialect: "SQLDialectBase", range_value: Any) -> str:
"range_upper_inc",
"range_lower_inf",
"range_upper_inf",
+ # Range type constructors
+ "int4range",
+ "int8range",
+ "numrange",
+ "tsrange",
+ "tstzrange",
+ "daterange",
]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py
new file mode 100644
index 00000000..af04ecb0
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py
@@ -0,0 +1,375 @@
+# src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py
+"""
+PostgreSQL text search functions for SQL expression generation.
+
+This module provides utility functions for generating PostgreSQL full-text search SQL expressions.
+
+Supported functions:
+- to_tsvector() - Convert text to tsvector
+- to_tsquery() - Convert to tsquery
+- plainto_tsquery() - Convert plain text to tsquery
+- phraseto_tsquery() - Convert phrase text to tsquery
+- websearch_to_tsquery() - Convert web search style query to tsquery
+- ts_matches() - Test tsvector @@ tsquery
+- ts_matches_expr() - Test tsvector @@@ tsquery (deprecated)
+- ts_rank() - Calculate relevance rank
+- ts_rank_cd() - Calculate rank by cover density
+- ts_headline() - Display query matches with highlighting
+- tsvector_concat() - Concatenate two tsvectors
+- tsvector_strip() - Remove positions and weights from tsvector
+- tsvector_setweight() - Set weight for all lexemes
+- tsvector_length() - Get number of lexemes
+
+Examples:
+ Generate tsvector from document:
+ >>> to_tsvector("hello world", config="english")
+ "to_tsvector('english', 'hello world')"
+
+ Generate tsquery from query:
+ >>> to_tsquery("hello & world", config="english")
+ "to_tsquery('english', 'hello & world')"
+
+ Test if tsvector matches tsquery:
+ >>> ts_matches("title_tsvector", "to_tsquery('hello')")
+ "title_tsvector @@ to_tsquery('hello')"
+
+ Calculate relevance rank:
+ >>> ts_rank("title_tsvector", "to_tsquery('hello')")
+ "ts_rank(title_tsvector, to_tsquery('hello'))"
+
+ Get highlighted text with matches:
+ >>> ts_headline("content", "to_tsquery('hello')")
+ "ts_headline(content, to_tsquery('hello'))"
+"""
+
+from typing import List, Optional
+
+
+def to_tsvector(document: str, config: Optional[str] = None) -> str:
+ """Generate SQL expression for PostgreSQL to_tsvector function.
+
+ Args:
+ document: The document text to convert to tsvector
+ config: Optional text search configuration (e.g., 'english', 'simple')
+
+ Returns:
+ str: SQL expression for to_tsvector function
+
+ Examples:
+ >>> to_tsvector("hello world")
+ "to_tsvector('hello world')"
+ >>> to_tsvector("hello world", "english")
+ "to_tsvector('english', 'hello world')"
+ """
+ if config:
+ return f"to_tsvector('{config}', {document})"
+ return f"to_tsvector({document})"
+
+
+def to_tsquery(query: str, config: Optional[str] = None) -> str:
+ """Generate SQL expression for PostgreSQL to_tsquery function.
+
+ Args:
+ query: The query string to convert to tsquery
+ config: Optional text search configuration
+
+ Returns:
+ str: SQL expression for to_tsquery function
+
+ Examples:
+ >>> to_tsquery("hello & world")
+ "to_tsquery('hello & world')"
+ >>> to_tsquery("hello & world", "english")
+ "to_tsquery('english', 'hello & world')"
+ """
+ if config:
+ return f"to_tsquery('{config}', '{query}')"
+ return f"to_tsquery('{query}')"
+
+
+def plainto_tsquery(query: str, config: Optional[str] = None) -> str:
+ """Generate SQL expression for PostgreSQL plainto_tsquery function.
+
+ Converts plain text to tsquery, handling special characters.
+
+ Args:
+ query: The plain text query
+ config: Optional text search configuration
+
+ Returns:
+ str: SQL expression for plainto_tsquery function
+
+ Examples:
+ >>> plainto_tsquery("hello world")
+ "plainto_tsquery('hello world')"
+ >>> plainto_tsquery("hello world", "english")
+ "plainto_tsquery('english', 'hello world')"
+ """
+ if config:
+ return f"plainto_tsquery('{config}', '{query}')"
+ return f"plainto_tsquery('{query}')"
+
+
+def phraseto_tsquery(query: str, config: Optional[str] = None) -> str:
+ """Generate SQL expression for PostgreSQL phraseto_tsquery function.
+
+ Converts text to tsquery for phrase search.
+
+ Args:
+ query: The phrase query string
+ config: Optional text search configuration
+
+ Returns:
+ str: SQL expression for phraseto_tsquery function
+
+ Examples:
+ >>> phraseto_tsquery("hello world")
+ "phraseto_tsquery('hello world')"
+ >>> phraseto_tsquery("hello world", "english")
+ "phraseto_tsquery('english', 'hello world')"
+ """
+ if config:
+ return f"phraseto_tsquery('{config}', '{query}')"
+ return f"phraseto_tsquery('{query}')"
+
+
+def websearch_to_tsquery(query: str, config: Optional[str] = None) -> str:
+ """Generate SQL expression for PostgreSQL websearch_to_tsquery function.
+
+ Converts web search style query to tsquery (PostgreSQL 11+).
+
+ Args:
+ query: The web search query string
+ config: Optional text search configuration
+
+ Returns:
+ str: SQL expression for websearch_to_tsquery function
+
+ Examples:
+ >>> websearch_to_tsquery("hello -world")
+ "websearch_to_tsquery('hello -world')"
+ >>> websearch_to_tsquery("hello -world", "english")
+ "websearch_to_tsquery('english', 'hello -world')"
+ """
+ if config:
+ return f"websearch_to_tsquery('{config}', '{query}')"
+ return f"websearch_to_tsquery('{query}')"
+
+
+def ts_matches(vector: str, query: str) -> str:
+ """Generate SQL expression for tsvector @@ tsquery match operator.
+
+ Args:
+ vector: The tsvector expression or column
+ query: The tsquery expression
+
+ Returns:
+ str: SQL expression using @@ operator
+
+ Examples:
+ >>> ts_matches("title_tsvector", "to_tsquery('hello')")
+ "title_tsvector @@ to_tsquery('hello')"
+ """
+ return f"{vector} @@ {query}"
+
+
+def ts_matches_expr(vector: str, query: str) -> str:
+ """Generate SQL expression for tsvector @@@ tsquery match operator.
+
+ The @@@ operator is a deprecated alias for @@.
+
+ Args:
+ vector: The tsvector expression or column
+ query: The tsquery expression
+
+ Returns:
+ str: SQL expression using @@@ operator
+
+ Examples:
+ >>> ts_matches_expr("title_tsvector", "to_tsquery('hello')")
+ "title_tsvector @@@ to_tsquery('hello')"
+ """
+ return f"{vector} @@@ {query}"
+
+
+def ts_rank(vector: str, query: str, weights: Optional[List[float]] = None, normalization: int = 0) -> str:
+ """Generate SQL expression for PostgreSQL ts_rank function.
+
+ Calculates relevance rank for tsvector matching tsquery.
+
+ Args:
+ vector: The tsvector expression or column
+ query: The tsquery expression
+ weights: Optional list of 4 weights [D, C, B, A] (default [0.1, 0.2, 0.4, 1.0])
+ normalization: Normalization method (0-32, default 0)
+
+ Returns:
+ str: SQL expression for ts_rank function
+
+ Examples:
+ >>> ts_rank("title_tsvector", "to_tsquery('hello')")
+ "ts_rank(title_tsvector, to_tsquery('hello'))"
+ >>> ts_rank("title_tsvector", "to_tsquery('hello')", [0.1, 0.2, 0.4, 1.0])
+ "ts_rank(array[0.1, 0.2, 0.4, 1.0], title_tsvector, to_tsquery('hello'))"
+ >>> ts_rank("title_tsvector", "to_tsquery('hello')", normalization=1)
+ "ts_rank(title_tsvector, to_tsquery('hello'), 1)"
+ """
+ if weights:
+ weights_str = f"array[{', '.join(str(w) for w in weights)}]"
+ if normalization != 0:
+ return f"ts_rank({weights_str}, {vector}, {query}, {normalization})"
+ return f"ts_rank({weights_str}, {vector}, {query})"
+ if normalization != 0:
+ return f"ts_rank({vector}, {query}, {normalization})"
+ return f"ts_rank({vector}, {query})"
+
+
+def ts_rank_cd(vector: str, query: str, weights: Optional[List[float]] = None, normalization: int = 0) -> str:
+ """Generate SQL expression for PostgreSQL ts_rank_cd function.
+
+ Calculates relevance rank using cover density.
+
+ Args:
+ vector: The tsvector expression or column
+ query: The tsquery expression
+ weights: Optional list of 4 weights [D, C, B, A]
+ normalization: Normalization method (0-32, default 0)
+
+ Returns:
+ str: SQL expression for ts_rank_cd function
+
+ Examples:
+ >>> ts_rank_cd("title_tsvector", "to_tsquery('hello')")
+ "ts_rank_cd(title_tsvector, to_tsquery('hello'))"
+ >>> ts_rank_cd("title_tsvector", "to_tsquery('hello')", [0.1, 0.2, 0.4, 1.0])
+ "ts_rank_cd(array[0.1, 0.2, 0.4, 1.0], title_tsvector, to_tsquery('hello'))"
+ """
+ if weights:
+ weights_str = f"array[{', '.join(str(w) for w in weights)}]"
+ if normalization != 0:
+ return f"ts_rank_cd({weights_str}, {vector}, {query}, {normalization})"
+ return f"ts_rank_cd({weights_str}, {vector}, {query})"
+ if normalization != 0:
+ return f"ts_rank_cd({vector}, {query}, {normalization})"
+ return f"ts_rank_cd({vector}, {query})"
+
+
+def ts_headline(document: str, query: str, config: Optional[str] = None, options: Optional[str] = None) -> str:
+ """Generate SQL expression for PostgreSQL ts_headline function.
+
+ Displays query matches in document with highlighting.
+
+ Args:
+ document: The document text or expression
+ query: The tsquery expression
+ config: Optional text search configuration
+ options: Optional headline options string
+
+ Returns:
+ str: SQL expression for ts_headline function
+
+ Examples:
+ >>> ts_headline("content", "to_tsquery('hello')")
+ "ts_headline(content, to_tsquery('hello'))"
+ >>> ts_headline("content", "to_tsquery('hello')", config="english")
+ "ts_headline('english', content, to_tsquery('hello'))"
+ >>> ts_headline("content", "to_tsquery('hello')", options="StartSel=, StopSel=")
+ "ts_headline(content, to_tsquery('hello'), 'StartSel=, StopSel=')"
+ """
+ if config:
+ if options:
+ return f"ts_headline('{config}', {document}, {query}, '{options}')"
+ return f"ts_headline('{config}', {document}, {query})"
+ if options:
+ return f"ts_headline({document}, {query}, '{options}')"
+ return f"ts_headline({document}, {query})"
+
+
+def tsvector_concat(vec1: str, vec2: str) -> str:
+ """Generate SQL expression for tsvector concatenation.
+
+ Args:
+ vec1: First tsvector expression
+ vec2: Second tsvector expression
+
+ Returns:
+ str: SQL expression using || operator
+
+ Examples:
+ >>> tsvector_concat("title_tsvector", "body_tsvector")
+ "title_tsvector || body_tsvector"
+ """
+ return f"{vec1} || {vec2}"
+
+
+def tsvector_strip(vec: str) -> str:
+ """Generate SQL expression for strip function.
+
+ Removes positions and weights from tsvector.
+
+ Args:
+ vec: The tsvector expression
+
+ Returns:
+ str: SQL expression for strip function
+
+ Examples:
+ >>> tsvector_strip("title_tsvector")
+ "strip(title_tsvector)"
+ """
+ return f"strip({vec})"
+
+
+def tsvector_setweight(vec: str, weight: str) -> str:
+ """Generate SQL expression for setweight function.
+
+ Sets weight for all lexemes in tsvector.
+
+ Args:
+ vec: The tsvector expression
+ weight: Weight character ('A', 'B', 'C', or 'D')
+
+ Returns:
+ str: SQL expression for setweight function
+
+ Examples:
+ >>> tsvector_setweight("title_tsvector", "A")
+ "setweight(title_tsvector, 'A')"
+ """
+ return f"setweight({vec}, '{weight}')"
+
+
+def tsvector_length(vec: str) -> str:
+ """Generate SQL expression for tsvector length.
+
+ Returns the number of lexemes in tsvector.
+
+ Args:
+ vec: The tsvector expression
+
+ Returns:
+ str: SQL expression for length function
+
+ Examples:
+ >>> tsvector_length("title_tsvector")
+ "length(title_tsvector)"
+ """
+ return f"length({vec})"
+
+
+__all__ = [
+ "to_tsvector",
+ "to_tsquery",
+ "plainto_tsquery",
+ "phraseto_tsquery",
+ "websearch_to_tsquery",
+ "ts_matches",
+ "ts_matches_expr",
+ "ts_rank",
+ "ts_rank_cd",
+ "ts_headline",
+ "tsvector_concat",
+ "tsvector_strip",
+ "tsvector_setweight",
+ "tsvector_length",
+]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py
new file mode 100644
index 00000000..8d2e7f62
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py
@@ -0,0 +1,157 @@
+# src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py
+"""
+PostgreSQL UUID Functions.
+
+This module provides SQL expression generators for PostgreSQL UUID
+functions and operators.
+
+PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-uuid.html
+
+Note: Some functions require the 'uuid-ossp' extension.
+Install with: CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+All functions follow the expression-dialect separation architecture:
+- First parameter is always the dialect instance
+- They return SQL expression strings
+"""
+
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rhosocial.activerecord.backend.dialect import SQLDialectBase
+
+
+def _to_sql(expr: Any) -> str:
+ """Convert an expression to its SQL string representation."""
+ if hasattr(expr, 'to_sql'):
+ return expr.to_sql()[0]
+ return str(expr)
+
+
+def uuid_generate_v1(dialect: "SQLDialectBase") -> str:
+ """
+ Generate a version 1 (time-based) UUID.
+
+ Requires the uuid-ossp extension.
+
+ Returns:
+ SQL expression: uuid_generate_v1()
+
+ Example:
+ >>> uuid_generate_v1(dialect)
+ 'uuid_generate_v1()'
+ """
+ return "uuid_generate_v1()"
+
+
+def uuid_generate_v1mc(dialect: "SQLDialectBase") -> str:
+ """
+ Generate a version 1 UUID using a random multicast MAC address.
+
+ Requires the uuid-ossp extension.
+
+ Returns:
+ SQL expression: uuid_generate_v1mc()
+
+ Example:
+ >>> uuid_generate_v1mc(dialect)
+ 'uuid_generate_v1mc()'
+ """
+ return "uuid_generate_v1mc()"
+
+
+def uuid_generate_v3(dialect: "SQLDialectBase", namespace: str, name: str) -> str:
+ """
+ Generate a version 3 (MD5 hash-based) UUID in the given namespace.
+
+ Requires the uuid-ossp extension.
+
+ Args:
+ dialect: The SQL dialect instance
+ namespace: UUID namespace (e.g., 'dns', 'url', 'oid', 'x500' or a specific UUID)
+ name: The name to hash
+
+ Returns:
+ SQL expression: uuid_generate_v3(namespace, name)
+
+ Example:
+ >>> uuid_generate_v3(dialect, "'dns'", "'example.com'")
+ "uuid_generate_v3('dns', 'example.com')"
+ """
+ return f"uuid_generate_v3({_to_sql(namespace)}, {_to_sql(name)})"
+
+
+def uuid_generate_v4(dialect: "SQLDialectBase") -> str:
+ """
+ Generate a version 4 (random) UUID.
+
+ Requires the uuid-ossp extension.
+
+ Returns:
+ SQL expression: uuid_generate_v4()
+
+ Example:
+ >>> uuid_generate_v4(dialect)
+ 'uuid_generate_v4()'
+ """
+ return "uuid_generate_v4()"
+
+
+def uuid_generate_v5(dialect: "SQLDialectBase", namespace: str, name: str) -> str:
+ """
+ Generate a version 5 (SHA-1 hash-based) UUID in the given namespace.
+
+ Requires the uuid-ossp extension.
+
+ Args:
+ dialect: The SQL dialect instance
+ namespace: UUID namespace (e.g., 'dns', 'url', 'oid', 'x500' or a specific UUID)
+ name: The name to hash
+
+ Returns:
+ SQL expression: uuid_generate_v5(namespace, name)
+
+ Example:
+ >>> uuid_generate_v5(dialect, "'dns'", "'example.com'")
+ "uuid_generate_v5('dns', 'example.com')"
+ """
+ return f"uuid_generate_v5({_to_sql(namespace)}, {_to_sql(name)})"
+
+
+def uuid_nil(dialect: "SQLDialectBase") -> str:
+ """
+ Return the nil UUID (all zeros).
+
+ Returns:
+ SQL expression: uuid_nil()
+
+ Example:
+ >>> uuid_nil(dialect)
+ 'uuid_nil()'
+ """
+ return "uuid_nil()"
+
+
+def uuid_max(dialect: "SQLDialectBase") -> str:
+ """
+ Return the maximum UUID (all ones except the first byte).
+
+ Returns:
+ SQL expression: uuid_max()
+
+ Example:
+ >>> uuid_max(dialect)
+ 'uuid_max()'
+ """
+ return "uuid_max()"
+
+
+__all__ = [
+ "uuid_generate_v1",
+ "uuid_generate_v1mc",
+ "uuid_generate_v3",
+ "uuid_generate_v4",
+ "uuid_generate_v5",
+ "uuid_nil",
+ "uuid_max",
+]
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py
new file mode 100644
index 00000000..7a33d357
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py
@@ -0,0 +1,116 @@
+# src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py
+"""
+PostgreSQL XML functions for SQL expression generation.
+
+This module provides utility functions for generating PostgreSQL XML-related SQL expressions.
+
+Supported functions:
+- xmlparse() - Parse XML content
+- xpath_query() - Execute XPath query on XML
+- xpath_exists() - Test if XPath expression matches
+- xml_is_well_formed() - Check if XML is well-formed
+
+Examples:
+ Parse XML document:
+ >>> xmlparse('- value
')
+ "XMLPARSE(DOCUMENT '- value
')"
+
+ Query XML with XPath:
+ >>> xpath_query("xml_column", "/root/item")
+ "xpath('/root/item', xml_column)"
+
+ Test if XPath exists:
+ >>> xpath_exists("xml_column", "/root/item[@id='1']")
+ "xpath_exists('/root/item[@id='1']', xml_column)"
+
+ Check if well-formed:
+ >>> xml_is_well_formed('')
+ "xml_is_well_formed('')"
+"""
+
+from typing import Dict, Optional, Union
+
+# Import type for annotations
+from ..types.xml import PostgresXML
+
+
+def xmlparse(content: str, document: bool = True, preserve_whitespace: bool = False) -> str:
+ """Generate XMLPARSE expression.
+
+ Args:
+ content: XML content string
+ document: Parse as DOCUMENT (default) or CONTENT
+ preserve_whitespace: Preserve whitespace
+
+ Returns:
+ SQL expression for XMLPARSE
+ """
+ doc_type = "DOCUMENT" if document else "CONTENT"
+ whitespace = "PRESERVE WHITESPACE" if preserve_whitespace else ""
+ return f"XMLPARSE({doc_type} '{content}' {whitespace})".strip()
+
+
+def xpath_query(xml_value: Union[str, PostgresXML], xpath: str, namespaces: Optional[Dict[str, str]] = None) -> str:
+ """Generate XPath query expression.
+
+ Args:
+ xml_value: XML value or column reference
+ xpath: XPath expression
+ namespaces: Optional namespace mappings
+
+ Returns:
+ SQL expression for xpath function
+ """
+ if isinstance(xml_value, PostgresXML):
+ xml_value = f"'{xml_value.content}'"
+
+ if namespaces:
+ ns_array = ", ".join(f"ARRAY[{k!s}, {v!s}]" for k, v in namespaces.items())
+ ns_param = f", ARRAY[{ns_array}]"
+ else:
+ ns_param = ""
+
+ return f"xpath('{xpath}', {xml_value}{ns_param})"
+
+
+def xpath_exists(xml_value: Union[str, PostgresXML], xpath: str, namespaces: Optional[Dict[str, str]] = None) -> str:
+ """Generate xpath_exists expression.
+
+ Args:
+ xml_value: XML value or column reference
+ xpath: XPath expression
+ namespaces: Optional namespace mappings
+
+ Returns:
+ SQL expression for xpath_exists function
+ """
+ if isinstance(xml_value, PostgresXML):
+ xml_value = f"'{xml_value.content}'"
+
+ if namespaces:
+ ns_array = ", ".join(f"ARRAY[{k!s}, {v!s}]" for k, v in namespaces.items())
+ ns_param = f", ARRAY[{ns_array}]"
+ else:
+ ns_param = ""
+
+ return f"xpath_exists('{xpath}', {xml_value}{ns_param})"
+
+
+def xml_is_well_formed(content: str) -> str:
+ """Generate xml_is_well_formed expression (PostgreSQL 9.1+).
+
+ Args:
+ content: XML content to check
+
+ Returns:
+ SQL expression for xml_is_well_formed function
+ """
+ return f"xml_is_well_formed('{content}')"
+
+
+__all__ = [
+ "xmlparse",
+ "xpath_query",
+ "xpath_exists",
+ "xml_is_well_formed",
+]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py
index 470dcf16..07c80018 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py
@@ -21,11 +21,13 @@
from .ddl.trigger import PostgresTriggerMixin
from .ddl.comment import PostgresCommentMixin
from .ddl.type import PostgresTypeMixin
+from .ddl.constraint import PostgresConstraintMixin
# DML mixins
from .dml.vacuum import PostgresVacuumMixin
from .dml.stored_procedure import PostgresStoredProcedureMixin
from .dml.extended_statistics import PostgresExtendedStatisticsMixin
+from .dml.advisory_lock import PostgresAdvisoryLockMixin
# Type mixins
from .types.enum import EnumTypeMixin
@@ -67,6 +69,7 @@
"PostgresVacuumMixin",
"PostgresStoredProcedureMixin",
"PostgresExtendedStatisticsMixin",
+ "PostgresAdvisoryLockMixin",
# Type mixins
"EnumTypeMixin",
"TypesDataTypeMixin",
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py
index 59910b89..e4ed4e7a 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py
@@ -6,6 +6,7 @@
from .trigger import PostgresTriggerMixin
from .comment import PostgresCommentMixin
from .type import PostgresTypeMixin
+from .constraint import PostgresConstraintMixin
__all__ = [
"PostgresPartitionMixin",
@@ -13,4 +14,5 @@
"PostgresTriggerMixin",
"PostgresCommentMixin",
"PostgresTypeMixin",
+ "PostgresConstraintMixin",
]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py
new file mode 100644
index 00000000..a936f54e
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py
@@ -0,0 +1,43 @@
+# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py
+"""PostgreSQL-specific constraint capability defaults.
+
+Provides default implementations for PostgreSQL-proprietary constraint
+features that are not part of the SQL standard.
+
+SQL standard constraint capability defaults are defined in the core
+ConstraintMixin (rhosocial.activerecord.backend.dialect.mixins).
+
+Note: The format_add_table_constraint_action method is defined directly
+in PostgresDialect rather than in this mixin, because Python's C3 MRO
+places SQLDialectBase before mixins in the inheritance chain, which
+prevents mixin methods from overriding base class methods of the same name.
+This mixin provides capability detection defaults only.
+"""
+
+
+class PostgresConstraintMixin:
+ """PostgreSQL-proprietary constraint capability defaults.
+
+ These features are PostgreSQL-specific and not part of the SQL standard.
+ Both NOT VALID and EXCLUDE constraints are supported by all PostgreSQL
+ versions, so they default to True.
+
+ SQL standard constraint capability defaults are in ConstraintMixin.
+ """
+
+ def supports_constraint_novalidate(self) -> bool:
+ """NOT VALID constraint option (PostgreSQL-proprietary).
+
+ Allows adding a constraint without validating existing data.
+ Supported by all PostgreSQL versions.
+ """
+ return True
+
+ def supports_exclude_constraint(self) -> bool:
+ """EXCLUDE constraints (PostgreSQL-proprietary).
+
+ Exclusion constraints prevent overlapping rows on specified
+ columns using specified operators.
+ Supported by all PostgreSQL versions.
+ """
+ return True
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py
new file mode 100644
index 00000000..34b7c2de
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py
@@ -0,0 +1,166 @@
+# src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py
+"""
+PostgreSQL advisory lock mixin implementation.
+
+Provides methods for working with PostgreSQL advisory locks, which are
+application-level locks managed by the database server.
+"""
+
+from typing import Tuple, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import (
+ AdvisoryLockExpression,
+ AdvisoryUnlockExpression,
+ AdvisoryUnlockAllExpression,
+ TryAdvisoryLockExpression,
+ )
+
+
+class PostgresAdvisoryLockMixin:
+ """
+ Mixin providing PostgreSQL advisory lock support.
+
+ Advisory locks are application-level locks that provide a way for
+ applications to coordinate activities across multiple sessions.
+
+ The lock functions available in PostgreSQL:
+ - pg_advisory_lock(key) - acquire exclusive session-level lock
+ - pg_advisory_lock_shared(key) - acquire shared session-level lock
+ - pg_advisory_unlock(key) - release session-level lock
+ - pg_advisory_unlock_all() - release all session-level locks
+ - pg_try_advisory_lock(key) - non-blocking acquire exclusive lock
+ - pg_try_advisory_lock_shared(key) - non-blocking acquire shared lock
+ - pg_advisory_xact_lock(key) - acquire exclusive transaction-level lock
+ - pg_advisory_xact_lock_shared(key) - acquire shared transaction-level lock
+ - pg_try_advisory_xact_lock(key) - non-blocking acquire transaction-level lock
+
+ Keys can be either:
+ - A single 64-bit integer (bigint)
+ - Two 32-bit integers (int, int)
+ """
+
+ def supports_advisory_lock(self) -> bool:
+ """
+ Whether advisory locks are supported.
+
+ Advisory locks are supported in PostgreSQL 9.0 and later.
+
+ Returns:
+ True if advisory locks are supported
+ """
+ return True # PostgreSQL 9.0+ supports advisory locks
+
+ def format_advisory_lock(self, expr: "AdvisoryLockExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for acquiring an advisory lock.
+
+ Generates:
+ - SELECT pg_advisory_lock(key) for exclusive session lock
+ - SELECT pg_advisory_lock_shared(key) for shared session lock
+ - SELECT pg_advisory_xact_lock(key) for exclusive transaction lock
+ - SELECT pg_advisory_xact_lock_shared(key) for shared transaction lock
+
+ Args:
+ expr: AdvisoryLockExpression with lock parameters
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ # Build function name based on options
+ if expr.session:
+ # Session-level lock
+ if expr.shared:
+ func_name = "pg_advisory_lock_shared"
+ else:
+ func_name = "pg_advisory_lock"
+ else:
+ # Transaction-level lock
+ if expr.shared:
+ func_name = "pg_advisory_xact_lock_shared"
+ else:
+ func_name = "pg_advisory_xact_lock"
+
+ # Handle key format
+ if isinstance(expr.key, tuple):
+ key1, key2 = expr.key
+ return f"SELECT {func_name}(%s, %s)", (key1, key2)
+ else:
+ return f"SELECT {func_name}(%s)", (expr.key,)
+
+ def format_advisory_unlock(self, expr: "AdvisoryUnlockExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for releasing an advisory lock.
+
+ Generates:
+ - SELECT pg_advisory_unlock(key) for exclusive lock
+ - SELECT pg_advisory_unlock_shared(key) for shared lock
+
+ Args:
+ expr: AdvisoryUnlockExpression with lock parameters
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ # Build function name based on shared flag
+ # Note: Shared locks must use pg_advisory_unlock_shared
+ func_name = "pg_advisory_unlock_shared" if expr.shared else "pg_advisory_unlock"
+
+ # Handle key format
+ if isinstance(expr.key, tuple):
+ key1, key2 = expr.key
+ return f"SELECT {func_name}(%s, %s)", (key1, key2)
+ else:
+ return f"SELECT {func_name}(%s)", (expr.key,)
+
+ def format_advisory_unlock_all(self, expr: "AdvisoryUnlockAllExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for releasing all advisory locks.
+
+ Generates:
+ - SELECT pg_advisory_unlock_all()
+
+ Args:
+ expr: AdvisoryUnlockAllExpression
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ return "SELECT pg_advisory_unlock_all()", ()
+
+ def format_try_advisory_lock(self, expr: "TryAdvisoryLockExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for non-blocking advisory lock acquisition.
+
+ Generates:
+ - SELECT pg_try_advisory_lock(key) for exclusive session lock
+ - SELECT pg_try_advisory_lock_shared(key) for shared session lock
+ - SELECT pg_try_advisory_xact_lock(key) for exclusive transaction lock
+ - SELECT pg_try_advisory_xact_lock_shared(key) for shared transaction lock
+
+ Args:
+ expr: TryAdvisoryLockExpression with lock parameters
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ # Build function name based on options
+ if expr.session:
+ # Session-level lock
+ if expr.shared:
+ func_name = "pg_try_advisory_lock_shared"
+ else:
+ func_name = "pg_try_advisory_lock"
+ else:
+ # Transaction-level lock
+ if expr.shared:
+ func_name = "pg_try_advisory_xact_lock_shared"
+ else:
+ func_name = "pg_try_advisory_xact_lock"
+
+ # Handle key format
+ if isinstance(expr.key, tuple):
+ key1, key2 = expr.key
+ return f"SELECT {func_name}(%s, %s)", (key1, key2)
+ else:
+ return f"SELECT {func_name}(%s)", (expr.key,)
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py
index d659cf6b..56ab0f7c 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py
@@ -20,11 +20,13 @@
from .ddl.trigger import PostgresTriggerSupport
from .ddl.comment import PostgresCommentSupport
from .ddl.type import PostgresTypeSupport
+from .ddl.constraint import PostgresConstraintSupport
# DML protocols
from .dml.vacuum import PostgresVacuumSupport
from .dml.stored_procedure import PostgresStoredProcedureSupport
from .dml.extended_statistics import PostgresExtendedStatisticsSupport
+from .dml.advisory_lock import PostgresAdvisoryLockSupport
# Type protocols
from .types.data_type import PostgresDataTypeSupport
@@ -61,10 +63,12 @@
"PostgresTriggerSupport",
"PostgresCommentSupport",
"PostgresTypeSupport",
+ "PostgresConstraintSupport",
# DML protocols
"PostgresVacuumSupport",
"PostgresStoredProcedureSupport",
"PostgresExtendedStatisticsSupport",
+ "PostgresAdvisoryLockSupport",
# Type protocols
"PostgresDataTypeSupport",
"MultirangeSupport",
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py
index b32febca..b35ad690 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py
@@ -6,6 +6,7 @@
from .trigger import PostgresTriggerSupport
from .comment import PostgresCommentSupport
from .type import PostgresTypeSupport
+from .constraint import PostgresConstraintSupport
__all__ = [
"PostgresPartitionSupport",
@@ -13,4 +14,5 @@
"PostgresTriggerSupport",
"PostgresCommentSupport",
"PostgresTypeSupport",
+ "PostgresConstraintSupport",
]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py
new file mode 100644
index 00000000..b18f8a5e
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py
@@ -0,0 +1,55 @@
+# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py
+"""PostgreSQL-proprietary constraint capability protocol definitions.
+
+This module contains the PostgresConstraintSupport protocol which defines
+the interface for PostgreSQL's proprietary constraint features that are
+not part of the SQL standard.
+
+SQL standard constraint capabilities are defined in the core
+ConstraintSupport protocol (rhosocial.activerecord.backend.dialect.protocols).
+
+PostgreSQL-proprietary features:
+- NOT VALID (add constraint without validating existing data)
+- EXCLUDE constraints
+"""
+
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class PostgresConstraintSupport(Protocol):
+ """PostgreSQL-proprietary constraint capabilities protocol.
+
+ These features are PostgreSQL-specific and not part of the SQL standard.
+ SQL standard constraint features (PRIMARY KEY, UNIQUE, CHECK, FK, etc.)
+ are defined in the core ConstraintSupport protocol.
+
+ Feature Source: Native support (no extension required)
+
+ Official Documentation:
+ - NOT VALID: https://www.postgresql.org/docs/current/sql-altertable.html
+ - EXCLUDE: https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION
+ """
+
+ def supports_constraint_novalidate(self) -> bool:
+ """Whether NOT VALID constraint option is supported.
+
+ PostgreSQL-proprietary feature. Allows adding a constraint
+ without validating existing data, then validating later
+ with VALIDATE CONSTRAINT.
+
+ Native feature, all PostgreSQL versions.
+ """
+ ...
+
+ def supports_exclude_constraint(self) -> bool:
+ """Whether EXCLUDE constraints are supported.
+
+ PostgreSQL-proprietary feature. Exclusion constraints ensure
+ that no two rows overlap on specified columns using specified
+ operators (e.g., && for range overlap).
+
+ Native feature, all PostgreSQL versions.
+ Requires appropriate operator class (e.g., btree_gist for gist).
+ """
+ ...
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/advisory_lock.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/advisory_lock.py
new file mode 100644
index 00000000..ff6beb94
--- /dev/null
+++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/advisory_lock.py
@@ -0,0 +1,96 @@
+# src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/advisory_lock.py
+"""
+PostgreSQL advisory lock protocol definitions.
+
+Advisory locks are application-level locks managed by the database server.
+They provide a way for applications to coordinate activities across sessions
+without relying on table-level or row-level locks.
+"""
+
+from typing import Protocol, Tuple, Union, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rhosocial.activerecord.backend.impl.postgres.expression.advisory import (
+ AdvisoryLockExpression,
+ AdvisoryUnlockExpression,
+ AdvisoryUnlockAllExpression,
+ TryAdvisoryLockExpression,
+ )
+
+
+class PostgresAdvisoryLockSupport(Protocol):
+ """
+ Protocol for PostgreSQL advisory lock support.
+
+ Advisory locks are application-level locks that provide a way for
+ applications to coordinate activities across multiple sessions.
+
+ Features:
+ - Session-level locks: Released explicitly or when session ends
+ - Transaction-level locks: Released when transaction ends
+ - Exclusive locks: Only one session can hold
+ - Shared locks: Multiple sessions can hold simultaneously
+ - Non-blocking try variants: Return boolean instead of waiting
+
+ PostgreSQL version support:
+ - All advisory lock functions: PostgreSQL 9.0+
+ """
+
+ def supports_advisory_lock(self) -> bool:
+ """
+ Whether advisory locks are supported.
+
+ Advisory locks are supported in PostgreSQL 9.0 and later.
+
+ Returns:
+ True if advisory locks are supported
+ """
+ ... # pragma: no cover
+
+ def format_advisory_lock(self, expr: "AdvisoryLockExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for acquiring an advisory lock.
+
+ Args:
+ expr: AdvisoryLockExpression with lock parameters
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ ... # pragma: no cover
+
+ def format_advisory_unlock(self, expr: "AdvisoryUnlockExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for releasing an advisory lock.
+
+ Args:
+ expr: AdvisoryUnlockExpression with lock parameters
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ ... # pragma: no cover
+
+ def format_advisory_unlock_all(self, expr: "AdvisoryUnlockAllExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for releasing all advisory locks.
+
+ Args:
+ expr: AdvisoryUnlockAllExpression
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ ... # pragma: no cover
+
+ def format_try_advisory_lock(self, expr: "TryAdvisoryLockExpression") -> Tuple[str, tuple]:
+ """
+ Format SQL for non-blocking advisory lock acquisition.
+
+ Args:
+ expr: TryAdvisoryLockExpression with lock parameters
+
+ Returns:
+ Tuple of (SQL string, parameters)
+ """
+ ... # pragma: no cover
\ No newline at end of file
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/types/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/types/__init__.py
index 1782da82..7f49ada8 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/types/__init__.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/types/__init__.py
@@ -100,10 +100,6 @@
# XML type
from .xml import (
PostgresXML,
- xmlparse,
- xpath_query,
- xpath_exists,
- xml_is_well_formed,
)
# Type constants (convenience references)
@@ -260,10 +256,6 @@
"PostgresMoney",
# XML type
"PostgresXML",
- "xmlparse",
- "xpath_query",
- "xpath_exists",
- "xml_is_well_formed",
# Type constants - Numeric
"SMALLINT",
"INTEGER",
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/types/text_search.py b/src/rhosocial/activerecord/backend/impl/postgres/types/text_search.py
index 5be8d924..da10b5e1 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/types/text_search.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/types/text_search.py
@@ -747,321 +747,6 @@ def _skip_whitespace(self) -> None:
self.pos += 1
-# Text search utility functions for SQL generation
-
-
-def to_tsvector(document: str, config: Optional[str] = None) -> str:
- """Generate SQL expression for PostgreSQL to_tsvector function.
-
- Args:
- document: The document text to convert to tsvector
- config: Optional text search configuration (e.g., 'english', 'simple')
-
- Returns:
- str: SQL expression for to_tsvector function
-
- Examples:
- >>> to_tsvector("hello world")
- "to_tsvector('hello world')"
- >>> to_tsvector("hello world", "english")
- "to_tsvector('english', 'hello world')"
- """
- if config:
- return f"to_tsvector('{config}', {document})"
- return f"to_tsvector({document})"
-
-
-def to_tsquery(query: str, config: Optional[str] = None) -> str:
- """Generate SQL expression for PostgreSQL to_tsquery function.
-
- Args:
- query: The query string to convert to tsquery
- config: Optional text search configuration
-
- Returns:
- str: SQL expression for to_tsquery function
-
- Examples:
- >>> to_tsquery("hello & world")
- "to_tsquery('hello & world')"
- >>> to_tsquery("hello & world", "english")
- "to_tsquery('english', 'hello & world')"
- """
- if config:
- return f"to_tsquery('{config}', '{query}')"
- return f"to_tsquery('{query}')"
-
-
-def plainto_tsquery(query: str, config: Optional[str] = None) -> str:
- """Generate SQL expression for PostgreSQL plainto_tsquery function.
-
- Converts plain text to tsquery, handling special characters.
-
- Args:
- query: The plain text query
- config: Optional text search configuration
-
- Returns:
- str: SQL expression for plainto_tsquery function
-
- Examples:
- >>> plainto_tsquery("hello world")
- "plainto_tsquery('hello world')"
- >>> plainto_tsquery("hello world", "english")
- "plainto_tsquery('english', 'hello world')"
- """
- if config:
- return f"plainto_tsquery('{config}', '{query}')"
- return f"plainto_tsquery('{query}')"
-
-
-def phraseto_tsquery(query: str, config: Optional[str] = None) -> str:
- """Generate SQL expression for PostgreSQL phraseto_tsquery function.
-
- Converts text to tsquery for phrase search.
-
- Args:
- query: The phrase query string
- config: Optional text search configuration
-
- Returns:
- str: SQL expression for phraseto_tsquery function
-
- Examples:
- >>> phraseto_tsquery("hello world")
- "phraseto_tsquery('hello world')"
- >>> phraseto_tsquery("hello world", "english")
- "phraseto_tsquery('english', 'hello world')"
- """
- if config:
- return f"phraseto_tsquery('{config}', '{query}')"
- return f"phraseto_tsquery('{query}')"
-
-
-def websearch_to_tsquery(query: str, config: Optional[str] = None) -> str:
- """Generate SQL expression for PostgreSQL websearch_to_tsquery function.
-
- Converts web search style query to tsquery (PostgreSQL 11+).
-
- Args:
- query: The web search query string
- config: Optional text search configuration
-
- Returns:
- str: SQL expression for websearch_to_tsquery function
-
- Examples:
- >>> websearch_to_tsquery("hello -world")
- "websearch_to_tsquery('hello -world')"
- >>> websearch_to_tsquery("hello -world", "english")
- "websearch_to_tsquery('english', 'hello -world')"
- """
- if config:
- return f"websearch_to_tsquery('{config}', '{query}')"
- return f"websearch_to_tsquery('{query}')"
-
-
-def ts_matches(vector: str, query: str) -> str:
- """Generate SQL expression for tsvector @@ tsquery match operator.
-
- Args:
- vector: The tsvector expression or column
- query: The tsquery expression
-
- Returns:
- str: SQL expression using @@ operator
-
- Examples:
- >>> ts_matches("title_tsvector", "to_tsquery('hello')")
- "title_tsvector @@ to_tsquery('hello')"
- """
- return f"{vector} @@ {query}"
-
-
-def ts_matches_expr(vector: str, query: str) -> str:
- """Generate SQL expression for tsvector @@@ tsquery match operator.
-
- The @@@ operator is a deprecated alias for @@.
-
- Args:
- vector: The tsvector expression or column
- query: The tsquery expression
-
- Returns:
- str: SQL expression using @@@ operator
-
- Examples:
- >>> ts_matches_expr("title_tsvector", "to_tsquery('hello')")
- "title_tsvector @@@ to_tsquery('hello')"
- """
- return f"{vector} @@@ {query}"
-
-
-def ts_rank(vector: str, query: str, weights: Optional[List[float]] = None, normalization: int = 0) -> str:
- """Generate SQL expression for PostgreSQL ts_rank function.
-
- Calculates relevance rank for tsvector matching tsquery.
-
- Args:
- vector: The tsvector expression or column
- query: The tsquery expression
- weights: Optional list of 4 weights [D, C, B, A] (default [0.1, 0.2, 0.4, 1.0])
- normalization: Normalization method (0-32, default 0)
-
- Returns:
- str: SQL expression for ts_rank function
-
- Examples:
- >>> ts_rank("title_tsvector", "to_tsquery('hello')")
- "ts_rank(title_tsvector, to_tsquery('hello'))"
- >>> ts_rank("title_tsvector", "to_tsquery('hello')", [0.1, 0.2, 0.4, 1.0])
- "ts_rank(array[0.1, 0.2, 0.4, 1.0], title_tsvector, to_tsquery('hello'))"
- >>> ts_rank("title_tsvector", "to_tsquery('hello')", normalization=1)
- "ts_rank(title_tsvector, to_tsquery('hello'), 1)"
- """
- if weights:
- weights_str = f"array[{', '.join(str(w) for w in weights)}]"
- if normalization != 0:
- return f"ts_rank({weights_str}, {vector}, {query}, {normalization})"
- return f"ts_rank({weights_str}, {vector}, {query})"
- if normalization != 0:
- return f"ts_rank({vector}, {query}, {normalization})"
- return f"ts_rank({vector}, {query})"
-
-
-def ts_rank_cd(vector: str, query: str, weights: Optional[List[float]] = None, normalization: int = 0) -> str:
- """Generate SQL expression for PostgreSQL ts_rank_cd function.
-
- Calculates relevance rank using cover density.
-
- Args:
- vector: The tsvector expression or column
- query: The tsquery expression
- weights: Optional list of 4 weights [D, C, B, A]
- normalization: Normalization method (0-32, default 0)
-
- Returns:
- str: SQL expression for ts_rank_cd function
-
- Examples:
- >>> ts_rank_cd("title_tsvector", "to_tsquery('hello')")
- "ts_rank_cd(title_tsvector, to_tsquery('hello'))"
- >>> ts_rank_cd("title_tsvector", "to_tsquery('hello')", [0.1, 0.2, 0.4, 1.0])
- "ts_rank_cd(array[0.1, 0.2, 0.4, 1.0], title_tsvector, to_tsquery('hello'))"
- """
- if weights:
- weights_str = f"array[{', '.join(str(w) for w in weights)}]"
- if normalization != 0:
- return f"ts_rank_cd({weights_str}, {vector}, {query}, {normalization})"
- return f"ts_rank_cd({weights_str}, {vector}, {query})"
- if normalization != 0:
- return f"ts_rank_cd({vector}, {query}, {normalization})"
- return f"ts_rank_cd({vector}, {query})"
-
-
-def ts_headline(document: str, query: str, config: Optional[str] = None, options: Optional[str] = None) -> str:
- """Generate SQL expression for PostgreSQL ts_headline function.
-
- Displays query matches in document with highlighting.
-
- Args:
- document: The document text or expression
- query: The tsquery expression
- config: Optional text search configuration
- options: Optional headline options string
-
- Returns:
- str: SQL expression for ts_headline function
-
- Examples:
- >>> ts_headline("content", "to_tsquery('hello')")
- "ts_headline(content, to_tsquery('hello'))"
- >>> ts_headline("content", "to_tsquery('hello')", config="english")
- "ts_headline('english', content, to_tsquery('hello'))"
- >>> ts_headline("content", "to_tsquery('hello')", options="StartSel=, StopSel=")
- "ts_headline(content, to_tsquery('hello'), 'StartSel=, StopSel=')"
- """
- if config:
- if options:
- return f"ts_headline('{config}', {document}, {query}, '{options}')"
- return f"ts_headline('{config}', {document}, {query})"
- if options:
- return f"ts_headline({document}, {query}, '{options}')"
- return f"ts_headline({document}, {query})"
-
-
-def tsvector_concat(vec1: str, vec2: str) -> str:
- """Generate SQL expression for tsvector concatenation.
-
- Args:
- vec1: First tsvector expression
- vec2: Second tsvector expression
-
- Returns:
- str: SQL expression using || operator
-
- Examples:
- >>> tsvector_concat("title_tsvector", "body_tsvector")
- "title_tsvector || body_tsvector"
- """
- return f"{vec1} || {vec2}"
-
-
-def tsvector_strip(vec: str) -> str:
- """Generate SQL expression for strip function.
-
- Removes positions and weights from tsvector.
-
- Args:
- vec: The tsvector expression
-
- Returns:
- str: SQL expression for strip function
-
- Examples:
- >>> tsvector_strip("title_tsvector")
- "strip(title_tsvector)"
- """
- return f"strip({vec})"
-
-
-def tsvector_setweight(vec: str, weight: str) -> str:
- """Generate SQL expression for setweight function.
-
- Sets weight for all lexemes in tsvector.
-
- Args:
- vec: The tsvector expression
- weight: Weight character ('A', 'B', 'C', or 'D')
-
- Returns:
- str: SQL expression for setweight function
-
- Examples:
- >>> tsvector_setweight("title_tsvector", "A")
- "setweight(title_tsvector, 'A')"
- """
- return f"setweight({vec}, '{weight}')"
-
-
-def tsvector_length(vec: str) -> str:
- """Generate SQL expression for tsvector length.
-
- Returns the number of lexemes in tsvector.
-
- Args:
- vec: The tsvector expression
-
- Returns:
- str: SQL expression for length function
-
- Examples:
- >>> tsvector_length("title_tsvector")
- "length(title_tsvector)"
- """
- return f"length({vec})"
-
-
__all__ = [
"TsVectorLexeme",
"PostgresTsVector",
@@ -1069,18 +754,4 @@ def tsvector_length(vec: str) -> str:
"TsQueryLexeme",
"TsQueryOperator",
"PostgresTsQuery",
- "to_tsvector",
- "to_tsquery",
- "plainto_tsquery",
- "phraseto_tsquery",
- "websearch_to_tsquery",
- "ts_matches",
- "ts_matches_expr",
- "ts_rank",
- "ts_rank_cd",
- "ts_headline",
- "tsvector_concat",
- "tsvector_strip",
- "tsvector_setweight",
- "tsvector_length",
]
diff --git a/src/rhosocial/activerecord/backend/impl/postgres/types/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/types/xml.py
index 7fb6c603..6e377196 100644
--- a/src/rhosocial/activerecord/backend/impl/postgres/types/xml.py
+++ b/src/rhosocial/activerecord/backend/impl/postgres/types/xml.py
@@ -16,7 +16,6 @@
"""
from dataclasses import dataclass
-from typing import Dict, Optional, Union
@dataclass
@@ -73,81 +72,4 @@ def is_well_formed(self) -> bool:
return True
-# XML utility functions for use with PostgreSQL
-
-
-def xmlparse(content: str, document: bool = True, preserve_whitespace: bool = False) -> str:
- """Generate XMLPARSE expression.
-
- Args:
- content: XML content string
- document: Parse as DOCUMENT (default) or CONTENT
- preserve_whitespace: Preserve whitespace
-
- Returns:
- SQL expression for XMLPARSE
- """
- doc_type = "DOCUMENT" if document else "CONTENT"
- whitespace = "PRESERVE WHITESPACE" if preserve_whitespace else ""
- return f"XMLPARSE({doc_type} '{content}' {whitespace})".strip()
-
-
-def xpath_query(xml_value: Union[str, PostgresXML], xpath: str, namespaces: Optional[Dict[str, str]] = None) -> str:
- """Generate XPath query expression.
-
- Args:
- xml_value: XML value or column reference
- xpath: XPath expression
- namespaces: Optional namespace mappings
-
- Returns:
- SQL expression for xpath function
- """
- if isinstance(xml_value, PostgresXML):
- xml_value = f"'{xml_value.content}'"
-
- if namespaces:
- ns_array = ", ".join(f"ARRAY[{k!s}, {v!s}]" for k, v in namespaces.items())
- ns_param = f", ARRAY[{ns_array}]"
- else:
- ns_param = ""
-
- return f"xpath('{xpath}', {xml_value}{ns_param})"
-
-
-def xpath_exists(xml_value: Union[str, PostgresXML], xpath: str, namespaces: Optional[Dict[str, str]] = None) -> str:
- """Generate xpath_exists expression.
-
- Args:
- xml_value: XML value or column reference
- xpath: XPath expression
- namespaces: Optional namespace mappings
-
- Returns:
- SQL expression for xpath_exists function
- """
- if isinstance(xml_value, PostgresXML):
- xml_value = f"'{xml_value.content}'"
-
- if namespaces:
- ns_array = ", ".join(f"ARRAY[{k!s}, {v!s}]" for k, v in namespaces.items())
- ns_param = f", ARRAY[{ns_array}]"
- else:
- ns_param = ""
-
- return f"xpath_exists('{xpath}', {xml_value}{ns_param})"
-
-
-def xml_is_well_formed(content: str) -> str:
- """Generate xml_is_well_formed expression (PostgreSQL 9.1+).
-
- Args:
- content: XML content to check
-
- Returns:
- SQL expression for xml_is_well_formed function
- """
- return f"xml_is_well_formed('{content}')"
-
-
-__all__ = ["PostgresXML", "xmlparse", "xpath_query", "xpath_exists", "xml_is_well_formed"]
+__all__ = ["PostgresXML"]
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py
index ad2852bb..8c8a3979 100644
--- a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py
@@ -218,3 +218,10 @@ async def async_postgres_control_backend():
backend = await provider.setup_async_backend(scenario_name)
yield backend
await provider.async_cleanup()
+
+
+@pytest.fixture(scope="function")
+def postgres_dialect():
+ """Fixture providing PostgresDialect instance for testing transaction expressions."""
+ from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
+ return PostgresDialect()
diff --git a/tests/rhosocial/activerecord_test/feature/backend/postgres/test_expressions_transaction.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expressions_transaction.py
similarity index 100%
rename from tests/rhosocial/activerecord_test/feature/backend/postgres/test_expressions_transaction.py
rename to tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expressions_transaction.py
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py
new file mode 100644
index 00000000..2758d846
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py
@@ -0,0 +1,130 @@
+# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py
+"""
+Tests for PostgreSQL Array functions.
+
+Functions: array_agg, array_append, array_cat, array_dims, array_fill,
+ array_length, array_lower, array_ndims, array_position,
+ array_positions, array_prepend, array_remove, array_replace,
+ array_to_string, array_upper, unnest, string_to_array
+"""
+
+from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
+from rhosocial.activerecord.backend.impl.postgres.functions.array import (
+ array_agg,
+ array_append,
+ array_cat,
+ array_dims,
+ array_fill,
+ array_length,
+ array_lower,
+ array_ndims,
+ array_position,
+ array_positions,
+ array_prepend,
+ array_remove,
+ array_replace,
+ array_to_string,
+ array_upper,
+ unnest,
+ array_agg_distinct,
+ string_to_array,
+)
+
+
+class TestPostgresArrayFunctions:
+ """Tests for PostgreSQL array functions."""
+
+ def test_array_agg(self, postgres_dialect: PostgresDialect):
+ """Test array_agg() function."""
+ result = array_agg(postgres_dialect, "value")
+ assert result == "array_agg(value)"
+
+ def test_array_append(self, postgres_dialect: PostgresDialect):
+ """Test array_append() function."""
+ result = array_append(postgres_dialect, "'{1,2}'", 3)
+ assert result == "array_append('{1,2}', 3)"
+
+ def test_array_cat(self, postgres_dialect: PostgresDialect):
+ """Test array_cat() function."""
+ result = array_cat(postgres_dialect, "'{1,2}'", "'{3,4}'")
+ assert result == "array_cat('{1,2}', '{3,4}')"
+
+ def test_array_dims(self, postgres_dialect: PostgresDialect):
+ """Test array_dims() function."""
+ result = array_dims(postgres_dialect, "'{{1,2},{3,4}}'")
+ assert result == "array_dims('{{1,2},{3,4}}')"
+
+ def test_array_fill(self, postgres_dialect: PostgresDialect):
+ """Test array_fill() function."""
+ result = array_fill(postgres_dialect, 0, "'{3,3}'")
+ assert result == "array_fill(0, '{3,3}')"
+
+ def test_array_length(self, postgres_dialect: PostgresDialect):
+ """Test array_length() function."""
+ result = array_length(postgres_dialect, "'{1,2,3}'", 1)
+ assert result == "array_length('{1,2,3}', 1)"
+
+ def test_array_lower(self, postgres_dialect: PostgresDialect):
+ """Test array_lower() function."""
+ result = array_lower(postgres_dialect, "'[1:3]={1,2,3}'", 1)
+ assert result == "array_lower('[1:3]={1,2,3}', 1)"
+
+ def test_array_ndims(self, postgres_dialect: PostgresDialect):
+ """Test array_ndims() function."""
+ result = array_ndims(postgres_dialect, "'{{1,2},{3,4}}'")
+ assert result == "array_ndims('{{1,2},{3,4}}')"
+
+ def test_array_position(self, postgres_dialect: PostgresDialect):
+ """Test array_position() function."""
+ result = array_position(postgres_dialect, "'{a,b,c}'", "'b'")
+ assert result == "array_position('{a,b,c}', 'b')"
+
+ def test_array_positions(self, postgres_dialect: PostgresDialect):
+ """Test array_positions() function."""
+ result = array_positions(postgres_dialect, "'[1,3,5,3]'", 3)
+ assert result == "array_positions('[1,3,5,3]', 3)"
+
+ def test_array_prepend(self, postgres_dialect: PostgresDialect):
+ """Test array_prepend() function."""
+ result = array_prepend(postgres_dialect, 1, "'{2,3}'")
+ assert result == "array_prepend(1, '{2,3}')"
+
+ def test_array_remove(self, postgres_dialect: PostgresDialect):
+ """Test array_remove() function."""
+ result = array_remove(postgres_dialect, "'{1,2,3,2}'", 2)
+ assert result == "array_remove('{1,2,3,2}', 2)"
+
+ def test_array_replace(self, postgres_dialect: PostgresDialect):
+ """Test array_replace() function."""
+ result = array_replace(postgres_dialect, "'{1,2,3}'", 2, 4)
+ assert result == "array_replace('{1,2,3}', 2, 4)"
+
+ def test_array_to_string(self, postgres_dialect: PostgresDialect):
+ """Test array_to_string() function."""
+ result = array_to_string(postgres_dialect, "'{1,2,3}'", ',')
+ assert result == "array_to_string('{1,2,3}', ',')"
+
+ def test_array_upper(self, postgres_dialect: PostgresDialect):
+ """Test array_upper() function."""
+ result = array_upper(postgres_dialect, "'[1:3]={1,2,3}'", 1)
+ assert result == "array_upper('[1:3]={1,2,3}', 1)"
+
+ def test_unnest_single(self, postgres_dialect: PostgresDialect):
+ """Test unnest() with single array."""
+ result = unnest(postgres_dialect, "'{1,2,3}'")
+ assert result == "unnest('{1,2,3}')"
+
+ def test_unnest_multiple(self, postgres_dialect: PostgresDialect):
+ """Test unnest() with multiple arrays."""
+ result = unnest(postgres_dialect, "'{1,2}'", "'{a,b}'")
+ assert result == "unnest('{1,2}', '{a,b}')"
+
+ def test_string_to_array(self, postgres_dialect: PostgresDialect):
+ """Test string_to_array() function."""
+ result = string_to_array(postgres_dialect, "'a,b,c'", ',')
+ assert result == "string_to_array('a,b,c', ',')"
+
+ def test_string_to_array_with_null(self, postgres_dialect: PostgresDialect):
+ """Test string_to_array() function with null_string."""
+ result = string_to_array(postgres_dialect, "'a,b,NULL,c'", ',', 'NULL')
+ assert result == "string_to_array('a,b,NULL,c', ',', 'NULL')"
\ No newline at end of file
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py
new file mode 100644
index 00000000..fd98f9a1
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py
@@ -0,0 +1,307 @@
+# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py
+"""
+Tests for PostgreSQL hstore functions.
+
+Functions: hstore_from_record, hstore_from_key_value, hstore_akeys,
+ hstore_skeys, hstore_avals, hstore_svals, hstore_each,
+ hstore_to_array, hstore_to_matrix, hstore_to_json,
+ hstore_to_jsonb, hstore_to_json_loose, hstore_to_jsonb_loose,
+ hstore_slice, hstore_exist, hstore_defined,
+ hstore_delete, hstore_delete_keys, hstore_delete_pairs,
+ hstore_populate_record,
+ hstore_get_value, hstore_get_values, hstore_concat,
+ hstore_key_exists, hstore_all_keys_exist, hstore_any_key_exists,
+ hstore_contains, hstore_contained_by,
+ hstore_subtract_key, hstore_subtract_keys, hstore_subtract_pairs,
+ hstore_to_array_operator, hstore_to_matrix_operator,
+ hstore_record_update,
+ hstore_subscript_get, hstore_subscript_set
+"""
+
+from unittest.mock import MagicMock
+
+from rhosocial.activerecord.backend.impl.postgres.functions.hstore import (
+ _to_sql,
+ hstore_from_record,
+ hstore_from_key_value,
+ hstore_akeys,
+ hstore_skeys,
+ hstore_avals,
+ hstore_svals,
+ hstore_each,
+ hstore_to_array,
+ hstore_to_matrix,
+ hstore_to_json,
+ hstore_to_jsonb,
+ hstore_to_json_loose,
+ hstore_to_jsonb_loose,
+ hstore_slice,
+ hstore_exist,
+ hstore_defined,
+ hstore_delete,
+ hstore_delete_keys,
+ hstore_delete_pairs,
+ hstore_populate_record,
+ hstore_get_value,
+ hstore_get_values,
+ hstore_concat,
+ hstore_key_exists,
+ hstore_all_keys_exist,
+ hstore_any_key_exists,
+ hstore_contains,
+ hstore_contained_by,
+ hstore_subtract_key,
+ hstore_subtract_keys,
+ hstore_subtract_pairs,
+ hstore_to_array_operator,
+ hstore_to_matrix_operator,
+ hstore_record_update,
+ hstore_subscript_get,
+ hstore_subscript_set,
+)
+
+
+class TestToSql:
+ """Tests for the internal _to_sql helper function."""
+
+ def test_to_sql_with_to_sql_method(self):
+ """Test _to_sql with an object that has a to_sql method."""
+ mock_expr = MagicMock()
+ mock_expr.to_sql.return_value = ("mocked_sql",)
+ result = _to_sql(mock_expr)
+ assert result == "mocked_sql"
+ mock_expr.to_sql.assert_called_once()
+
+ def test_to_sql_with_plain_string(self):
+ """Test _to_sql with a plain string value."""
+ result = _to_sql("plain_value")
+ assert result == "plain_value"
+
+ def test_to_sql_with_number(self):
+ """Test _to_sql with a number value."""
+ result = _to_sql(42)
+ assert result == "42"
+
+
+class TestHstoreConstructors:
+ """Tests for hstore constructor functions."""
+
+ def test_hstore_from_record(self):
+ """Test hstore_from_record() function."""
+ result = hstore_from_record("ROW(1,2)")
+ assert result == "hstore(ROW(1,2))"
+
+ def test_hstore_from_key_value(self):
+ """Test hstore_from_key_value() function."""
+ result = hstore_from_key_value("'name'", "'value'")
+ assert result == "hstore('name', 'value')"
+
+
+class TestHstoreKeyValueExtraction:
+ """Tests for hstore key/value extraction functions."""
+
+ def test_hstore_akeys(self):
+ """Test hstore_akeys() function."""
+ result = hstore_akeys("data")
+ assert result == "akeys(data)"
+
+ def test_hstore_skeys(self):
+ """Test hstore_skeys() function."""
+ result = hstore_skeys("data")
+ assert result == "skeys(data)"
+
+ def test_hstore_avals(self):
+ """Test hstore_avals() function."""
+ result = hstore_avals("data")
+ assert result == "avals(data)"
+
+ def test_hstore_svals(self):
+ """Test hstore_svals() function."""
+ result = hstore_svals("data")
+ assert result == "svals(data)"
+
+ def test_hstore_each(self):
+ """Test hstore_each() function."""
+ result = hstore_each("data")
+ assert result == "each(data)"
+
+
+class TestHstoreConversion:
+ """Tests for hstore conversion functions."""
+
+ def test_hstore_to_array(self):
+ """Test hstore_to_array() function."""
+ result = hstore_to_array("data")
+ assert result == "hstore_to_array(data)"
+
+ def test_hstore_to_matrix(self):
+ """Test hstore_to_matrix() function."""
+ result = hstore_to_matrix("data")
+ assert result == "hstore_to_matrix(data)"
+
+ def test_hstore_to_json(self):
+ """Test hstore_to_json() function."""
+ result = hstore_to_json("data")
+ assert result == "hstore_to_json(data)"
+
+ def test_hstore_to_jsonb(self):
+ """Test hstore_to_jsonb() function."""
+ result = hstore_to_jsonb("data")
+ assert result == "hstore_to_jsonb(data)"
+
+ def test_hstore_to_json_loose(self):
+ """Test hstore_to_json_loose() function."""
+ result = hstore_to_json_loose("data")
+ assert result == "hstore_to_json_loose(data)"
+
+ def test_hstore_to_jsonb_loose(self):
+ """Test hstore_to_jsonb_loose() function."""
+ result = hstore_to_jsonb_loose("data")
+ assert result == "hstore_to_jsonb_loose(data)"
+
+
+class TestHstoreSubset:
+ """Tests for hstore subset functions."""
+
+ def test_hstore_slice(self):
+ """Test hstore_slice() function."""
+ result = hstore_slice("data", "ARRAY['a', 'b']")
+ assert result == "slice(data, ARRAY['a', 'b'])"
+
+
+class TestHstoreExistence:
+ """Tests for hstore existence functions."""
+
+ def test_hstore_exist(self):
+ """Test hstore_exist() function."""
+ result = hstore_exist("data", "'name'")
+ assert result == "exist(data, 'name')"
+
+ def test_hstore_defined(self):
+ """Test hstore_defined() function."""
+ result = hstore_defined("data", "'name'")
+ assert result == "defined(data, 'name')"
+
+
+class TestHstoreDelete:
+ """Tests for hstore delete functions."""
+
+ def test_hstore_delete(self):
+ """Test hstore_delete() function."""
+ result = hstore_delete("data", "'name'")
+ assert result == "delete(data, 'name')"
+
+ def test_hstore_delete_keys(self):
+ """Test hstore_delete_keys() function."""
+ result = hstore_delete_keys("data", "ARRAY['name', 'age']")
+ assert result == "delete(data, ARRAY['name', 'age'])"
+
+ def test_hstore_delete_pairs(self):
+ """Test hstore_delete_pairs() function."""
+ result = hstore_delete_pairs("data", "'a=>1'::hstore")
+ assert result == "delete(data, 'a=>1'::hstore)"
+
+
+class TestHstoreRecord:
+ """Tests for hstore record functions."""
+
+ def test_hstore_populate_record(self):
+ """Test hstore_populate_record() function."""
+ result = hstore_populate_record("ROW(1,2)", "'f1=>42'::hstore")
+ assert result == "populate_record(ROW(1,2), 'f1=>42'::hstore)"
+
+
+class TestHstoreOperators:
+ """Tests for hstore operator functions."""
+
+ def test_hstore_get_value(self):
+ """Test hstore_get_value() function (operator ->)."""
+ result = hstore_get_value("data", "'name'")
+ assert result == "data -> 'name'"
+
+ def test_hstore_get_values(self):
+ """Test hstore_get_values() function (operator -> text[])."""
+ result = hstore_get_values("data", "ARRAY['name', 'age']")
+ assert result == "data -> ARRAY['name', 'age']"
+
+ def test_hstore_concat(self):
+ """Test hstore_concat() function (operator ||)."""
+ result = hstore_concat("'a=>1'::hstore", "'b=>2'::hstore")
+ assert result == "'a=>1'::hstore || 'b=>2'::hstore"
+
+ def test_hstore_key_exists(self):
+ """Test hstore_key_exists() function (operator ?)."""
+ result = hstore_key_exists("data", "'name'")
+ assert result == "data ? 'name'"
+
+ def test_hstore_all_keys_exist(self):
+ """Test hstore_all_keys_exist() function (operator ?&)."""
+ result = hstore_all_keys_exist("data", "ARRAY['a', 'b']")
+ assert result == "data ?& ARRAY['a', 'b']"
+
+ def test_hstore_any_key_exists(self):
+ """Test hstore_any_key_exists() function (operator ?|)."""
+ result = hstore_any_key_exists("data", "ARRAY['a', 'b']")
+ assert result == "data ?| ARRAY['a', 'b']"
+
+ def test_hstore_contains(self):
+ """Test hstore_contains() function (operator @>)."""
+ result = hstore_contains("data", "'a=>1'")
+ assert result == "data @> 'a=>1'"
+
+ def test_hstore_contained_by(self):
+ """Test hstore_contained_by() function (operator <@)."""
+ result = hstore_contained_by("'a=>1'", "data")
+ assert result == "'a=>1' <@ data"
+
+
+class TestHstoreSubtraction:
+ """Tests for hstore subtraction operator functions."""
+
+ def test_hstore_subtract_key(self):
+ """Test hstore_subtract_key() function (operator - text)."""
+ result = hstore_subtract_key("data", "'name'")
+ assert result == "data - 'name'"
+
+ def test_hstore_subtract_keys(self):
+ """Test hstore_subtract_keys() function (operator - text[])."""
+ result = hstore_subtract_keys("data", "ARRAY['a', 'b']")
+ assert result == "data - ARRAY['a', 'b']"
+
+ def test_hstore_subtract_pairs(self):
+ """Test hstore_subtract_pairs() function (operator - hstore)."""
+ result = hstore_subtract_pairs("data", "'a=>1'::hstore")
+ assert result == "data - 'a=>1'::hstore"
+
+
+class TestHstoreSpecialOperators:
+ """Tests for hstore special operator functions."""
+
+ def test_hstore_to_array_operator(self):
+ """Test hstore_to_array_operator() function (operator %%)."""
+ result = hstore_to_array_operator("data")
+ assert result == "%%data"
+
+ def test_hstore_to_matrix_operator(self):
+ """Test hstore_to_matrix_operator() function (operator %#)."""
+ result = hstore_to_matrix_operator("data")
+ assert result == "%#data"
+
+ def test_hstore_record_update(self):
+ """Test hstore_record_update() function (operator #=)."""
+ result = hstore_record_update("ROW(1,2)", "'f1=>42'::hstore")
+ assert result == "ROW(1,2) #= 'f1=>42'::hstore"
+
+
+class TestHstoreSubscript:
+ """Tests for hstore subscript access functions."""
+
+ def test_hstore_subscript_get(self):
+ """Test hstore_subscript_get() function."""
+ result = hstore_subscript_get("data", "'name'")
+ assert result == "data['name']"
+
+ def test_hstore_subscript_set(self):
+ """Test hstore_subscript_set() function."""
+ result = hstore_subscript_set("data", "'name'", "'value'")
+ assert result == "data['name'] = 'value'"
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py
new file mode 100644
index 00000000..62df9656
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py
@@ -0,0 +1,258 @@
+# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py
+"""
+Tests for PostgreSQL-specific enhanced math functions.
+
+These include additional mathematical functions beyond the basic math module.
+"""
+from rhosocial.activerecord.backend.expression import Column
+from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
+from rhosocial.activerecord.backend.impl.postgres.functions.math_enhanced import (
+ round_,
+ pow,
+ power,
+ sqrt,
+ mod,
+ ceil,
+ floor,
+ trunc,
+ max_,
+ min_,
+ avg,
+)
+
+
+class TestPostgresMathEnhancedFunctions:
+ """Tests for PostgreSQL enhanced math functions."""
+
+ def test_round__default(self, postgres_dialect: PostgresDialect):
+ """Test round_() with default precision."""
+ result = round_(postgres_dialect, Column(postgres_dialect, "value"))
+ sql, _ = result.to_sql()
+ assert "ROUND(" in sql
+ assert '"value"' in sql
+
+ def test_round__with_precision(self, postgres_dialect: PostgresDialect):
+ """Test round_() with precision."""
+ result = round_(postgres_dialect, Column(postgres_dialect, "price"), 2)
+ sql, _ = result.to_sql()
+ assert "ROUND(" in sql
+
+ def test_round__with_literal(self, postgres_dialect: PostgresDialect):
+ """Test round_() with literal value."""
+ result = round_(postgres_dialect, 3.14159, 2)
+ sql, _ = result.to_sql()
+ assert "ROUND(" in sql
+
+ def test_pow(self, postgres_dialect: PostgresDialect):
+ """Test pow() function."""
+ result = pow(postgres_dialect, Column(postgres_dialect, "base"), 2)
+ sql, _ = result.to_sql()
+ assert "POW(" in sql
+
+ def test_pow_both_columns(self, postgres_dialect: PostgresDialect):
+ """Test pow() with both column references."""
+ result = pow(
+ postgres_dialect,
+ Column(postgres_dialect, "x"),
+ Column(postgres_dialect, "y")
+ )
+ sql, _ = result.to_sql()
+ assert "POW(" in sql
+
+ def test_power(self, postgres_dialect: PostgresDialect):
+ """Test power() function (alias for POW)."""
+ result = power(postgres_dialect, 2, 3)
+ sql, _ = result.to_sql()
+ assert "POWER(" in sql
+
+ def test_sqrt(self, postgres_dialect: PostgresDialect):
+ """Test sqrt() function."""
+ result = sqrt(postgres_dialect, Column(postgres_dialect, "value"))
+ sql, _ = result.to_sql()
+ assert "SQRT(" in sql
+ assert '"value"' in sql
+
+ def test_sqrt_with_literal(self, postgres_dialect: PostgresDialect):
+ """Test sqrt() with literal value."""
+ result = sqrt(postgres_dialect, 16)
+ sql, _ = result.to_sql()
+ assert "SQRT(" in sql
+
+ def test_mod(self, postgres_dialect: PostgresDialect):
+ """Test mod() function."""
+ result = mod(postgres_dialect, Column(postgres_dialect, "total"), 10)
+ sql, _ = result.to_sql()
+ assert "MOD(" in sql
+
+ def test_mod_both_columns(self, postgres_dialect: PostgresDialect):
+ """Test mod() with both column references."""
+ result = mod(
+ postgres_dialect,
+ Column(postgres_dialect, "dividend"),
+ Column(postgres_dialect, "divisor")
+ )
+ sql, _ = result.to_sql()
+ assert "MOD(" in sql
+
+ def test_ceil(self, postgres_dialect: PostgresDialect):
+ """Test ceil() function."""
+ result = ceil(postgres_dialect, Column(postgres_dialect, "value"))
+ sql, _ = result.to_sql()
+ assert "CEIL(" in sql
+ assert '"value"' in sql
+
+ def test_ceil_with_literal(self, postgres_dialect: PostgresDialect):
+ """Test ceil() with literal value."""
+ result = ceil(postgres_dialect, 3.14)
+ sql, _ = result.to_sql()
+ assert "CEIL(" in sql
+
+ def test_floor(self, postgres_dialect: PostgresDialect):
+ """Test floor() function."""
+ result = floor(postgres_dialect, Column(postgres_dialect, "value"))
+ sql, _ = result.to_sql()
+ assert "FLOOR(" in sql
+ assert '"value"' in sql
+
+ def test_floor_with_literal(self, postgres_dialect: PostgresDialect):
+ """Test floor() with literal value."""
+ result = floor(postgres_dialect, 3.14)
+ sql, _ = result.to_sql()
+ assert "FLOOR(" in sql
+
+ def test_trunc(self, postgres_dialect: PostgresDialect):
+ """Test trunc() function."""
+ result = trunc(postgres_dialect, Column(postgres_dialect, "value"))
+ sql, _ = result.to_sql()
+ assert "TRUNC(" in sql
+ assert '"value"' in sql
+
+ def test_trunc_with_literal(self, postgres_dialect: PostgresDialect):
+ """Test trunc() with literal value."""
+ result = trunc(postgres_dialect, 3.14)
+ sql, _ = result.to_sql()
+ assert "TRUNC(" in sql
+
+ def test_trunc_with_precision(self, postgres_dialect: PostgresDialect):
+ """Test trunc() with precision."""
+ result = trunc(postgres_dialect, 3.14159, 2)
+ sql, _ = result.to_sql()
+ assert "TRUNC(" in sql
+
+ def test_max__two_args(self, postgres_dialect: PostgresDialect):
+ """Test max_() with two arguments (uses GREATEST)."""
+ result = max_(postgres_dialect, Column(postgres_dialect, "a"), Column(postgres_dialect, "b"))
+ sql, _ = result.to_sql()
+ assert "GREATEST(" in sql
+
+ def test_max__multiple_args(self, postgres_dialect: PostgresDialect):
+ """Test max_() with multiple arguments (uses GREATEST)."""
+ result = max_(
+ postgres_dialect,
+ Column(postgres_dialect, "a"),
+ Column(postgres_dialect, "b"),
+ Column(postgres_dialect, "c")
+ )
+ sql, _ = result.to_sql()
+ assert "GREATEST(" in sql
+
+ def test_max__with_literals(self, postgres_dialect: PostgresDialect):
+ """Test max_() with literal values (uses GREATEST)."""
+ result = max_(postgres_dialect, 1, 2, 3)
+ sql, _ = result.to_sql()
+ assert "GREATEST(" in sql
+
+ def test_min__two_args(self, postgres_dialect: PostgresDialect):
+ """Test min_() with two arguments (uses LEAST)."""
+ result = min_(postgres_dialect, Column(postgres_dialect, "a"), Column(postgres_dialect, "b"))
+ sql, _ = result.to_sql()
+ assert "LEAST(" in sql
+
+ def test_min__multiple_args(self, postgres_dialect: PostgresDialect):
+ """Test min_() with multiple arguments (uses LEAST)."""
+ result = min_(
+ postgres_dialect,
+ Column(postgres_dialect, "a"),
+ Column(postgres_dialect, "b"),
+ Column(postgres_dialect, "c")
+ )
+ sql, _ = result.to_sql()
+ assert "LEAST(" in sql
+
+ def test_min__with_literals(self, postgres_dialect: PostgresDialect):
+ """Test min_() with literal values (uses LEAST)."""
+ result = min_(postgres_dialect, 1, 2, 3)
+ sql, _ = result.to_sql()
+ assert "LEAST(" in sql
+
+ def test_avg(self, postgres_dialect: PostgresDialect):
+ """Test avg() aggregate function."""
+ result = avg(postgres_dialect, Column(postgres_dialect, "price"))
+ sql, _ = result.to_sql()
+ assert "AVG(" in sql
+ assert '"price"' in sql
+
+ def test_avg_with_literal(self, postgres_dialect: PostgresDialect):
+ """Test avg() with literal value."""
+ result = avg(postgres_dialect, 100)
+ sql, _ = result.to_sql()
+ assert "AVG(" in sql
+
+ def test_round__with_string_integer(self, postgres_dialect: PostgresDialect):
+ """Test round_() with string integer value."""
+ result = round_(postgres_dialect, "123", 2)
+ sql, _ = result.to_sql()
+ assert "ROUND(" in sql
+
+ def test_round__with_string_float(self, postgres_dialect: PostgresDialect):
+ """Test round_() with string float value."""
+ result = round_(postgres_dialect, "3.14159", 2)
+ sql, _ = result.to_sql()
+ assert "ROUND(" in sql
+
+ def test_round__with_string_column_name(self, postgres_dialect: PostgresDialect):
+ """Test round_() with non-numeric string treated as column."""
+ result = round_(postgres_dialect, "column_name", 2)
+ sql, _ = result.to_sql()
+ assert "ROUND(" in sql
+ assert '"column_name"' in sql
+
+ def test_pow_with_string_integer(self, postgres_dialect: PostgresDialect):
+ """Test pow() with string integer exponent."""
+ result = pow(postgres_dialect, Column(postgres_dialect, "base"), "2")
+ sql, _ = result.to_sql()
+ assert "POW(" in sql
+
+ def test_sqrt_with_string_integer(self, postgres_dialect: PostgresDialect):
+ """Test sqrt() with string integer value."""
+ result = sqrt(postgres_dialect, "16")
+ sql, _ = result.to_sql()
+ assert "SQRT(" in sql
+
+ def test_mod_with_string_divisor(self, postgres_dialect: PostgresDialect):
+ """Test mod() with string divisor."""
+ result = mod(postgres_dialect, Column(postgres_dialect, "total"), "10")
+ sql, _ = result.to_sql()
+ assert "MOD(" in sql
+
+ def test_max__with_string_literals(self, postgres_dialect: PostgresDialect):
+ """Test max_() with non-numeric string values (treated as columns in GREATEST)."""
+ result = max_(postgres_dialect, "a", "b", "c")
+ sql, _ = result.to_sql()
+ assert "GREATEST(" in sql
+ # Non-numeric strings should be treated as column names and quoted
+ assert '"a"' in sql
+
+ def test_min__with_string_literals(self, postgres_dialect: PostgresDialect):
+ """Test min_() with non-numeric string values (treated as columns in LEAST)."""
+ result = min_(postgres_dialect, "a", "b", "c")
+ sql, _ = result.to_sql()
+ assert "LEAST(" in sql
+ # Non-numeric strings should be treated as column names and quoted
+ assert '"a"' in sql
+
+ def test_avg_with_string_literal(self, postgres_dialect: PostgresDialect):
+ """Test avg() with string numeric value."""
+ result = avg(postgres_dialect, "100")
+ sql, _ = result.to_sql()
+ assert "AVG(" in sql
\ No newline at end of file
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py
new file mode 100644
index 00000000..1fcd3e2e
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py
@@ -0,0 +1,113 @@
+# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py
+"""
+Tests for PostgreSQL Network Address functions.
+
+Functions: inet_client_addr, inet_client_port, inet_server_addr,
+ inet_server_port, inet_merge, inet_and, inet_or, inetnot,
+ inet_set_mask, inet_masklen, inet_netmask, inet_network,
+ inet_recv, inet_show, cidr_netmask, macaddr8_set7bit
+"""
+
+from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
+from rhosocial.activerecord.backend.impl.postgres.functions.network import (
+ inet_client_addr,
+ inet_client_port,
+ inet_server_addr,
+ inet_server_port,
+ inet_merge,
+ inet_and,
+ inet_or,
+ inetnot,
+ inet_set_mask,
+ inet_masklen,
+ inet_netmask,
+ inet_network,
+ inet_recv,
+ inet_show,
+ cidr_netmask,
+ macaddr8_set7bit,
+)
+
+
+class TestPostgresNetworkFunctions:
+ """Tests for PostgreSQL network functions."""
+
+ def test_inet_client_addr(self, postgres_dialect: PostgresDialect):
+ """Test inet_client_addr() function."""
+ result = inet_client_addr(postgres_dialect)
+ assert result == "inet_client_addr()"
+
+ def test_inet_client_port(self, postgres_dialect: PostgresDialect):
+ """Test inet_client_port() function."""
+ result = inet_client_port(postgres_dialect)
+ assert result == "inet_client_port()"
+
+ def test_inet_server_addr(self, postgres_dialect: PostgresDialect):
+ """Test inet_server_addr() function."""
+ result = inet_server_addr(postgres_dialect)
+ assert result == "inet_server_addr()"
+
+ def test_inet_server_port(self, postgres_dialect: PostgresDialect):
+ """Test inet_server_port() function."""
+ result = inet_server_port(postgres_dialect)
+ assert result == "inet_server_port()"
+
+ def test_inet_merge(self, postgres_dialect: PostgresDialect):
+ """Test inet_merge() function."""
+ result = inet_merge(postgres_dialect, "'192.168.1.0/24'", "'192.168.2.0/24'")
+ assert result == "inet_merge('192.168.1.0/24', '192.168.2.0/24')"
+
+ def test_inet_and(self, postgres_dialect: PostgresDialect):
+ """Test inet_and() function."""
+ result = inet_and(postgres_dialect, "'192.168.1.1'", "'255.255.255.0'")
+ assert result == "inet_and('192.168.1.1', '255.255.255.0')"
+
+ def test_inet_or(self, postgres_dialect: PostgresDialect):
+ """Test inet_or() function."""
+ result = inet_or(postgres_dialect, "'192.168.1.1'", "'0.0.0.255'")
+ assert result == "inet_or('192.168.1.1', '0.0.0.255')"
+
+ def test_inetnot(self, postgres_dialect: PostgresDialect):
+ """Test inetnot() function."""
+ result = inetnot(postgres_dialect, "'192.168.1.1'")
+ assert result == "inetnot('192.168.1.1')"
+
+ def test_inet_set_mask(self, postgres_dialect: PostgresDialect):
+ """Test inet_set_mask() function."""
+ result = inet_set_mask(postgres_dialect, "'192.168.1.1'", 24)
+ assert result == "set_masklen('192.168.1.1', 24)"
+
+ def test_inet_masklen(self, postgres_dialect: PostgresDialect):
+ """Test inet_masklen() function."""
+ result = inet_masklen(postgres_dialect, "'192.168.1.1/24'")
+ assert result == "masklen('192.168.1.1/24')"
+
+ def test_inet_netmask(self, postgres_dialect: PostgresDialect):
+ """Test inet_netmask() function."""
+ result = inet_netmask(postgres_dialect, "'192.168.1.1/24'")
+ assert result == "netmask('192.168.1.1/24')"
+
+ def test_inet_network(self, postgres_dialect: PostgresDialect):
+ """Test inet_network() function."""
+ result = inet_network(postgres_dialect, "'192.168.1.1/24'")
+ assert result == "network('192.168.1.1/24')"
+
+ def test_inet_recv(self, postgres_dialect: PostgresDialect):
+ """Test inet_recv() function (host)."""
+ result = inet_recv(postgres_dialect, "'192.168.1.1/24'")
+ assert result == "host('192.168.1.1/24')"
+
+ def test_inet_show(self, postgres_dialect: PostgresDialect):
+ """Test inet_show() function (text)."""
+ result = inet_show(postgres_dialect, "'192.168.1.1'")
+ assert result == "text('192.168.1.1')"
+
+ def test_cidr_netmask(self, postgres_dialect: PostgresDialect):
+ """Test cidr_netmask() function (broadcast)."""
+ result = cidr_netmask(postgres_dialect, "'192.168.1.0/24'")
+ assert result == "broadcast('192.168.1.0/24')"
+
+ def test_macaddr8_set7bit(self, postgres_dialect: PostgresDialect):
+ """Test macaddr8_set7bit() function."""
+ result = macaddr8_set7bit(postgres_dialect, "'08:00:2b:01:02:03'")
+ assert result == "macaddr8_set7bit('08:00:2b:01:02:03')"
\ No newline at end of file
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py
new file mode 100644
index 00000000..6cdba6ae
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py
@@ -0,0 +1,70 @@
+# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py
+"""
+Tests for PostgreSQL Range Type Constructors.
+
+Functions: int4range, int8range, numrange, tsrange, tstzrange, daterange
+"""
+
+from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
+from rhosocial.activerecord.backend.impl.postgres.functions.range import (
+ int4range,
+ int8range,
+ numrange,
+ tsrange,
+ tstzrange,
+ daterange,
+)
+
+
+class TestRangeConstructors:
+ """Tests for PostgreSQL range type constructors."""
+
+ def test_int4range_with_bounds(self, postgres_dialect: PostgresDialect):
+ """Test int4range() with lower and upper bounds."""
+ result = int4range(postgres_dialect, 1, 10)
+ assert result == "int4range(1, 10)"
+
+ def test_int4range_with_bounds_and_inclusive(self, postgres_dialect: PostgresDialect):
+ """Test int4range() with inclusive bounds."""
+ result = int4range(postgres_dialect, 1, 10, "[)")
+ assert result == "int4range(1, 10, '[)')"
+
+ def test_int4range_empty(self, postgres_dialect: PostgresDialect):
+ """Test int4range() with no arguments."""
+ result = int4range(postgres_dialect)
+ assert result == "int4range()"
+
+ def test_int8range_with_bounds(self, postgres_dialect: PostgresDialect):
+ """Test int8range() with lower and upper bounds."""
+ result = int8range(postgres_dialect, 1, 1000000)
+ assert result == "int8range(1, 1000000)"
+
+ def test_int8range_with_bounds_and_exclusive(self, postgres_dialect: PostgresDialect):
+ """Test int8range() with exclusive bounds."""
+ result = int8range(postgres_dialect, 1, 100, "()")
+ assert result == "int8range(1, 100, '()')"
+
+ def test_numrange_with_bounds(self, postgres_dialect: PostgresDialect):
+ """Test numrange() with lower and upper bounds."""
+ result = numrange(postgres_dialect, 1.5, 10.5)
+ assert result == "numrange(1.5, 10.5)"
+
+ def test_numrange_with_bounds_and_inclusive(self, postgres_dialect: PostgresDialect):
+ """Test numrange() with inclusive bounds."""
+ result = numrange(postgres_dialect, 1.5, 10.5, "[]")
+ assert result == "numrange(1.5, 10.5, '[]')"
+
+ def test_tsrange_with_bounds(self, postgres_dialect: PostgresDialect):
+ """Test tsrange() with lower and upper bounds."""
+ result = tsrange(postgres_dialect, "'2024-01-01 00:00:00'", "'2024-12-31 23:59:59'")
+ assert result == "tsrange('2024-01-01 00:00:00', '2024-12-31 23:59:59')"
+
+ def test_tstzrange_with_bounds(self, postgres_dialect: PostgresDialect):
+ """Test tstzrange() with lower and upper bounds."""
+ result = tstzrange(postgres_dialect, "'2024-01-01 00:00:00+00'", "'2024-12-31 23:59:59+00'")
+ assert result == "tstzrange('2024-01-01 00:00:00+00', '2024-12-31 23:59:59+00')"
+
+ def test_daterange_with_bounds(self, postgres_dialect: PostgresDialect):
+ """Test daterange() with lower and upper bounds."""
+ result = daterange(postgres_dialect, "'2024-01-01'", "'2024-12-31'")
+ assert result == "daterange('2024-01-01', '2024-12-31')"
\ No newline at end of file
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py
new file mode 100644
index 00000000..754665d9
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py
@@ -0,0 +1,57 @@
+# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py
+"""
+Tests for PostgreSQL UUID functions.
+
+Functions: uuid_generate_v1, uuid_generate_v1mc, uuid_generate_v3,
+ uuid_generate_v4, uuid_generate_v5, uuid_nil, uuid_max
+"""
+
+from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
+from rhosocial.activerecord.backend.impl.postgres.functions.uuid import (
+ uuid_generate_v1,
+ uuid_generate_v1mc,
+ uuid_generate_v3,
+ uuid_generate_v4,
+ uuid_generate_v5,
+ uuid_nil,
+ uuid_max,
+)
+
+
+class TestPostgresUUIDFunctions:
+ """Tests for PostgreSQL UUID functions."""
+
+ def test_uuid_generate_v1(self, postgres_dialect: PostgresDialect):
+ """Test uuid_generate_v1() function."""
+ result = uuid_generate_v1(postgres_dialect)
+ assert result == "uuid_generate_v1()"
+
+ def test_uuid_generate_v1mc(self, postgres_dialect: PostgresDialect):
+ """Test uuid_generate_v1mc() function."""
+ result = uuid_generate_v1mc(postgres_dialect)
+ assert result == "uuid_generate_v1mc()"
+
+ def test_uuid_generate_v3(self, postgres_dialect: PostgresDialect):
+ """Test uuid_generate_v3() function."""
+ result = uuid_generate_v3(postgres_dialect, "'dns'", "'example.com'")
+ assert result == "uuid_generate_v3('dns', 'example.com')"
+
+ def test_uuid_generate_v4(self, postgres_dialect: PostgresDialect):
+ """Test uuid_generate_v4() function."""
+ result = uuid_generate_v4(postgres_dialect)
+ assert result == "uuid_generate_v4()"
+
+ def test_uuid_generate_v5(self, postgres_dialect: PostgresDialect):
+ """Test uuid_generate_v5() function."""
+ result = uuid_generate_v5(postgres_dialect, "'dns'", "'example.com'")
+ assert result == "uuid_generate_v5('dns', 'example.com')"
+
+ def test_uuid_nil(self, postgres_dialect: PostgresDialect):
+ """Test uuid_nil() function."""
+ result = uuid_nil(postgres_dialect)
+ assert result == "uuid_nil()"
+
+ def test_uuid_max(self, postgres_dialect: PostgresDialect):
+ """Test uuid_max() function."""
+ result = uuid_max(postgres_dialect)
+ assert result == "uuid_max()"
\ No newline at end of file
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py
index d7f34b85..a7e4760d 100644
--- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py
@@ -10,6 +10,8 @@
from rhosocial.activerecord.backend.impl.postgres.types.xml import (
PostgresXML,
+)
+from rhosocial.activerecord.backend.impl.postgres.functions.xml import (
xmlparse,
xpath_query,
xpath_exists,
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_advisory_lock.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_advisory_lock.py
new file mode 100644
index 00000000..1f2ba0dd
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_advisory_lock.py
@@ -0,0 +1,212 @@
+# tests/rhosocial/activerecord_postgres_test/feature/backend/test_advisory_lock.py
+"""
+PostgreSQL advisory lock tests.
+
+This module tests PostgreSQL's advisory lock functionality:
+- Session-level locks
+- Transaction-level locks
+- Exclusive locks
+- Shared locks
+- Non-blocking try variants
+- Context manager usage
+"""
+
+from rhosocial.activerecord.backend.impl.postgres.expression.advisory import (
+ AdvisoryLockExpression,
+ AdvisoryUnlockExpression,
+ AdvisoryUnlockAllExpression,
+ TryAdvisoryLockExpression,
+)
+
+
+class TestAdvisoryLockExpression:
+ """Test advisory lock expression SQL generation."""
+
+ def test_advisory_lock_expression_basic(self, postgres_backend_single):
+ """Test basic advisory lock expression."""
+ dialect = postgres_backend_single.dialect
+
+ expr = AdvisoryLockExpression(dialect, key=12345)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_advisory_lock(%s)"
+ assert params == (12345,)
+
+ def test_advisory_lock_expression_shared(self, postgres_backend_single):
+ """Test shared advisory lock expression."""
+ dialect = postgres_backend_single.dialect
+
+ expr = AdvisoryLockExpression(dialect, key=12345, shared=True)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_advisory_lock_shared(%s)"
+ assert params == (12345,)
+
+ def test_advisory_lock_expression_transaction_level(self, postgres_backend_single):
+ """Test transaction-level advisory lock expression."""
+ dialect = postgres_backend_single.dialect
+
+ expr = AdvisoryLockExpression(dialect, key=12345, session=False)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_advisory_xact_lock(%s)"
+ assert params == (12345,)
+
+ def test_advisory_lock_expression_two_keys(self, postgres_backend_single):
+ """Test advisory lock with two keys."""
+ dialect = postgres_backend_single.dialect
+
+ expr = AdvisoryLockExpression(dialect, key=(123, 456))
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_advisory_lock(%s, %s)"
+ assert params == (123, 456)
+
+ def test_advisory_unlock_expression(self, postgres_backend_single):
+ """Test advisory unlock expression."""
+ dialect = postgres_backend_single.dialect
+
+ expr = AdvisoryUnlockExpression(dialect, key=12345)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_advisory_unlock(%s)"
+ assert params == (12345,)
+
+ def test_advisory_unlock_all_expression(self, postgres_backend_single):
+ """Test advisory unlock all expression."""
+ dialect = postgres_backend_single.dialect
+
+ expr = AdvisoryUnlockAllExpression(dialect)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_advisory_unlock_all()"
+ assert params == ()
+
+ def test_try_advisory_lock_expression(self, postgres_backend_single):
+ """Test try advisory lock expression."""
+ dialect = postgres_backend_single.dialect
+
+ expr = TryAdvisoryLockExpression(dialect, key=12345)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_try_advisory_lock(%s)"
+ assert params == (12345,)
+
+ def test_try_advisory_lock_expression_shared(self, postgres_backend_single):
+ """Test try shared advisory lock expression."""
+ dialect = postgres_backend_single.dialect
+
+ expr = TryAdvisoryLockExpression(dialect, key=12345, shared=True)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_try_advisory_lock_shared(%s)"
+ assert params == (12345,)
+
+
+class TestAdvisoryLockBackend:
+ """Test advisory lock backend methods."""
+
+ def test_execute_advisory_lock(self, postgres_backend_single):
+ """Test acquiring and releasing advisory lock."""
+ # Acquire lock
+ postgres_backend_single.execute_advisory_lock(key=12345)
+ # Release lock
+ result = postgres_backend_single.execute_advisory_unlock(key=12345)
+ assert result is True
+
+ def test_execute_advisory_lock_two_keys(self, postgres_backend_single):
+ """Test advisory lock with two keys."""
+ # Acquire lock
+ postgres_backend_single.execute_advisory_lock(key=(123, 456))
+ # Release lock
+ result = postgres_backend_single.execute_advisory_unlock(key=(123, 456))
+ assert result is True
+
+ def test_execute_advisory_lock_shared(self, postgres_backend_single):
+ """Test shared advisory lock."""
+ # Acquire shared lock
+ postgres_backend_single.execute_advisory_lock(key=12345, shared=True)
+ # Release shared lock (must use shared=True)
+ result = postgres_backend_single.execute_advisory_unlock(key=12345, shared=True)
+ assert result is True
+
+ def test_try_advisory_lock_success(self, postgres_backend_single):
+ """Test try advisory lock when available."""
+ result = postgres_backend_single.try_advisory_lock(key=12345)
+ assert result is True
+ # Clean up
+ postgres_backend_single.execute_advisory_unlock(key=12345)
+
+ def test_try_advisory_lock_conflict(self, postgres_backend_single, postgres_control_backend):
+ """Test try advisory lock when already locked."""
+ # Acquire lock in main backend
+ postgres_backend_single.execute_advisory_lock(key=99999)
+
+ try:
+ # Try to acquire same lock in control backend (should fail)
+ result = postgres_control_backend.try_advisory_lock(key=99999)
+ assert result is False
+ finally:
+ # Clean up
+ postgres_backend_single.execute_advisory_unlock(key=99999)
+
+ def test_advisory_unlock_all(self, postgres_backend_single):
+ """Test releasing all advisory locks."""
+ # Acquire multiple locks
+ postgres_backend_single.execute_advisory_lock(key=11111)
+ postgres_backend_single.execute_advisory_lock(key=22222)
+ postgres_backend_single.execute_advisory_lock(key=33333)
+
+ # Release all
+ postgres_backend_single.execute_advisory_unlock_all()
+
+ # Verify locks are released
+ result = postgres_backend_single.try_advisory_lock(key=11111)
+ assert result is True
+ postgres_backend_single.execute_advisory_unlock(key=11111)
+
+ def test_advisory_lock_context_manager(self, postgres_backend_single):
+ """Test advisory lock context manager."""
+ with postgres_backend_single.advisory_lock(key=12345):
+ # Lock should be held
+ # Try to acquire from same session - should succeed
+ # But if we try from another session it would block
+ pass
+ # Lock should be released after context exit
+ result = postgres_backend_single.try_advisory_lock(key=12345)
+ assert result is True
+ postgres_backend_single.execute_advisory_unlock(key=12345)
+
+
+class TestAsyncAdvisoryLockExpression:
+ """Async tests for advisory lock expression SQL generation."""
+
+ async def test_async_advisory_lock_expression_basic(self, async_postgres_backend_single):
+ """Test basic advisory lock expression (async)."""
+ dialect = async_postgres_backend_single.dialect
+
+ expr = AdvisoryLockExpression(dialect, key=12345)
+ sql, params = expr.to_sql()
+ assert sql == "SELECT pg_advisory_lock(%s)"
+ assert params == (12345,)
+
+
+class TestAsyncAdvisoryLockBackend:
+ """Async tests for advisory lock backend methods."""
+
+ async def test_async_execute_advisory_lock(self, async_postgres_backend_single):
+ """Test acquiring and releasing advisory lock (async)."""
+ # Acquire lock
+ await async_postgres_backend_single.execute_advisory_lock(key=12345)
+ # Release lock
+ result = await async_postgres_backend_single.execute_advisory_unlock(key=12345)
+ assert result is True
+
+ async def test_async_try_advisory_lock(self, async_postgres_backend_single):
+ """Test try advisory lock (async)."""
+ result = await async_postgres_backend_single.try_advisory_lock(key=12345)
+ assert result is True
+ # Clean up
+ await async_postgres_backend_single.execute_advisory_unlock(key=12345)
+
+ async def test_async_advisory_lock_context_manager(self, async_postgres_backend_single):
+ """Test advisory lock async context manager."""
+ async with async_postgres_backend_single.advisory_lock(key=12345):
+ # Lock should be held
+ pass
+ # Lock should be released after context exit
+ result = await async_postgres_backend_single.try_advisory_lock(key=12345)
+ assert result is True
+ await async_postgres_backend_single.execute_advisory_unlock(key=12345)
\ No newline at end of file
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py
new file mode 100644
index 00000000..969db48b
--- /dev/null
+++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py
@@ -0,0 +1,219 @@
+# tests/rhosocial/activerecord_postgres_test/feature/query/test_row_level_locks.py
+"""
+PostgreSQL row-level lock tests.
+
+This module tests PostgreSQL's advanced row-level locking features:
+- FOR UPDATE (exclusive lock)
+- FOR NO KEY UPDATE (weaker exclusive lock)
+- FOR SHARE (shared lock)
+- FOR KEY SHARE (weakest shared lock)
+
+Version requirements:
+- FOR UPDATE: All PostgreSQL versions
+- FOR NO KEY UPDATE: PostgreSQL 9.0+
+- FOR SHARE: PostgreSQL 9.0+
+- FOR KEY SHARE: PostgreSQL 9.3+
+- SKIP LOCKED: PostgreSQL 9.5+
+"""
+import pytest
+import pytest_asyncio
+
+from rhosocial.activerecord.backend.impl.postgres.expression.locking import (
+ LockStrength,
+ PostgresForUpdateClause,
+)
+from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
+
+
+class TestRowLevelLockStrength:
+ """Test PostgreSQL row-level lock strength support."""
+
+ def test_supports_for_update(self, postgres_backend_single):
+ """Test that FOR UPDATE is always supported."""
+ dialect = postgres_backend_single.dialect
+ assert dialect.supports_for_update() is True
+ assert dialect.supports_lock_strength(LockStrength.UPDATE) is True
+
+ def test_supports_for_share(self, postgres_backend_single):
+ """Test FOR SHARE support (PostgreSQL 9.0+)."""
+ dialect = postgres_backend_single.dialect
+ if dialect.version >= (9, 0, 0):
+ assert dialect.supports_for_share() is True
+ assert dialect.supports_lock_strength(LockStrength.SHARE) is True
+ else:
+ assert dialect.supports_for_share() is False
+
+ def test_supports_for_no_key_update(self, postgres_backend_single):
+ """Test FOR NO KEY UPDATE support (PostgreSQL 9.0+)."""
+ dialect = postgres_backend_single.dialect
+ if dialect.version >= (9, 0, 0):
+ assert dialect.supports_for_no_key_update() is True
+ assert dialect.supports_lock_strength(LockStrength.NO_KEY_UPDATE) is True
+ else:
+ assert dialect.supports_for_no_key_update() is False
+
+ def test_supports_for_key_share(self, postgres_backend_single):
+ """Test FOR KEY SHARE support (PostgreSQL 9.3+)."""
+ dialect = postgres_backend_single.dialect
+ if dialect.version >= (9, 3, 0):
+ assert dialect.supports_for_key_share() is True
+ assert dialect.supports_lock_strength(LockStrength.KEY_SHARE) is True
+ else:
+ assert dialect.supports_for_key_share() is False
+
+
+class TestPostgresForUpdateClauseFormatting:
+ """Test FOR UPDATE clause SQL formatting."""
+
+ def test_for_update_basic(self, postgres_backend_single):
+ """Test basic FOR UPDATE clause."""
+ dialect = postgres_backend_single.dialect
+ clause = PostgresForUpdateClause(dialect)
+ sql, params = clause.to_sql()
+ assert sql == "FOR UPDATE"
+ assert params == ()
+
+ def test_for_share_basic(self, postgres_backend_single):
+ """Test FOR SHARE clause."""
+ dialect = postgres_backend_single.dialect
+ if not dialect.supports_for_share():
+ pytest.skip("FOR SHARE not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE)
+ sql, params = clause.to_sql()
+ assert sql == "FOR SHARE"
+ assert params == ()
+
+ def test_for_no_key_update_basic(self, postgres_backend_single):
+ """Test FOR NO KEY UPDATE clause."""
+ dialect = postgres_backend_single.dialect
+ if not dialect.supports_for_no_key_update():
+ pytest.skip("FOR NO KEY UPDATE not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.NO_KEY_UPDATE)
+ sql, params = clause.to_sql()
+ assert sql == "FOR NO KEY UPDATE"
+ assert params == ()
+
+ def test_for_key_share_basic(self, postgres_backend_single):
+ """Test FOR KEY SHARE clause."""
+ dialect = postgres_backend_single.dialect
+ if not dialect.supports_for_key_share():
+ pytest.skip("FOR KEY SHARE not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.KEY_SHARE)
+ sql, params = clause.to_sql()
+ assert sql == "FOR KEY SHARE"
+ assert params == ()
+
+ def test_for_update_with_nowait(self, postgres_backend_single):
+ """Test FOR UPDATE with NOWAIT."""
+ dialect = postgres_backend_single.dialect
+ clause = PostgresForUpdateClause(dialect, nowait=True)
+ sql, params = clause.to_sql()
+ assert sql == "FOR UPDATE NOWAIT"
+ assert params == ()
+
+ def test_for_share_with_nowait(self, postgres_backend_single):
+ """Test FOR SHARE with NOWAIT."""
+ dialect = postgres_backend_single.dialect
+ if not dialect.supports_for_share():
+ pytest.skip("FOR SHARE not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE, nowait=True)
+ sql, params = clause.to_sql()
+ assert sql == "FOR SHARE NOWAIT"
+ assert params == ()
+
+ def test_for_update_with_skip_locked(self, postgres_backend_single):
+ """Test FOR UPDATE with SKIP LOCKED."""
+ dialect = postgres_backend_single.dialect
+ if not dialect.supports_for_update_skip_locked():
+ pytest.skip("SKIP LOCKED not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, skip_locked=True)
+ sql, params = clause.to_sql()
+ assert sql == "FOR UPDATE SKIP LOCKED"
+ assert params == ()
+
+ def test_for_share_with_skip_locked(self, postgres_backend_single):
+ """Test FOR SHARE with SKIP LOCKED."""
+ dialect = postgres_backend_single.dialect
+ if not dialect.supports_for_share():
+ pytest.skip("FOR SHARE not supported on this PostgreSQL version")
+ if not dialect.supports_for_update_skip_locked():
+ pytest.skip("SKIP LOCKED not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE, skip_locked=True)
+ sql, params = clause.to_sql()
+ assert sql == "FOR SHARE SKIP LOCKED"
+ assert params == ()
+
+ def test_for_update_with_of_columns(self, postgres_backend_single):
+ """Test FOR UPDATE with OF columns."""
+ dialect = postgres_backend_single.dialect
+ clause = PostgresForUpdateClause(dialect, of_columns=["users", "orders"])
+ sql, params = clause.to_sql()
+ assert sql == 'FOR UPDATE OF "users", "orders"'
+ assert params == ()
+
+ def test_for_share_with_of_columns(self, postgres_backend_single):
+ """Test FOR SHARE with OF columns."""
+ dialect = postgres_backend_single.dialect
+ if not dialect.supports_for_share():
+ pytest.skip("FOR SHARE not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE, of_columns=["users"])
+ sql, params = clause.to_sql()
+ assert sql == 'FOR SHARE OF "users"'
+ assert params == ()
+
+ def test_unsupported_lock_strength_raises_error(self, postgres_backend_single):
+ """Test that unsupported lock strength raises error."""
+ dialect = postgres_backend_single.dialect
+
+ # Create a mock dialect that doesn't support FOR SHARE
+ if dialect.supports_for_share():
+ # Skip if this version actually supports it
+ pytest.skip("This test requires a PostgreSQL version < 9.0")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE)
+ with pytest.raises(UnsupportedFeatureError):
+ clause.to_sql()
+
+
+class TestAsyncRowLevelLockStrength:
+ """Async tests for PostgreSQL row-level lock strength support."""
+
+ async def test_async_supports_for_update(self, async_postgres_backend_single):
+ """Test that FOR UPDATE is always supported (async)."""
+ dialect = async_postgres_backend_single.dialect
+ assert dialect.supports_for_update() is True
+ assert dialect.supports_lock_strength(LockStrength.UPDATE) is True
+
+ async def test_async_supports_for_share(self, async_postgres_backend_single):
+ """Test FOR SHARE support (async)."""
+ dialect = async_postgres_backend_single.dialect
+ if dialect.version >= (9, 0, 0):
+ assert dialect.supports_for_share() is True
+ else:
+ assert dialect.supports_for_share() is False
+
+ async def test_async_for_update_basic(self, async_postgres_backend_single):
+ """Test basic FOR UPDATE clause (async)."""
+ dialect = async_postgres_backend_single.dialect
+ clause = PostgresForUpdateClause(dialect)
+ sql, params = clause.to_sql()
+ assert sql == "FOR UPDATE"
+ assert params == ()
+
+ async def test_async_for_share_basic(self, async_postgres_backend_single):
+ """Test FOR SHARE clause (async)."""
+ dialect = async_postgres_backend_single.dialect
+ if not dialect.supports_for_share():
+ pytest.skip("FOR SHARE not supported on this PostgreSQL version")
+
+ clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE)
+ sql, params = clause.to_sql()
+ assert sql == "FOR SHARE"
+ assert params == ()
\ No newline at end of file
diff --git a/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py
index 0f5c450e..746effed 100644
--- a/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py
+++ b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py
@@ -27,7 +27,8 @@ def ensure_compatible_event_loop():
if sys.platform == "win32":
policy = asyncio.get_event_loop_policy()
if not isinstance(policy, asyncio.WindowsSelectorEventLoopPolicy):
- asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy)
+ new_policy = asyncio.WindowsSelectorEventLoopPolicy()
+ asyncio.set_event_loop_policy(new_policy)
ensure_compatible_event_loop()
diff --git a/tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py b/tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py
deleted file mode 100644
index 460be036..00000000
--- a/tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py
-"""Fixtures for PostgreSQL transaction expression tests."""
-import pytest
-
-from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
-
-
-@pytest.fixture(scope="function")
-def postgres_dialect():
- """Fixture providing PostgresDialect instance for testing transaction expressions."""
- return PostgresDialect()