From 7e5d57a5f5b295811ffae12484a1b94e27818c93 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 11:49:58 +0800 Subject: [PATCH 1/7] feat(errors): classify SQL Server driver errors by taxonomy --- .../impl/sqlserver/backend/async_backend.py | 49 +-- .../backend/impl/sqlserver/backend/backend.py | 67 +--- .../backend/impl/sqlserver/error_mapping.py | 333 ++++++++++++++++++ .../impl/sqlserver/mixins/backend_mixin.py | 75 ++-- .../errors/test_error_taxonomy_contract.py | 2 + .../backend/test_driver_error_mapping.py | 172 +++++++++ 6 files changed, 578 insertions(+), 120 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/error_mapping.py create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/errors/test_error_taxonomy_contract.py create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_driver_error_mapping.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index 2292db6..7862d97 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -22,20 +22,14 @@ ) from rhosocial.activerecord.backend.base.operations import AsyncSQLOperationsMixin from rhosocial.activerecord.backend.base.transaction_management import AsyncTransactionManagementMixin -from rhosocial.activerecord.backend.errors import ( - ConnectionError, - DatabaseError, - DeadlockError, - IntegrityError, - OperationalError, - QueryError, -) +from rhosocial.activerecord.backend.errors import ConnectionError from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.logging.redaction import unwrap_sensitive from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from ..config import SQLServerConnectionConfig from ..dialect import SQLServerDialect +from ..error_mapping import classify_sqlserver_error from ..async_transaction import AsyncSQLServerTransactionManager from .backend import SQLServerUnicodeDialect from ..mixins import SQLServerBackendMixin @@ -630,41 +624,20 @@ async def executescript(self, sql_script: str) -> None: await self._handle_error(e) async def _handle_error(self, error: Exception) -> None: - """Handle SQL Server-specific errors. - + """Handle SQL Server-specific errors asynchronously. + + Uses the same code -> class -> message recognition order as the sync + backend, so both halves translate a driver failure identically. + Args: error: The exception to handle - + Raises: Appropriate backend exception """ - error_msg = str(error).lower() - - if ( - 'integrity' in error_msg - or 'constraint' in error_msg - or 'does not allow nulls' in error_msg - or 'cannot insert the value null' in error_msg - ): - if "duplicate" in error_msg or "unique" in error_msg: - self.log(logging.DEBUG, f"Unique constraint violation: {str(error)}") - raise IntegrityError(f"Unique constraint violation: {str(error)}") - elif "foreign key" in error_msg or "reference" in error_msg: - self.log(logging.DEBUG, f"Foreign key constraint violation: {str(error)}") - raise IntegrityError(f"Foreign key constraint violation: {str(error)}") - self.log(logging.DEBUG, f"Integrity error: {str(error)}") - raise IntegrityError(self._normalize_integrity_message(str(error))) - - if 'deadlock' in error_msg or '1205' in error_msg: - self.log(logging.DEBUG, f"Deadlock detected: {str(error)}") - raise DeadlockError(str(error)) - - if 'timeout' in error_msg or 'connection' in error_msg: - self.log(logging.DEBUG, f"Operational error: {str(error)}") - raise OperationalError(str(error)) - - self.log(logging.DEBUG, f"Database error: {str(error)}") - raise DatabaseError(str(error)) + mapped = classify_sqlserver_error(error) + self._log_classified_error(error, mapped) + raise mapped from error async def _handle_auto_commit_if_needed(self) -> None: """Handle auto-commit for async operations.""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index 750226f..19599dd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py @@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type import pyodbc -from pyodbc import Error as PyODBCError, InterfaceError, DatabaseError as PyODBCDatabaseError +from pyodbc import Error as PyODBCError, InterfaceError from rhosocial.activerecord.backend.base import StorageBackend from rhosocial.activerecord.backend.base.execution_logging import ( @@ -21,14 +21,7 @@ log_batch_summary, log_execution_error, ) -from rhosocial.activerecord.backend.errors import ( - ConnectionError, - DatabaseError, - DeadlockError, - IntegrityError, - OperationalError, - QueryError, -) +from rhosocial.activerecord.backend.errors import ConnectionError from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.logging.redaction import unwrap_sensitive @@ -36,6 +29,7 @@ from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin from ..config import SQLServerConnectionConfig from ..dialect import SQLServerDialect +from ..error_mapping import classify_sqlserver_error from ..transaction import SQLServerTransactionManager from ..mixins import SQLServerBackendMixin, SQLServerConcurrencyMixin from ..query_nodes import health_check_sql, server_property_value_sql @@ -592,51 +586,26 @@ def executescript(self, sql_script: str) -> None: def _handle_error(self, error: Exception) -> None: """Handle SQL Server-specific errors and convert to appropriate exceptions. - - Converts pyodbc exceptions to the appropriate backend exceptions. - + + Recognition follows the phase 3b order: native error number first, + then class, then message text. The mapped error carries diagnostics + (driver code, backend, transient hint) before it is raised. + Args: error: The exception to handle - + Raises: - IntegrityError: For constraint violations - DeadlockError: For deadlock situations - OperationalError: For connection/timeout errors + IntegrityError: For unique, foreign key and NOT NULL violations + DeadlockError: For deadlock situations (error 1205) + LockError: For lock-wait timeouts (error 1222) + QueryError: For syntax errors and unknown objects + ConnectionError: For login/connect failures + OperationalError: For network and timeout failures DatabaseError: For other database errors """ - error_msg = str(error).lower() - - if isinstance(error, pyodbc.IntegrityError): - if "duplicate" in error_msg or "unique" in error_msg: - self.log(logging.DEBUG, f"Unique constraint violation: {str(error)}") - raise IntegrityError(f"Unique constraint violation: {str(error)}") - elif "foreign key" in error_msg or "reference" in error_msg: - self.log(logging.DEBUG, f"Foreign key constraint violation: {str(error)}") - raise IntegrityError(f"Foreign key constraint violation: {str(error)}") - self.log(logging.DEBUG, f"Integrity error: {str(error)}") - raise IntegrityError(self._normalize_integrity_message(str(error))) - - elif isinstance(error, pyodbc.DatabaseError): - if hasattr(error, 'args') and len(error.args) > 0: - error_code = error.args[0] - if error_code == 1205: - self.log(logging.DEBUG, f"Deadlock detected: {str(error)}") - raise DeadlockError(str(error)) - - self.log(logging.DEBUG, f"Database error: {str(error)}") - raise DatabaseError(str(error)) - - elif isinstance(error, pyodbc.OperationalError): - self.log(logging.DEBUG, f"Operational error: {str(error)}") - raise OperationalError(str(error)) - - elif isinstance(error, PyODBCError): - self.log(logging.DEBUG, f"SQL Server error: {str(error)}") - raise DatabaseError(str(error)) - - else: - self.log(logging.DEBUG, f"Unexpected error: {str(error)}") - raise error + mapped = classify_sqlserver_error(error) + self._log_classified_error(error, mapped) + raise mapped from error def _handle_auto_commit_if_needed(self) -> None: """Handle auto-commit for SQL Server. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/error_mapping.py b/src/rhosocial/activerecord/backend/impl/sqlserver/error_mapping.py new file mode 100644 index 0000000..25d4bb4 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/error_mapping.py @@ -0,0 +1,333 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/error_mapping.py +"""Translate SQL Server driver errors onto the shared semantic taxonomy. + +Recognition follows the phase 3b order: native error number first, then the +pyodbc exception class, then message text as a last resort. A mapped error +carries ``operation``, ``driver_code``, ``backend`` and ``transient`` +diagnostics so callers and logs can tell why the operation failed without +parsing the message again. + +pyodbc 5.x raises DB-API exceptions whose ``args`` are normally +``(sqlstate, message)``; the SQL Server native error number is embedded in the +message as ``[2627]`` or ``(2627)``. The helpers below accept either form, so +synthetic exceptions built for tests resolve the same way as driver-raised +ones. +""" + +import re +from typing import Optional + +from rhosocial.activerecord.backend.errors import ( + ConnectionError, + DatabaseError, + DeadlockError, + IntegrityError, + LockError, + OperationalError, + QueryError, + attach_diagnostics, +) + +BACKEND_NAME = "sqlserver" +OPERATION_NAME = "execute" + +# SQL Server native error numbers. +UNIQUE_VIOLATION_CODES = frozenset({2627, 2601}) +FOREIGN_KEY_VIOLATION_CODE = 547 +NOT_NULL_VIOLATION_CODE = 515 +DEADLOCK_CODE = 1205 +LOCK_TIMEOUT_CODE = 1222 +LOGIN_FAILED_CODE = 18456 +CANNOT_OPEN_DATABASE_CODE = 4060 +INVALID_OBJECT_CODE = 208 +SYNTAX_ERROR_CODES = frozenset({102, 156}) +PERMISSION_DENIED_CODES = frozenset({229, 230}) +NETWORK_ERROR_CODES = frozenset({ + 53, # named pipes / server not found + 64, # network connection dropped + 232, # named pipes pipe end + 233, # named pipes pipe end + 10054, # TCP connection reset + 40197, # service error + 40501, # service busy + 40613, # database currently unavailable + 11001, # host not found +}) + +_UNIQUE_MESSAGE_PATTERNS = ( + "duplicate key", + "duplicate entry", + "unique constraint", + "unique key", + "violation of unique", +) +_FOREIGN_KEY_MESSAGE_PATTERNS = ( + "foreign key", + "reference constraint", +) +_NOT_NULL_MESSAGE_PATTERNS = ( + "does not allow nulls", + "cannot insert the value null", + "not null constraint", +) +_INTEGRITY_MESSAGE_PATTERNS = ( + "integrity violation", + "integrity constraint", + "constraint violation", + "check constraint", +) +_DEADLOCK_MESSAGE_PATTERNS = ( + "deadlock", +) +_LOCK_TIMEOUT_MESSAGE_PATTERNS = ( + "lock request time out", + "lock timeout", + "lock wait timeout", + "lock wait time out", +) +_LOGIN_MESSAGE_PATTERNS = ( + "login failed", + "authentication failed", +) +_CANNOT_OPEN_DATABASE_PATTERNS = ( + "cannot open database", + "unable to open database", +) +_INVALID_OBJECT_MESSAGE_PATTERNS = ( + "invalid object name", + "invalid column name", +) +_SYNTAX_MESSAGE_PATTERNS = ( + "incorrect syntax", + "syntax error", + "unclosed quotation mark", +) +_PERMISSION_MESSAGE_PATTERNS = ( + "permission denied", + "permission was denied", +) +_CONNECTION_MESSAGE_PATTERNS = ( + "connection refused", + "connection reset", + "connection was closed", + "connection is closed", + "connection was broken", + "broken pipe", + "communication link failure", + "named pipes provider", + "network-related", + "network error", + "tcp provider", + "server was not found", + "error locating server", + "unable to connect", + "could not open a connection", + "connection timeout", + "query timeout expired", + "timeout expired", +) + +# ``[2627]``, ``(2627)``, ``Msg 2627`` and ``error 2627`` cover the forms +# pyodbc, ODBC and SQL Server itself produce. The four-digit bound avoids +# mistaking SQLSTATE values such as ``08001`` for native error numbers. +_MESSAGE_CODE_PATTERNS = ( + re.compile(r"\[(\d{1,4})\]"), + re.compile(r"\((\d{1,4})\)"), + re.compile(r"\bmsg\s*(\d{1,4})\b", re.IGNORECASE), + re.compile(r"\berror\s+(\d{1,4})\b", re.IGNORECASE), +) + + +def normalize_integrity_message(message: str) -> str: + """Add the cross-backend ``cannot be null`` phrase to 515 messages.""" + lowered = message.lower() + if "does not allow nulls" in lowered or "cannot insert the value null" in lowered: + if "cannot be null" not in lowered: + message = message + " - cannot be null" + return message + + +def extract_driver_code(error: Exception) -> Optional[int]: + """Return the SQL Server native error number, when one can be recovered. + + ``args`` is tried first because a numeric first argument is the most + direct signal. The message is then scanned for the ``[2627]``/``(2627)``/ + ``Msg 2627`` forms SQL Server uses. SQLSTATE strings such as ``23000`` + are deliberately not treated as native error numbers. + """ + for arg in getattr(error, "args", ()) or (): + if isinstance(arg, int) and not isinstance(arg, bool): + return arg + if isinstance(arg, str) and arg.isdigit() and len(arg) <= 4: + return int(arg) + + message = str(error) + for pattern in _MESSAGE_CODE_PATTERNS: + match = pattern.search(message) + if match: + return int(match.group(1)) + return None + + +def _matches(lowered: str, patterns) -> bool: + return any(pattern in lowered for pattern in patterns) + + +def _translated( + error_type, + message: str, + driver_code: Optional[int], + *, + transient: bool, +) -> DatabaseError: + return attach_diagnostics( + error_type(message), + operation=OPERATION_NAME, + sqlstate=None, + driver_code=driver_code, + backend=BACKEND_NAME, + transient=transient, + ) + + +def classify_sqlserver_error(error: Exception) -> DatabaseError: + """Map a pyodbc or synthetic driver exception onto a taxonomy error. + + The returned instance is annotated but not raised; callers raise it + ``from error`` so the driver exception stays in the chain. + + Args: + error: Exception raised by pyodbc, aioodbc or a test double. + + Returns: + An :class:`~rhosocial.activerecord.backend.errors.DatabaseError` + instance from the shared taxonomy. + """ + message = str(error) + lowered = message.lower() + driver_code = extract_driver_code(error) + + # 1. Native error number: stable across message wording changes. + if driver_code in UNIQUE_VIOLATION_CODES: + return _translated( + IntegrityError, + f"Unique constraint violation: {message}", + driver_code, + transient=False, + ) + if driver_code == FOREIGN_KEY_VIOLATION_CODE: + return _translated( + IntegrityError, + f"Foreign key constraint violation: {message}", + driver_code, + transient=False, + ) + if driver_code == NOT_NULL_VIOLATION_CODE: + return _translated( + IntegrityError, + normalize_integrity_message(message), + driver_code, + transient=False, + ) + if driver_code == DEADLOCK_CODE: + return _translated(DeadlockError, message, driver_code, transient=True) + if driver_code == LOCK_TIMEOUT_CODE: + return _translated(LockError, message, driver_code, transient=True) + if driver_code == LOGIN_FAILED_CODE: + return _translated( + ConnectionError, + f"Login failed: {message}", + driver_code, + transient=False, + ) + if driver_code == CANNOT_OPEN_DATABASE_CODE: + return _translated(ConnectionError, message, driver_code, transient=False) + if driver_code == INVALID_OBJECT_CODE: + return _translated(QueryError, message, driver_code, transient=False) + if driver_code in SYNTAX_ERROR_CODES: + return _translated(QueryError, message, driver_code, transient=False) + if driver_code in PERMISSION_DENIED_CODES: + # Retrying a permission failure never helps; keep the generic type so + # callers that only distinguish infrastructure from data still work. + return _translated(DatabaseError, message, driver_code, transient=False) + if driver_code in NETWORK_ERROR_CODES: + return _translated(OperationalError, message, driver_code, transient=True) + + # 2. Message text. It is the fallback for drivers that omit the number and + # for synthetic exceptions used in unit tests. + if _matches(lowered, _UNIQUE_MESSAGE_PATTERNS): + return _translated( + IntegrityError, + f"Unique constraint violation: {message}", + driver_code, + transient=False, + ) + if _matches(lowered, _FOREIGN_KEY_MESSAGE_PATTERNS): + return _translated( + IntegrityError, + f"Foreign key constraint violation: {message}", + driver_code, + transient=False, + ) + if _matches(lowered, _NOT_NULL_MESSAGE_PATTERNS): + return _translated( + IntegrityError, + normalize_integrity_message(message), + driver_code, + transient=False, + ) + if _matches(lowered, _INTEGRITY_MESSAGE_PATTERNS): + return _translated( + IntegrityError, + normalize_integrity_message(message), + driver_code, + transient=False, + ) + if _matches(lowered, _DEADLOCK_MESSAGE_PATTERNS): + return _translated(DeadlockError, message, driver_code, transient=True) + if _matches(lowered, _LOCK_TIMEOUT_MESSAGE_PATTERNS): + return _translated(LockError, message, driver_code, transient=True) + if _matches(lowered, _LOGIN_MESSAGE_PATTERNS): + return _translated( + ConnectionError, + f"Login failed: {message}", + driver_code, + transient=False, + ) + if _matches(lowered, _CANNOT_OPEN_DATABASE_PATTERNS): + return _translated(ConnectionError, message, driver_code, transient=False) + if _matches(lowered, _INVALID_OBJECT_MESSAGE_PATTERNS): + return _translated(QueryError, message, driver_code, transient=False) + if _matches(lowered, _SYNTAX_MESSAGE_PATTERNS): + return _translated(QueryError, message, driver_code, transient=False) + if _matches(lowered, _PERMISSION_MESSAGE_PATTERNS): + return _translated(DatabaseError, message, driver_code, transient=False) + if _matches(lowered, _CONNECTION_MESSAGE_PATTERNS): + return _translated(OperationalError, message, driver_code, transient=True) + + # 3. Driver exception class. pyodbc maps SQLSTATE families onto DB-API + # classes, so this catches errors whose text is not distinctive. + try: + import pyodbc + except ImportError: # pragma: no cover - pyodbc is a hard dependency + pyodbc = None + + if pyodbc is not None: + if isinstance(error, pyodbc.IntegrityError): + return _translated( + IntegrityError, + normalize_integrity_message(message), + driver_code, + transient=False, + ) + if isinstance(error, pyodbc.ProgrammingError): + return _translated(QueryError, message, driver_code, transient=False) + if isinstance(error, pyodbc.InterfaceError): + return _translated(ConnectionError, message, driver_code, transient=False) + if isinstance(error, pyodbc.OperationalError): + return _translated(OperationalError, message, driver_code, transient=True) + if isinstance(error, (pyodbc.DatabaseError, pyodbc.Error)): + return _translated(DatabaseError, message, driver_code, transient=False) + + # 4. Unknown exception: keep the generic database error contract. + return _translated(DatabaseError, message, driver_code, transient=False) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py index d9eafca..8d3855e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py @@ -1,7 +1,18 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +import logging import re from typing import Any, Dict, Optional, Set, Tuple, Type, TYPE_CHECKING +from rhosocial.activerecord.backend.errors import ( + ConnectionError, + DatabaseError, + DeadlockError, + IntegrityError, + LockError, + OperationalError, + QueryError, +) +from ..error_mapping import classify_sqlserver_error, normalize_integrity_message from ..query_nodes import ( identity_columns_sql, identity_current_sql, @@ -407,39 +418,37 @@ def _normalize_integrity_message(self, message: str) -> str: NULL ... column does not allow nulls") does not contain them, so inject them while preserving the original detail. """ - lowered = message.lower() - if "does not allow nulls" in lowered or "cannot insert the value null" in lowered: - if "cannot be null" not in lowered: - message = message + " - cannot be null" - return message + return normalize_integrity_message(message) - def _handle_error(self, error: Exception) -> None: - from pyodbc import ( - Error as PyODBCError, - InterfaceError, - DatabaseError as PyODBCDatabaseError, - ) - from rhosocial.activerecord.backend.errors import ( - ConnectionError, - DatabaseError, - DeadlockError, - IntegrityError, - OperationalError, - ) + def _log_classified_error(self, error: Exception, mapped: DatabaseError) -> None: + """Log a translated error under the historic message family. - error_msg = str(error) - - if isinstance(error, InterfaceError): - raise ConnectionError(error_msg) from error - elif isinstance(error, PyODBCDatabaseError): - if "deadlock" in error_msg.lower() or "1205" in error_msg: - raise DeadlockError(error_msg) from error - if "integrity" in error_msg.lower() or "547" in error_msg or "2627" in error_msg: - raise IntegrityError(error_msg) from error - raise DatabaseError(error_msg) from error - elif isinstance(error, PyODBCError): - if self._is_connection_error(error): - raise ConnectionError(error_msg) from error - raise OperationalError(error_msg) from error + The execution-logging tests assert on phrases such as ``Unique + constraint violation`` and ``Deadlock detected``; keep those labels + stable while the mapping itself lives in one place. + """ + message = str(mapped) + detail = str(error) + if message.startswith("Unique constraint violation"): + self.log(logging.DEBUG, f"Unique constraint violation: {detail}") + elif message.startswith("Foreign key constraint violation"): + self.log(logging.DEBUG, f"Foreign key constraint violation: {detail}") + elif isinstance(mapped, IntegrityError): + self.log(logging.DEBUG, f"Integrity error: {detail}") + elif isinstance(mapped, DeadlockError): + self.log(logging.DEBUG, f"Deadlock detected: {detail}") + elif isinstance(mapped, LockError): + self.log(logging.DEBUG, f"Lock timeout: {detail}") + elif isinstance(mapped, QueryError): + self.log(logging.DEBUG, f"Query error: {detail}") + elif isinstance(mapped, OperationalError): + self.log(logging.DEBUG, f"Operational error: {detail}") + elif isinstance(mapped, ConnectionError): + self.log(logging.DEBUG, f"Connection error: {detail}") else: - raise error \ No newline at end of file + self.log(logging.DEBUG, f"Database error: {detail}") + + def _handle_error(self, error: Exception) -> None: + mapped = classify_sqlserver_error(error) + self._log_classified_error(error, mapped) + raise mapped from error \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/errors/test_error_taxonomy_contract.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/errors/test_error_taxonomy_contract.py new file mode 100644 index 0000000..e650586 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/errors/test_error_taxonomy_contract.py @@ -0,0 +1,2 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/errors/test_error_taxonomy_contract.py +from rhosocial.activerecord.testsuite.feature.backend.errors.test_error_taxonomy import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_driver_error_mapping.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_driver_error_mapping.py new file mode 100644 index 0000000..171be1d --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_driver_error_mapping.py @@ -0,0 +1,172 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_driver_error_mapping.py +"""Unit tests for the SQL Server driver-error taxonomy mapping. + +No database is required: synthetic pyodbc exceptions carry the native error +number and message the driver would supply, and the mapping methods run on an +unconnected backend. +""" + +import pytest + +pyodbc = pytest.importorskip("pyodbc") + +from rhosocial.activerecord.backend.errors import ( # noqa: E402 + ConnectionError, + DatabaseError, + DeadlockError, + IntegrityError, + LockError, + OperationalError, + QueryError, +) +from rhosocial.activerecord.backend.impl.sqlserver.backend.backend import ( # noqa: E402 + SQLServerBackend, +) + + +@pytest.fixture +def offline_backend(): + """An unconnected backend used only for its error translation.""" + return SQLServerBackend( + host="localhost", + database="taxonomy_test", + username="sa", + password="not-used", + ) + + +def _handle(backend, error): + with pytest.raises(DatabaseError) as info: + backend._handle_error(error) + return info.value + + +class TestDriverErrorMapping: + def test_unique_violation_from_code(self, offline_backend): + error = pyodbc.IntegrityError( + "23000", + "[23000] Violation of UNIQUE KEY constraint 'UQ' (2627)", + ) + mapped = _handle(offline_backend, error) + assert isinstance(mapped, IntegrityError) + assert "Unique constraint violation" in str(mapped) + assert mapped.driver_code == 2627 + assert mapped.operation == "execute" + assert mapped.backend == "sqlserver" + assert mapped.transient is False + assert mapped.retryable is False + assert mapped.__cause__ is error + + def test_unique_violation_from_numeric_first_arg(self, offline_backend): + error = pyodbc.Error(2601, "Cannot insert duplicate key row") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, IntegrityError) + assert mapped.driver_code == 2601 + + def test_foreign_key_violation(self, offline_backend): + error = pyodbc.IntegrityError( + "23000", + "[23000] The INSERT conflicted with the FOREIGN KEY constraint (547)", + ) + mapped = _handle(offline_backend, error) + assert isinstance(mapped, IntegrityError) + assert "Foreign key constraint violation" in str(mapped) + assert mapped.driver_code == 547 + assert mapped.transient is False + + def test_not_null_violation(self, offline_backend): + error = pyodbc.Error( + 515, + "Cannot insert the value NULL into column 'name'; column does not allow nulls", + ) + mapped = _handle(offline_backend, error) + assert isinstance(mapped, IntegrityError) + assert mapped.driver_code == 515 + assert "cannot be null" in str(mapped) + assert mapped.transient is False + + def test_deadlock(self, offline_backend): + error = pyodbc.DatabaseError(1205, "Deadlock victim") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, DeadlockError) + assert mapped.driver_code == 1205 + assert mapped.transient is True + assert mapped.retryable is True + + def test_lock_timeout(self, offline_backend): + error = pyodbc.DatabaseError(1222, "Lock request time out period exceeded") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, LockError) + assert mapped.driver_code == 1222 + assert mapped.transient is True + assert mapped.retryable is True + + def test_login_failed(self, offline_backend): + error = pyodbc.OperationalError(18456, "Login failed for user 'sa'") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, ConnectionError) + assert mapped.driver_code == 18456 + assert "Login failed" in str(mapped) + assert mapped.transient is False + assert mapped.retryable is False + + def test_cannot_open_database(self, offline_backend): + error = pyodbc.OperationalError(4060, "Cannot open database requested by login") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, ConnectionError) + assert mapped.driver_code == 4060 + assert mapped.transient is False + + def test_invalid_object(self, offline_backend): + error = pyodbc.ProgrammingError(208, "Invalid object name 'missing_table'") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, QueryError) + assert mapped.driver_code == 208 + assert mapped.transient is False + + def test_syntax_error(self, offline_backend): + error = pyodbc.ProgrammingError(102, "Incorrect syntax near 'BOGUS'") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, QueryError) + assert mapped.driver_code == 102 + assert mapped.transient is False + + def test_syntax_error_from_message_without_code(self, offline_backend): + error = pyodbc.Error("Incorrect syntax near the keyword 'SELECT'") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, QueryError) + assert mapped.driver_code is None + + def test_permission_denied_is_not_retryable(self, offline_backend): + error = pyodbc.DatabaseError(229, "Permission denied on object 'users'") + mapped = _handle(offline_backend, error) + assert type(mapped) is DatabaseError + assert mapped.driver_code == 229 + assert mapped.transient is False + assert mapped.retryable is False + + def test_connection_loss_is_transient_operational_error(self, offline_backend): + error = pyodbc.OperationalError( + "08001", + "[08001] Connection was closed while executing the statement", + ) + mapped = _handle(offline_backend, error) + assert isinstance(mapped, OperationalError) + assert mapped.transient is True + assert mapped.retryable is True + + def test_network_code_is_transient_operational_error(self, offline_backend): + error = pyodbc.Error(11001, "Host not found") + mapped = _handle(offline_backend, error) + assert isinstance(mapped, OperationalError) + assert mapped.driver_code == 11001 + assert mapped.transient is True + + def test_generic_database_error(self, offline_backend): + error = pyodbc.DatabaseError("HY000", "[HY000] General database failure") + mapped = _handle(offline_backend, error) + assert type(mapped) is DatabaseError + assert mapped.driver_code is None + assert mapped.transient is False + assert mapped.retryable is False + assert mapped.__cause__ is error From 91e0177eeeaa7710e35b3b706e64352717fab52a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 13:11:52 +0800 Subject: [PATCH 2/7] fix(execute): align SQL Server execute error phases --- .../impl/sqlserver/backend/async_backend.py | 96 +++++-- .../backend/impl/sqlserver/backend/backend.py | 66 +++-- .../impl/sqlserver/mixins/backend_mixin.py | 51 +++- .../backend/test_execute_error_contract.py | 243 ++++++++++++++++++ 4 files changed, 416 insertions(+), 40 deletions(-) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index 7862d97..22c63b4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -22,7 +22,11 @@ ) from rhosocial.activerecord.backend.base.operations import AsyncSQLOperationsMixin from rhosocial.activerecord.backend.base.transaction_management import AsyncTransactionManagementMixin -from rhosocial.activerecord.backend.errors import ConnectionError +from rhosocial.activerecord.backend.errors import ( + ConnectionError, + DatabaseError, + TransactionError, +) from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.logging.redaction import unwrap_sensitive @@ -265,12 +269,51 @@ async def _get_cursor(self): return await self._connection.cursor() + async def _execute_control_sql_async(self, sql: str) -> None: + """Run a session-control statement through the mapped error path.""" + cursor = await self._get_cursor() + try: + await cursor.execute(sql) + except Exception as exc: + await self._handle_error(exc) + finally: + await self._close_cursor_quietly_async(cursor) + + async def _close_cursor_quietly_async(self, cursor) -> None: + """Close an async cursor, logging rather than masking an in-flight error.""" + try: + await cursor.close() + except Exception as exc: + self.log(logging.DEBUG, f"Failed to close cursor: {exc}") + + async def _execute_control_sql_async_quietly(self, sql: str) -> None: + """Run a cleanup statement without masking the original failure.""" + try: + await self._execute_control_sql_async(sql) + except Exception as exc: + self.log(logging.WARNING, f"Failed to reset session state ({sql}): {exc}") + + async def _reset_identity_insert_quietly(self, table: str) -> None: + """Turn ``IDENTITY_INSERT`` off without replacing an in-flight failure.""" + try: + await self.set_identity_insert(table, False) + except Exception as exc: + self.log( + logging.WARNING, + f"Failed to disable IDENTITY_INSERT for {table}: {exc}", + ) + async def _get_scope_identity_async(self) -> Optional[int]: """Get the identity value of the last INSERT via @@IDENTITY.""" sql, params = session_identity_sql(self.dialect) - async with await self._get_cursor() as cursor: + cursor = await self._get_cursor() + try: await cursor.execute(sql, params) row = await cursor.fetchone() + except Exception as exc: + await self._handle_error(exc) + finally: + await self._close_cursor_quietly_async(cursor) if row and row[0] is not None: return int(row[0]) return None @@ -278,8 +321,7 @@ async def _get_scope_identity_async(self) -> Optional[int]: async def set_identity_insert(self, table: str, on: bool = True) -> None: """Enable or disable IDENTITY_INSERT for a table (async).""" sql, _params = self.dialect.format_set_identity_insert(table, on) - async with await self._get_cursor() as cursor: - await cursor.execute(sql) + await self._execute_control_sql_async(sql) async def _get_identity_columns_async(self, table_name: str) -> Set[str]: """Return the set of identity column names for a table (async, cached).""" @@ -346,13 +388,11 @@ async def execute( explain_split = self._split_explain_statement(sql) if explain_split is not None: set_clause, statement = explain_split - async with await self._get_cursor() as cursor: - await cursor.execute(f"SET {set_clause} ON") + await self._execute_control_sql_async(f"SET {set_clause} ON") try: return await self._execute_statement(statement, params, options) finally: - async with await self._get_cursor() as cursor: - await cursor.execute(f"SET {set_clause} OFF") + await self._execute_control_sql_async_quietly(f"SET {set_clause} OFF") return await self._execute_statement(sql, params, options) @@ -413,7 +453,7 @@ async def _execute_statement( await cursor.execute(final_sql, final_params or ()) finally: if identity_table is not None: - await self.set_identity_insert(identity_table, False) + await self._reset_identity_insert_quietly(identity_table) is_select = options.process_result_set if options.process_result_set is not None else options.stmt_type == StatementType.DQL data = None @@ -463,7 +503,10 @@ async def _execute_statement( except Exception as e: log_execution_error(self, e, options) + if isinstance(e, DatabaseError): + raise await self._handle_error(e) + raise DatabaseError(str(e)) from e async def execute_many( self, @@ -511,7 +554,11 @@ async def execute_many( except Exception as e: log_execution_error(self, e, options) + if isinstance(e, DatabaseError): + raise await self._handle_error(e) + # Same defensive fallback as the sync batch path. + raise DatabaseError(str(e)) from e async def get_server_version(self) -> Tuple[int, int, int]: """Get SQL Server server version. @@ -611,9 +658,10 @@ async def executescript(self, sql_script: str) -> None: for batch in batches: batch = batch.strip() - if batch: - async with await self._get_cursor() as cursor: - await cursor.execute(batch) + if not batch: + continue + async with await self._get_cursor() as cursor: + await cursor.execute(batch) await self._handle_auto_commit_if_needed() duration = time.perf_counter() - start_time @@ -621,7 +669,10 @@ async def executescript(self, sql_script: str) -> None: except Exception as e: self.log(logging.DEBUG, f"Error executing SQL script: {str(e)}") + if isinstance(e, DatabaseError): + raise await self._handle_error(e) + raise DatabaseError(str(e)) from e async def _handle_error(self, error: Exception) -> None: """Handle SQL Server-specific errors asynchronously. @@ -635,6 +686,10 @@ async def _handle_error(self, error: Exception) -> None: Raises: Appropriate backend exception """ + # A framework error already carries its phase semantics; re-mapping it + # would replace the original type with a generic driver error. + if isinstance(error, DatabaseError): + raise error mapped = classify_sqlserver_error(error) self._log_classified_error(error, mapped) raise mapped from error @@ -643,9 +698,20 @@ async def _handle_auto_commit_if_needed(self) -> None: """Handle auto-commit for async operations.""" if not self.in_transaction and self._connection: if not self.config.autocommit: - async with await self._get_cursor() as cursor: - await cursor.execute("COMMIT") - self.log(logging.DEBUG, "Auto-committed operation") + await self._handle_auto_commit() + + async def _handle_auto_commit(self) -> None: + """Commit outside a transaction, surfacing a failure as a transaction error.""" + if self._connection is None or self.config.autocommit: + return + cursor = await self._get_cursor() + try: + await cursor.execute("COMMIT") + except Exception as exc: + raise TransactionError("Auto-commit failed") from exc + finally: + await self._close_cursor_quietly_async(cursor) + self.log(logging.DEBUG, "Auto-committed operation") def _create_introspector(self): """Create a SQL Server introspector.""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index 19599dd..2e4247f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py @@ -21,7 +21,11 @@ log_batch_summary, log_execution_error, ) -from rhosocial.activerecord.backend.errors import ConnectionError +from rhosocial.activerecord.backend.errors import ( + ConnectionError, + DatabaseError, + TransactionError, +) from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.logging.redaction import unwrap_sensitive @@ -360,15 +364,11 @@ def execute( explain_split = self._split_explain_statement(sql) if explain_split is not None: set_clause, statement = explain_split - cursor = self._get_cursor() - cursor.execute(f"SET {set_clause} ON") - cursor.close() + self._execute_control_sql(f"SET {set_clause} ON") try: return self._execute_with_options(statement, params, options) finally: - cursor = self._get_cursor() - cursor.execute(f"SET {set_clause} OFF") - cursor.close() + self._execute_control_sql_quietly(f"SET {set_clause} OFF") identity_table = self._identity_insert_info(sql, params) if identity_table is not None: @@ -377,7 +377,7 @@ def execute( result = self._execute_with_options(sql, params, options) finally: if identity_table is not None: - self.set_identity_insert(identity_table, False) + self._reset_identity_insert_quietly(identity_table) if options.stmt_type == StatementType.DML and sql_upper.startswith("INSERT"): scope_id = self.get_scope_identity() @@ -407,6 +407,16 @@ def _execute_query(self, cursor: Any, sql: str, params: Optional[Tuple]) -> Any: finally: cursor.noscan = previous + def _reset_identity_insert_quietly(self, table: str) -> None: + """Turn ``IDENTITY_INSERT`` off without replacing an in-flight failure.""" + try: + self.set_identity_insert(table, False) + except Exception as exc: + self.log( + logging.WARNING, + f"Failed to disable IDENTITY_INSERT for {table}: {exc}", + ) + def execute_many( self, sql: str, @@ -452,13 +462,18 @@ def execute_many( return QueryResult(affected_rows=affected_rows, data=None, duration=duration) - except PyODBCError as e: + except Exception as e: log_execution_error(self, e, options) + if isinstance(e, DatabaseError): + raise self._handle_error(e) + # ``_handle_error`` must raise; keep a defensive fallback so a + # handler cannot turn a failure into a ``None`` result. + raise DatabaseError(str(e)) from e finally: if cursor: - cursor.close() + self._close_cursor_quietly(cursor) def get_server_version(self) -> Tuple[int, int, int]: """Get SQL Server server version. @@ -571,18 +586,24 @@ def executescript(self, sql_script: str) -> None: for batch in batches: batch = batch.strip() - if batch: - cursor = self._connection.cursor() + if not batch: + continue + cursor = self._connection.cursor() + try: cursor.execute(batch) - cursor.close() + finally: + self._close_cursor_quietly(cursor) self._handle_auto_commit_if_needed() duration = time.perf_counter() - start_time self.log(logging.INFO, f"SQL script executed successfully, duration={duration:.3f}s") - except PyODBCError as e: + except Exception as e: self.log(logging.DEBUG, f"Error executing SQL script: {str(e)}") + if isinstance(e, DatabaseError): + raise self._handle_error(e) + raise DatabaseError(str(e)) from e def _handle_error(self, error: Exception) -> None: """Handle SQL Server-specific errors and convert to appropriate exceptions. @@ -603,6 +624,10 @@ def _handle_error(self, error: Exception) -> None: OperationalError: For network and timeout failures DatabaseError: For other database errors """ + # A framework error already carries its phase semantics; re-mapping it + # would replace the original type with a generic driver error. + if isinstance(error, DatabaseError): + raise error mapped = classify_sqlserver_error(error) self._log_classified_error(error, mapped) raise mapped from error @@ -616,8 +641,17 @@ def _handle_auto_commit_if_needed(self) -> None: """ if not self.in_transaction and self._connection: if not self.config.autocommit: - self._connection.commit() - self.log(logging.DEBUG, "Auto-committed operation") + self._handle_auto_commit() + + def _handle_auto_commit(self) -> None: + """Commit outside a transaction, surfacing a failure as a transaction error.""" + if self._connection is None or self.config.autocommit: + return + try: + self._connection.commit() + except Exception as exc: + raise TransactionError("Auto-commit failed") from exc + self.log(logging.DEBUG, "Auto-committed operation") def _is_connection_error(self, error: Exception) -> bool: """Check if an error indicates a connection loss. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py index 8d3855e..b02f213 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py @@ -180,24 +180,53 @@ def _identity_insert_info( return table_name return None + def _execute_control_sql(self, sql: str) -> None: + """Execute a session-control statement through the mapped error path. + + ``SET ...`` statements used to run straight against the cursor, so a + driver exception escaped untranslated. Routing them through + :meth:`_handle_error` gives them the same taxonomy as ``execute``. + """ + cursor = self._get_cursor() + try: + cursor.execute(sql) + except Exception as exc: + self._handle_error(exc) + finally: + self._close_cursor_quietly(cursor) + + def _execute_control_sql_quietly(self, sql: str) -> None: + """Execute a cleanup statement without masking the original failure.""" + try: + self._execute_control_sql(sql) + except Exception as exc: + self.log(logging.WARNING, f"Failed to reset session state ({sql}): {exc}") + + def _close_cursor_quietly(self, cursor) -> None: + """Close a cursor, logging rather than masking an in-flight error.""" + try: + cursor.close() + except Exception as exc: + self.log(logging.DEBUG, f"Failed to close cursor: {exc}") + def set_nocount(self, on: bool = True) -> None: state = "ON" if on else "OFF" - cursor = self._get_cursor() - cursor.execute(f"SET NOCOUNT {state}") - cursor.close() + self._execute_control_sql(f"SET NOCOUNT {state}") def set_identity_insert(self, table: str, on: bool = True) -> None: sql, _params = self.dialect.format_set_identity_insert(table, on) - cursor = self._get_cursor() - cursor.execute(sql) - cursor.close() + self._execute_control_sql(sql) def get_scope_identity(self) -> Optional[int]: sql, params = session_identity_sql(self.dialect) cursor = self._get_cursor() - cursor.execute(sql, params) - row = cursor.fetchone() - cursor.close() + try: + cursor.execute(sql, params) + row = cursor.fetchone() + except Exception as exc: + self._handle_error(exc) + finally: + self._close_cursor_quietly(cursor) if row and row[0] is not None: return int(row[0]) return None @@ -449,6 +478,10 @@ def _log_classified_error(self, error: Exception, mapped: DatabaseError) -> None self.log(logging.DEBUG, f"Database error: {detail}") def _handle_error(self, error: Exception) -> None: + # A framework error already carries its phase semantics; re-mapping it + # would replace the original type with a generic driver error. + if isinstance(error, DatabaseError): + raise error mapped = classify_sqlserver_error(error) self._log_classified_error(error, mapped) raise mapped from error \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py new file mode 100644 index 0000000..9ad8169 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py @@ -0,0 +1,243 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py +"""Execute error contract for the SQL Server backend, without a database. + +The contract under test is core's phase-specific error taxonomy: + +* a session-control statement that fails while wrapping an ``EXPLAIN`` runs + through the mapped path, so the driver failure becomes a taxonomy error; +* the ``finally`` that turns the session flag back off must log and keep the + original failure, never replace it; +* ``execute_many`` / ``executescript`` share ``execute``'s classifier and do + not replace an error that is already a framework error; +* an auto-commit failure is a ``TransactionError`` chained to the driver + failure. + +Cursors and connections are doubles: the backend is instantiated but never +connects, so no SQL Server instance is required. +""" + +import pytest + +pyodbc = pytest.importorskip("pyodbc") + +from rhosocial.activerecord.backend.errors import ( # noqa: E402 + DatabaseError, + DeadlockError, + QueryError, + TransactionError, +) +from rhosocial.activerecord.backend.impl.sqlserver.backend.backend import ( # noqa: E402 + SQLServerBackend, +) +from rhosocial.activerecord.backend.impl.sqlserver.mixins import ( # noqa: E402 + SQLServerBackendMixin, +) + + +class _ScriptedCursor: + """Cursor double answering each statement through a scripted handler.""" + + description = None + rowcount = 0 + lastrowid = None + noscan = None + + def __init__(self, handler): + self._handler = handler + self.executed = [] + + def execute(self, sql, params=None): + self.executed.append(sql) + return self._handler(sql, params) + + def executemany(self, sql, params_list): + self.executed.append(sql) + return self._handler(sql, params_list) + + def fetchone(self): + return None + + def fetchall(self): + return [] + + def close(self): + pass + + +class _Connection: + """Connection double recording commits and handing out scripted cursors.""" + + def __init__(self, handler): + self._handler = handler + self.commits = 0 + self.commit_error = None + + def commit(self): + self.commits += 1 + if self.commit_error is not None: + raise self.commit_error + + def cursor(self): + return _ScriptedCursor(self._handler) + + +@pytest.fixture +def offline_backend(): + """An unconnected backend used only for its execution/error wiring.""" + return SQLServerBackend( + host="localhost", + database="execute_contract", + username="sa", + password="not-used", + ) + + +def _install(backend, handler): + """Attach a fake connection and route ``_get_cursor`` to scripted cursors.""" + connection = _Connection(handler) + backend._connection = connection + backend._get_cursor = lambda: _ScriptedCursor(handler) + return connection + + +def _raising(error): + """Return a handler that raises ``error`` for every statement.""" + + def handler(sql, params=None): + raise error + + return handler + + +class TestExplainSessionFlag: + def test_failing_set_on_raises_mapped_error_with_original_cause(self, offline_backend): + driver_error = pyodbc.ProgrammingError(102, "Incorrect syntax near 'SET'.") + _install(offline_backend, _raising(driver_error)) + + with pytest.raises(QueryError) as info: + offline_backend.execute("SET SHOWPLAN_TEXT ON;SELECT 1") + + assert info.value.__cause__ is driver_error + assert "Incorrect syntax near 'SET'" in str(info.value) + assert info.value.driver_code == 102 + assert info.value.backend == "sqlserver" + + def test_cleanup_failure_does_not_replace_original_error(self, offline_backend): + deadlock = pyodbc.DatabaseError(1205, "Transaction was deadlocked on lock resources") + off_error = pyodbc.ProgrammingError(102, "Incorrect syntax near 'OFF'.") + seen = [] + + def handler(sql, params=None): + seen.append(sql) + if sql.startswith("SET ") and sql.endswith(" OFF"): + raise off_error + if sql.startswith("SET "): + return None + raise deadlock + + _install(offline_backend, handler) + + with pytest.raises(DeadlockError) as info: + offline_backend.execute("SET SHOWPLAN_TEXT ON;SELECT 1") + + assert info.value.__cause__ is deadlock + assert "deadlocked" in str(info.value) + assert "SET SHOWPLAN_TEXT OFF" in seen + + +class TestExecuteManyContract: + def test_deadlock_maps_to_deadlock_error(self, offline_backend): + deadlock = pyodbc.DatabaseError(1205, "Deadlock victim") + _install(offline_backend, _raising(deadlock)) + + with pytest.raises(DeadlockError) as info: + offline_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert info.value.__cause__ is deadlock + assert info.value.driver_code == 1205 + assert info.value.transient is True + + def test_success_goes_through_the_auto_commit_contract(self, offline_backend): + offline_backend.config.autocommit = False + connection = _install(offline_backend, lambda sql, params=None: None) + + result = offline_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert result.affected_rows == 0 + assert connection.commits == 1 + + def test_framework_error_is_not_re_mapped(self, offline_backend): + framework_error = QueryError("already classified") + _install(offline_backend, _raising(framework_error)) + + with pytest.raises(QueryError) as info: + offline_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert info.value is framework_error + + def test_commit_failure_raises_transaction_error(self, offline_backend): + offline_backend.config.autocommit = False + commit_error = pyodbc.OperationalError( + "08S01", "[08S01] Communication link failure" + ) + connection = _install(offline_backend, lambda sql, params=None: None) + connection.commit_error = commit_error + + with pytest.raises(TransactionError) as info: + offline_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert info.value.__cause__ is commit_error + + +class TestExecutescriptContract: + def test_driver_error_is_classified(self, offline_backend): + driver_error = pyodbc.ProgrammingError(102, "Incorrect syntax near 'GO'") + _install(offline_backend, _raising(driver_error)) + + with pytest.raises(QueryError) as info: + offline_backend.executescript("SELECT 1\nGO\nSELECT 2") + + assert info.value.__cause__ is driver_error + assert info.value.driver_code == 102 + + def test_framework_error_is_not_re_mapped(self, offline_backend): + framework_error = TransactionError("already classified") + _install(offline_backend, _raising(framework_error)) + + with pytest.raises(TransactionError) as info: + offline_backend.executescript("SELECT 1\nGO\nSELECT 2") + + assert info.value is framework_error + + +class TestAutoCommitContract: + def test_commit_failure_raises_transaction_error(self, offline_backend): + offline_backend.config.autocommit = False + commit_error = pyodbc.OperationalError( + "08S01", "[08S01] Communication link failure" + ) + connection = _install(offline_backend, lambda sql, params=None: None) + connection.commit_error = commit_error + + with pytest.raises(TransactionError) as info: + offline_backend.execute("INSERT INTO t (v) VALUES (1)") + + assert info.value.__cause__ is commit_error + + +class TestHandlerShortCircuit: + def test_backend_handler_raises_framework_error_unchanged(self, offline_backend): + framework_error = TransactionError("already classified") + + with pytest.raises(TransactionError) as info: + offline_backend._handle_error(framework_error) + + assert info.value is framework_error + + def test_mixin_handler_raises_framework_error_unchanged(self, offline_backend): + framework_error = DatabaseError("already classified") + + with pytest.raises(DatabaseError) as info: + SQLServerBackendMixin._handle_error(offline_backend, framework_error) + + assert info.value is framework_error From 230333f589e9d88c4b95e745472dabec574f678e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 13:28:27 +0800 Subject: [PATCH 3/7] test(execute): add SQL Server async contract parity --- .../backend/test_execute_error_contract.py | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py index 9ad8169..0e55ebc 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py @@ -19,6 +19,7 @@ import pytest pyodbc = pytest.importorskip("pyodbc") +aioodbc = pytest.importorskip("aioodbc") from rhosocial.activerecord.backend.errors import ( # noqa: E402 DatabaseError, @@ -26,6 +27,9 @@ QueryError, TransactionError, ) +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import ( # noqa: E402 + AsyncSQLServerBackend, +) from rhosocial.activerecord.backend.impl.sqlserver.backend.backend import ( # noqa: E402 SQLServerBackend, ) @@ -241,3 +245,218 @@ def test_mixin_handler_raises_framework_error_unchanged(self, offline_backend): SQLServerBackendMixin._handle_error(offline_backend, framework_error) assert info.value is framework_error + + +class _AsyncScriptedCursor: + """Async cursor double, also usable as an async context manager.""" + + description = None + rowcount = 0 + lastrowid = None + + def __init__(self, handler, log): + self._handler = handler + self._log = log + self.executed = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, sql, params=None): + self.executed.append(sql) + self._log.append(sql) + return self._handler(sql, params) + + async def executemany(self, sql, params_list): + self.executed.append(sql) + self._log.append(sql) + return self._handler(sql, params_list) + + async def fetchone(self): + return None + + async def fetchall(self): + return [] + + async def close(self): + pass + + +@pytest.fixture +def offline_async_backend(): + """An unconnected async backend used only for its execution/error wiring.""" + return AsyncSQLServerBackend( + host="localhost", + database="execute_contract", + username="sa", + password="not-used", + ) + + +def _install_async(backend, handler): + """Attach a sentinel connection and route ``_get_cursor`` to async doubles.""" + statements = [] + + async def make_cursor(): + return _AsyncScriptedCursor(handler, statements) + + backend._connection = object() + backend._get_cursor = make_cursor + return statements + + +class TestAsyncExplainSessionFlag: + @pytest.mark.asyncio + async def test_failing_set_on_raises_mapped_error_with_original_cause( + self, offline_async_backend + ): + driver_error = pyodbc.ProgrammingError(102, "Incorrect syntax near 'SET'.") + _install_async(offline_async_backend, _raising(driver_error)) + + with pytest.raises(QueryError) as info: + await offline_async_backend.execute("SET SHOWPLAN_TEXT ON;SELECT 1") + + assert info.value.__cause__ is driver_error + assert "Incorrect syntax near 'SET'" in str(info.value) + assert info.value.driver_code == 102 + assert info.value.backend == "sqlserver" + + @pytest.mark.asyncio + async def test_cleanup_failure_does_not_replace_original_error( + self, offline_async_backend + ): + deadlock = pyodbc.DatabaseError(1205, "Transaction was deadlocked on lock resources") + off_error = pyodbc.ProgrammingError(102, "Incorrect syntax near 'OFF'.") + + def handler(sql, params=None): + if sql.startswith("SET ") and sql.endswith(" OFF"): + raise off_error + if sql.startswith("SET "): + return None + raise deadlock + + statements = _install_async(offline_async_backend, handler) + + with pytest.raises(DeadlockError) as info: + await offline_async_backend.execute("SET SHOWPLAN_TEXT ON;SELECT 1") + + assert info.value.__cause__ is deadlock + assert "deadlocked" in str(info.value) + assert "SET SHOWPLAN_TEXT OFF" in statements + + +class TestAsyncExecuteManyContract: + @pytest.mark.asyncio + async def test_deadlock_maps_to_deadlock_error(self, offline_async_backend): + deadlock = pyodbc.DatabaseError(1205, "Deadlock victim") + _install_async(offline_async_backend, _raising(deadlock)) + + with pytest.raises(DeadlockError) as info: + await offline_async_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert info.value.__cause__ is deadlock + assert info.value.driver_code == 1205 + assert info.value.transient is True + + @pytest.mark.asyncio + async def test_success_goes_through_the_auto_commit_contract( + self, offline_async_backend + ): + offline_async_backend.config.autocommit = False + statements = _install_async( + offline_async_backend, lambda sql, params=None: None + ) + + result = await offline_async_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert result.affected_rows == 0 + assert "COMMIT" in statements + + @pytest.mark.asyncio + async def test_framework_error_is_not_re_mapped(self, offline_async_backend): + framework_error = QueryError("already classified") + _install_async(offline_async_backend, _raising(framework_error)) + + with pytest.raises(QueryError) as info: + await offline_async_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert info.value is framework_error + + @pytest.mark.asyncio + async def test_commit_failure_raises_transaction_error(self, offline_async_backend): + offline_async_backend.config.autocommit = False + commit_error = pyodbc.OperationalError( + "08S01", "[08S01] Communication link failure" + ) + + def handler(sql, params=None): + if sql == "COMMIT": + raise commit_error + return None + + _install_async(offline_async_backend, handler) + + with pytest.raises(TransactionError) as info: + await offline_async_backend.execute_many("UPDATE t SET v = ?", [(1,)]) + + assert info.value.__cause__ is commit_error + + +class TestAsyncExecutescriptContract: + @pytest.mark.asyncio + async def test_driver_error_is_classified(self, offline_async_backend): + driver_error = pyodbc.ProgrammingError(102, "Incorrect syntax near 'GO'") + _install_async(offline_async_backend, _raising(driver_error)) + + with pytest.raises(QueryError) as info: + await offline_async_backend.executescript("SELECT 1\nGO\nSELECT 2") + + assert info.value.__cause__ is driver_error + assert info.value.driver_code == 102 + + @pytest.mark.asyncio + async def test_framework_error_is_not_re_mapped(self, offline_async_backend): + framework_error = TransactionError("already classified") + _install_async(offline_async_backend, _raising(framework_error)) + + with pytest.raises(TransactionError) as info: + await offline_async_backend.executescript("SELECT 1\nGO\nSELECT 2") + + assert info.value is framework_error + + +class TestAsyncAutoCommitContract: + @pytest.mark.asyncio + async def test_commit_failure_raises_transaction_error(self, offline_async_backend): + offline_async_backend.config.autocommit = False + commit_error = pyodbc.OperationalError( + "08S01", "[08S01] Communication link failure" + ) + + def handler(sql, params=None): + if sql == "COMMIT": + raise commit_error + return None + + _install_async(offline_async_backend, handler) + + with pytest.raises(TransactionError) as info: + await offline_async_backend.execute("UPDATE t SET v = 1") + + assert info.value.__cause__ is commit_error + + +class TestAsyncHandlerShortCircuit: + @pytest.mark.asyncio + async def test_backend_handler_raises_framework_error_unchanged( + self, offline_async_backend + ): + framework_error = TransactionError("already classified") + + with pytest.raises(TransactionError) as info: + await offline_async_backend._handle_error(framework_error) + + assert info.value is framework_error From 965bbe9933b9a7589f1d24229eba80aca0f740f1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 13:34:24 +0800 Subject: [PATCH 4/7] style(sqlserver): clean unused imports and wrap a long line --- .../backend/impl/sqlserver/backend/async_backend.py | 11 ++++++----- .../backend/impl/sqlserver/backend/backend.py | 4 +++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index 22c63b4..1c23a75 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -6,11 +6,10 @@ using aioodbc as the async driver wrapper. """ -import asyncio import logging import time from contextlib import asynccontextmanager -from typing import Any, AsyncIterator, Dict, List, Optional, Set, Tuple +from typing import Any, AsyncIterator, List, Optional, Set, Tuple from rhosocial.activerecord.backend.base import AsyncStorageBackend from rhosocial.activerecord.backend.base.execution_logging import ( @@ -20,8 +19,6 @@ log_execution_start, log_execution_summary, ) -from rhosocial.activerecord.backend.base.operations import AsyncSQLOperationsMixin -from rhosocial.activerecord.backend.base.transaction_management import AsyncTransactionManagementMixin from rhosocial.activerecord.backend.errors import ( ConnectionError, DatabaseError, @@ -455,7 +452,11 @@ async def _execute_statement( if identity_table is not None: await self._reset_identity_insert_quietly(identity_table) - is_select = options.process_result_set if options.process_result_set is not None else options.stmt_type == StatementType.DQL + is_select = ( + options.process_result_set + if options.process_result_set is not None + else options.stmt_type == StatementType.DQL + ) data = None if is_select and cursor.description: rows = await cursor.fetchall() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index 2e4247f..f651061 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py @@ -214,7 +214,9 @@ def _register_sqlserver_adapters(self) -> None: for db_type in db_types: self.adapter_registry.register(adapter, py_type, db_type, allow_override=True) - import datetime as dt, decimal, uuid + import datetime as dt + import decimal + import uuid type_map = [ (SQLServerDateTimeAdapter(), dt.datetime, dt.datetime), (SQLServerDateTimeOffsetAdapter(), dt.datetime, "datetimeoffset"), From 6fb7d0785ceffe050468f9866137e94b3dc6839d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 14:28:05 +0800 Subject: [PATCH 5/7] ci: pin core and testsuite feature/error-taxonomy branches --- .github/workflows/test.yml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0190425..03f40da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,8 +54,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -144,8 +144,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -234,8 +234,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -324,8 +324,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -414,8 +414,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -504,8 +504,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -595,8 +595,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -685,8 +685,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | @@ -784,8 +784,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" - name: Install dependencies run: | From 0e1c9e2ec5efd640fc25b7f692f678c65710fddb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 15:46:56 +0800 Subject: [PATCH 6/7] docs(changelog): add fragment for the error taxonomy PR (#15) --- changelog.d/15.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/15.added.md diff --git a/changelog.d/15.added.md b/changelog.d/15.added.md new file mode 100644 index 0000000..66e1b95 --- /dev/null +++ b/changelog.d/15.added.md @@ -0,0 +1 @@ +Classified SQL Server driver errors into the semantic error taxonomy (data, record, policy, concurrency, infrastructure) and aligned `execute` / `execute_many` with the execute-phase error contract: framework errors pass through, auto-commit failures raise `TransactionError`, and only transient connection failures are retried. Added contract tests covering the mapping and phases. From 0a223ba5e94329ca68f533b40acc3a81c0e3e6c8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 15:52:43 +0800 Subject: [PATCH 7/7] ci: mark feature-branch pip installs as reviewed (#15) --- .github/workflows/test.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03f40da..5204def 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -145,7 +145,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -235,7 +235,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -325,7 +325,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -415,7 +415,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -505,7 +505,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -596,7 +596,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -686,7 +686,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: | @@ -785,7 +785,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR - name: Install dependencies run: |