Skip to content

Commit e6dbdaa

Browse files
committed
Add soft delete session support
1 parent 07b69be commit e6dbdaa

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

sqlmodel/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@
118118
from .main import Relationship as Relationship
119119
from .main import SQLModel as SQLModel
120120
from .orm.session import Session as Session
121+
from .soft_delete import SoftDeleteMixin as SoftDeleteMixin
122+
from .soft_delete import SoftDeleteSession as SoftDeleteSession
121123
from .sql.expression import all_ as all_
122124
from .sql.expression import and_ as and_
123125
from .sql.expression import any_ as any_

sqlmodel/soft_delete.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from collections.abc import Mapping, Sequence
2+
from datetime import datetime, timezone
3+
from typing import Any, ClassVar, TypeVar, overload
4+
5+
from sqlalchemy import util
6+
from sqlalchemy.engine.cursor import CursorResult
7+
from sqlalchemy.engine.result import ScalarResult, TupleResult
8+
from sqlalchemy.sql.dml import UpdateBase
9+
10+
from .main import Field
11+
from .orm.session import Session
12+
from .sql.base import Executable
13+
from .sql.expression import Select, SelectOfScalar
14+
15+
_TSelectParam = TypeVar("_TSelectParam", bound=Any)
16+
17+
18+
class SoftDeleteMixin:
19+
__soft_delete_field__: ClassVar[str] = "deleted_at"
20+
21+
deleted_at: datetime | None = Field(default=None)
22+
23+
24+
def get_soft_delete_field(model: type[Any]) -> str | None:
25+
field_name = getattr(model, "__soft_delete_field__", None)
26+
if not isinstance(field_name, str):
27+
return None
28+
if not hasattr(model, field_name):
29+
return None
30+
return field_name
31+
32+
33+
class SoftDeleteSession(Session):
34+
def delete(self, instance: Any, hard_delete: bool = False) -> None:
35+
field_name = get_soft_delete_field(type(instance))
36+
if not hard_delete and field_name is not None:
37+
setattr(instance, field_name, datetime.now(timezone.utc))
38+
self.add(instance)
39+
return None
40+
return super().delete(instance)
41+
42+
@overload
43+
def exec(
44+
self,
45+
statement: Select[_TSelectParam],
46+
*,
47+
params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
48+
execution_options: Mapping[str, Any] = util.EMPTY_DICT,
49+
bind_arguments: dict[str, Any] | None = None,
50+
_parent_execute_state: Any | None = None,
51+
_add_event: Any | None = None,
52+
) -> TupleResult[_TSelectParam]: ...
53+
54+
@overload
55+
def exec(
56+
self,
57+
statement: SelectOfScalar[_TSelectParam],
58+
*,
59+
params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
60+
execution_options: Mapping[str, Any] = util.EMPTY_DICT,
61+
bind_arguments: dict[str, Any] | None = None,
62+
_parent_execute_state: Any | None = None,
63+
_add_event: Any | None = None,
64+
) -> ScalarResult[_TSelectParam]: ...
65+
66+
@overload
67+
def exec(
68+
self,
69+
statement: UpdateBase,
70+
*,
71+
params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
72+
execution_options: Mapping[str, Any] = util.EMPTY_DICT,
73+
bind_arguments: dict[str, Any] | None = None,
74+
_parent_execute_state: Any | None = None,
75+
_add_event: Any | None = None,
76+
) -> CursorResult[Any]: ...
77+
78+
def exec(
79+
self,
80+
statement: Select[_TSelectParam]
81+
| SelectOfScalar[_TSelectParam]
82+
| Executable[_TSelectParam]
83+
| UpdateBase,
84+
*,
85+
params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
86+
execution_options: Mapping[str, Any] = util.EMPTY_DICT,
87+
bind_arguments: dict[str, Any] | None = None,
88+
_parent_execute_state: Any | None = None,
89+
_add_event: Any | None = None,
90+
) -> TupleResult[_TSelectParam] | ScalarResult[_TSelectParam] | CursorResult[Any]:
91+
statement = self._filter_soft_deleted(statement)
92+
return super().exec(
93+
statement,
94+
params=params,
95+
execution_options=execution_options,
96+
bind_arguments=bind_arguments,
97+
_parent_execute_state=_parent_execute_state,
98+
_add_event=_add_event,
99+
)
100+
101+
def _filter_soft_deleted(self, statement: Any) -> Any:
102+
if statement.get_execution_options().get("include_deleted"):
103+
return statement
104+
105+
models: set[type[Any]] = set()
106+
for description in getattr(statement, "column_descriptions", ()):
107+
model = description.get("entity")
108+
if isinstance(model, type):
109+
models.add(model)
110+
111+
for model in models:
112+
field_name = get_soft_delete_field(model)
113+
if field_name is not None:
114+
statement = statement.where(getattr(model, field_name).is_(None))
115+
return statement

