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()