From ce3f47341a1d34364c8dbe39c82aaf235895b5ad Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 11:33:06 +0800 Subject: [PATCH 1/5] feat(errors): add semantic error categories --- docs/en_US/cli/errors.md | 29 +++ docs/zh_CN/cli/errors.md | 26 +++ src/rhosocial/activerecord/backend/errors.py | 157 ++++++++++++++-- .../activerecord/cli/error_mapping.py | 23 ++- .../error_taxonomy/test_error_taxonomy.py | 174 ++++++++++++++++++ 5 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_error_taxonomy.py diff --git a/docs/en_US/cli/errors.md b/docs/en_US/cli/errors.md index 31034d25..e196973e 100644 --- a/docs/en_US/cli/errors.md +++ b/docs/en_US/cli/errors.md @@ -22,6 +22,35 @@ never changes framework semantics: models still validate when constructed, and Please see [Output and pipes](output.md#exit-codes) for the complete code list. +## Error taxonomy + +Model and backend errors carry semantic categories, so an application can catch +by intent instead of listing concrete classes. Every concrete error is still a +`DatabaseError`: + +```text +DatabaseError +├── DataError ValidationError, IntegrityError, TypeConversionError +├── RecordError RecordNotFound, UnaddressableRecordError +├── PolicyError ReadOnlyError +├── ConcurrencyError StaleRecordError, LockError (DeadlockError) +├── InfrastructureError ConnectionError, OperationalError +├── TransactionError IsolationLevelError, Unsupported*Error +├── QueryError +└── BulkOperationError BulkValidationError, BulkStateError +``` + +`BulkValidationError` inherits both `BulkOperationError` and `DataError`. + +`retryable` is an advisory hint: concurrency errors set it to `True`; +infrastructure errors default to `False` and become retryable only when the +backend marks the failure as transient. It never triggers an automatic retry. + +Backends attach `operation`, `sqlstate`, `driver_code`, `backend`, and +`transient` with `attach_diagnostics()` before raising. Unknown subclasses still +map to the right exit code because the CLI checks the category before the +generic `DatabaseError` fallback. + ## Model exceptions versus CLI errors The model layer raises `backend.errors.RecordNotFound` when a query returns no diff --git a/docs/zh_CN/cli/errors.md b/docs/zh_CN/cli/errors.md index a1245d80..e95c4570 100644 --- a/docs/zh_CN/cli/errors.md +++ b/docs/zh_CN/cli/errors.md @@ -21,6 +21,32 @@ CLI 把模型层和后端异常翻译成命令级错误,不改变框架语义 完整退出码见[输出与管道](output.md#退出码)。 +## 错误分类 + +模型层和后端错误带有语义分类,应用可以按意图捕获,而不必逐个列出具体类。 +所有具体错误仍然是 `DatabaseError`: + +```text +DatabaseError +├── DataError ValidationError、IntegrityError、TypeConversionError +├── RecordError RecordNotFound、UnaddressableRecordError +├── PolicyError ReadOnlyError +├── ConcurrencyError StaleRecordError、LockError(DeadlockError) +├── InfrastructureError ConnectionError、OperationalError +├── TransactionError IsolationLevelError、Unsupported*Error +├── QueryError +└── BulkOperationError BulkValidationError、BulkStateError +``` + +`BulkValidationError` 同时继承 `BulkOperationError` 和 `DataError`。 + +`retryable` 只是提示:并发类为 `True`;基础设施类默认 `False`,只有后端标记为瞬态时 +才变为可重试。它不会触发自动重试。 + +后端在抛出前通过 `attach_diagnostics()` 附加 `operation`、`sqlstate`、`driver_code`、 +`backend`、`transient`。即使是新的后端子类,CLI 也会先按类别匹配,再落到通用 +`DatabaseError`,退出码不会错。 + ## 模型异常与 CLI 错误 查询无结果时模型层抛 `backend.errors.RecordNotFound`;CLI 层抛自己的 diff --git a/src/rhosocial/activerecord/backend/errors.py b/src/rhosocial/activerecord/backend/errors.py index 5687694a..42674bfa 100644 --- a/src/rhosocial/activerecord/backend/errors.py +++ b/src/rhosocial/activerecord/backend/errors.py @@ -1,17 +1,93 @@ -# src/rhosocial/activerecord/backend/errors.py -from typing import Any, TYPE_CHECKING +"""Database error hierarchy with semantic categories. + +Concrete error classes keep their names and raise sites. Semantic base classes +let an application catch by intent (data problem, missing record, policy, +concurrency, infrastructure) without checking each concrete class. + +Every concrete class is still a :class:`DatabaseError`, so existing +``except DatabaseError`` handlers keep working. +""" + +from typing import Any, Optional, TYPE_CHECKING if TYPE_CHECKING: from typing import List, Tuple class DatabaseError(Exception): - """Base class for database errors""" + """Base class for database errors. + + Attributes: + retryable: Advisory hint. ``True`` means a retry may succeed; it is not + an automatic retry policy. Concrete classes and backends override it. + transient: ``True`` for infrastructure failures that are expected to pass + on retry (timeouts, lost connections); ``None`` when unknown. + operation: Optional operation label attached by a backend. + sqlstate: Optional SQLSTATE attached by a backend. + driver_code: Optional driver error code attached by a backend. + backend: Optional backend name attached by a backend. + """ + + retryable = False + transient = None + operation: Optional[str] = None + sqlstate: Optional[str] = None + driver_code: Optional[Any] = None + backend: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Semantic categories +# --------------------------------------------------------------------------- + + +class DataError(DatabaseError): + """The data itself is invalid or violates a constraint.""" + + retryable = False + + +class RecordError(DatabaseError): + """The record does not exist or cannot be addressed by primary key.""" + + retryable = False + + +class PolicyError(DatabaseError): + """The framework refused the operation for policy reasons. + + Attributes: + model_name: Model the policy applies to, when known. + operation: Refused operation, when known. + """ + + retryable = False + model_name: Optional[str] = None + + +class ConcurrencyError(DatabaseError): + """A concurrent writer caused the operation to lose a race.""" + + retryable = True + + +class InfrastructureError(DatabaseError): + """The connection or runtime environment failed the operation. + + ``retryable`` stays False by default: authentication and permission + failures do not improve on retry. Backends mark transient failures with + ``attach_diagnostics(..., transient=True)``. + """ + + retryable = False - pass +# --------------------------------------------------------------------------- +# Concrete errors +# --------------------------------------------------------------------------- -class ConnectionError(DatabaseError): + +class ConnectionError(InfrastructureError): """Connection error""" pass @@ -29,13 +105,13 @@ class QueryError(DatabaseError): pass -class ValidationError(DatabaseError): +class ValidationError(DataError): """Data validation error""" pass -class ReadOnlyError(DatabaseError): +class ReadOnlyError(PolicyError): """A write was attempted on a read-only model. Raised before any statement is issued, so a refused write leaves the @@ -53,7 +129,7 @@ def __init__(self, model_name: str, operation: str): ) -class LockError(DatabaseError): +class LockError(ConcurrencyError): """Lock error""" pass @@ -65,13 +141,13 @@ class DeadlockError(LockError): pass -class IntegrityError(DatabaseError): +class IntegrityError(DataError): """Integrity constraint error""" pass -class StaleRecordError(DatabaseError): +class StaleRecordError(ConcurrencyError): """The record version no longer matches the stored row. Raised by optimistic locking when the UPDATE affects no rows because the @@ -113,25 +189,25 @@ def __init__( super().__init__(message) -class TypeConversionError(DatabaseError): +class TypeConversionError(DataError): """Type conversion error""" pass -class OperationalError(DatabaseError): +class OperationalError(InfrastructureError): """Operational error (timeout, connection lost, etc.)""" pass -class RecordNotFound(DatabaseError): +class RecordNotFound(RecordError): """Record not found""" pass -class UnaddressableRecordError(DatabaseError): +class UnaddressableRecordError(RecordError): """A keyless model was asked for single-row access by primary key. ``__primary_key__ = None`` declares a relation with no single-row identity, @@ -161,8 +237,12 @@ class BulkOperationError(DatabaseError): pass -class BulkValidationError(BulkOperationError): - """Bulk validation failed, contains indices and error messages of failed records""" +class BulkValidationError(BulkOperationError, DataError): + """Bulk validation failed, contains indices and error messages of failed records. + + Inherits both :class:`BulkOperationError` (bulk semantics) and + :class:`DataError` (invalid data), so either catch group works. + """ def __init__(self, errors: "List[Tuple[int, str]]", message: str = "Bulk validation failed"): self.errors = errors @@ -186,7 +266,7 @@ class UnsupportedTransactionModeError(TransactionError): """Raised when requesting an unsupported transaction mode. This error is raised when a transaction mode (e.g., READ ONLY) is - requested but the database backend does not support it. + requested and the database backend does not support it. Attributes: feature: The unsupported feature name. @@ -205,7 +285,7 @@ def __init__(self, feature: str, backend: str, message: str = ""): class UnsupportedIsolationLevelError(TransactionError): """Raised when requesting an unsupported isolation level. - This error is raised when an isolation level is requested but the + This error is raised when an isolation level is requested and the database backend does not support it. Attributes: @@ -220,3 +300,44 @@ def __init__(self, isolation_level: str, backend: str, message: str = ""): if message: error_msg = f"{error_msg}. {message}" super().__init__(error_msg) + + +def attach_diagnostics( + error: DatabaseError, + *, + operation: Optional[str] = None, + sqlstate: Optional[str] = None, + driver_code: Optional[Any] = None, + backend: Optional[str] = None, + transient: Optional[bool] = None, +) -> DatabaseError: + """Attach driver context to an error without changing its type or message. + + Backends call this right before raising, so callers and logs can tell + which operation, SQLSTATE, or driver code produced the failure. + + Args: + error: The error instance to annotate; returned unchanged. + operation: Operation label such as ``"insert"`` or ``"connect"``. + sqlstate: Driver SQLSTATE, when the driver exposes one. + driver_code: Driver-specific numeric or string error code. + backend: Backend name such as ``"mysql"``. + transient: Whether an infrastructure failure is expected to pass on + retry. A transient infrastructure error becomes ``retryable``. + + Returns: + The same error instance, annotated. + """ + if operation is not None: + error.operation = operation + if sqlstate is not None: + error.sqlstate = sqlstate + if driver_code is not None: + error.driver_code = driver_code + if backend is not None: + error.backend = backend + if transient is not None: + error.transient = transient + if transient and isinstance(error, InfrastructureError): + error.retryable = True + return error diff --git a/src/rhosocial/activerecord/cli/error_mapping.py b/src/rhosocial/activerecord/cli/error_mapping.py index fc08a286..c84affac 100644 --- a/src/rhosocial/activerecord/cli/error_mapping.py +++ b/src/rhosocial/activerecord/cli/error_mapping.py @@ -18,21 +18,26 @@ from ..backend.errors import ( BulkStateError, BulkValidationError, + ConcurrencyError, ConnectionError as BackendConnectionError, + DataError, DatabaseError, DeadlockError, + InfrastructureError, IntegrityError, LockError, OperationalError, + PolicyError, QueryError, ReadOnlyError, + RecordError, RecordNotFound, StaleRecordError, TypeConversionError, UnaddressableRecordError, ValidationError as DBValidationError, ) -from .errors import CLIError, CliNotFoundError, ReadOnlyModelError +from .errors import CLIError, CliNotFoundError, ReadOnlyModelError, EXIT_READ_ONLY def _pydantic_summary(error: PydanticValidationError) -> str: @@ -115,6 +120,22 @@ def translate_error( return CLIError(f"Database operation failed{context}: {exc}") if isinstance(exc, QueryError): return CLIError(f"Query failed{context}: {exc}") + # Category fallbacks: backend-specific subclasses inherit these bases, so a + # new driver-specific error still gets a sensible message and exit code. + if isinstance(exc, PolicyError): + if exc.model_name and exc.operation: + return ReadOnlyModelError(exc.model_name, exc.operation) + return CLIError(f"Operation refused by policy{context}: {exc}", exit_code=EXIT_READ_ONLY) + if isinstance(exc, RecordError): + return CLIError(f"Record error{context}: {exc}") + if isinstance(exc, ConcurrencyError): + return CLIError( + f"Concurrency conflict{context}: {exc}. Reload the record and retry." + ) + if isinstance(exc, InfrastructureError): + return CLIError(f"Database infrastructure error{context}: {exc}") + if isinstance(exc, DataError): + return CLIError(f"Invalid data{context}: {exc}") if isinstance(exc, DatabaseError): return CLIError(f"Database error{context}: {exc}") return None diff --git a/tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_error_taxonomy.py b/tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_error_taxonomy.py new file mode 100644 index 00000000..5892d809 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_error_taxonomy.py @@ -0,0 +1,174 @@ +# tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_error_taxonomy.py +"""Semantic error taxonomy: hierarchy, retry hints, diagnostics, CLI mapping.""" + +from rhosocial.activerecord.backend.errors import ( + BulkOperationError, + BulkValidationError, + ConcurrencyError, + ConnectionError, + DataError, + DatabaseError, + DeadlockError, + InfrastructureError, + IntegrityError, + LockError, + OperationalError, + PolicyError, + QueryError, + ReadOnlyError, + RecordError, + RecordNotFound, + StaleRecordError, + TypeConversionError, + UnaddressableRecordError, + ValidationError, + attach_diagnostics, +) +from rhosocial.activerecord.cli.error_mapping import translate_error +from rhosocial.activerecord.cli.errors import EXIT_READ_ONLY, CliNotFoundError, CLIError + + +class TestHierarchy: + def test_every_error_is_a_database_error(self): + errors = [ + ValidationError("bad"), + IntegrityError("dup"), + TypeConversionError("type"), + RecordNotFound("missing"), + UnaddressableRecordError("Keyless", "read"), + ReadOnlyError("Model", "create"), + StaleRecordError(model=object, primary_key=1, expected_version=1), + LockError("lock"), + DeadlockError("deadlock"), + ConnectionError("connection"), + OperationalError("operation"), + QueryError("query"), + BulkValidationError([(0, "bad")]), + BulkOperationError("bulk"), + ] + assert all(isinstance(item, DatabaseError) for item in errors) + + def test_data_errors(self): + for error in ( + ValidationError("bad"), + IntegrityError("dup"), + TypeConversionError("type"), + ): + assert isinstance(error, DataError) + + def test_record_errors(self): + assert isinstance(RecordNotFound("missing"), RecordError) + assert isinstance(UnaddressableRecordError("M", "read"), RecordError) + + def test_policy_errors(self): + assert isinstance(ReadOnlyError("Model", "create"), PolicyError) + + def test_concurrency_errors(self): + assert isinstance(LockError("lock"), ConcurrencyError) + assert isinstance(DeadlockError("deadlock"), ConcurrencyError) + assert isinstance( + StaleRecordError(model=object, primary_key=1, expected_version=1), + ConcurrencyError, + ) + + def test_infrastructure_errors(self): + assert isinstance(ConnectionError("connection"), InfrastructureError) + assert isinstance(OperationalError("operation"), InfrastructureError) + + def test_bulk_validation_is_both_bulk_and_data(self): + error = BulkValidationError([(0, "bad")]) + assert isinstance(error, BulkOperationError) + assert isinstance(error, DataError) + + +class TestRetryHints: + def test_concurrency_errors_are_retryable(self): + assert LockError("lock").retryable is True + assert DeadlockError("deadlock").retryable is True + assert ( + StaleRecordError(model=object, primary_key=1, expected_version=1).retryable + is True + ) + + def test_data_and_policy_errors_are_not_retryable(self): + assert IntegrityError("dup").retryable is False + assert ValidationError("bad").retryable is False + assert ReadOnlyError("Model", "create").retryable is False + + def test_infrastructure_is_not_retryable_by_default(self): + assert ConnectionError("connection").retryable is False + assert OperationalError("operation").retryable is False + + def test_transient_infrastructure_becomes_retryable(self): + error = attach_diagnostics( + OperationalError("timeout"), operation="select", transient=True + ) + assert error.transient is True + assert error.retryable is True + + + +class TestDiagnostics: + def test_attach_diagnostics_sets_fields(self): + error = attach_diagnostics( + IntegrityError("dup"), + operation="insert", + sqlstate="23505", + driver_code=1062, + backend="mysql", + ) + assert error.operation == "insert" + assert error.sqlstate == "23505" + assert error.driver_code == 1062 + assert error.backend == "mysql" + + def test_attach_diagnostics_returns_same_instance(self): + error = IntegrityError("dup") + assert attach_diagnostics(error) is error + + def test_transient_false_keeps_non_retryable(self): + error = attach_diagnostics(BackendOperational(), transient=False) + assert error.transient is False + assert error.retryable is False + + +class BackendOperational(OperationalError): + """A backend-specific subclass, as a driver mapping would create.""" + + +class BackendConcurrency(ConcurrencyError): + """A backend-specific concurrency error.""" + + +class BackendData(DataError): + """A backend-specific data error.""" + + +class BackendPolicy(PolicyError): + """A backend-specific policy error.""" + + +class TestCliCategoryMapping: + def _translate(self, error): + return translate_error(error, operation="dml create") + + def test_unknown_concurrency_subclass_is_exit_one(self): + translated = self._translate(BackendConcurrency("deadlock")) + assert isinstance(translated, CLIError) + assert translated.exit_code == 1 + assert "Concurrency conflict" in translated.message + + def test_unknown_data_subclass_is_exit_one(self): + translated = self._translate(BackendData("bad value")) + assert translated.exit_code == 1 + assert "Invalid data" in translated.message + + def test_unknown_policy_subclass_without_context_is_exit_six(self): + translated = self._translate(BackendPolicy("refused")) + assert translated.exit_code == EXIT_READ_ONLY + + def test_record_not_found_stays_exit_three(self): + translated = self._translate(RecordNotFound("Record not found")) + assert isinstance(translated, CliNotFoundError) + assert translated.exit_code == 3 + From a29efbc49938f0ac8a169e2bbd886a5ee461c821 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 12:59:44 +0800 Subject: [PATCH 2/5] fix(execute): enforce phase-specific error contract --- .../activerecord/backend/base/execution.py | 66 ++++-- .../error_taxonomy/test_execute_contract.py | 200 ++++++++++++++++++ 2 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_execute_contract.py diff --git a/src/rhosocial/activerecord/backend/base/execution.py b/src/rhosocial/activerecord/backend/base/execution.py index dcaee6ad..86fef966 100644 --- a/src/rhosocial/activerecord/backend/base/execution.py +++ b/src/rhosocial/activerecord/backend/base/execution.py @@ -11,6 +11,7 @@ from typing import Optional, Tuple, List, Union, Dict from ...logging.redaction import unwrap_sensitive +from ..errors import DatabaseError from ..options import ExecutionOptions from ..result import QueryResult from ..schema import StatementType @@ -71,8 +72,13 @@ def execute( duration, and optionally result data if process_result_set is True Raises: - DatabaseError: If the query execution fails - ConnectionError: If there are connection-related issues + TypeConversionError: A parameter adapter or result adapter failed. + ConnectionError: Connecting (explicitly or automatically) failed. + IntegrityError: A constraint was violated. + ConcurrencyError: A deadlock, lock wait, or serialization conflict won. + QueryError: The statement or its bindings are invalid. + TransactionError: The automatic commit failed after execution. + DatabaseError: A backend-specific failure without a narrower category. """ start_time = time.perf_counter() if options is None: @@ -126,7 +132,12 @@ def execute( return result except Exception as e: log_execution_error(self, e, options) - return self._handle_execution_error(e) + if isinstance(e, DatabaseError): + raise + self._handle_execution_error(e) + # _handle_error() is required to raise. Keep a defensive fallback so + # a backend that forgets cannot turn a failure into a None result. + raise DatabaseError(str(e)) from e def execute_many( self, @@ -150,8 +161,16 @@ def execute_many( options: Optional ExecutionOptions carrying the logging context Returns: - QueryResult object containing total affected rows and execution duration, - or None if an error occurred + QueryResult object containing total affected rows and execution duration. + + Raises: + TypeConversionError: A parameter adapter or result adapter failed. + ConnectionError: Connecting (explicitly or automatically) failed. + IntegrityError: A constraint was violated. + ConcurrencyError: A deadlock, lock wait, or serialization conflict won. + QueryError: The statement or its bindings are invalid. + TransactionError: The automatic commit failed after execution. + DatabaseError: A backend-specific failure without a narrower category. Note: This method expects all parameters in params_list to be already database-compatible. @@ -175,7 +194,10 @@ def execute_many( return QueryResult(affected_rows=cursor.rowcount, duration=duration) except Exception as e: log_execution_error(self, e, options) - return self._handle_execution_error(e) + if isinstance(e, DatabaseError): + raise + self._handle_execution_error(e) + raise DatabaseError(str(e)) from e class AsyncExecutionMixin: @@ -226,8 +248,13 @@ async def execute( duration, and optionally result data if process_result_set is True Raises: - DatabaseError: If the query execution fails - ConnectionError: If there are connection-related issues + TypeConversionError: A parameter adapter or result adapter failed. + ConnectionError: Connecting (explicitly or automatically) failed. + IntegrityError: A constraint was violated. + ConcurrencyError: A deadlock, lock wait, or serialization conflict won. + QueryError: The statement or its bindings are invalid. + TransactionError: The automatic commit failed after execution. + DatabaseError: A backend-specific failure without a narrower category. """ start_time = time.perf_counter() if options is None: @@ -281,7 +308,11 @@ async def execute( return result except Exception as e: log_execution_error(self, e, options) - return await self._handle_execution_error(e) + if isinstance(e, DatabaseError): + raise + await self._handle_execution_error(e) + # Same defensive contract as the synchronous execute. + raise DatabaseError(str(e)) from e async def execute_many( self, @@ -303,8 +334,16 @@ async def execute_many( options: Optional ExecutionOptions carrying the logging context Returns: - QueryResult object containing total affected rows and execution duration, - or None if an error occurred + QueryResult object containing total affected rows and execution duration. + + Raises: + TypeConversionError: A parameter adapter or result adapter failed. + ConnectionError: Connecting (explicitly or automatically) failed. + IntegrityError: A constraint was violated. + ConcurrencyError: A deadlock, lock wait, or serialization conflict won. + QueryError: The statement or its bindings are invalid. + TransactionError: The automatic commit failed after execution. + DatabaseError: A backend-specific failure without a narrower category. Note: This method expects all parameters in params_list to be already database-compatible. @@ -327,4 +366,7 @@ async def execute_many( return QueryResult(affected_rows=cursor.rowcount, duration=duration) except Exception as e: log_execution_error(self, e, options) - return await self._handle_execution_error(e) + if isinstance(e, DatabaseError): + raise + await self._handle_execution_error(e) + raise DatabaseError(str(e)) from e diff --git a/tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_execute_contract.py b/tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_execute_contract.py new file mode 100644 index 00000000..2fb50718 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_execute_contract.py @@ -0,0 +1,200 @@ +# tests/rhosocial/activerecord_test/feature/backend/error_taxonomy/test_execute_contract.py +"""Core execute contract: phase-specific error types and no swallowed failures.""" + +import logging + +import pytest + +from rhosocial.activerecord.backend.base.execution import AsyncExecutionMixin, ExecutionMixin +from rhosocial.activerecord.backend.errors import ( + DatabaseError, + QueryError, + TransactionError, + TypeConversionError, +) +from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType +from rhosocial.activerecord.backend.result import QueryResult + + +class _Cursor: + rowcount = 0 + description = None + error = None + + def execute(self, sql, params=None): + if self.error is not None: + raise self.error + return self + + def executemany(self, sql, params_list): + if self.error is not None: + raise self.error + return self + + def close(self): + pass + + +class _Connection: + closed = False + + def __init__(self, commit_error=None): + self._commit_error = commit_error + + def cursor(self): + return _Cursor() + + def commit(self): + if self._commit_error is not None: + raise self._commit_error + + +class _BaseStub: + def __init__(self, *, execute_error=None, commit_error=None, convert_error=None, + handler_returns_none=False): + self._connection = None + self.logger = logging.getLogger("test.execute.contract") + self.execute_error = execute_error + self.commit_error = commit_error + self.convert_error = convert_error + self.handler_returns_none = handler_returns_none + + def connect(self): + self._connection = _Connection(self.commit_error) + + def _get_cursor(self): + cursor = self._connection.cursor() + cursor.error = self.execute_error + return cursor + + def get_default_adapter_suggestions(self): + return {} + + def prepare_parameters(self, params, adapters): + if self.convert_error is not None: + raise self.convert_error + return params + + def _prepare_sql_and_params(self, sql, params): + return sql, params + + def _execute_query(self, cursor, sql, params): + if self.execute_error is not None: + raise self.execute_error + return cursor + + def _process_result_set(self, cursor, is_select, adapters, mapping): + return [] + + def _build_query_result(self, cursor, data, duration): + return QueryResult(affected_rows=cursor.rowcount, data=data, duration=duration) + + def _handle_auto_commit_if_needed(self): + if self.commit_error is not None: + raise TransactionError("auto-commit failed") from self.commit_error + + def _handle_execution_error(self, error): + if self.handler_returns_none: + return None + if isinstance(error, DatabaseError): + raise error + if isinstance(error, ValueError): + raise QueryError(str(error)) from error + raise DatabaseError(str(error)) from error + + +class _SyncBackend(ExecutionMixin, _BaseStub): + pass + + +class _AsyncBackend(AsyncExecutionMixin, _BaseStub): + async def connect(self): + self._connection = _Connection(self.commit_error) + + async def _get_cursor(self): + cursor = self._connection.cursor() + cursor.error = self.execute_error + return cursor + + async def _execute_query(self, cursor, sql, params): + if self.execute_error is not None: + raise self.execute_error + return cursor + + async def _process_result_set(self, cursor, is_select, adapters, mapping): + return [] + + async def _handle_auto_commit_if_needed(self): + if self.commit_error is not None: + raise TransactionError("auto-commit failed") from self.commit_error + + async def _handle_execution_error(self, error): + if self.handler_returns_none: + return None + if isinstance(error, DatabaseError): + raise error + if isinstance(error, ValueError): + raise QueryError(str(error)) from error + raise DatabaseError(str(error)) from error + + +def _options(): + return ExecutionOptions(stmt_type=StatementType.DML) + + +def test_driver_error_maps_to_query_error(): + backend = _SyncBackend(execute_error=ValueError("bad sql")) + with pytest.raises(QueryError): + backend.execute("select 1", options=_options()) + + +def test_framework_error_is_not_re_wrapped(): + backend = _SyncBackend(convert_error=TypeConversionError("bad value")) + with pytest.raises(TypeConversionError): + backend.execute("insert into t values (?)", (object(),), options=_options()) + + +def test_handler_that_returns_none_does_not_return_none(): + backend = _SyncBackend(execute_error=ValueError("boom"), handler_returns_none=True) + with pytest.raises(DatabaseError): + backend.execute("select 1", options=_options()) + + +def test_auto_commit_failure_raises_transaction_error(): + backend = _SyncBackend(commit_error=RuntimeError("commit failed")) + with pytest.raises(TransactionError) as info: + backend.execute("insert into t values (1)", options=_options()) + assert isinstance(info.value.__cause__, RuntimeError) + + +def test_execute_many_auto_commit_failure_raises_transaction_error(): + backend = _SyncBackend(commit_error=RuntimeError("commit failed")) + with pytest.raises(TransactionError): + backend.execute_many("insert into t values (?)", [(1,)], options=_options()) + + +def test_execute_many_handler_none_does_not_return_none(): + backend = _SyncBackend(execute_error=ValueError("boom"), handler_returns_none=True) + with pytest.raises(DatabaseError): + backend.execute_many("insert into t values (?)", [(1,)], options=_options()) + + +@pytest.mark.asyncio +async def test_async_framework_error_is_not_re_wrapped(): + backend = _AsyncBackend(convert_error=TypeConversionError("bad value")) + with pytest.raises(TypeConversionError): + await backend.execute("insert into t values (?)", (object(),), options=_options()) + + +@pytest.mark.asyncio +async def test_async_auto_commit_failure_raises_transaction_error(): + backend = _AsyncBackend(commit_error=RuntimeError("commit failed")) + with pytest.raises(TransactionError): + await backend.execute("insert into t values (1)", options=_options()) + + +@pytest.mark.asyncio +async def test_async_handler_none_does_not_return_none(): + backend = _AsyncBackend(execute_error=ValueError("boom"), handler_returns_none=True) + with pytest.raises(DatabaseError): + await backend.execute("select 1", options=_options()) From fbccddc219b37dd39bbb6270312da0b8a33e2ab1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 13:52:46 +0800 Subject: [PATCH 3/5] fix(cli): map TransactionError and refresh affected docs --- docs/en_US/cli/errors.md | 1 + docs/en_US/events/lifecycle.md | 4 ++-- docs/en_US/performance/concurrency.md | 2 +- docs/zh_CN/cli/errors.md | 1 + docs/zh_CN/events/lifecycle.md | 3 ++- docs/zh_CN/performance/concurrency.md | 2 +- src/rhosocial/activerecord/cli/error_mapping.py | 3 +++ .../feature/cli/test_cli_error_mapping.py | 8 ++++++++ 8 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/en_US/cli/errors.md b/docs/en_US/cli/errors.md index e196973e..5c961f7a 100644 --- a/docs/en_US/cli/errors.md +++ b/docs/en_US/cli/errors.md @@ -16,6 +16,7 @@ never changes framework semantics: models still validate when constructed, and | Version conflict | `Record version mismatch ...` | 1 | | Lock or deadlock | `Record is locked ...` / `Deadlock detected ...` | 1 | | Connection failure | `Database connection failed ...` | 1 | +| Transaction failure (commit) | `Database transaction failed ...` | 1 | | Other database error | `Database error ...` | 1 | | Missing backend or config class | `Cannot import the ...` | 4 | | Unknown failure | `Unexpected error (TypeName): ...` | 1 | diff --git a/docs/en_US/events/lifecycle.md b/docs/en_US/events/lifecycle.md index 1b13014f..0ee2bfd1 100644 --- a/docs/en_US/events/lifecycle.md +++ b/docs/en_US/events/lifecycle.md @@ -314,8 +314,8 @@ class Product(OptimisticLockMixin, ActiveRecord): # The OptimisticLockMixin uses AFTER_UPDATE to verify the update: # - Checks result.affected_rows > 0 -# - Raises StaleRecordError (a DatabaseError subclass) if the record -# was updated by another process +# - Raises StaleRecordError (a DatabaseError subclass) if no row matched +# the version; the row may have been updated or deleted by another process ``` ## Migration Guide diff --git a/docs/en_US/performance/concurrency.md b/docs/en_US/performance/concurrency.md index ac138a4c..fb9ed094 100644 --- a/docs/en_US/performance/concurrency.md +++ b/docs/en_US/performance/concurrency.md @@ -16,7 +16,7 @@ class Post(DefaultOptimisticLockMixin, ActiveRecord): **How it works**: 1. When reading data, fetch the current `version` (e.g., 1). 2. When updating, the SQL adds a condition `WHERE id = ... AND version = 1`. -3. If the number of updated rows is 0, it means the `version` has changed in the meantime (modified by someone else), and a `StaleRecordError` is raised. +3. If the number of updated rows is 0, the version no longer matches: the row may have been updated or deleted by someone else, and a `StaleRecordError` is raised. ```python from rhosocial.activerecord.backend.errors import StaleRecordError diff --git a/docs/zh_CN/cli/errors.md b/docs/zh_CN/cli/errors.md index e95c4570..e6be3192 100644 --- a/docs/zh_CN/cli/errors.md +++ b/docs/zh_CN/cli/errors.md @@ -15,6 +15,7 @@ CLI 把模型层和后端异常翻译成命令级错误,不改变框架语义 | 版本冲突 | `Record version mismatch ...` | 1 | | 锁或死锁 | `Record is locked ...` / `Deadlock detected ...` | 1 | | 连接失败 | `Database connection failed ...` | 1 | +| 事务失败(提交) | `Database transaction failed ...` | 1 | | 其它数据库错误 | `Database error ...` | 1 | | 后端或配置类缺失 | `Cannot import the ...` | 4 | | 未知异常 | `Unexpected error (TypeName): ...` | 1 | diff --git a/docs/zh_CN/events/lifecycle.md b/docs/zh_CN/events/lifecycle.md index 81639c0f..4af238e3 100644 --- a/docs/zh_CN/events/lifecycle.md +++ b/docs/zh_CN/events/lifecycle.md @@ -313,7 +313,8 @@ class Product(OptimisticLockMixin, ActiveRecord): # OptimisticLockMixin 使用 AFTER_UPDATE 来验证更新: # - 检查 result.affected_rows > 0 -# - 如果记录被其他进程更新则抛出 StaleRecordError(DatabaseError 的子类) +# - 如果没有行匹配该版本则抛出 StaleRecordError(DatabaseError 的子类); +# 记录可能已被其他进程更新或删除 ``` ## 迁移指南 diff --git a/docs/zh_CN/performance/concurrency.md b/docs/zh_CN/performance/concurrency.md index a0a4233b..faf730d4 100644 --- a/docs/zh_CN/performance/concurrency.md +++ b/docs/zh_CN/performance/concurrency.md @@ -16,7 +16,7 @@ class Post(DefaultOptimisticLockMixin, ActiveRecord): **工作原理**: 1. 读取数据时,获取当前 `version` (如 1)。 2. 更新时,SQL 附加条件 `WHERE id = ... AND version = 1`。 -3. 如果更新行数为 0,说明期间 `version` 已变(被别人改了),抛出 `StaleRecordError`。 +3. 如果更新行数为 0,说明 `version` 已不匹配:记录可能已被别人更新或删除,抛出 `StaleRecordError`。 ```python from rhosocial.activerecord.backend.errors import StaleRecordError diff --git a/src/rhosocial/activerecord/cli/error_mapping.py b/src/rhosocial/activerecord/cli/error_mapping.py index c84affac..422c4430 100644 --- a/src/rhosocial/activerecord/cli/error_mapping.py +++ b/src/rhosocial/activerecord/cli/error_mapping.py @@ -33,6 +33,7 @@ RecordError, RecordNotFound, StaleRecordError, + TransactionError, TypeConversionError, UnaddressableRecordError, ValidationError as DBValidationError, @@ -120,6 +121,8 @@ def translate_error( return CLIError(f"Database operation failed{context}: {exc}") if isinstance(exc, QueryError): return CLIError(f"Query failed{context}: {exc}") + if isinstance(exc, TransactionError): + return CLIError(f"Database transaction failed{context}: {exc}") # Category fallbacks: backend-specific subclasses inherit these bases, so a # new driver-specific error still gets a sensible message and exit code. if isinstance(exc, PolicyError): diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py index 9dceb9c3..0b6d1127 100644 --- a/tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_error_mapping.py @@ -10,6 +10,7 @@ RecordNotFound, StaleRecordError, UnaddressableRecordError, + TransactionError, ) from rhosocial.activerecord.cli import build_parser from rhosocial.activerecord.cli.error_mapping import operation_label, translate_error @@ -106,6 +107,13 @@ def test_integrity_error_is_exit_one(self): assert translated.exit_code == EXIT_ERROR assert "Integrity constraint failed during dml create" in translated.message + def test_transaction_error_is_exit_one_with_context(self): + translated = translate_error( + TransactionError("Auto-commit failed"), operation="dml create" + ) + assert translated.exit_code == EXIT_ERROR + assert "Database transaction failed during dml create" in translated.message + def test_unknown_error_returns_none(self): assert translate_error(ValueError("unexpected")) is None From e5b1db466d9f3d956c0225cca41c6391636d1904 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 14:19:45 +0800 Subject: [PATCH 4/5] docs(examples): align examples with current model and event APIs --- .../chapter_03_modeling/basic_models.py | 6 +++--- .../pydantic_integration.py | 6 +++--- .../chapter_04_relationships/relationships.py | 14 ++++++------- docs/examples/chapter_05_querying/querying.py | 8 ++++---- .../connection_pool_async_usage.py | 20 +++++++++---------- .../chapter_08_performance/performance.py | 6 +++--- .../chapter_10_events/01_lifecycle_hooks.py | 5 +++-- .../chapter_10_events/02_event_listeners.py | 2 +- docs/examples/chapter_13_cli/cli_runner.py | 10 ++++++---- .../graphql_fastapi/models.py | 12 +++++------ .../graphql_fastapi/test_graphql.py | 12 +++++------ 11 files changed, 52 insertions(+), 49 deletions(-) diff --git a/docs/examples/chapter_03_modeling/basic_models.py b/docs/examples/chapter_03_modeling/basic_models.py index f8905f83..f215c49d 100644 --- a/docs/examples/chapter_03_modeling/basic_models.py +++ b/docs/examples/chapter_03_modeling/basic_models.py @@ -11,7 +11,7 @@ from pydantic import Field, field_validator from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base import FieldProxy, UseColumn -from rhosocial.activerecord.field import UUIDMixin, TimestampMixin +from rhosocial.activerecord.field import UUIDMixin, DefaultTimestampMixin from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions @@ -34,7 +34,7 @@ def summary(self) -> str: # --- Models --- -class User(UUIDMixin, TimestampMixin, ActiveRecord): +class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord): """User Model""" username: str = Field(..., max_length=50) @@ -59,7 +59,7 @@ def table_name(cls) -> str: return "users" -class Post(UUIDMixin, TimestampMixin, ContentMixin, ActiveRecord): +class Post(UUIDMixin, DefaultTimestampMixin, ContentMixin, ActiveRecord): """Post Model""" user_id: uuid.UUID # Foreign Key diff --git a/docs/examples/chapter_03_modeling/pydantic_integration.py b/docs/examples/chapter_03_modeling/pydantic_integration.py index 0f26d7f3..8567839a 100644 --- a/docs/examples/chapter_03_modeling/pydantic_integration.py +++ b/docs/examples/chapter_03_modeling/pydantic_integration.py @@ -27,7 +27,7 @@ ) from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base import UseAdapter -from rhosocial.activerecord.field import UUIDMixin, TimestampMixin +from rhosocial.activerecord.field import UUIDMixin, DefaultTimestampMixin from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions @@ -42,7 +42,7 @@ # Method 1: Define directly as ActiveRecord subclass # Use UUIDMixin for automatic ID generation -class User(UUIDMixin, TimestampMixin, ActiveRecord): +class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord): username: str email: str age: Optional[int] = None @@ -451,7 +451,7 @@ def main(): config = SQLiteConnectionConfig(database=":memory:") # Define a model that can store list as JSON - class DemoModel(UUIDMixin, TimestampMixin, ActiveRecord): + class DemoModel(UUIDMixin, DefaultTimestampMixin, ActiveRecord): title: str data_list: Annotated[List[str], UseAdapter(JsonListAdapter(), str)] = [] is_active: bool = True diff --git a/docs/examples/chapter_04_relationships/relationships.py b/docs/examples/chapter_04_relationships/relationships.py index 76a10a37..a58e3fc2 100644 --- a/docs/examples/chapter_04_relationships/relationships.py +++ b/docs/examples/chapter_04_relationships/relationships.py @@ -11,7 +11,7 @@ from typing import ClassVar from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasOne, BelongsTo, HasMany -from rhosocial.activerecord.field import UUIDMixin, TimestampMixin +from rhosocial.activerecord.field import UUIDMixin, DefaultTimestampMixin from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions @@ -20,7 +20,7 @@ # --- Models --- -class User(UUIDMixin, TimestampMixin, ActiveRecord): +class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord): username: str @classmethod @@ -33,7 +33,7 @@ def table_name(cls) -> str: posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="user_id", inverse_of="author") -class Profile(UUIDMixin, TimestampMixin, ActiveRecord): +class Profile(UUIDMixin, DefaultTimestampMixin, ActiveRecord): user_id: uuid.UUID bio: str @@ -45,7 +45,7 @@ def table_name(cls) -> str: user: ClassVar[BelongsTo["User"]] = BelongsTo(foreign_key="user_id", inverse_of="profile") -class Post(UUIDMixin, TimestampMixin, ActiveRecord): +class Post(UUIDMixin, DefaultTimestampMixin, ActiveRecord): user_id: uuid.UUID title: str content: str @@ -64,7 +64,7 @@ def table_name(cls) -> str: post_tags: ClassVar[HasMany["PostTag"]] = HasMany(foreign_key="post_id", inverse_of="post") -class Comment(UUIDMixin, TimestampMixin, ActiveRecord): +class Comment(UUIDMixin, DefaultTimestampMixin, ActiveRecord): post_id: uuid.UUID user_id: uuid.UUID body: str @@ -73,7 +73,7 @@ class Comment(UUIDMixin, TimestampMixin, ActiveRecord): author: ClassVar[BelongsTo["User"]] = BelongsTo(foreign_key="user_id") # No inverse defined on User for simplicity -class Tag(UUIDMixin, TimestampMixin, ActiveRecord): +class Tag(UUIDMixin, DefaultTimestampMixin, ActiveRecord): name: str @classmethod @@ -83,7 +83,7 @@ def table_name(cls) -> str: post_tags: ClassVar[HasMany["PostTag"]] = HasMany(foreign_key="tag_id", inverse_of="tag") -class PostTag(UUIDMixin, TimestampMixin, ActiveRecord): +class PostTag(UUIDMixin, DefaultTimestampMixin, ActiveRecord): post_id: uuid.UUID tag_id: uuid.UUID diff --git a/docs/examples/chapter_05_querying/querying.py b/docs/examples/chapter_05_querying/querying.py index 635f9246..a1e49c5e 100644 --- a/docs/examples/chapter_05_querying/querying.py +++ b/docs/examples/chapter_05_querying/querying.py @@ -11,7 +11,7 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.relation import BelongsTo, HasMany -from rhosocial.activerecord.field import UUIDMixin, TimestampMixin +from rhosocial.activerecord.field import UUIDMixin, DefaultTimestampMixin from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions @@ -20,7 +20,7 @@ # --- Models --- -class User(UUIDMixin, TimestampMixin, ActiveRecord): +class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord): username: str age: int is_active: bool = True @@ -35,7 +35,7 @@ def table_name(cls) -> str: return "users" -class Post(UUIDMixin, TimestampMixin, ActiveRecord): +class Post(UUIDMixin, DefaultTimestampMixin, ActiveRecord): user_id: uuid.UUID title: str views: int = 0 @@ -93,7 +93,7 @@ def main(): # 4. Basic Filtering print("\n--- Basic Filtering ---") # Active users older than 20 - users = User.query().where(User.c.is_active).where(User.c.age > 20).all() + users = User.query().where(User.c.is_active == True).where(User.c.age > 20).all() for u in users: print(f"Found User: {u.username}, Age: {u.age}") diff --git a/docs/examples/chapter_06_connection/connection_pool_async_usage.py b/docs/examples/chapter_06_connection/connection_pool_async_usage.py index 92c7b38d..aea832bf 100644 --- a/docs/examples/chapter_06_connection/connection_pool_async_usage.py +++ b/docs/examples/chapter_06_connection/connection_pool_async_usage.py @@ -16,9 +16,9 @@ PoolConfig, AsyncBackendPool, get_current_async_pool, - get_current_transaction_backend, - get_current_connection_backend, - get_current_backend, + get_current_async_transaction_backend, + get_current_async_connection_backend, + get_current_async_backend, ) from rhosocial.activerecord.backend.impl.sqlite.backend.async_backend import AsyncSQLiteBackend from rhosocial.activerecord.backend.options import ExecutionOptions @@ -28,9 +28,9 @@ async def print_context_state(label: str): """Helper to print current context state.""" pool = get_current_async_pool() - tx_backend = get_current_transaction_backend() - conn_backend = get_current_connection_backend() - current_backend = get_current_backend() + tx_backend = get_current_async_transaction_backend() + conn_backend = get_current_async_connection_backend() + current_backend = get_current_async_backend() print(f"\n{label}:") print(f" Async Pool: {pool is not None}") @@ -54,7 +54,7 @@ class AsyncItemRepository: async def create(self, name: str, worker_id: int) -> int: """Create a new item.""" - backend = get_current_backend() + backend = get_current_async_backend() if backend is None: raise RuntimeError("No connection in current context") @@ -67,7 +67,7 @@ async def create(self, name: str, worker_id: int) -> int: async def count(self) -> int: """Count all items.""" - backend = get_current_backend() + backend = get_current_async_backend() if backend is None: raise RuntimeError("No connection in current context") @@ -152,8 +152,8 @@ async def main(): await print_context_state("In async transaction context") # Both transaction and connection are set - tx = get_current_transaction_backend() - conn = get_current_connection_backend() + tx = get_current_async_transaction_backend() + conn = get_current_async_connection_backend() assert tx is backend assert conn is backend diff --git a/docs/examples/chapter_08_performance/performance.py b/docs/examples/chapter_08_performance/performance.py index a042832d..8f0baaff 100644 --- a/docs/examples/chapter_08_performance/performance.py +++ b/docs/examples/chapter_08_performance/performance.py @@ -11,7 +11,7 @@ from typing import ClassVar from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base import FieldProxy -from rhosocial.activerecord.field import OptimisticLockMixin, UUIDMixin, TimestampMixin +from rhosocial.activerecord.field import DefaultOptimisticLockMixin, UUIDMixin, DefaultTimestampMixin from rhosocial.activerecord.relation import HasMany, BelongsTo from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig @@ -22,7 +22,7 @@ # --- Models --- -class User(UUIDMixin, TimestampMixin, ActiveRecord): +class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord): username: str c: ClassVar[FieldProxy] = FieldProxy() @@ -34,7 +34,7 @@ def table_name(cls) -> str: posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="user_id") -class Post(OptimisticLockMixin, UUIDMixin, TimestampMixin, ActiveRecord): +class Post(DefaultOptimisticLockMixin, UUIDMixin, DefaultTimestampMixin, ActiveRecord): user_id: uuid.UUID title: str content: str diff --git a/docs/examples/chapter_10_events/01_lifecycle_hooks.py b/docs/examples/chapter_10_events/01_lifecycle_hooks.py index 25776e69..d77f7bae 100644 --- a/docs/examples/chapter_10_events/01_lifecycle_hooks.py +++ b/docs/examples/chapter_10_events/01_lifecycle_hooks.py @@ -13,6 +13,7 @@ from rhosocial.activerecord.interface.base import ModelEvent from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.errors import DatabaseError from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -70,7 +71,7 @@ def _before_delete_handler(self, instance, **kwargs): """Called before DELETE.""" print(f" [Hook] before_delete: About to delete user '{self.username}'") - def _after_delete_handler(self, instance, result, **kwargs): + def _after_delete_handler(self, instance, result=None, **kwargs): """Called after DELETE.""" print(f" [Hook] after_delete: User '{self.username}' deleted") @@ -166,7 +167,7 @@ def _validate_username(self, instance, data, **kwargs): try: admin = StrictUser(username="admin", email="admin@example.com") admin.save() - except ValueError as e: + except DatabaseError as e: print(f" [Error] {e}") print(" Save operation was aborted by before_insert hook") diff --git a/docs/examples/chapter_10_events/02_event_listeners.py b/docs/examples/chapter_10_events/02_event_listeners.py index 23dc9de3..4223ec53 100644 --- a/docs/examples/chapter_10_events/02_event_listeners.py +++ b/docs/examples/chapter_10_events/02_event_listeners.py @@ -94,7 +94,7 @@ def _audit_update(self, instance, data, dirty_fields, result, **kwargs): """Audit log for update operations.""" print(f" [Audit] UPDATE: {self.__class__.__name__} id={self.id}") - def _audit_delete(self, instance, result, **kwargs): + def _audit_delete(self, instance, result=None, **kwargs): """Audit log for delete operations.""" print(f" [Audit] DELETE: {self.__class__.__name__} id={self.id}") diff --git a/docs/examples/chapter_13_cli/cli_runner.py b/docs/examples/chapter_13_cli/cli_runner.py index 04a871dc..48faaa16 100644 --- a/docs/examples/chapter_13_cli/cli_runner.py +++ b/docs/examples/chapter_13_cli/cli_runner.py @@ -94,10 +94,12 @@ def run_cli(args, stdin=None, echo=True): shlex.quote(arg) for arg in _display_args(args) ) print(f"$ {command}") - if completed.stdout.strip(): - print(completed.stdout.rstrip()) - if completed.stderr.strip(): - print(f"[stderr] {completed.stderr.rstrip()}") + stdout_text = completed.stdout or "" + stderr_text = completed.stderr or "" + if stdout_text.strip(): + print(stdout_text.rstrip()) + if stderr_text.strip(): + print(f"[stderr] {stderr_text.rstrip()}") print(f"[exit {completed.returncode}]") print() return completed diff --git a/docs/examples/chapter_14_scenarios/graphql_fastapi/models.py b/docs/examples/chapter_14_scenarios/graphql_fastapi/models.py index ced35780..99018be4 100644 --- a/docs/examples/chapter_14_scenarios/graphql_fastapi/models.py +++ b/docs/examples/chapter_14_scenarios/graphql_fastapi/models.py @@ -5,11 +5,11 @@ from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.field import UUIDMixin, TimestampMixin +from rhosocial.activerecord.field import UUIDMixin, DefaultTimestampMixin from rhosocial.activerecord.relation import BelongsTo, HasMany -class User(UUIDMixin, TimestampMixin, ActiveRecord): +class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord): username: str email: str @@ -25,7 +25,7 @@ def table_name(cls) -> str: return "users" -class Post(UUIDMixin, TimestampMixin, ActiveRecord): +class Post(UUIDMixin, DefaultTimestampMixin, ActiveRecord): user_id: uuid.UUID title: str content: str @@ -42,7 +42,7 @@ def table_name(cls) -> str: return "posts" -class Comment(UUIDMixin, TimestampMixin, ActiveRecord): +class Comment(UUIDMixin, DefaultTimestampMixin, ActiveRecord): user_id: uuid.UUID post_id: uuid.UUID content: str @@ -62,7 +62,7 @@ def table_name(cls) -> str: def setup_database(): """Initialize the database and create tables.""" backend = SQLiteBackend( - connection_config=SQLiteConnectionConfig(database="graphql_example.db", options={"check_same_thread": False}) + connection_config=SQLiteConnectionConfig(database="graphql_example.db", check_same_thread=False) ) # Share the backend across models User.__backend__ = backend @@ -107,7 +107,7 @@ def setup_database(): def seed_data(): """Seed some initial data.""" - if User.count() > 0: + if User.query().count() > 0: return alice = User(username="alice", email="alice@example.com") diff --git a/docs/examples/chapter_14_scenarios/graphql_fastapi/test_graphql.py b/docs/examples/chapter_14_scenarios/graphql_fastapi/test_graphql.py index c771ad29..392a693c 100644 --- a/docs/examples/chapter_14_scenarios/graphql_fastapi/test_graphql.py +++ b/docs/examples/chapter_14_scenarios/graphql_fastapi/test_graphql.py @@ -24,7 +24,7 @@ def test_query(): # Setup DB # Let's manually setup backend to :memory: to be safe and clean backend = SQLiteBackend( - connection_config=SQLiteConnectionConfig(database=":memory:", options={"check_same_thread": False}) + connection_config=SQLiteConnectionConfig(database=":memory:", check_same_thread=False) ) User.__backend__ = backend Post.__backend__ = backend @@ -79,12 +79,12 @@ def test_query(): } """ - # Mock context with loaders - context = {"loaders": Loaders()} + # Mock context with loaders; aiodataloader needs a running loop. + async def run_query(): + context = {"loaders": Loaders()} + return await schema.execute_async(query, context_value=context) - # Run async query - loop = asyncio.get_event_loop() - result = loop.run_until_complete(schema.execute_async(query, context_value=context)) + result = asyncio.run(run_query()) if result.errors: print("Errors:", result.errors) From dee5aef30694f86fda8a35b9d97ddd2c8fb32e4d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 11 Nov 2026 15:27:09 +0800 Subject: [PATCH 5/5] docs(changelog): add fragment for the error taxonomy PR (#139) --- changelog.d/139.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/139.added.md diff --git a/changelog.d/139.added.md b/changelog.d/139.added.md new file mode 100644 index 00000000..ece55d36 --- /dev/null +++ b/changelog.d/139.added.md @@ -0,0 +1 @@ +Added semantic error categories (`DataError`, `RecordError`, `PolicyError`, `ConcurrencyError`, `InfrastructureError`) under `DatabaseError`, retry/transient hints, driver diagnostics, a phase-specific `execute` error contract that stops swallowing auto-commit failures, CLI mapping for `TransactionError`, and aligned documentation examples.