diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0190425..5204def 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" # NOSONAR - 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" # NOSONAR - 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" # NOSONAR - 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" # NOSONAR - 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" # NOSONAR - 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" # NOSONAR - 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" # NOSONAR - 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" # NOSONAR - 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" # NOSONAR - name: Install dependencies run: | 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. 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..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,15 +19,10 @@ 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, - DeadlockError, - IntegrityError, - OperationalError, - QueryError, + TransactionError, ) from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType from rhosocial.activerecord.backend.result import QueryResult @@ -36,6 +30,7 @@ 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 @@ -271,12 +266,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 @@ -284,8 +318,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).""" @@ -352,13 +385,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) @@ -419,9 +450,13 @@ 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 + 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() @@ -469,7 +504,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, @@ -517,7 +555,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. @@ -617,9 +659,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 @@ -627,52 +670,49 @@ 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. - + """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)) + # 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 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 750226f..f651061 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 ( @@ -24,10 +24,7 @@ from rhosocial.activerecord.backend.errors import ( ConnectionError, DatabaseError, - DeadlockError, - IntegrityError, - OperationalError, - QueryError, + TransactionError, ) from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType from rhosocial.activerecord.backend.result import QueryResult @@ -36,6 +33,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 @@ -216,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"), @@ -366,15 +366,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: @@ -383,7 +379,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() @@ -413,6 +409,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, @@ -458,13 +464,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. @@ -577,66 +588,51 @@ 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. - - 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)}") + # 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 def _handle_auto_commit_if_needed(self) -> None: """Handle auto-commit for SQL Server. @@ -647,8 +643,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/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..b02f213 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, @@ -169,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 @@ -407,39 +447,41 @@ 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: + # 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/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 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..0e55ebc --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execute_error_contract.py @@ -0,0 +1,462 @@ +# 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") +aioodbc = pytest.importorskip("aioodbc") + +from rhosocial.activerecord.backend.errors import ( # noqa: E402 + DatabaseError, + DeadlockError, + 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, +) +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 + + +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