From 320ea44baaae06f74f89c0452829c6e9d960a8eb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 8 Apr 2026 22:05:31 +0800 Subject: [PATCH 01/13] chore: bump version to 1.0.0.dev10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From 408ac6bec3ae4ef51c7e8ed7fd64d6cc7445ab29 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:58:15 +0800 Subject: [PATCH 02/13] feat(postgres): add row-level lock enhancement and advisory lock support (#23) * feat(postgres): add row-level lock enhancement and advisory lock support Row-level lock enhancement: - FOR SHARE: Shared lock, allows reads but blocks updates (PG 9.0+) - FOR NO KEY UPDATE: Weaker exclusive lock (PG 9.0+) - FOR KEY SHARE: Weakest shared lock (PG 9.3+) - Add LockStrength enum in core query_parts - Extend ForUpdateClause to support different lock strengths - Add capability detection methods in PostgreSQLDialect Advisory lock support: - Session-level locks: pg_advisory_lock, pg_advisory_lock_shared - Transaction-level locks: pg_advisory_xact_lock, pg_advisory_xact_lock_shared - Non-blocking try variants: pg_try_advisory_lock, pg_try_advisory_lock_shared - Unlock functions: pg_advisory_unlock, pg_advisory_unlock_all - Context manager support for automatic lock release - Support for both single-key and two-key variants Implementation: - Add PostgreSQLAdvisoryLockSupport protocol - Add AdvisoryLockMixin for backend methods - Add expression classes in core advisory module - Implement format_for_update_clause with lock strength - Add version-aware capability detection Tests: - test_row_level_locks.py: Row-level lock strength tests - test_advisory_lock.py: Advisory lock functionality tests - Both sync and async versions covered Official Documentation: - Row-level locks: https://www.postgresql.org/docs/current/explicit-locking.html - Advisory locks: https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS * refactor(postgres): migrate advisory lock and lock strength to extension package - Move advisory lock expressions from core to postgres/expression/advisory/ - Define LockStrength enum and PostgresForUpdateClause in postgres/expression/ - PostgresForUpdateClause.to_sql() handles PostgreSQL-specific lock strengths - Update import paths in backend, mixin, and protocol files - Reorganize test directory: move activerecord_test/ to activerecord_postgres_test/ - Add postgres_dialect fixture to backend conftest.py - Add .claude/tmp/ to .gitignore Co-Authored-By: Claude Opus 4.6 * ci: update dependency sources to Git branches - rhosocial-activerecord: git+https@release/v1.0.0.dev23 - rhosocial-activerecord-testsuite: git+https@release/v1.0.0.dev10 Co-Authored-By: Claude Opus 4.6 * chore: add changelog fragment for PR #23 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/test.yml | 6 +- .gitignore | 4 + changelog.d/23.added.md | 1 + .../impl/postgres/backend/async_backend.py | 153 +++++++++++- .../backend/impl/postgres/backend/sync.py | 146 +++++++++++- .../backend/impl/postgres/dialect.py | 44 ++++ .../impl/postgres/expression/__init__.py | 26 +++ .../postgres/expression/advisory/__init__.py | 23 ++ .../impl/postgres/expression/advisory/lock.py | 215 +++++++++++++++++ .../impl/postgres/expression/locking.py | 178 ++++++++++++++ .../backend/impl/postgres/mixins/__init__.py | 2 + .../impl/postgres/mixins/dml/advisory_lock.py | 166 +++++++++++++ .../impl/postgres/protocols/__init__.py | 2 + .../postgres/protocols/dml/advisory_lock.py | 96 ++++++++ .../feature/backend/conftest.py | 7 + .../test_expressions_transaction.py | 0 .../feature/backend/test_advisory_lock.py | 212 +++++++++++++++++ .../feature/backend/test_row_level_locks.py | 219 ++++++++++++++++++ .../feature/backend/postgres/conftest.py | 11 - 19 files changed, 1492 insertions(+), 19 deletions(-) create mode 100644 changelog.d/23.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/advisory_lock.py rename tests/rhosocial/{activerecord_test/feature/backend/postgres => activerecord_postgres_test/feature/backend/expression}/test_expressions_transaction.py (100%) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_advisory_lock.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py delete mode 100644 tests/rhosocial/activerecord_test/feature/backend/postgres/conftest.py 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.d/23.added.md b/changelog.d/23.added.md new file mode 100644 index 00000000..fac5e03b --- /dev/null +++ b/changelog.d/23.added.md @@ -0,0 +1 @@ +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+). \ No newline at end of file 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..47289d70 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -99,6 +99,7 @@ # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, + PostgresAdvisoryLockMixin, # Introspection capability mixin PostgresIntrospectionCapabilityMixin, ) @@ -137,6 +138,7 @@ PostgresParallelQuerySupport, PostgresStoredProcedureSupport, PostgresExtendedStatisticsSupport, + PostgresAdvisoryLockSupport, ) if TYPE_CHECKING: @@ -222,6 +224,7 @@ class PostgresDialect( # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, + PostgresAdvisoryLockMixin, # Protocol supports SetOperationSupport, TruncateSupport, @@ -286,6 +289,7 @@ class PostgresDialect( PostgresParallelQuerySupport, PostgresStoredProcedureSupport, PostgresExtendedStatisticsSupport, + PostgresAdvisoryLockSupport, ): """ PostgreSQL dialect implementation that adapts to the PostgreSQL version. @@ -461,6 +465,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 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/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 470dcf16..fe2be7ba 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -26,6 +26,7 @@ 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 +68,7 @@ "PostgresVacuumMixin", "PostgresStoredProcedureMixin", "PostgresExtendedStatisticsMixin", + "PostgresAdvisoryLockMixin", # Type mixins "EnumTypeMixin", "TypesDataTypeMixin", 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..fa6d1adf 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -25,6 +25,7 @@ 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 @@ -65,6 +66,7 @@ "PostgresVacuumSupport", "PostgresStoredProcedureSupport", "PostgresExtendedStatisticsSupport", + "PostgresAdvisoryLockSupport", # Type protocols "PostgresDataTypeSupport", "MultirangeSupport", 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/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/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_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() From 329a5c52dd050a5cce11985b8102b5082b254199 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:53:37 +0800 Subject: [PATCH 03/13] refactor: migrate functions from types to functions modules (#24) * refactor: migrate functions from types to functions modules Split responsibilities between types and functions modules for text search and XML: **Text Search** - Create: functions/text_search.py with SQL generation functions - Moved functions (14 total): - 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 - Retained in types/text_search.py: PostgresTsVector, PostgresTsQuery, and related classes **XML** - Create: functions/xml.py with SQL generation functions - Moved functions (4 total): xmlparse, xpath_query, xpath_exists, xml_is_well_formed - Retained in types/xml.py: PostgresXML class **Module Updates** - functions/__init__.py: Export all new text search and XML functions - types/__init__.py: Remove function exports, keep only type exports - backend/__init__.py: Update imports to use functions.text_search - test_xml_types.py: Update to import functions from functions.xml **Benefits** - Clear separation of concerns: types for data structures, functions for SQL generators - Better module organization following single responsibility principle - Easier maintenance and discoverability of SQL generation utilities Co-Authored-By: Claude Opus 4.6 * chore: add changelog fragment for PR #24 --------- Co-authored-by: Claude Opus 4.6 --- changelog.d/24.fixed.md | 1 + .../backend/impl/postgres/__init__.py | 2 + .../impl/postgres/functions/__init__.py | 48 +++ .../impl/postgres/functions/text_search.py | 375 ++++++++++++++++++ .../backend/impl/postgres/functions/xml.py | 116 ++++++ .../backend/impl/postgres/types/__init__.py | 8 - .../impl/postgres/types/text_search.py | 329 --------------- .../backend/impl/postgres/types/xml.py | 80 +--- .../backend/postgres/test_xml_types.py | 2 + 9 files changed, 545 insertions(+), 416 deletions(-) create mode 100644 changelog.d/24.fixed.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py diff --git a/changelog.d/24.fixed.md b/changelog.d/24.fixed.md new file mode 100644 index 00000000..a4688bde --- /dev/null +++ b/changelog.d/24.fixed.md @@ -0,0 +1 @@ +Refactored SQL function generation utilities by migrating them from types/ to functions/ directory for better module responsibility separation. 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/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index dc647f92..2b3a366f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -18,6 +18,8 @@ - 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 """ # Range functions @@ -102,6 +104,32 @@ 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, +) + __all__ = [ # Range functions @@ -171,4 +199,24 @@ "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", ] 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/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/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/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, From 644956adc7e1ac24cc39133dd5a7ad8da13f424d Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:26:56 +0800 Subject: [PATCH 04/13] feat(postgres): add math enhanced functions with proper naming (#25) * feat(postgres): add math enhanced functions with proper naming - round_ (round is Python builtin) - pow, power (POWER alias) - sqrt, mod, ceil, floor, trunc - max_, min_ (GREATEST/LEAST for scalar, MAX/MIN for aggregate) - avg (AVG aggregate) Co-Authored-By: Claude Opus 4.6 * chore: add changelog fragment for PR #25 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- changelog.d/25.added.md | 1 + .../impl/postgres/functions/__init__.py | 28 ++ .../impl/postgres/functions/math_enhanced.py | 381 ++++++++++++++++++ .../test_postgres_math_enhanced_functions.py | 258 ++++++++++++ 4 files changed, 668 insertions(+) create mode 100644 changelog.d/25.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py diff --git a/changelog.d/25.added.md b/changelog.d/25.added.md new file mode 100644 index 00000000..923c0efb --- /dev/null +++ b/changelog.d/25.added.md @@ -0,0 +1 @@ +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. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index 2b3a366f..ad2e95f3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -20,6 +20,7 @@ - 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 @@ -130,6 +131,21 @@ xml_is_well_formed, ) +# Math enhanced functions +from .math_enhanced import ( + round_, + pow, + power, + sqrt, + mod, + ceil, + floor, + trunc, + max_, + min_, + avg, +) + __all__ = [ # Range functions @@ -219,4 +235,16 @@ "xpath_query", "xpath_exists", "xml_is_well_formed", + # Math enhanced functions + "round_", + "pow", + "power", + "sqrt", + "mod", + "ceil", + "floor", + "trunc", + "max_", + "min_", + "avg", ] 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/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 From 91f82bd5b7dba3971d0c0ae593bb6cfeef262a23 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:16:49 +0800 Subject: [PATCH 05/13] feat(postgres): add comprehensive PostgreSQL function support (array, network, UUID, hstore, range) (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(postgres): add array, network, UUID functions and fix range constructors New function modules: - array.py: 17 array functions (array_agg, array_append, etc.) - network.py: 15 network functions (inet_client_addr, inet_merge, etc.) - uuid.py: 7 UUID functions (uuid_generate_v1, uuid_generate_v4, etc.) Fix range constructors to use sentinel pattern for bounds parameter: - int4range, int8range, numrange, tsrange, tstzrange, daterange - Omit bounds when using default inclusive '[]', include when explicitly specified Co-Authored-By: Claude Opus 4.6 * feat(postgres): add comprehensive hstore function support Add PostgreSQL hstore extension functions including: - Constructors: hstore_from_record, hstore_from_key_value - Key/Value extraction: akeys, skeys, avals, svals, each - Conversion: hstore_to_array, hstore_to_matrix, hstore_to_json, hstore_to_jsonb, hstore_to_json_loose, hstore_to_jsonb_loose - 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_*, hstore_to_array_operator, hstore_to_matrix_operator, hstore_record_update - Subscript: hstore_subscript_get, hstore_subscript_set Note: Generic function names (exist, delete, defined, slice, populate_record, record_update) are prefixed with hstore_ to avoid confusion with unrelated functionality. * refactor(postgres): rename hstore extraction functions with hstore_ prefix Rename generic function names to avoid confusion: - akeys → hstore_akeys - skeys → hstore_skeys - avals → hstore_avals - svals → hstore_svals - each → hstore_each All hstore functions now consistently use the hstore_ prefix. * chore: add changelog fragment for PR #26 * test(postgres): add hstore function tests for full coverage --------- Co-authored-by: Claude Opus 4.6 --- changelog.d/26.added.md | 1 + .../impl/postgres/functions/__init__.py | 181 +++++ .../backend/impl/postgres/functions/array.py | 390 +++++++++ .../backend/impl/postgres/functions/hstore.py | 750 ++++++++++++++++++ .../impl/postgres/functions/network.py | 321 ++++++++ .../backend/impl/postgres/functions/range.py | 210 +++++ .../backend/impl/postgres/functions/uuid.py | 157 ++++ .../postgres/test_postgres_array_functions.py | 130 +++ .../test_postgres_hstore_functions.py | 307 +++++++ .../test_postgres_network_functions.py | 113 +++ .../test_postgres_range_constructors.py | 70 ++ .../postgres/test_postgres_uuid_functions.py | 57 ++ 12 files changed, 2687 insertions(+) create mode 100644 changelog.d/26.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/array.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/network.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py diff --git a/changelog.d/26.added.md b/changelog.d/26.added.md new file mode 100644 index 00000000..772a287f --- /dev/null +++ b/changelog.d/26.added.md @@ -0,0 +1 @@ +Added comprehensive PostgreSQL function support: array (18), network address (16), UUID (8), hstore (33), and range constructors (6). diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index ad2e95f3..cee5d167 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -146,6 +146,99 @@ 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 @@ -247,4 +340,92 @@ "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/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/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/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_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 From ce1dae432972956d8a366516ad2ea05e86231804 Mon Sep 17 00:00:00 2001 From: zhao vistart <864657+vistart@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:30:32 +0800 Subject: [PATCH 06/13] feat(backend): add PostgreSQL constraint support protocol (#27) * feat(postgres): add constraint DDL support with EXCLUDE and NOT VALID Add PostgreSQL-specific constraint DDL formatting directly in PostgresDialect (not via mixin override, due to Python C3 MRO placing SQLDialectBase before mixins): - EXCLUDE constraint support with USING, elements, WHERE, DEFERRABLE - NOT VALID suffix for skipping validation of existing rows - format_add_table_constraint_action overrides base class for PG extensions - PostgresConstraintMixin retained as organizational placeholder Co-Authored-By: Claude Opus 4.6 * feat(backend): add PostgreSQL constraint support protocol - Add PostgresConstraintSupport protocol for PostgreSQL-proprietary constraint features (NOT VALID, EXCLUDE constraints) - Implement supports_constraint_enforced() returning False (PostgreSQL uses NOT VALID instead of ENFORCED/NOT ENFORCED) - Add supports_constraint_novalidate() and supports_exclude_constraint() to PostgresConstraintMixin * chore: add changelog fragment for PR #27 --------- Co-authored-by: Claude Opus 4.6 --- changelog.d/27.added.md | 1 + .../backend/impl/postgres/dialect.py | 130 ++++++++++++++++++ .../backend/impl/postgres/mixins/__init__.py | 1 + .../impl/postgres/mixins/ddl/__init__.py | 2 + .../impl/postgres/mixins/ddl/constraint.py | 43 ++++++ .../impl/postgres/protocols/__init__.py | 2 + .../impl/postgres/protocols/ddl/__init__.py | 2 + .../impl/postgres/protocols/ddl/constraint.py | 55 ++++++++ 8 files changed, 236 insertions(+) create mode 100644 changelog.d/27.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py diff --git a/changelog.d/27.added.md b/changelog.d/27.added.md new file mode 100644 index 00000000..7be3a867 --- /dev/null +++ b/changelog.d/27.added.md @@ -0,0 +1 @@ +Add PostgreSQL constraint support protocol with EXCLUDE and NOT VALID options \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 47289d70..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, @@ -131,6 +134,7 @@ PostgresTriggerSupport, PostgresCommentSupport, PostgresTypeSupport, + PostgresConstraintSupport, # Type feature protocols MultirangeSupport, EnumTypeSupport, @@ -189,6 +193,7 @@ class PostgresDialect( IndexMixin, SequenceMixin, TableMixin, + ConstraintMixin, # Introspection capability PostgresIntrospectionCapabilityMixin, # PostgreSQL-specific mixins @@ -217,6 +222,7 @@ class PostgresDialect( PostgresTriggerMixin, PostgresCommentMixin, PostgresTypeMixin, + PostgresConstraintMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -252,6 +258,7 @@ class PostgresDialect( IndexSupport, SequenceSupport, TableSupport, + ConstraintSupport, # Introspection protocol IntrospectionSupport, # Transaction control protocol @@ -282,6 +289,7 @@ class PostgresDialect( PostgresTriggerSupport, PostgresCommentSupport, PostgresTypeSupport, + PostgresConstraintSupport, # Type feature protocols MultirangeSupport, EnumTypeSupport, @@ -890,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. @@ -1088,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/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index fe2be7ba..07c80018 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -21,6 +21,7 @@ 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 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/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index fa6d1adf..56ab0f7c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -20,6 +20,7 @@ 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 @@ -62,6 +63,7 @@ "PostgresTriggerSupport", "PostgresCommentSupport", "PostgresTypeSupport", + "PostgresConstraintSupport", # DML protocols "PostgresVacuumSupport", "PostgresStoredProcedureSupport", 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). + """ + ... From ce608ce31885f72760289d089bd707bca0d32e91 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 12 Apr 2026 12:21:18 +0800 Subject: [PATCH 07/13] docs: add DDL, BackendGroup docs and worker isolation examples - Add DDL documentation covering PostgreSQL-specific DDL operations - Add BackendGroup documentation for connection management - Add worker_isolation examples demonstrating connection sharing anti-pattern - Fix schema to include order_number column --- docs/en_US/README.md | 17 +- docs/en_US/ddl/README.md | 77 +++ .../backend_group.md | 101 ++++ docs/examples/chapter_04_ddl/ddl.py | 202 ++++++++ .../chapter_05_worker_isolation/README.md | 197 ++++++++ .../chapter_05_worker_isolation/config.py | 119 +++++ .../exp1_shared_connection.py | 300 ++++++++++++ .../exp2_isolated_connection.py | 354 ++++++++++++++ .../exp3_endurance_test.py | 460 ++++++++++++++++++ .../chapter_05_worker_isolation/models.py | 82 ++++ docs/zh_CN/README.md | 17 +- docs/zh_CN/ddl/README.md | 77 +++ 12 files changed, 1993 insertions(+), 10 deletions(-) create mode 100644 docs/en_US/ddl/README.md create mode 100644 docs/en_US/installation_and_configuration/backend_group.md create mode 100644 docs/examples/chapter_04_ddl/ddl.py create mode 100644 docs/examples/chapter_05_worker_isolation/README.md create mode 100644 docs/examples/chapter_05_worker_isolation/config.py create mode 100644 docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py create mode 100644 docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py create mode 100644 docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py create mode 100644 docs/examples/chapter_05_worker_isolation/models.py create mode 100644 docs/zh_CN/ddl/README.md 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/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/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 From bb71e0595b813289ad416f3c2847427b2f5eaaef Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 12 Apr 2026 15:35:36 +0800 Subject: [PATCH 08/13] fix(tests): resolve asyncio.set_event_loop_policy assertion error on Python 3.8 Instantiate WindowsSelectorEventLoopPolicy before passing to set_event_loop_policy() to avoid assertion failure when the policy argument is not an AbstractEventLoopPolicy instance. --- .../activerecord_postgres_test/feature/connection/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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() From e887a34a5c3467bbde8c533554d24885da5b47de Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 12 Apr 2026 16:30:46 +0800 Subject: [PATCH 09/13] fix(examples): add Windows event loop compatibility for async PostgreSQL - Add WindowsSelectorEventLoopPolicy setup for Windows async workers - Add Windows test results to README.md - Fixes Psycopg ProactorEventLoop compatibility error on Windows --- .../parallel_workers/README.md | 100 ++++++++++++++++++ .../exp1_basic_multiprocess.py | 2 + .../exp2_postgres_async_advantage.py | 4 + .../exp4_partition_correct.py | 4 + 4 files changed, 110 insertions(+) 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)) From f90180f5e604b65dab444149cc366871a0d0e321 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 12 Apr 2026 16:54:50 +0800 Subject: [PATCH 10/13] docs: add test verification conclusions for parallel workers (zh_CN + en_US) Add comprehensive Section 9 with: - Test environment details (macOS Tahoe 26, Windows 11) - Test results for exp1-exp5 - Platform-specific notes for Windows event loop - Cross-platform conclusions --- docs/en_US/scenarios/parallel_workers.md | 99 ++++++++++++++++++++++++ docs/zh_CN/scenarios/parallel_workers.md | 99 ++++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/docs/en_US/scenarios/parallel_workers.md b/docs/en_US/scenarios/parallel_workers.md index abbbbc51..448c1231 100644 --- a/docs/en_US/scenarios/parallel_workers.md +++ b/docs/en_US/scenarios/parallel_workers.md @@ -634,3 +634,102 @@ 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 | 3.8-3.14 | 8.3+ | 17.x | +| 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 | ~0.8s | ~2.5s | ~2.8s | ~0.3x | +| Windows | 0.910s | 2.998s | 3.163s | 0.3x | + +> **Note**: Process startup overhead may exceed parallel benefits for small datasets. Larger datasets show better speedup. + +#### Async Feature Testing (exp2) + +| Platform | Same-process Sync Serial | Same-process Async Sequential | Multiprocess Sync | Multiprocess Async | +|----------|--------------------------|-------------------------------|-------------------|--------------------| +| macOS | ~0.2s | ~0.2s | ~1.1s | ~1.2s | +| Windows | 0.204s | 0.218s | 1.133s | 1.264s | + +#### 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 + +#### Correct Solution Testing (exp4) + +| Solution | macOS Time | Windows Time | Verification | +|----------|------------|--------------|--------------| +| A: Data Partitioning (Sync) | ~1.1s | 1.170s | ✓ No duplicates | +| A: Data Partitioning (Async) | ~1.2s | 1.263s | ✓ No duplicates | +| B: Atomic Claiming (Sync) | ~1.7s | 1.812s | ✓ No duplicates | +| B: Atomic Claiming (Async) | ~1.3s | 1.446s | ✓ No duplicates | +| C: Atomic + Retry (Sync) | ~2.3s | 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 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 vs MySQL deadlock error codes**: + - PostgreSQL: SQLSTATE 40P01 + - MySQL: errno 1213 diff --git a/docs/zh_CN/scenarios/parallel_workers.md b/docs/zh_CN/scenarios/parallel_workers.md index ec7d9311..fccbd312 100644 --- a/docs/zh_CN/scenarios/parallel_workers.md +++ b/docs/zh_CN/scenarios/parallel_workers.md @@ -651,3 +651,102 @@ 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 | 3.8-3.14 | 8.3+ | 17.x | +| 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 | ~0.8s | ~2.5s | ~2.8s | ~0.3x | +| Windows | 0.910s | 2.998s | 3.163s | 0.3x | + +> **说明**: 多进程启动开销在小数据量下可能超过并行收益,大数据量时加速效果更明显。 + +#### 异步特性测试 (exp2) + +| 平台 | 同进程同步串行 | 同进程异步顺序 | 多进程同步 | 多进程异步 | +|------|----------------|----------------|------------|------------| +| macOS | ~0.2s | ~0.2s | ~1.1s | ~1.2s | +| Windows | 0.204s | 0.218s | 1.133s | 1.264s | + +#### 死锁检测测试 (exp3) + +所有平台均成功触发 PostgreSQL 死锁检测机制: +- 死锁被自动检测(SQLSTATE 40P01) +- 代价较小的事务被回滚 +- 未捕获异常时,回滚事务的工作丢失 + +#### 正确方案测试 (exp4) + +| 方案 | macOS 耗时 | Windows 耗时 | 验证结果 | +|------|------------|--------------|----------| +| A: 数据分区(同步) | ~1.1s | 1.170s | ✓ 无重复 | +| A: 数据分区(异步) | ~1.2s | 1.263s | ✓ 无重复 | +| B: 原子领取(同步) | ~1.7s | 1.812s | ✓ 无重复 | +| B: 原子领取(异步) | ~1.3s | 1.446s | ✓ 无重复 | +| C: 原子+重试(同步) | ~2.3s | 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 结论 + +1. **多进程是并行 Worker 的正确方案**: 所有平台均验证通过 +2. **同步后端更稳定**: 异步后端在 Windows 上需要额外配置 +3. **死锁重试方案推荐用于生产**: 不依赖数据可分区性,自动处理死锁 +4. **数据分区效率最高**: MVCC 无锁竞争,适合可分区场景 +5. **PostgreSQL vs MySQL 死锁错误码**: + - PostgreSQL: SQLSTATE 40P01 + - MySQL: errno 1213 From 1f1dfa999045a6965177c5508811ef76854f70e5 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 12 Apr 2026 17:40:21 +0800 Subject: [PATCH 11/13] docs: update parallel_workers test results for macOS Tahoe 26 arm64 Add detailed experimental data from Python 3.8.10 environment: - macOS arm64 shows stable 1.0x speedup with sync multiprocess - Async exp2 triggered deadlock (SQLSTATE 40P01) due to small dataset - Updated test environment details with exact version numbers - Added notes on PostgreSQL deadlock behavior in concurrent testing --- docs/en_US/scenarios/parallel_workers.md | 24 ++++++++++++++---------- docs/zh_CN/scenarios/parallel_workers.md | 24 ++++++++++++++---------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/en_US/scenarios/parallel_workers.md b/docs/en_US/scenarios/parallel_workers.md index 448c1231..6aca1aa1 100644 --- a/docs/en_US/scenarios/parallel_workers.md +++ b/docs/en_US/scenarios/parallel_workers.md @@ -645,7 +645,7 @@ Tests verified across multiple environments: | Platform | Operating System | Python Version | pytest Version | PostgreSQL Version | |----------|------------------|----------------|----------------|-------------------| -| macOS | macOS Tahoe 26 | 3.8-3.14 | 8.3+ | 17.x | +| 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 @@ -654,34 +654,38 @@ Tests verified across multiple environments: | Platform | Serial Time | Sync Multiprocess | Async Multiprocess | Speedup | |----------|-------------|-------------------|--------------------|---------| -| macOS | ~0.8s | ~2.5s | ~2.8s | ~0.3x | -| Windows | 0.910s | 2.998s | 3.163s | 0.3x | +| 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. +> **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 | ~0.2s | ~0.2s | ~1.1s | ~1.2s | +| 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) | ~1.1s | 1.170s | ✓ No duplicates | -| A: Data Partitioning (Async) | ~1.2s | 1.263s | ✓ No duplicates | -| B: Atomic Claiming (Sync) | ~1.7s | 1.812s | ✓ No duplicates | -| B: Atomic Claiming (Async) | ~1.3s | 1.446s | ✓ No duplicates | -| C: Atomic + Retry (Sync) | ~2.3s | 2.501s | ✓ No duplicates | +| 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) diff --git a/docs/zh_CN/scenarios/parallel_workers.md b/docs/zh_CN/scenarios/parallel_workers.md index fccbd312..e503eaf5 100644 --- a/docs/zh_CN/scenarios/parallel_workers.md +++ b/docs/zh_CN/scenarios/parallel_workers.md @@ -662,7 +662,7 @@ def transfer_task(from_id: int, to_id: int, amount: float, conn_params: dict): | 平台 | 操作系统 | Python 版本 | pytest 版本 | PostgreSQL 版本 | |------|----------|-------------|-------------|-----------------| -| macOS | macOS Tahoe 26 | 3.8-3.14 | 8.3+ | 17.x | +| 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 测试结果汇总 @@ -671,34 +671,38 @@ def transfer_task(from_id: int, to_id: int, amount: float, conn_params: dict): | 平台 | 串行耗时 | 同步多进程 | 异步多进程 | 加速比 | |------|----------|------------|------------|--------| -| macOS | ~0.8s | ~2.5s | ~2.8s | ~0.3x | -| Windows | 0.910s | 2.998s | 3.163s | 0.3x | +| 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 | ~0.2s | ~0.2s | ~1.1s | ~1.2s | +| 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: 数据分区(同步) | ~1.1s | 1.170s | ✓ 无重复 | -| A: 数据分区(异步) | ~1.2s | 1.263s | ✓ 无重复 | -| B: 原子领取(同步) | ~1.7s | 1.812s | ✓ 无重复 | -| B: 原子领取(异步) | ~1.3s | 1.446s | ✓ 无重复 | -| C: 原子+重试(同步) | ~2.3s | 2.501s | ✓ 无重复 | +| 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) From 8f8373cb71c09a864b82fb123f786b6feaa02a02 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 12 Apr 2026 21:46:26 +0800 Subject: [PATCH 12/13] feat(examples): add exp6 and FastAPI examples for connection management - Add exp6_backend_group_pool.py to compare BackendGroup vs BackendPool - Add chapter_10_fastapi/ with complete FastAPI async example - Update parallel_workers.md (zh_CN + en_US) with exp6 results - Document BackendPool as recommended approach for PostgreSQL - Document connection pooling best practices for FastAPI Key findings from exp6: - BackendPool (20/20 success) outperforms BackendGroup for thread safety - PostgreSQL (threadsafety=2) supports connection pooling - BackendPool is recommended for FastAPI + PostgreSQL - Connection pooling provides efficient connection reuse --- docs/en_US/scenarios/parallel_workers.md | 137 ++++++- .../exp6_backend_group_pool.py | 361 ++++++++++++++++++ docs/examples/chapter_10_fastapi/README.md | 143 +++++++ docs/examples/chapter_10_fastapi/app.py | 325 ++++++++++++++++ .../chapter_10_fastapi/config_loader.py | 24 ++ docs/examples/chapter_10_fastapi/database.py | 163 ++++++++ docs/examples/chapter_10_fastapi/models.py | 73 ++++ docs/zh_CN/scenarios/parallel_workers.md | 214 ++++++++++- 8 files changed, 1438 insertions(+), 2 deletions(-) create mode 100644 docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py create mode 100644 docs/examples/chapter_10_fastapi/README.md create mode 100644 docs/examples/chapter_10_fastapi/app.py create mode 100644 docs/examples/chapter_10_fastapi/config_loader.py create mode 100644 docs/examples/chapter_10_fastapi/database.py create mode 100644 docs/examples/chapter_10_fastapi/models.py diff --git a/docs/en_US/scenarios/parallel_workers.md b/docs/en_US/scenarios/parallel_workers.md index 6aca1aa1..cfe2f873 100644 --- a/docs/en_US/scenarios/parallel_workers.md +++ b/docs/en_US/scenarios/parallel_workers.md @@ -728,12 +728,147 @@ def main(): No special configuration needed; default event loop works correctly. -### 9.4 Conclusions +### 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_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/scenarios/parallel_workers.md b/docs/zh_CN/scenarios/parallel_workers.md index e503eaf5..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` 关联关系的同步与异步对等用法。 @@ -745,12 +746,223 @@ def main(): 无需特殊配置,默认 event loop 即可正常工作。 -### 9.4 结论 +### 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 From 8ad08b816ec47ccb7e10c53f2f5cf0f5e1eead12 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 13 Apr 2026 01:41:58 +0800 Subject: [PATCH 13/13] docs: update CHANGELOG for v1.0.0.dev10 --- CHANGELOG.md | 16 ++++++++++++++++ changelog.d/23.added.md | 1 - changelog.d/24.fixed.md | 1 - changelog.d/25.added.md | 1 - changelog.d/26.added.md | 1 - changelog.d/27.added.md | 1 - 6 files changed, 16 insertions(+), 5 deletions(-) delete mode 100644 changelog.d/23.added.md delete mode 100644 changelog.d/24.fixed.md delete mode 100644 changelog.d/25.added.md delete mode 100644 changelog.d/26.added.md delete mode 100644 changelog.d/27.added.md 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/changelog.d/23.added.md b/changelog.d/23.added.md deleted file mode 100644 index fac5e03b..00000000 --- a/changelog.d/23.added.md +++ /dev/null @@ -1 +0,0 @@ -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+). \ No newline at end of file diff --git a/changelog.d/24.fixed.md b/changelog.d/24.fixed.md deleted file mode 100644 index a4688bde..00000000 --- a/changelog.d/24.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Refactored SQL function generation utilities by migrating them from types/ to functions/ directory for better module responsibility separation. diff --git a/changelog.d/25.added.md b/changelog.d/25.added.md deleted file mode 100644 index 923c0efb..00000000 --- a/changelog.d/25.added.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changelog.d/26.added.md b/changelog.d/26.added.md deleted file mode 100644 index 772a287f..00000000 --- a/changelog.d/26.added.md +++ /dev/null @@ -1 +0,0 @@ -Added comprehensive PostgreSQL function support: array (18), network address (16), UUID (8), hstore (33), and range constructors (6). diff --git a/changelog.d/27.added.md b/changelog.d/27.added.md deleted file mode 100644 index 7be3a867..00000000 --- a/changelog.d/27.added.md +++ /dev/null @@ -1 +0,0 @@ -Add PostgreSQL constraint support protocol with EXCLUDE and NOT VALID options \ No newline at end of file