lush-dal-protocol 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lush_dal_protocol/__init__.py +40 -0
- lush_dal_protocol/dto.py +75 -0
- lush_dal_protocol/errors.py +30 -0
- lush_dal_protocol/protocols/__init__.py +22 -0
- lush_dal_protocol/protocols/api_contracts.py +314 -0
- lush_dal_protocol/protocols/dal.py +357 -0
- lush_dal_protocol/utils/__init__.py +11 -0
- lush_dal_protocol/utils/retry.py +48 -0
- lush_dal_protocol/utils/sql.py +44 -0
- lush_dal_protocol-0.1.0.dist-info/METADATA +20 -0
- lush_dal_protocol-0.1.0.dist-info/RECORD +12 -0
- lush_dal_protocol-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""lush-dal-protocol — ORM 无关的数据访问层协议抽象.
|
|
2
|
+
|
|
3
|
+
本包仅包含纯 Protocol / 接口声明, 不依赖任何具体 ORM.
|
|
4
|
+
下游适配包 (如 lush-sqlalchemyx) 负责实现这些协议.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .dto import BaseCU, BaseDTO, CUModelT, DTOModelT, StdBaseCU, StdBaseDTO
|
|
8
|
+
from .errors import DBRetryableError
|
|
9
|
+
from .protocols import (
|
|
10
|
+
AsyncBaseDALProtocol,
|
|
11
|
+
AsyncReadDALProtocol,
|
|
12
|
+
AsyncWriteDALProtocol,
|
|
13
|
+
SyncBaseDALProtocol,
|
|
14
|
+
SyncReadDALProtocol,
|
|
15
|
+
SyncWriteDALProtocol,
|
|
16
|
+
)
|
|
17
|
+
from .protocols.api_contracts import AsyncDALConformanceTests, SyncDALConformanceTests
|
|
18
|
+
from .utils import DEFAULT_RETRY_CONFIG, RetryConfig, escape_like, filtered_in_sql_values
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncDALConformanceTests",
|
|
22
|
+
"AsyncBaseDALProtocol",
|
|
23
|
+
"AsyncReadDALProtocol",
|
|
24
|
+
"AsyncWriteDALProtocol",
|
|
25
|
+
"BaseCU",
|
|
26
|
+
"BaseDTO",
|
|
27
|
+
"CUModelT",
|
|
28
|
+
"DBRetryableError",
|
|
29
|
+
"DEFAULT_RETRY_CONFIG",
|
|
30
|
+
"DTOModelT",
|
|
31
|
+
"RetryConfig",
|
|
32
|
+
"StdBaseCU",
|
|
33
|
+
"StdBaseDTO",
|
|
34
|
+
"SyncBaseDALProtocol",
|
|
35
|
+
"SyncDALConformanceTests",
|
|
36
|
+
"SyncReadDALProtocol",
|
|
37
|
+
"SyncWriteDALProtocol",
|
|
38
|
+
"escape_like",
|
|
39
|
+
"filtered_in_sql_values",
|
|
40
|
+
]
|
lush_dal_protocol/dto.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""数据传输对象 (DTO) 与创建/更新 (CU) 模型的 ORM 无关基类.
|
|
2
|
+
|
|
3
|
+
这些基类只依赖 Pydantic, 不绑定任何具体 ORM.
|
|
4
|
+
下游适配包可以通过子类化并绑定 ``_Table`` 来关联具体 ORM 模型.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
from typing import Any, ClassVar, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
OrmModelT = TypeVar("OrmModelT")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseCU(BaseModel, Generic[OrmModelT]):
|
|
18
|
+
"""创建/更新模型基类.
|
|
19
|
+
|
|
20
|
+
子类需设置 ``_Table`` 类变量指向具体 ORM 模型类,
|
|
21
|
+
并实现 ``to_orm_model()`` 返回对应的 ORM 实例.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(str_strip_whitespace=True)
|
|
25
|
+
|
|
26
|
+
_Table: ClassVar[type] # pyright: ignore[reportGeneralTypeIssues]
|
|
27
|
+
|
|
28
|
+
def to_orm_model(self) -> OrmModelT:
|
|
29
|
+
"""将 CU 模型转换为 ORM 模型实例.
|
|
30
|
+
|
|
31
|
+
默认实现使用 ``model_dump`` 生成字典后传入 ``_Table`` 构造函数.
|
|
32
|
+
子类可覆盖此方法以适配不同 ORM 的构造方式.
|
|
33
|
+
"""
|
|
34
|
+
model_data = self.model_dump(exclude_unset=True, exclude={"id"})
|
|
35
|
+
return self._Table(**model_data)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
CUModelT = TypeVar("CUModelT", bound=BaseCU[Any])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BaseDTO(BaseModel, Generic[CUModelT]):
|
|
42
|
+
"""数据传输对象基类.
|
|
43
|
+
|
|
44
|
+
子类需设置 ``_CU`` 类变量指向对应的 CU 类.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(from_attributes=True)
|
|
48
|
+
|
|
49
|
+
_CU: ClassVar[type[CUModelT]] # pyright: ignore[reportGeneralTypeIssues]
|
|
50
|
+
|
|
51
|
+
def to_cu(self) -> CUModelT:
|
|
52
|
+
"""将 DTO 转换为对应的 CU 模型."""
|
|
53
|
+
return self._CU.model_validate(self)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
DTOModelT = TypeVar("DTOModelT", bound=BaseDTO[Any] | BaseModel)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class StdBaseCU(BaseCU[OrmModelT]):
|
|
60
|
+
"""标准 CU 基类: 包含创建人/修改人等标准字段."""
|
|
61
|
+
|
|
62
|
+
create_operator_id: int = 0
|
|
63
|
+
update_operator_id: int | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StdBaseDTO(BaseDTO[CUModelT]):
|
|
67
|
+
"""标准 DTO 基类: 包含 id、时间戳、操作人等标准字段."""
|
|
68
|
+
|
|
69
|
+
id: int = Field(..., description="ID")
|
|
70
|
+
create_datetime: datetime.datetime = Field(..., description="创建时间")
|
|
71
|
+
create_operator_id: int = Field(..., description="创建人")
|
|
72
|
+
update_datetime: datetime.datetime | None = Field(None, description="修改时间")
|
|
73
|
+
update_operator_id: int | None = Field(None, description="修改人")
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(from_attributes=True)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""DAL 层通用异常定义."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
OPTIMISTIC_LOCK_ERROR_MSG_TRAIT: Final[str] = "乐观锁更新失败"
|
|
8
|
+
PESSIMISTIC_LOCK_ERROR_MSG_TRAIT: Final[str] = "悲观锁获取失败"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DBRetryableError(Exception):
|
|
12
|
+
"""数据库可重试异常.
|
|
13
|
+
|
|
14
|
+
表示一个由于并发冲突导致的、可以通过重试解决的数据库操作异常.
|
|
15
|
+
这类异常不是错误, 而是正常的并发控制机制, 应该被捕获并重试.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str = "数据库操作冲突,需要重试") -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.message = message
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_pessimistic_lock_retry_error(self) -> bool:
|
|
24
|
+
"""是否为悲观锁重试异常."""
|
|
25
|
+
return PESSIMISTIC_LOCK_ERROR_MSG_TRAIT in self.message
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_optimistic_lock_retry_error(self) -> bool:
|
|
29
|
+
"""是否为乐观锁重试异常."""
|
|
30
|
+
return OPTIMISTIC_LOCK_ERROR_MSG_TRAIT in self.message
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""协议定义与一致性验证子包."""
|
|
2
|
+
|
|
3
|
+
from .api_contracts import AsyncDALConformanceTests, SyncDALConformanceTests
|
|
4
|
+
from .dal import (
|
|
5
|
+
AsyncBaseDALProtocol,
|
|
6
|
+
AsyncReadDALProtocol,
|
|
7
|
+
AsyncWriteDALProtocol,
|
|
8
|
+
SyncBaseDALProtocol,
|
|
9
|
+
SyncReadDALProtocol,
|
|
10
|
+
SyncWriteDALProtocol,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AsyncBaseDALProtocol",
|
|
15
|
+
"AsyncDALConformanceTests",
|
|
16
|
+
"AsyncReadDALProtocol",
|
|
17
|
+
"AsyncWriteDALProtocol",
|
|
18
|
+
"SyncBaseDALProtocol",
|
|
19
|
+
"SyncDALConformanceTests",
|
|
20
|
+
"SyncReadDALProtocol",
|
|
21
|
+
"SyncWriteDALProtocol",
|
|
22
|
+
]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""DAL 一致性验证测试套件.
|
|
2
|
+
|
|
3
|
+
提供可复用的测试 mixin 类, 下游 ORM 适配包可以继承这些测试类
|
|
4
|
+
并注入具体的 session、DAL、CU、DTO 来验证实现是否符合协议约定.
|
|
5
|
+
|
|
6
|
+
使用方式::
|
|
7
|
+
|
|
8
|
+
from lush_dal_protocol.protocols.api_contracts import SyncDALConformanceTests
|
|
9
|
+
|
|
10
|
+
class TestMySQLAlchemyDAL(SyncDALConformanceTests):
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def dal_class(self):
|
|
13
|
+
return MyConcreteDAL
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def session(self):
|
|
17
|
+
# 返回你的 ORM session
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def sample_cu(self):
|
|
22
|
+
# 返回一个有效的 CU 实例
|
|
23
|
+
return MyCU(name="test")
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def entity_id_field(self):
|
|
27
|
+
return "id"
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SyncDALConformanceTests:
|
|
36
|
+
"""同步 DAL 一致性验证测试套件.
|
|
37
|
+
|
|
38
|
+
下游实现继承此类并通过 pytest fixture 注入:
|
|
39
|
+
- ``dal_class``: DAL 类 (应实现 SyncBaseDALProtocol)
|
|
40
|
+
- ``session``: 数据库会话
|
|
41
|
+
- ``sample_cu``: 有效的 CU 实例
|
|
42
|
+
- ``entity_id_field``: 实体主键字段名, 默认 "id"
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def _get_entity_id(self, entity: Any, field: str = "id") -> int:
|
|
46
|
+
return getattr(entity, field)
|
|
47
|
+
|
|
48
|
+
def test_create_returns_entity(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
49
|
+
"""create() 应返回具有有效 ID 的 ORM 实体."""
|
|
50
|
+
entity = dal_class.create(session, sample_cu)
|
|
51
|
+
assert entity is not None
|
|
52
|
+
assert hasattr(entity, "id")
|
|
53
|
+
|
|
54
|
+
def test_create_no_refresh(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
55
|
+
"""create(need_refresh=False) 应返回实体但不执行 refresh."""
|
|
56
|
+
entity = dal_class.create(session, sample_cu, need_refresh=False)
|
|
57
|
+
assert entity is not None
|
|
58
|
+
|
|
59
|
+
def test_get_by_id_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
60
|
+
"""get_by_id() 对已存在的实体应返回非 None."""
|
|
61
|
+
entity = dal_class.create(session, sample_cu)
|
|
62
|
+
eid = self._get_entity_id(entity)
|
|
63
|
+
found = dal_class.get_by_id(session, eid)
|
|
64
|
+
assert found is not None
|
|
65
|
+
|
|
66
|
+
def test_get_by_id_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
67
|
+
"""get_by_id() 对不存在的 ID 应返回 None."""
|
|
68
|
+
result = dal_class.get_by_id(session, 999999)
|
|
69
|
+
assert result is None
|
|
70
|
+
|
|
71
|
+
def test_ret_dto_after_get_by_id(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
72
|
+
"""ret_dto_after_get_by_id() 应返回 DTO 对象."""
|
|
73
|
+
entity = dal_class.create(session, sample_cu)
|
|
74
|
+
eid = self._get_entity_id(entity)
|
|
75
|
+
dto = dal_class.ret_dto_after_get_by_id(session, eid)
|
|
76
|
+
assert dto is not None
|
|
77
|
+
|
|
78
|
+
def test_ret_dto_after_get_by_id_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
79
|
+
"""ret_dto_after_get_by_id() 对不存在的 ID 应返回 None."""
|
|
80
|
+
result = dal_class.ret_dto_after_get_by_id(session, 999999)
|
|
81
|
+
assert result is None
|
|
82
|
+
|
|
83
|
+
def test_get_all_default_pagination(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
84
|
+
"""get_all() 默认参数应返回列表."""
|
|
85
|
+
dal_class.create(session, sample_cu)
|
|
86
|
+
result = dal_class.get_all(session)
|
|
87
|
+
assert isinstance(result, list)
|
|
88
|
+
assert len(result) >= 1
|
|
89
|
+
|
|
90
|
+
def test_get_all_with_pagination(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
91
|
+
"""get_all(skip, limit) 应正确分页."""
|
|
92
|
+
dal_class.create(session, sample_cu)
|
|
93
|
+
dal_class.create(session, sample_cu)
|
|
94
|
+
page1 = dal_class.get_all(session, skip=0, limit=1)
|
|
95
|
+
assert len(page1) == 1
|
|
96
|
+
|
|
97
|
+
def test_count_returns_int(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
98
|
+
"""count() 应返回非负整数."""
|
|
99
|
+
initial = dal_class.count(session)
|
|
100
|
+
assert isinstance(initial, int)
|
|
101
|
+
assert initial >= 0
|
|
102
|
+
|
|
103
|
+
dal_class.create(session, sample_cu)
|
|
104
|
+
after = dal_class.count(session)
|
|
105
|
+
assert after == initial + 1
|
|
106
|
+
|
|
107
|
+
def test_exists_true_for_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
108
|
+
"""exists() 对已存在的 ID 应返回 True."""
|
|
109
|
+
entity = dal_class.create(session, sample_cu)
|
|
110
|
+
eid = self._get_entity_id(entity)
|
|
111
|
+
assert dal_class.exists(session, eid) is True
|
|
112
|
+
|
|
113
|
+
def test_exists_false_for_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
114
|
+
"""exists() 对不存在的 ID 应返回 False."""
|
|
115
|
+
assert dal_class.exists(session, 999999) is False
|
|
116
|
+
|
|
117
|
+
def test_batch_get_id__entity(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
118
|
+
"""batch_get_id__entity() 应返回 {id: entity} 字典."""
|
|
119
|
+
e1 = dal_class.create(session, sample_cu)
|
|
120
|
+
e2 = dal_class.create(session, sample_cu)
|
|
121
|
+
eid1, eid2 = self._get_entity_id(e1), self._get_entity_id(e2)
|
|
122
|
+
|
|
123
|
+
result = dal_class.batch_get_id__entity(session, [eid1, eid2, 999999])
|
|
124
|
+
assert eid1 in result
|
|
125
|
+
assert eid2 in result
|
|
126
|
+
assert 999999 not in result
|
|
127
|
+
|
|
128
|
+
def test_batch_get_id__dto(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
129
|
+
"""batch_get_id__dto() 应返回 {id: DTO} 字典."""
|
|
130
|
+
e1 = dal_class.create(session, sample_cu)
|
|
131
|
+
eid1 = self._get_entity_id(e1)
|
|
132
|
+
|
|
133
|
+
result = dal_class.batch_get_id__dto(session, [eid1])
|
|
134
|
+
assert eid1 in result
|
|
135
|
+
|
|
136
|
+
def test_ret_dto_after_create(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
137
|
+
"""ret_dto_after_create() 应返回 DTO 对象."""
|
|
138
|
+
dto = dal_class.ret_dto_after_create(session, sample_cu)
|
|
139
|
+
assert dto is not None
|
|
140
|
+
|
|
141
|
+
def test_update_only_set_by_id_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
142
|
+
"""update_only_set_by_id() 对已存在的实体应返回更新后的实体."""
|
|
143
|
+
entity = dal_class.create(session, sample_cu)
|
|
144
|
+
eid = self._get_entity_id(entity)
|
|
145
|
+
updated = dal_class.update_only_set_by_id(session, eid, sample_cu)
|
|
146
|
+
assert updated is not None
|
|
147
|
+
|
|
148
|
+
def test_update_only_set_by_id_nonexistent(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
149
|
+
"""update_only_set_by_id() 对不存在的 ID 应返回 None."""
|
|
150
|
+
result = dal_class.update_only_set_by_id(session, 999999, sample_cu)
|
|
151
|
+
assert result is None
|
|
152
|
+
|
|
153
|
+
def test_delete_by_id_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
154
|
+
"""delete_by_id() 对已存在的实体应返回 True."""
|
|
155
|
+
entity = dal_class.create(session, sample_cu)
|
|
156
|
+
eid = self._get_entity_id(entity)
|
|
157
|
+
assert dal_class.delete_by_id(session, eid) is True
|
|
158
|
+
|
|
159
|
+
def test_delete_by_id_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
160
|
+
"""delete_by_id() 对不存在的 ID 应返回 False."""
|
|
161
|
+
assert dal_class.delete_by_id(session, 999999) is False
|
|
162
|
+
|
|
163
|
+
def test_delete_then_get_returns_none(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
164
|
+
"""删除后再 get_by_id 应返回 None (验证软删除/物理删除生效)."""
|
|
165
|
+
entity = dal_class.create(session, sample_cu)
|
|
166
|
+
eid = self._get_entity_id(entity)
|
|
167
|
+
dal_class.delete_by_id(session, eid)
|
|
168
|
+
session.expire_all()
|
|
169
|
+
result = dal_class.get_by_id(session, eid)
|
|
170
|
+
assert result is None
|
|
171
|
+
|
|
172
|
+
def test_iter_record_dtos_yields(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
173
|
+
"""iter_record_dtos() 应以迭代器方式返回 DTO."""
|
|
174
|
+
dal_class.create(session, sample_cu)
|
|
175
|
+
records = list(dal_class.iter_record_dtos(session, batch_size=10))
|
|
176
|
+
assert len(records) >= 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class AsyncDALConformanceTests:
|
|
180
|
+
"""异步 DAL 一致性验证测试套件.
|
|
181
|
+
|
|
182
|
+
下游实现继承此类并通过 pytest fixture 注入:
|
|
183
|
+
- ``dal_class``: DAL 类 (应实现 AsyncBaseDALProtocol)
|
|
184
|
+
- ``session``: 异步数据库会话
|
|
185
|
+
- ``sample_cu``: 有效的 CU 实例
|
|
186
|
+
- ``entity_id_field``: 实体主键字段名, 默认 "id"
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def _get_entity_id(self, entity: Any, field: str = "id") -> int:
|
|
190
|
+
return getattr(entity, field)
|
|
191
|
+
|
|
192
|
+
async def test_create_returns_entity(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
193
|
+
"""create() 应返回具有有效 ID 的 ORM 实体."""
|
|
194
|
+
entity = await dal_class.create(session, sample_cu)
|
|
195
|
+
assert entity is not None
|
|
196
|
+
assert hasattr(entity, "id")
|
|
197
|
+
|
|
198
|
+
async def test_create_no_refresh(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
199
|
+
"""create(need_refresh=False) 应返回实体但不执行 refresh."""
|
|
200
|
+
entity = await dal_class.create(session, sample_cu, need_refresh=False)
|
|
201
|
+
assert entity is not None
|
|
202
|
+
|
|
203
|
+
async def test_get_by_id_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
204
|
+
"""get_by_id() 对已存在的实体应返回非 None."""
|
|
205
|
+
entity = await dal_class.create(session, sample_cu)
|
|
206
|
+
eid = self._get_entity_id(entity)
|
|
207
|
+
found = await dal_class.get_by_id(session, eid)
|
|
208
|
+
assert found is not None
|
|
209
|
+
|
|
210
|
+
async def test_get_by_id_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
211
|
+
"""get_by_id() 对不存在的 ID 应返回 None."""
|
|
212
|
+
result = await dal_class.get_by_id(session, 999999)
|
|
213
|
+
assert result is None
|
|
214
|
+
|
|
215
|
+
async def test_ret_dto_after_get_by_id(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
216
|
+
"""ret_dto_after_get_by_id() 应返回 DTO 对象."""
|
|
217
|
+
entity = await dal_class.create(session, sample_cu)
|
|
218
|
+
eid = self._get_entity_id(entity)
|
|
219
|
+
dto = await dal_class.ret_dto_after_get_by_id(session, eid)
|
|
220
|
+
assert dto is not None
|
|
221
|
+
|
|
222
|
+
async def test_ret_dto_after_get_by_id_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
223
|
+
"""ret_dto_after_get_by_id() 对不存在的 ID 应返回 None."""
|
|
224
|
+
result = await dal_class.ret_dto_after_get_by_id(session, 999999)
|
|
225
|
+
assert result is None
|
|
226
|
+
|
|
227
|
+
async def test_get_all_default_pagination(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
228
|
+
"""get_all() 默认参数应返回列表."""
|
|
229
|
+
await dal_class.create(session, sample_cu)
|
|
230
|
+
result = await dal_class.get_all(session)
|
|
231
|
+
assert isinstance(result, list)
|
|
232
|
+
assert len(result) >= 1
|
|
233
|
+
|
|
234
|
+
async def test_get_all_with_pagination(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
235
|
+
"""get_all(skip, limit) 应正确分页."""
|
|
236
|
+
await dal_class.create(session, sample_cu)
|
|
237
|
+
await dal_class.create(session, sample_cu)
|
|
238
|
+
page1 = await dal_class.get_all(session, skip=0, limit=1)
|
|
239
|
+
assert len(page1) == 1
|
|
240
|
+
|
|
241
|
+
async def test_count_returns_int(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
242
|
+
"""count() 应返回非负整数."""
|
|
243
|
+
initial = await dal_class.count(session)
|
|
244
|
+
assert isinstance(initial, int)
|
|
245
|
+
await dal_class.create(session, sample_cu)
|
|
246
|
+
after = await dal_class.count(session)
|
|
247
|
+
assert after == initial + 1
|
|
248
|
+
|
|
249
|
+
async def test_exists_true_for_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
250
|
+
"""exists() 对已存在的 ID 应返回 True."""
|
|
251
|
+
entity = await dal_class.create(session, sample_cu)
|
|
252
|
+
eid = self._get_entity_id(entity)
|
|
253
|
+
assert await dal_class.exists(session, eid) is True
|
|
254
|
+
|
|
255
|
+
async def test_exists_false_for_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
256
|
+
"""exists() 对不存在的 ID 应返回 False."""
|
|
257
|
+
assert await dal_class.exists(session, 999999) is False
|
|
258
|
+
|
|
259
|
+
async def test_batch_get_id__entity(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
260
|
+
"""batch_get_id__entity() 应返回 {id: entity} 字典."""
|
|
261
|
+
e1 = await dal_class.create(session, sample_cu)
|
|
262
|
+
eid1 = self._get_entity_id(e1)
|
|
263
|
+
result = await dal_class.batch_get_id__entity(session, [eid1, 999999])
|
|
264
|
+
assert eid1 in result
|
|
265
|
+
assert 999999 not in result
|
|
266
|
+
|
|
267
|
+
async def test_batch_get_id__dto(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
268
|
+
"""batch_get_id__dto() 应返回 {id: DTO} 字典."""
|
|
269
|
+
e1 = await dal_class.create(session, sample_cu)
|
|
270
|
+
eid1 = self._get_entity_id(e1)
|
|
271
|
+
result = await dal_class.batch_get_id__dto(session, [eid1])
|
|
272
|
+
assert eid1 in result
|
|
273
|
+
|
|
274
|
+
async def test_ret_dto_after_create(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
275
|
+
"""ret_dto_after_create() 应返回 DTO 对象."""
|
|
276
|
+
dto = await dal_class.ret_dto_after_create(session, sample_cu)
|
|
277
|
+
assert dto is not None
|
|
278
|
+
|
|
279
|
+
async def test_update_only_set_by_id_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
280
|
+
"""update_only_set_by_id() 对已存在的实体应返回更新后的实体."""
|
|
281
|
+
entity = await dal_class.create(session, sample_cu)
|
|
282
|
+
eid = self._get_entity_id(entity)
|
|
283
|
+
updated = await dal_class.update_only_set_by_id(session, eid, sample_cu)
|
|
284
|
+
assert updated is not None
|
|
285
|
+
|
|
286
|
+
async def test_update_only_set_by_id_nonexistent(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
287
|
+
"""update_only_set_by_id() 对不存在的 ID 应返回 None."""
|
|
288
|
+
result = await dal_class.update_only_set_by_id(session, 999999, sample_cu)
|
|
289
|
+
assert result is None
|
|
290
|
+
|
|
291
|
+
async def test_delete_by_id_existing(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
292
|
+
"""delete_by_id() 对已存在的实体应返回 True."""
|
|
293
|
+
entity = await dal_class.create(session, sample_cu)
|
|
294
|
+
eid = self._get_entity_id(entity)
|
|
295
|
+
assert await dal_class.delete_by_id(session, eid) is True
|
|
296
|
+
|
|
297
|
+
async def test_delete_by_id_nonexistent(self, dal_class: Any, session: Any) -> None:
|
|
298
|
+
"""delete_by_id() 对不存在的 ID 应返回 False."""
|
|
299
|
+
assert await dal_class.delete_by_id(session, 999999) is False
|
|
300
|
+
|
|
301
|
+
async def test_delete_then_get_returns_none(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
302
|
+
"""删除后再 get_by_id 应返回 None (验证软删除/物理删除生效)."""
|
|
303
|
+
entity = await dal_class.create(session, sample_cu)
|
|
304
|
+
eid = self._get_entity_id(entity)
|
|
305
|
+
await dal_class.delete_by_id(session, eid)
|
|
306
|
+
session.expire_all()
|
|
307
|
+
result = await dal_class.get_by_id(session, eid)
|
|
308
|
+
assert result is None
|
|
309
|
+
|
|
310
|
+
async def test_iter_record_dtos_yields(self, dal_class: Any, session: Any, sample_cu: Any) -> None:
|
|
311
|
+
"""iter_record_dtos() 应以异步迭代器方式返回 DTO."""
|
|
312
|
+
await dal_class.create(session, sample_cu)
|
|
313
|
+
records = [dto async for dto in dal_class.iter_record_dtos(session, batch_size=10)]
|
|
314
|
+
assert len(records) >= 1
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""DAL 操作协议 (Protocol) 定义.
|
|
2
|
+
|
|
3
|
+
定义了同步/异步两套 ReadDAL、WriteDAL、BaseDAL 的操作协议.
|
|
4
|
+
下游 ORM 适配包应实现这些协议以保持一致的用户接口.
|
|
5
|
+
|
|
6
|
+
注意: 这里的 Protocol 用 ``SessionT`` 和 ``EntityT`` 等泛型参数
|
|
7
|
+
屏蔽了具体 ORM 的会话和实体类型.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import AsyncIterator, Iterable, Iterator
|
|
13
|
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from ..dto import CUModelT, DTOModelT
|
|
16
|
+
|
|
17
|
+
SessionT = TypeVar("SessionT", contravariant=True)
|
|
18
|
+
EntityT = TypeVar("EntityT")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class SyncReadDALProtocol(Protocol[SessionT, EntityT, DTOModelT]):
|
|
23
|
+
"""同步只读 DAL 协议.
|
|
24
|
+
|
|
25
|
+
定义了下游实现必须提供的只读数据访问方法.
|
|
26
|
+
所有方法均为 classmethod, 接收显式 session 参数.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def get_by_id(cls, session: SessionT, entity_id: int) -> EntityT | None:
|
|
31
|
+
"""根据主键 ID 获取单个 ORM 实体.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
session: 数据库会话.
|
|
35
|
+
entity_id: 实体主键 ID.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
找到则返回 ORM 实体实例, 否则返回 ``None``.
|
|
39
|
+
|
|
40
|
+
行为约定:
|
|
41
|
+
- 应支持软删除过滤 (如果实体混入了软删除标记).
|
|
42
|
+
- 不应触发额外的 commit/flush.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def get_all(cls, session: SessionT, skip: int = 0, limit: int = 100) -> list[DTOModelT]:
|
|
48
|
+
"""分页获取实体列表, 以 DTO 形式返回.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
session: 数据库会话.
|
|
52
|
+
skip: 跳过的记录数 (偏移量), 默认 0.
|
|
53
|
+
limit: 最大返回数量, 默认 100.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
DTO 对象列表, 可能为空.
|
|
57
|
+
|
|
58
|
+
行为约定:
|
|
59
|
+
- skip=0, limit=100 为默认值.
|
|
60
|
+
- 返回 DTO (而非 ORM 实体), 方便序列化.
|
|
61
|
+
- 应按主键升序或插入顺序排列.
|
|
62
|
+
"""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def count(cls, session: SessionT) -> int:
|
|
67
|
+
"""统计实体总数.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
session: 数据库会话.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
满足条件的记录数 (非负整数).
|
|
74
|
+
|
|
75
|
+
行为约定:
|
|
76
|
+
- 应排除软删除的记录.
|
|
77
|
+
- 无记录时返回 0 而非 None.
|
|
78
|
+
"""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def exists(cls, session: SessionT, entity_id: int) -> bool:
|
|
83
|
+
"""判断指定 ID 的实体是否存在.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
session: 数据库会话.
|
|
87
|
+
entity_id: 实体主键 ID.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
存在返回 ``True``, 否则 ``False``.
|
|
91
|
+
|
|
92
|
+
行为约定:
|
|
93
|
+
- 应排除软删除的记录.
|
|
94
|
+
"""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def ret_dto_after_get_by_id(cls, session: SessionT, entity_id: int, need_refresh: bool = True) -> DTOModelT | None:
|
|
99
|
+
"""根据主键 ID 获取实体并转为 DTO 返回.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
session: 数据库会话.
|
|
103
|
+
entity_id: 实体主键 ID.
|
|
104
|
+
need_refresh: 是否在返回前刷新实体 (从 DB 重新加载), 默认 True.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
找到则返回 DTO 对象, 否则返回 ``None``.
|
|
108
|
+
|
|
109
|
+
行为约定:
|
|
110
|
+
- need_refresh=True 时应确保返回的数据反映 DB 最新状态.
|
|
111
|
+
- 转换为 DTO 时应使用 ``_DTO.model_validate(entity, from_attributes=True)``.
|
|
112
|
+
"""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def batch_get_id__entity(cls, session: SessionT, entity_ids: Iterable[int]) -> dict[int, EntityT]:
|
|
117
|
+
"""批量获取实体, 返回 {id: entity} 字典.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
session: 数据库会话.
|
|
121
|
+
entity_ids: 主键 ID 可迭代对象.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
存在的实体映射, key 为 ID, value 为 ORM 实体.
|
|
125
|
+
不存在的 ID 不会出现在结果中.
|
|
126
|
+
|
|
127
|
+
行为约定:
|
|
128
|
+
- 应对 entity_ids 去重和过滤无效值 (None / 空字符串).
|
|
129
|
+
- 使用 SQL IN 查询, 避免 N+1.
|
|
130
|
+
"""
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def batch_get_id__dto(cls, session: SessionT, entity_ids: Iterable[int]) -> dict[int, DTOModelT]:
|
|
135
|
+
"""批量获取实体, 返回 {id: DTO} 字典.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
session: 数据库会话.
|
|
139
|
+
entity_ids: 主键 ID 可迭代对象.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
存在的实体映射, key 为 ID, value 为 DTO 对象.
|
|
143
|
+
|
|
144
|
+
行为约定:
|
|
145
|
+
- 语义同 ``batch_get_id__entity``, 但值为 DTO.
|
|
146
|
+
"""
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def iter_record_dtos(cls, session: SessionT, *, batch_size: int = 500) -> Iterator[DTOModelT]:
|
|
151
|
+
"""以迭代器方式逐条返回全部记录的 DTO.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
session: 数据库会话.
|
|
155
|
+
batch_size: 每批从数据库拉取的记录数, 默认 500.
|
|
156
|
+
|
|
157
|
+
Yields:
|
|
158
|
+
DTOModelT: 逐条的 DTO 对象.
|
|
159
|
+
|
|
160
|
+
行为约定:
|
|
161
|
+
- 用于大数据量场景, 避免一次性加载全部记录到内存.
|
|
162
|
+
- 内部应使用服务端游标 (yield_per / stream) 实现.
|
|
163
|
+
"""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@runtime_checkable
|
|
168
|
+
class SyncWriteDALProtocol(Protocol[SessionT, EntityT, DTOModelT, CUModelT]):
|
|
169
|
+
"""同步写入 DAL 协议.
|
|
170
|
+
|
|
171
|
+
定义了下游实现必须提供的写入数据访问方法.
|
|
172
|
+
所有方法均为 classmethod, 接收显式 session 参数.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def create(cls, session: SessionT, cu: CUModelT, need_refresh: bool = True) -> EntityT:
|
|
177
|
+
"""根据 CU 模型创建新实体.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
session: 数据库会话.
|
|
181
|
+
cu: 创建/更新模型实例.
|
|
182
|
+
need_refresh: 创建后是否刷新实体 (获取自增 ID 等 DB 端生成值), 默认 True.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
新创建的 ORM 实体实例.
|
|
186
|
+
|
|
187
|
+
行为约定:
|
|
188
|
+
- 调用 ``cu.to_orm_model()`` (或等效方法) 生成 ORM 实体.
|
|
189
|
+
- 执行 ``session.add(entity)`` + ``session.flush()``.
|
|
190
|
+
- need_refresh=True 时应额外调用 ``session.refresh(entity)``.
|
|
191
|
+
- **不应** 自动 commit, 事务由调用方控制.
|
|
192
|
+
"""
|
|
193
|
+
...
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def ret_dto_after_create(cls, session: SessionT, cu: CUModelT, need_refresh: bool = True) -> DTOModelT:
|
|
197
|
+
"""创建新实体并以 DTO 形式返回.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
session: 数据库会话.
|
|
201
|
+
cu: 创建/更新模型实例.
|
|
202
|
+
need_refresh: 同 ``create`` 的 need_refresh.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
新创建实体对应的 DTO 对象.
|
|
206
|
+
|
|
207
|
+
行为约定:
|
|
208
|
+
- 语义等同于 ``cls.create(session, cu) → convert to DTO``.
|
|
209
|
+
"""
|
|
210
|
+
...
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def update_only_set_by_id(cls, session: SessionT, entity_id: int, cu: CUModelT, need_refresh: bool = False) -> EntityT | None:
|
|
214
|
+
"""仅更新 CU 中已设置 (非 unset) 的字段.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
session: 数据库会话.
|
|
218
|
+
entity_id: 要更新的实体主键 ID.
|
|
219
|
+
cu: 创建/更新模型, 仅 ``model_dump(exclude_unset=True)`` 中的字段会被更新.
|
|
220
|
+
need_refresh: 更新后是否刷新, 默认 False.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
更新后的 ORM 实体, 若 ID 不存在则返回 ``None``.
|
|
224
|
+
|
|
225
|
+
行为约定:
|
|
226
|
+
- 使用 ``cu.model_dump(exclude_unset=True, exclude={\"id\"})`` 获取变更字段.
|
|
227
|
+
- 仅 ``setattr`` 有效字段 (entity 上存在的属性).
|
|
228
|
+
- 执行 ``session.flush()`` 而非 commit.
|
|
229
|
+
"""
|
|
230
|
+
...
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def delete_by_id(cls, session: SessionT, entity_id: int) -> bool:
|
|
234
|
+
"""根据主键 ID 删除实体.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
session: 数据库会话.
|
|
238
|
+
entity_id: 要删除的实体主键 ID.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
成功删除返回 ``True``, ID 不存在返回 ``False``.
|
|
242
|
+
|
|
243
|
+
行为约定:
|
|
244
|
+
- 如果实体混入了 ``SoftDeleteTableMixin``, 应执行软删除 (标记 is_delete=1)
|
|
245
|
+
而非物理删除.
|
|
246
|
+
- 软删除通过 ``session.delete(entity)`` 触发 before_flush 事件实现.
|
|
247
|
+
- 执行 ``session.flush()`` 而非 commit.
|
|
248
|
+
"""
|
|
249
|
+
...
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@runtime_checkable
|
|
253
|
+
class SyncBaseDALProtocol(
|
|
254
|
+
SyncReadDALProtocol[SessionT, EntityT, DTOModelT],
|
|
255
|
+
SyncWriteDALProtocol[SessionT, EntityT, DTOModelT, CUModelT],
|
|
256
|
+
Protocol[SessionT, EntityT, DTOModelT, CUModelT],
|
|
257
|
+
):
|
|
258
|
+
"""同步完整 DAL 协议 (读 + 写).
|
|
259
|
+
|
|
260
|
+
组合了 ``SyncReadDALProtocol`` 和 ``SyncWriteDALProtocol`` 的全部方法.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
...
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@runtime_checkable
|
|
267
|
+
class AsyncReadDALProtocol(Protocol[SessionT, EntityT, DTOModelT]):
|
|
268
|
+
"""异步只读 DAL 协议.
|
|
269
|
+
|
|
270
|
+
语义与 ``SyncReadDALProtocol`` 完全一致, 所有方法为 ``async def``.
|
|
271
|
+
详细行为约定请参见同步版对应方法的 docstring.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
async def get_by_id(cls, session: SessionT, entity_id: int) -> EntityT | None:
|
|
276
|
+
"""根据主键 ID 获取单个 ORM 实体 (异步版)."""
|
|
277
|
+
...
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
async def get_all(cls, session: SessionT, skip: int = 0, limit: int = 100) -> list[DTOModelT]:
|
|
281
|
+
"""分页获取实体列表, 以 DTO 形式返回 (异步版)."""
|
|
282
|
+
...
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
async def count(cls, session: SessionT) -> int:
|
|
286
|
+
"""统计实体总数 (异步版)."""
|
|
287
|
+
...
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
async def exists(cls, session: SessionT, entity_id: int) -> bool:
|
|
291
|
+
"""判断指定 ID 的实体是否存在 (异步版)."""
|
|
292
|
+
...
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
async def ret_dto_after_get_by_id(cls, session: SessionT, entity_id: int, need_refresh: bool = True) -> DTOModelT | None:
|
|
296
|
+
"""根据主键 ID 获取实体并转为 DTO 返回 (异步版)."""
|
|
297
|
+
...
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
async def batch_get_id__entity(cls, session: SessionT, entity_ids: Iterable[int]) -> dict[int, EntityT]:
|
|
301
|
+
"""批量获取实体, 返回 {id: entity} 字典 (异步版)."""
|
|
302
|
+
...
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
async def batch_get_id__dto(cls, session: SessionT, entity_ids: Iterable[int]) -> dict[int, DTOModelT]:
|
|
306
|
+
"""批量获取实体, 返回 {id: DTO} 字典 (异步版)."""
|
|
307
|
+
...
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def iter_record_dtos(cls, session: SessionT, *, batch_size: int = 500) -> AsyncIterator[DTOModelT]:
|
|
311
|
+
"""以异步迭代器方式逐条返回全部记录的 DTO (异步版)."""
|
|
312
|
+
...
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@runtime_checkable
|
|
316
|
+
class AsyncWriteDALProtocol(Protocol[SessionT, EntityT, DTOModelT, CUModelT]):
|
|
317
|
+
"""异步写入 DAL 协议.
|
|
318
|
+
|
|
319
|
+
语义与 ``SyncWriteDALProtocol`` 完全一致, 所有方法为 ``async def``.
|
|
320
|
+
详细行为约定请参见同步版对应方法的 docstring.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
async def create(cls, session: SessionT, cu: CUModelT, need_refresh: bool = True) -> EntityT:
|
|
325
|
+
"""根据 CU 模型创建新实体 (异步版)."""
|
|
326
|
+
...
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
async def ret_dto_after_create(cls, session: SessionT, cu: CUModelT, need_refresh: bool = True) -> DTOModelT:
|
|
330
|
+
"""创建新实体并以 DTO 形式返回 (异步版)."""
|
|
331
|
+
...
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
async def update_only_set_by_id(
|
|
335
|
+
cls, session: SessionT, entity_id: int, cu: CUModelT, need_refresh: bool = False
|
|
336
|
+
) -> EntityT | None:
|
|
337
|
+
"""仅更新 CU 中已设置的字段 (异步版)."""
|
|
338
|
+
...
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
async def delete_by_id(cls, session: SessionT, entity_id: int) -> bool:
|
|
342
|
+
"""根据主键 ID 删除实体 (异步版)."""
|
|
343
|
+
...
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@runtime_checkable
|
|
347
|
+
class AsyncBaseDALProtocol(
|
|
348
|
+
AsyncReadDALProtocol[SessionT, EntityT, DTOModelT],
|
|
349
|
+
AsyncWriteDALProtocol[SessionT, EntityT, DTOModelT, CUModelT],
|
|
350
|
+
Protocol[SessionT, EntityT, DTOModelT, CUModelT],
|
|
351
|
+
):
|
|
352
|
+
"""异步完整 DAL 协议 (读 + 写).
|
|
353
|
+
|
|
354
|
+
组合了 ``AsyncReadDALProtocol`` 和 ``AsyncWriteDALProtocol`` 的全部方法.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
...
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""通用重试配置."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RetryConfig:
|
|
11
|
+
"""重试策略配置.
|
|
12
|
+
|
|
13
|
+
支持指数退避 + 可选抖动.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
max_attempts: int = 3
|
|
17
|
+
initial_delay: float = 0.1
|
|
18
|
+
max_delay: float = 2.0
|
|
19
|
+
exponential_base: float = 2.0
|
|
20
|
+
jitter: bool = True
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
if self.max_attempts < 1:
|
|
24
|
+
raise ValueError(f"max_attempts 必须>=1, 当前值: {self.max_attempts}")
|
|
25
|
+
if self.initial_delay < 0:
|
|
26
|
+
raise ValueError(f"initial_delay 必须>=0, 当前值: {self.initial_delay}")
|
|
27
|
+
if self.max_delay < self.initial_delay:
|
|
28
|
+
raise ValueError(f"max_delay({self.max_delay}) 必须>=initial_delay({self.initial_delay})")
|
|
29
|
+
if self.exponential_base <= 1:
|
|
30
|
+
raise ValueError(f"exponential_base 必须>1, 当前值: {self.exponential_base}")
|
|
31
|
+
|
|
32
|
+
def calculate_delay(self, attempt: int) -> float:
|
|
33
|
+
"""根据当前重试次数计算等待时间 (秒)."""
|
|
34
|
+
if attempt <= 0:
|
|
35
|
+
return 0.0
|
|
36
|
+
|
|
37
|
+
delay = self.initial_delay * (self.exponential_base ** (attempt - 1))
|
|
38
|
+
delay = min(delay, self.max_delay)
|
|
39
|
+
|
|
40
|
+
if self.jitter and delay > 0:
|
|
41
|
+
jitter_range = delay * 0.2
|
|
42
|
+
delay = delay + random.uniform(-jitter_range, jitter_range) # noqa: S311
|
|
43
|
+
delay = max(0, min(delay, self.max_delay))
|
|
44
|
+
|
|
45
|
+
return delay
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
DEFAULT_RETRY_CONFIG = RetryConfig(max_attempts=3, initial_delay=0.1, max_delay=1.0)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""ORM 无关的通用工具函数."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
V = TypeVar("V")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def filtered_in_sql_values(
|
|
13
|
+
values: Iterable[V] | None,
|
|
14
|
+
target_type_as: Callable[[V], T] = lambda x: x,
|
|
15
|
+
) -> list[T]:
|
|
16
|
+
"""过滤并去重 SQL IN 子句的值列表.
|
|
17
|
+
|
|
18
|
+
跳过 ``None`` 和空字符串, 类型转换失败的值也会被忽略.
|
|
19
|
+
"""
|
|
20
|
+
if not values:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
items: list[T] = []
|
|
24
|
+
seen = set[T]()
|
|
25
|
+
|
|
26
|
+
for item in values:
|
|
27
|
+
if item is None or item == "":
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
converted_value = target_type_as(item)
|
|
31
|
+
if converted_value not in seen:
|
|
32
|
+
seen.add(converted_value)
|
|
33
|
+
items.append(converted_value)
|
|
34
|
+
except (ValueError, TypeError):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
return items
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def escape_like(value: str, escape_char: str = "\\") -> tuple[str, str]:
|
|
41
|
+
"""转义用于 SQL LIKE 的特殊字符并返回转义后的值和转义字符."""
|
|
42
|
+
v = value.replace(escape_char, escape_char + escape_char)
|
|
43
|
+
v = v.replace("%", escape_char + "%").replace("_", escape_char + "_")
|
|
44
|
+
return v, escape_char
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: lush-dal-protocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DAL (Data Access Layer) protocol for the lush ecosystem
|
|
5
|
+
Author: straydragon
|
|
6
|
+
Author-email: straydragon <straydragonl@foxmail.com>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Requires-Dist: pydantic>=2.11.0,<3.0.0
|
|
11
|
+
Requires-Dist: typing-extensions>=4.12.2
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# lush-dal-protocol
|
|
16
|
+
|
|
17
|
+
ORM 无关的数据访问层 (DAL) 协议抽象包。
|
|
18
|
+
|
|
19
|
+
仅包含纯 Protocol / 接口声明,不依赖任何具体 ORM。
|
|
20
|
+
下游适配包(如 `lush-sqlalchemyx`)负责实现这些协议。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
lush_dal_protocol/__init__.py,sha256=Josy6Xw8N2hOrmxOEs0NYcmacdx3n2fxYC_RXVqDjNM,1144
|
|
2
|
+
lush_dal_protocol/dto.py,sha256=aoUppasqMoWuyexEQPHUfh3Rq4FQmfeM4y7rYQ-tf1I,2382
|
|
3
|
+
lush_dal_protocol/errors.py,sha256=5DbtpGXNJhaedCEaV-gJoZ_dGiVuq8taEWApblU6F74,1012
|
|
4
|
+
lush_dal_protocol/protocols/__init__.py,sha256=JV6HFrOM38xoQF6UoSE_ugmBcksGzBHrfElPfcP70hQ,543
|
|
5
|
+
lush_dal_protocol/protocols/api_contracts.py,sha256=tDKlTOrDEqqjE8X6k9bsx5zdUVTdOy5e-nU3t_vep9Q,14687
|
|
6
|
+
lush_dal_protocol/protocols/dal.py,sha256=UJE8P5kPZdRETgv2unt26BjUxQvOm1hBrhGuQawZXI4,11770
|
|
7
|
+
lush_dal_protocol/utils/__init__.py,sha256=hc4RrHv57xIK3XIMNBl8J107RFxO8J-CBnxJhcGQVb4,250
|
|
8
|
+
lush_dal_protocol/utils/retry.py,sha256=dhlJ7QSWhv3xOnjabmY0Gw8B18TNLsbozZsL-bG-j7E,1550
|
|
9
|
+
lush_dal_protocol/utils/sql.py,sha256=L7M6SCUyAKgFsA-SHOXELsnRRtHh-cAhKkQw_cAUDmc,1217
|
|
10
|
+
lush_dal_protocol-0.1.0.dist-info/WHEEL,sha256=XkDrRXQq-qVsrKMtsDUOHeLkiG7UK4Ds0JuG05OqKU4,81
|
|
11
|
+
lush_dal_protocol-0.1.0.dist-info/METADATA,sha256=4htIAB8ism7XhsCWjDyp0HPds_-aod28Xcbuo8ySRbk,686
|
|
12
|
+
lush_dal_protocol-0.1.0.dist-info/RECORD,,
|