Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/15.added.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -20,22 +19,18 @@
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
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
Expand Down Expand Up @@ -271,21 +266,59 @@ 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

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)."""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -617,62 +659,60 @@ 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
self.log(logging.INFO, f"SQL script executed successfully, duration={duration:.3f}s")

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."""
Expand Down
Loading
Loading