Soft Deletion Architecture
Overview
Soft-deleted rows stay in the database (deleted_at set) but are hidden from normal queries. That preserves history and FK integrity while still supporting restore and admin visibility.
Design goals
- Base model —
deleted_at,soft_delete()/restore()on sharedBaseso new entities get the same behavior without one-off flags. - Automatic filters — A compile-time listener adds
deleted_at IS NULLso callers do not manually append the predicate everywhere. - Correct pagination — Filters apply before
LIMIT/OFFSETwhere SQLAlchemy requires it (see event listener). - Escape hatches —
without_soft_delete_filter(), QueryBuilder helpers, and CRUD flags for recycle-bin and restore flows.
Architecture
The implementation uses a layered approach with four complementary mechanisms:
1. Base Model Enhancement
All models inherit from Base which now includes:
Location: apps/backend/src/rhesis/backend/app/models/base.py
2. SQLAlchemy Event Listener (Automatic Filtering)
A before_compile event listener automatically adds deleted_at IS NULL filter to ALL queries. This is the core mechanism that enables automatic filtering without code changes.
Key features:
- Intercepts queries at compilation time
- Catches
InvalidRequestErrorfor queries withLIMIT/OFFSET - Modifies
_where_criteriatuple directly when.filter()fails - Respects
_include_soft_deletedflag for explicit control
Location: apps/backend/src/rhesis/backend/app/models/soft_delete_events.py
3. QueryBuilder Enhancements
New methods for explicit control over soft delete behavior:
Location: apps/backend/src/rhesis/backend/app/utils/model_utils.py
4. Context Manager (Global Control)
Use the without_soft_delete_filter() context manager to temporarily disable filtering:
Location: apps/backend.src/rhesis/backend/app/database.py
CRUD Operations
The CRUD utilities have been enhanced to support soft deletion:
Location: apps/backend/src/rhesis/backend/app/utils/crud_utils.py
Recycle Bin API (Superuser Only)
A complete REST API for managing deleted records is available at /recycle:
List Available Models
Get Deleted Records
Restore a Record
Permanently Delete a Record
Get Recycle Bin Statistics
Bulk Restore
Empty Recycle Bin for a Model
Location: apps/backend/src/rhesis/backend/app/routers/recycle.py
How Pagination Works with Soft Deletion
This is a critical aspect of the implementation:
The event listener ensures soft delete filters are applied BEFORE LIMIT/OFFSET in the SQL query. Here’s how:
- When
.first()or.limit()are called, SQLAlchemy addsLIMITbefore query compilation - The event listener attempts to use
.filter()to add the soft delete condition - If
InvalidRequestErroris raised (becauseLIMIT/OFFSETalready applied), the listener catches it - It then modifies the
_where_criteriatuple directly, ensuring the filter becomes part of theWHEREclause
Result: Both count queries and paginated results correctly exclude deleted records, providing accurate pagination metadata.
Example Query Behavior
Database Migration
The migration adds deleted_at column and index to all tables:
Migration file: apps/backend/src/rhesis/backend/alembic/versions/e364aaec703f_add_soft_delete_support.py
Testing
Coverage includes CRUD, QueryBuilder, event listener, context manager, recycle routes, org scoping, and edge cases (e.g. .first()).
Test files:
tests/backend/utils/test_soft_delete_crud.pytests/backend/utils/test_soft_delete_querybuilder.pytests/backend/routes/test_recycle.py
Usage Examples
Basic Soft Delete and Restore
Using QueryBuilder
Using Context Manager
Key Implementation Files
| File | Purpose |
|---|---|
app/models/base.py | Base model with soft deletion columns and methods |
app/models/soft_delete_events.py | SQLAlchemy event listener for automatic filtering |
app/database.py | Context manager for global control |
app/utils/crud_utils.py | Enhanced CRUD operations |
app/utils/model_utils.py | QueryBuilder with soft delete methods |
app/routers/recycle.py | REST API for recycle bin management |
alembic/versions/e364aaec703f_add_soft_delete_support.py | Database migration |