tests/test_soft_delete.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from datetime import datetime
2+
3+
from sqlmodel import (
4+
Field,
5+
SoftDeleteMixin,
6+
SoftDeleteSession,
7+
SQLModel,
8+
create_engine,
9+
select,
10+
)
11+
12+
13+
def test_soft_delete_filters_select_results() -> None:
14+
class Hero(SQLModel, SoftDeleteMixin, table=True):
15+
id: int | None = Field(default=None, primary_key=True)
16+
name: str
17+
18+
engine = create_engine("sqlite://")
19+
SQLModel.metadata.create_all(engine)
20+
21+
with SoftDeleteSession(engine) as session:
22+
active_hero = Hero(name="Deadpond")
23+
deleted_hero = Hero(name="Spider-Boy")
24+
session.add(active_hero)
25+
session.add(deleted_hero)
26+
session.commit()
27+
28+
session.delete(deleted_hero)
29+
session.commit()
30+
31+
heroes = session.exec(select(Hero)).all()
32+
assert heroes == [active_hero]
33+
assert isinstance(deleted_hero.deleted_at, datetime)
34+
35+
36+
def test_soft_delete_can_include_deleted_results() -> None:
37+
class Hero(SQLModel, SoftDeleteMixin, table=True):
38+
id: int | None = Field(default=None, primary_key=True)
39+
name: str
40+
41+
engine = create_engine("sqlite://")
42+
SQLModel.metadata.create_all(engine)
43+
44+
with SoftDeleteSession(engine) as session:
45+
active_hero = Hero(name="Deadpond")
46+
deleted_hero = Hero(name="Spider-Boy")
47+
session.add(active_hero)
48+
session.add(deleted_hero)
49+
session.commit()
50+
51+
session.delete(deleted_hero)
52+
session.commit()
53+
54+
statement = select(Hero).execution_options(include_deleted=True)
55+
heroes = session.exec(statement).all()
56+
assert heroes == [active_hero, deleted_hero]
57+
58+
59+
def test_soft_delete_supports_custom_field_name() -> None:
60+
class Hero(SQLModel, table=True):
61+
__soft_delete_field__ = "removed_at"
62+
63+
id: int | None = Field(default=None, primary_key=True)
64+
name: str
65+
removed_at: datetime | None = Field(default=None)
66+
67+
engine = create_engine("sqlite://")
68+
SQLModel.metadata.create_all(engine)
69+
70+
with SoftDeleteSession(engine) as session:
71+
active_hero = Hero(name="Deadpond")
72+
deleted_hero = Hero(name="Spider-Boy")
73+
session.add(active_hero)
74+
session.add(deleted_hero)
75+
session.commit()
76+
77+
session.delete(deleted_hero)
78+
session.commit()
79+
80+
heroes = session.exec(select(Hero)).all()
81+
assert heroes == [active_hero]
82+
assert isinstance(deleted_hero.removed_at, datetime)

0 commit comments

Comments
 (0)