Skip to content

Commit f0aaf05

Browse files
authored
Add deleted time column for logical deletion (#60)
* Add deleted time column for logical deletion * Update testcases
1 parent eca61cb commit f0aaf05

File tree

16 files changed

+468
-391
lines changed

16 files changed

+468
-391
lines changed

docs/getting-started/quick-start.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,17 @@ class Post(Base):
6464

6565
```python
6666
from pydantic import BaseModel
67-
from typing import Optional
67+
6868

6969
class UserCreate(BaseModel):
7070
name: str
7171
email: str
7272
is_active: bool = True
7373

7474
class UserUpdate(BaseModel):
75-
name: Optional[str] = None
76-
email: Optional[str] = None
77-
is_active: Optional[bool] = None
75+
name: str | None = None
76+
email: str | None = None
77+
is_active: str | None = None
7878

7979
class PostCreate(BaseModel):
8080
title: str

docs/usage/crud.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,14 @@ deleted_count = await user_crud.delete_model_by_column(
199199
allow_multiple=True,
200200
created_at__lt=datetime.now() - timedelta(days=30)
201201
)
202+
203+
# 逻辑删除(软删除)
204+
deleted_count = await user_crud.delete_model_by_column(
205+
session,
206+
logical_deletion=True, # 启用逻辑删除
207+
allow_multiple=False,
208+
id=1
209+
)
202210
```
203211

204212
## 事务控制

sqlalchemy_crud_plus/crud.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3-
from __future__ import annotations
4-
3+
from datetime import datetime, timezone
54
from typing import Any, Generic, Sequence
65

76
from sqlalchemy import (
@@ -36,6 +35,7 @@
3635
class CRUDPlus(Generic[Model]):
3736
def __init__(self, model: type[Model]):
3837
self.model = model
38+
self.model_column_names = [column.key for column in model.__table__.columns]
3939
self.primary_key = self._get_primary_key()
4040

4141
def _get_primary_key(self) -> Column | list[Column]:
@@ -602,7 +602,9 @@ async def delete_model_by_column(
602602
session: AsyncSession,
603603
allow_multiple: bool = False,
604604
logical_deletion: bool = False,
605-
deleted_flag_column: str = 'del_flag',
605+
deleted_flag_column: str = 'is_deleted',
606+
deleted_at_column: str = 'deleted_at',
607+
deleted_at_factory: datetime = datetime.now(timezone.utc),
606608
flush: bool = False,
607609
commit: bool = False,
608610
**kwargs,
@@ -614,13 +616,15 @@ async def delete_model_by_column(
614616
:param allow_multiple: If `True`, allows deleting multiple records that match the filters
615617
:param logical_deletion: If `True`, enable logical deletion instead of physical deletion
616618
:param deleted_flag_column: Column name for logical deletion flag
619+
:param deleted_at_column: Column name for delete time,automatic judgment
620+
:param deleted_at_factory: The delete time column datetime factory function
617621
:param flush: If `True`, flush all object changes to the database
618622
:param commit: If `True`, commits the transaction immediately
619623
:param kwargs: Filter expressions using field__operator=value syntax
620624
:return:
621625
"""
622626
if logical_deletion:
623-
if not hasattr(self.model, deleted_flag_column):
627+
if deleted_flag_column not in self.model_column_names:
624628
raise ModelColumnError(f'Column {deleted_flag_column} is not found in {self.model}')
625629

626630
filters = parse_filters(self.model, **kwargs)
@@ -633,8 +637,13 @@ async def delete_model_by_column(
633637
if total_count > 1:
634638
raise MultipleResultsError(f'Only one record is expected to be deleted, found {total_count} records.')
635639

640+
data = {deleted_flag_column: True}
641+
642+
if deleted_at_column in self.model_column_names:
643+
data[deleted_at_column] = deleted_at_factory
644+
636645
stmt = (
637-
update(self.model).where(*filters).values(**{deleted_flag_column: True})
646+
update(self.model).where(*filters).values(**data)
638647
if logical_deletion
639648
else delete(self.model).where(*filters)
640649
)

tests/conftest.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from sqlalchemy_crud_plus import CRUDPlus
1212
from tests.models.basic import Base, Ins, InsPks
1313
from tests.models.relations import RelationBase, RelCategory, RelPost, RelProfile, RelRole, RelUser, user_role
14-
from tests.schemas.relations import RelPostCreate, RelProfileCreate, RelRoleCreate, RelUserCreate
14+
from tests.schemas.relations import CreateRelPost, CreateRelProfile, CreateRelRole, CreateRelUser
1515

1616
_async_engine = create_async_engine('sqlite+aiosqlite:///:memory:', future=True, echo=False)
1717
_async_db_session = async_sessionmaker(_async_engine, autoflush=False, expire_on_commit=False)
@@ -45,16 +45,16 @@ async def async_db_session() -> AsyncGenerator[AsyncSession, None]:
4545

4646

4747
@pytest_asyncio.fixture
48-
async def populated_db(async_db_session: AsyncSession, crud_ins: CRUDPlus[Ins]) -> list[Ins]:
48+
async def sample_ins(async_db_session: AsyncSession, crud_ins: CRUDPlus[Ins]) -> list[Ins]:
4949
"""Provide a database populated with test data."""
5050
async with async_db_session.begin():
51-
test_data = [Ins(name=f'item_{i}', del_flag=(i % 2 == 0)) for i in range(1, 11)]
51+
test_data = [Ins(name=f'item_{i}', is_deleted=(i % 2 == 0)) for i in range(1, 11)]
5252
async_db_session.add_all(test_data)
5353
return test_data
5454

5555

5656
@pytest_asyncio.fixture
57-
async def populated_db_pks(async_db_session: AsyncSession) -> dict[str, list[InsPks]]:
57+
async def sample_ins_pks(async_db_session: AsyncSession) -> dict[str, list[InsPks]]:
5858
"""Provide a database populated with composite key test data."""
5959
async with async_db_session.begin():
6060
men_data = [InsPks(id=i, name=f'man_{i}', sex='men') for i in range(1, 4)]
@@ -103,7 +103,7 @@ async def rel_sample_users(async_db_session: AsyncSession) -> list[RelUser]:
103103
async with async_db_session.begin():
104104
users = []
105105
for i in range(1, 4):
106-
user_data = RelUserCreate(name=f'user_{i}')
106+
user_data = CreateRelUser(name=f'user_{i}')
107107
user = RelUser(**user_data.model_dump())
108108
async_db_session.add(user)
109109
users.append(user)
@@ -116,7 +116,7 @@ async def rel_sample_profiles(async_db_session: AsyncSession, rel_sample_users:
116116
async with async_db_session.begin():
117117
profiles = []
118118
for i, user in enumerate(rel_sample_users[:2]):
119-
profile_data = RelProfileCreate(bio=f'Bio for {user.name}')
119+
profile_data = CreateRelProfile(bio=f'Bio for {user.name}')
120120
profile = RelProfile(user_id=user.id, **profile_data.model_dump())
121121
async_db_session.add(profile)
122122
profiles.append(profile)
@@ -147,7 +147,7 @@ async def rel_sample_posts(
147147
async with async_db_session.begin():
148148
posts = []
149149
for i in range(6):
150-
post_data = RelPostCreate(
150+
post_data = CreateRelPost(
151151
title=f'Post {i + 1}',
152152
category_id=rel_sample_categories[i % len(rel_sample_categories)].id if i < 4 else None,
153153
)
@@ -163,7 +163,7 @@ async def rel_sample_roles(async_db_session: AsyncSession) -> list[RelRole]:
163163
async with async_db_session.begin():
164164
roles = []
165165
for role_name in ['admin', 'editor']:
166-
role_data = RelRoleCreate(name=role_name)
166+
role_data = CreateRelRole(name=role_name)
167167
role = RelRole(**role_data.model_dump())
168168
async_db_session.add(role)
169169
roles.append(role)

tests/models/basic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class Ins(Base):
1717

1818
id: Mapped[int] = mapped_column(init=False, primary_key=True, index=True, autoincrement=True)
1919
name: Mapped[str] = mapped_column(String(64))
20-
del_flag: Mapped[bool] = mapped_column(default=False)
20+
is_deleted: Mapped[bool] = mapped_column(default=False)
2121
created_time: Mapped[datetime] = mapped_column(init=False, default_factory=datetime.now)
2222
updated_time: Mapped[datetime | None] = mapped_column(init=False, onupdate=datetime.now)
2323

@@ -28,6 +28,6 @@ class InsPks(Base):
2828
id: Mapped[int] = mapped_column(primary_key=True, index=True)
2929
name: Mapped[str] = mapped_column(String(64))
3030
sex: Mapped[str] = mapped_column(String(16), primary_key=True, index=True)
31-
del_flag: Mapped[bool] = mapped_column(default=False)
31+
is_deleted: Mapped[bool] = mapped_column(default=False)
3232
created_time: Mapped[datetime] = mapped_column(init=False, default_factory=datetime.now)
3333
updated_time: Mapped[datetime | None] = mapped_column(init=False, onupdate=datetime.now)

tests/schemas/basic.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3-
from typing import Optional
4-
53
from pydantic import BaseModel
64

75

8-
class InsCreate(BaseModel):
6+
class CreateIns(BaseModel):
97
name: str
10-
del_flag: bool = False
8+
is_deleted: bool = False
119

1210

13-
class InsUpdate(BaseModel):
14-
name: Optional[str] = None
15-
del_flag: Optional[bool] = None
11+
class UpdateIns(BaseModel):
12+
name: str | None = None
13+
is_deleted: bool | None = None
1614

1715

18-
class InsPksCreate(BaseModel):
16+
class CreateInsPks(BaseModel):
1917
id: int
2018
name: str
2119
sex: str
2220

2321

24-
class InsPksUpdate(BaseModel):
25-
name: Optional[str] = None
22+
class UpdateInsPks(BaseModel):
23+
name: str | None = None

tests/schemas/relations.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,74 +3,74 @@
33
from pydantic import BaseModel, ConfigDict
44

55

6-
class RelUserCreate(BaseModel):
6+
class CreateRelUser(BaseModel):
77
name: str
88

99

10-
class RelProfileCreate(BaseModel):
10+
class CreateRelProfile(BaseModel):
1111
bio: str
1212

1313

14-
class RelCategoryCreate(BaseModel):
14+
class CreateRelCategory(BaseModel):
1515
name: str
1616
parent_id: int | None = None
1717

1818

19-
class RelPostCreate(BaseModel):
19+
class CreateRelPost(BaseModel):
2020
title: str
2121
category_id: int | None = None
2222

2323

24-
class RelRoleCreate(BaseModel):
24+
class CreateRelRole(BaseModel):
2525
name: str
2626

2727

28-
class RelUserUpdate(BaseModel):
28+
class UpdateRelUser(BaseModel):
2929
name: str | None = None
3030

3131

32-
class RelProfileUpdate(BaseModel):
32+
class UpdateRelProfile(BaseModel):
3333
bio: str | None = None
3434

3535

36-
class RelCategoryUpdate(BaseModel):
36+
class UpdateRelCategory(BaseModel):
3737
name: str | None = None
3838
parent_id: int | None = None
3939

4040

41-
class RelPostUpdate(BaseModel):
41+
class UpdateRelPost(BaseModel):
4242
title: str | None = None
4343
category_id: int | None = None
4444

4545

46-
class RelRoleUpdate(BaseModel):
46+
class UpdateRelRole(BaseModel):
4747
name: str | None = None
4848

4949

50-
class RelUserResponse(BaseModel):
50+
class RelUserDetail(BaseModel):
5151
model_config = ConfigDict(from_attributes=True)
5252

5353
id: int
5454
name: str
5555

5656

57-
class RelProfileResponse(BaseModel):
57+
class RelProfileDetail(BaseModel):
5858
model_config = ConfigDict(from_attributes=True)
5959

6060
id: int
6161
user_id: int
6262
bio: str
6363

6464

65-
class RelCategoryResponse(BaseModel):
65+
class RelCategoryDetail(BaseModel):
6666
model_config = ConfigDict(from_attributes=True)
6767

6868
id: int
6969
name: str
7070
parent_id: int | None
7171

7272

73-
class RelPostResponse(BaseModel):
73+
class RelPostDetail(BaseModel):
7474
model_config = ConfigDict(from_attributes=True)
7575

7676
id: int
@@ -79,7 +79,7 @@ class RelPostResponse(BaseModel):
7979
category_id: int | None
8080

8181

82-
class RelRoleResponse(BaseModel):
82+
class RelRoleDetail(BaseModel):
8383
model_config = ConfigDict(from_attributes=True)
8484

8585
id: int

tests/test_composite_keys.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from sqlalchemy_crud_plus import CRUDPlus
88
from sqlalchemy_crud_plus.errors import CompositePrimaryKeysError
99
from tests.models.basic import InsPks
10-
from tests.schemas.basic import InsPksCreate, InsPksUpdate
10+
from tests.schemas.basic import CreateInsPks, UpdateInsPks
1111

1212

1313
@pytest.mark.asyncio
1414
async def test_composite_key_create_model(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
15-
data = InsPksCreate(id=100, name='test_user', sex='test')
15+
data = CreateInsPks(id=100, name='test_user', sex='test')
1616
result = await crud_ins_pks.create_model(async_db_session, data, commit=True)
1717

1818
assert result.id == 100
@@ -22,7 +22,7 @@ async def test_composite_key_create_model(async_db_session: AsyncSession, crud_i
2222

2323
@pytest.mark.asyncio
2424
async def test_composite_key_select_model(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
25-
data = InsPksCreate(id=101, name='select_test', sex='test')
25+
data = CreateInsPks(id=101, name='select_test', sex='test')
2626
await crud_ins_pks.create_model(async_db_session, data, commit=True)
2727

2828
result = await crud_ins_pks.select_model(async_db_session, (101, 'test'))
@@ -35,19 +35,19 @@ async def test_composite_key_select_model(async_db_session: AsyncSession, crud_i
3535

3636
@pytest.mark.asyncio
3737
async def test_composite_key_update_model(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
38-
data = InsPksCreate(id=102, name='update_test', sex='test')
38+
data = CreateInsPks(id=102, name='update_test', sex='test')
3939
await crud_ins_pks.create_model(async_db_session, data, commit=True)
4040

4141
updated_count = await crud_ins_pks.update_model(
42-
async_db_session, (102, 'test'), InsPksUpdate(name='updated_name'), commit=True
42+
async_db_session, (102, 'test'), UpdateInsPks(name='updated_name'), commit=True
4343
)
4444

4545
assert updated_count == 1
4646

4747

4848
@pytest.mark.asyncio
4949
async def test_composite_key_delete_model(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
50-
data = InsPksCreate(id=103, name='delete_test', sex='test')
50+
data = CreateInsPks(id=103, name='delete_test', sex='test')
5151
await crud_ins_pks.create_model(async_db_session, data, commit=True)
5252

5353
deleted_count = await crud_ins_pks.delete_model(async_db_session, (103, 'test'), commit=True)
@@ -57,7 +57,7 @@ async def test_composite_key_delete_model(async_db_session: AsyncSession, crud_i
5757

5858
@pytest.mark.asyncio
5959
async def test_composite_key_create_models(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
60-
data = [InsPksCreate(id=200, name='batch_1', sex='test1'), InsPksCreate(id=201, name='batch_2', sex='test2')]
60+
data = [CreateInsPks(id=200, name='batch_1', sex='test1'), CreateInsPks(id=201, name='batch_2', sex='test2')]
6161

6262
results = await crud_ins_pks.create_models(async_db_session, data, commit=True)
6363

@@ -69,9 +69,9 @@ async def test_composite_key_create_models(async_db_session: AsyncSession, crud_
6969
@pytest.mark.asyncio
7070
async def test_composite_key_count(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
7171
data = [
72-
InsPksCreate(id=300, name='count_1', sex='count_test'),
73-
InsPksCreate(id=301, name='count_2', sex='count_test'),
74-
InsPksCreate(id=302, name='count_3', sex='count_test'),
72+
CreateInsPks(id=300, name='count_1', sex='count_test'),
73+
CreateInsPks(id=301, name='count_2', sex='count_test'),
74+
CreateInsPks(id=302, name='count_3', sex='count_test'),
7575
]
7676
await crud_ins_pks.create_models(async_db_session, data, commit=True)
7777

@@ -82,7 +82,7 @@ async def test_composite_key_count(async_db_session: AsyncSession, crud_ins_pks:
8282

8383
@pytest.mark.asyncio
8484
async def test_composite_key_exists(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
85-
data = InsPksCreate(id=400, name='exists_test', sex='exists')
85+
data = CreateInsPks(id=400, name='exists_test', sex='exists')
8686
await crud_ins_pks.create_model(async_db_session, data, commit=True)
8787

8888
exists = await crud_ins_pks.exists(async_db_session, id=400, sex='exists')
@@ -93,8 +93,8 @@ async def test_composite_key_exists(async_db_session: AsyncSession, crud_ins_pks
9393
@pytest.mark.asyncio
9494
async def test_composite_key_select_models(async_db_session: AsyncSession, crud_ins_pks: CRUDPlus[InsPks]):
9595
data = [
96-
InsPksCreate(id=500, name='select_1', sex='select_test'),
97-
InsPksCreate(id=501, name='select_2', sex='select_test'),
96+
CreateInsPks(id=500, name='select_1', sex='select_test'),
97+
CreateInsPks(id=501, name='select_2', sex='select_test'),
9898
]
9999
await crud_ins_pks.create_models(async_db_session, data, commit=True)
100100

0 commit comments

Comments
 (0)