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
1 change: 1 addition & 0 deletions changelog.d/139.added.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions docs/en_US/cli/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,42 @@ 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 |

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
Expand Down
4 changes: 2 additions & 2 deletions docs/en_US/events/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/en_US/performance/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/examples/chapter_03_modeling/basic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/examples/chapter_03_modeling/pydantic_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/examples/chapter_04_relationships/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,7 +20,7 @@
# --- Models ---


class User(UUIDMixin, TimestampMixin, ActiveRecord):
class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord):
username: str

@classmethod
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
8 changes: 4 additions & 4 deletions docs/examples/chapter_05_querying/querying.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,7 +20,7 @@
# --- Models ---


class User(UUIDMixin, TimestampMixin, ActiveRecord):
class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord):
username: str
age: int
is_active: bool = True
Expand All @@ -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
Expand Down Expand Up @@ -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}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")
Expand All @@ -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")

Expand All @@ -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")

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

Expand Down
6 changes: 3 additions & 3 deletions docs/examples/chapter_08_performance/performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +22,7 @@
# --- Models ---


class User(UUIDMixin, TimestampMixin, ActiveRecord):
class User(UUIDMixin, DefaultTimestampMixin, ActiveRecord):
username: str

c: ClassVar[FieldProxy] = FieldProxy()
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/examples/chapter_10_events/01_lifecycle_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/chapter_10_events/02_event_listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down
10 changes: 6 additions & 4 deletions docs/examples/chapter_13_cli/cli_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading