lush-sqlalchemyx 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (31) hide show
  1. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/PKG-INFO +1 -1
  2. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/pyproject.toml +1 -4
  3. lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/__init__.py +19 -0
  4. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/__init__.py +11 -33
  5. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_async.py +41 -132
  6. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_common.py +108 -8
  7. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_pagination.py +2 -2
  8. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_repository.py +36 -24
  9. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_sync.py +41 -132
  10. lush_sqlalchemyx-0.3.1/src/lush_sqlalchemyx/base/dal/_async_v2.py +0 -184
  11. lush_sqlalchemyx-0.3.1/src/lush_sqlalchemyx/base/dal/_params.py +0 -32
  12. lush_sqlalchemyx-0.3.1/src/lush_sqlalchemyx/base/dal/_sync_v2.py +0 -183
  13. lush_sqlalchemyx-0.3.1/src/lush_sqlalchemyx/shortcuts/__init__.py +0 -0
  14. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/README.md +0 -0
  15. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/_compat.py +0 -0
  16. {lush_sqlalchemyx-0.3.1/src/lush_sqlalchemyx → lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/base}/__init__.py +0 -0
  17. {lush_sqlalchemyx-0.3.1/src/lush_sqlalchemyx/base → lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/integrations}/__init__.py +0 -0
  18. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/fastapi/__init__.py +0 -0
  19. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/fastapi/depends/__init__.py +0 -0
  20. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/flask/__init__.py +0 -0
  21. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/flask/ext.py +0 -0
  22. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/__init__.py +0 -0
  23. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/__init__.py +0 -0
  24. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/manager.py +0 -0
  25. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/mapper.py +0 -0
  26. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/sync_manager.py +0 -0
  27. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/sync_mapper.py +0 -0
  28. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/py.typed +0 -0
  29. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/same_impl_just_warn_wrapper.py +0 -0
  30. {lush_sqlalchemyx-0.3.1/src/lush_sqlalchemyx/integrations → lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/shortcuts}/__init__.py +0 -0
  31. {lush_sqlalchemyx-0.3.1 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/shortcuts/meta.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: lush-sqlalchemyx
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: SQLAlchemy helpers (DAL) and async MySQL managers, with some web frameworks integrations
5
5
  Author: straydragon
6
6
  Author-email: straydragon <straydragonl@foxmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lush-sqlalchemyx"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "SQLAlchemy helpers (DAL) and async MySQL managers, with some web frameworks integrations"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -149,9 +149,6 @@ ignore = [
149
149
  ]
150
150
 
151
151
  [tool.ruff.lint.per-file-ignores]
152
- "**/dal/_*_v2.py" = [
153
- "ARG003", # extra 参数是 ABC 合规所必需但在部分方法中未使用
154
- ]
155
152
  "tests/**/*.py" = [
156
153
  # === 测试代码质量 ===
157
154
  "B011", # assert False应改为raise AssertionError
@@ -0,0 +1,19 @@
1
+ """lush-sqlalchemyx — SQLAlchemy DAL 实现 + async/sync MySQL manager + 框架集成.
2
+
3
+ 导入本包时会自动注册软删除 Session 事件监听器.
4
+ 如需显式管理钩子生命周期, 参见 :func:`register_soft_delete_hooks` 等.
5
+ """
6
+
7
+ from lush_sqlalchemyx.base.dal import (
8
+ is_soft_delete_hooks_registered,
9
+ register_soft_delete_hooks,
10
+ setup_dal_hooks,
11
+ unregister_soft_delete_hooks,
12
+ )
13
+
14
+ __all__ = (
15
+ "is_soft_delete_hooks_registered",
16
+ "register_soft_delete_hooks",
17
+ "setup_dal_hooks",
18
+ "unregister_soft_delete_hooks",
19
+ )
@@ -6,21 +6,14 @@
6
6
  # --- shared (sync/async agnostic) ---
7
7
  # --- lush-dal-protocol ABCs (ORM 无关的抽象层) ---
8
8
  from lush_dal_protocol import (
9
- AbstractAsyncAdvancedWriteDAL,
10
9
  AbstractAsyncBaseDAL,
11
- AbstractAsyncBatchFieldDAL,
12
- AbstractAsyncLockDAL,
13
- AbstractAsyncRawSQLDAL,
14
10
  AbstractAsyncReadDAL,
15
11
  AbstractAsyncWriteDAL,
16
- AbstractSyncAdvancedWriteDAL,
17
12
  AbstractSyncBaseDAL,
18
- AbstractSyncBatchFieldDAL,
19
- AbstractSyncLockDAL,
20
- AbstractSyncRawSQLDAL,
21
13
  AbstractSyncReadDAL,
22
14
  AbstractSyncWriteDAL,
23
15
  )
16
+ from lush_dal_protocol.params.pagination import CursorPagination, CursorResult, OffsetPagination, PageResult
24
17
 
25
18
  # --- async (requires sqlalchemy[asyncio]) ---
26
19
  from ._async import (
@@ -40,9 +33,6 @@ from ._async import (
40
33
  async_temp_set_lock_wait_timeout,
41
34
  async_with_retry,
42
35
  )
43
-
44
- # --- V2 (ABC-compliant, options-based) ---
45
- from ._async_v2 import AsyncBaseDALV2, AsyncReadDALV2, AsyncWriteDALV2
46
36
  from ._common import (
47
37
  DEFAULT_RETRY_CONFIG,
48
38
  OPTIMISTIC_LOCK_ERROR_MSG_TRAIT,
@@ -53,6 +43,7 @@ from ._common import (
53
43
  CUModelT,
54
44
  DBRetryableError,
55
45
  DTOModelT,
46
+ FieldIsDeleteSoftDeleteTableMixin,
56
47
  FieldMixin,
57
48
  ReadOnlyMixin,
58
49
  RetryConfig,
@@ -62,16 +53,16 @@ from ._common import (
62
53
  StdBaseDTO,
63
54
  escape_like,
64
55
  filtered_in_sql_values,
56
+ is_soft_delete_hooks_registered,
57
+ register_soft_delete_hooks,
58
+ setup_dal_hooks,
59
+ unregister_soft_delete_hooks,
65
60
  )
66
61
 
67
62
  # Event listener references — accessed by tests via getattr(module, name).
68
63
  from ._common import __prevent_readonly_write as __prevent_readonly_write # pyright: ignore[reportPrivateUsage]
69
64
  from ._common import __receive_before_flush as __receive_before_flush # pyright: ignore[reportPrivateUsage]
70
65
  from ._pagination import (
71
- CursorPagination,
72
- CursorResult,
73
- OffsetPagination,
74
- PageResult,
75
66
  build_cursor_stmt,
76
67
  build_offset_stmt,
77
68
  decode_cursor,
@@ -79,7 +70,6 @@ from ._pagination import (
79
70
  make_cursor_result,
80
71
  make_page_result,
81
72
  )
82
- from ._params import SQLAExtra
83
73
  from ._repository import AsyncSQLAlchemyRepository, SyncSQLAlchemyRepository
84
74
 
85
75
  # --- sync ---
@@ -100,7 +90,6 @@ from ._sync import (
100
90
  sync_temp_set_lock_wait_timeout,
101
91
  sync_with_retry,
102
92
  )
103
- from ._sync_v2 import SyncBaseDALV2, SyncReadDALV2, SyncWriteDALV2
104
93
 
105
94
  __all__ = (
106
95
  # common
@@ -118,10 +107,15 @@ __all__ = (
118
107
  "RetryConfig",
119
108
  "SQLATableT",
120
109
  "SoftDeleteTableMixin",
110
+ "FieldIsDeleteSoftDeleteTableMixin",
121
111
  "StdBaseCU",
122
112
  "StdBaseDTO",
123
113
  "escape_like",
124
114
  "filtered_in_sql_values",
115
+ "is_soft_delete_hooks_registered",
116
+ "register_soft_delete_hooks",
117
+ "setup_dal_hooks",
118
+ "unregister_soft_delete_hooks",
125
119
  # async
126
120
  "AsyncBaseDAL",
127
121
  "AsyncRawDAL",
@@ -165,30 +159,14 @@ __all__ = (
165
159
  "encode_cursor",
166
160
  "make_cursor_result",
167
161
  "make_page_result",
168
- # V2
169
- "AsyncBaseDALV2",
170
- "AsyncReadDALV2",
171
- "AsyncWriteDALV2",
172
- "SQLAExtra",
173
- "SyncBaseDALV2",
174
- "SyncReadDALV2",
175
- "SyncWriteDALV2",
176
162
  # repository
177
163
  "AsyncSQLAlchemyRepository",
178
164
  "SyncSQLAlchemyRepository",
179
165
  # lush-dal-protocol ABCs
180
- "AbstractAsyncAdvancedWriteDAL",
181
166
  "AbstractAsyncBaseDAL",
182
- "AbstractAsyncBatchFieldDAL",
183
- "AbstractAsyncLockDAL",
184
- "AbstractAsyncRawSQLDAL",
185
167
  "AbstractAsyncReadDAL",
186
168
  "AbstractAsyncWriteDAL",
187
- "AbstractSyncAdvancedWriteDAL",
188
169
  "AbstractSyncBaseDAL",
189
- "AbstractSyncBatchFieldDAL",
190
- "AbstractSyncLockDAL",
191
- "AbstractSyncRawSQLDAL",
192
170
  "AbstractSyncReadDAL",
193
171
  "AbstractSyncWriteDAL",
194
172
  )
@@ -15,6 +15,7 @@ from contextlib import asynccontextmanager, suppress
15
15
  from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, ParamSpec, TypeVar, cast
16
16
 
17
17
  import sqlalchemy as sa
18
+ from lush_dal_protocol.abc import AbstractAsyncReadDAL, AbstractAsyncWriteDAL
18
19
  from pydantic import BaseModel
19
20
  from sqlalchemy import ColumnExpressionArgument
20
21
  from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession
@@ -30,6 +31,7 @@ from ._common import (
30
31
  CUModelT,
31
32
  DBRetryableError,
32
33
  DTOModelT,
34
+ FieldIsDeleteSoftDeleteTableMixin,
33
35
  ReadOnlyMixin,
34
36
  RetryConfig,
35
37
  SoftDeleteTableMixin,
@@ -141,7 +143,7 @@ class ReadOnlyBasicAsyncBaseTable(AsyncSqlATableBase, ReadOnlyMixin):
141
143
  __abstract__ = True
142
144
 
143
145
 
144
- class StdAsyncBaseTable(BasicAsyncBaseTable, SoftDeleteTableMixin):
146
+ class StdAsyncBaseTable(BasicAsyncBaseTable, FieldIsDeleteSoftDeleteTableMixin):
145
147
  """标准异步表类: 包含 id/时间戳/操作人/软删除等标准字段.
146
148
 
147
149
  .. deprecated::
@@ -316,7 +318,11 @@ class AsyncRawReadDAL:
316
318
  last_id = getattr(batch[-1], id_attr.key)
317
319
 
318
320
 
319
- class AsyncReadDAL(AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT]):
321
+ class AsyncReadDAL(
322
+ AsyncRawReadDAL,
323
+ AbstractAsyncReadDAL[AsyncSession, AsyncSQLATableT, DTOModelT, int],
324
+ Generic[AsyncSQLATableT, DTOModelT],
325
+ ):
320
326
  """抽象只读数据访问层基类."""
321
327
 
322
328
  _Table: ClassVar[type[AsyncSQLATableT]] # pyright: ignore[reportGeneralTypeIssues]
@@ -328,7 +334,10 @@ class AsyncReadDAL(AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT]):
328
334
  session: AsyncSession,
329
335
  entity_id: int,
330
336
  ) -> AsyncSQLATableT | None:
331
- return await session.get(cls._Table, entity_id)
337
+ entity = await session.get(cls._Table, entity_id)
338
+ if entity is not None and isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted:
339
+ return None
340
+ return entity
332
341
 
333
342
  @classmethod
334
343
  async def batch_get_field__entity(
@@ -402,6 +411,8 @@ class AsyncReadDAL(AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT]):
402
411
  ) -> DTOModelT | None:
403
412
  entity = await session.get(cls._Table, entity_id)
404
413
  if entity:
414
+ if isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted:
415
+ return None
405
416
  if need_refresh:
406
417
  await session.refresh(entity)
407
418
  return cls._DTO.model_validate(entity)
@@ -423,18 +434,20 @@ class AsyncReadDAL(AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT]):
423
434
  @classmethod
424
435
  async def exists(cls, session: AsyncSession, entity_id: int) -> bool:
425
436
  entity = await session.get(cls._Table, entity_id)
426
- return entity is not None
437
+ if entity is None:
438
+ return False
439
+ return not (isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted)
427
440
 
428
441
  @classmethod
429
- async def _get_by_id_for_update_core(
442
+ async def get_by_id_for_update(
430
443
  cls,
431
444
  session: AsyncSession,
432
445
  entity_id: int,
433
446
  *,
434
- timeout: int | None = None,
447
+ lock_wait_timeout: int | None = None,
435
448
  ) -> AsyncSQLATableT | None:
436
449
  try:
437
- async with async_temp_set_lock_wait_timeout(session, timeout):
450
+ async with async_temp_set_lock_wait_timeout(session, lock_wait_timeout):
438
451
  stmt = (
439
452
  sa.select(cls._Table)
440
453
  .where(cls._Table.id == entity_id) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownArgumentType]
@@ -449,29 +462,19 @@ class AsyncReadDAL(AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT]):
449
462
  raise
450
463
 
451
464
  @classmethod
452
- async def get_by_id_for_update(
453
- cls,
454
- session: AsyncSession,
455
- entity_id: int,
456
- *,
457
- lock_wait_timeout: int | None = None,
458
- ) -> AsyncSQLATableT | None:
459
- return await cls._get_by_id_for_update_core(session, entity_id, timeout=lock_wait_timeout)
460
-
461
- @classmethod
462
- async def _batch_get_for_update_core(
465
+ async def batch_get_for_update(
463
466
  cls,
464
467
  session: AsyncSession,
465
468
  entity_ids: Iterable[int],
466
469
  *,
467
- timeout: int | None = None,
470
+ lock_wait_timeout: int | None = None,
468
471
  ) -> list[AsyncSQLATableT]:
469
472
  filtered_ids = filtered_in_sql_values(entity_ids, int)
470
473
  if not filtered_ids:
471
474
  return []
472
475
 
473
476
  try:
474
- async with async_temp_set_lock_wait_timeout(session, timeout):
477
+ async with async_temp_set_lock_wait_timeout(session, lock_wait_timeout):
475
478
  stmt = (
476
479
  sa.select(cls._Table)
477
480
  .where(cls._Table.id.in_(filtered_ids)) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownArgumentType]
@@ -486,25 +489,15 @@ class AsyncReadDAL(AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT]):
486
489
  raise
487
490
 
488
491
  @classmethod
489
- async def batch_get_for_update(
490
- cls,
491
- session: AsyncSession,
492
- entity_ids: Iterable[int],
493
- *,
494
- lock_wait_timeout: int | None = None,
495
- ) -> list[AsyncSQLATableT]:
496
- return await cls._batch_get_for_update_core(session, entity_ids, timeout=lock_wait_timeout)
497
-
498
- @classmethod
499
- async def _get_one_for_update_core(
492
+ async def get_one_for_update(
500
493
  cls,
501
494
  session: AsyncSession,
502
495
  *,
503
496
  where_clauses: list[ColumnExpressionArgument[bool]],
504
- timeout: int | None = None,
497
+ lock_wait_timeout: int | None = None,
505
498
  ) -> AsyncSQLATableT | None:
506
499
  try:
507
- async with async_temp_set_lock_wait_timeout(session, timeout):
500
+ async with async_temp_set_lock_wait_timeout(session, lock_wait_timeout):
508
501
  stmt = sa.select(cls._Table).with_for_update()
509
502
 
510
503
  for clause in where_clauses:
@@ -518,16 +511,6 @@ class AsyncReadDAL(AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT]):
518
511
  raise DBRetryableError(f"{PESSIMISTIC_LOCK_ERROR_MSG_TRAIT}-条件锁等待超时: {error_msg}") from e
519
512
  raise
520
513
 
521
- @classmethod
522
- async def get_one_for_update(
523
- cls,
524
- session: AsyncSession,
525
- *,
526
- where_clauses: list[ColumnExpressionArgument[bool]],
527
- lock_wait_timeout: int | None = None,
528
- ) -> AsyncSQLATableT | None:
529
- return await cls._get_one_for_update_core(session, where_clauses=where_clauses, timeout=lock_wait_timeout)
530
-
531
514
  @classmethod
532
515
  async def iter_record_dtos(
533
516
  cls,
@@ -564,7 +547,12 @@ class AsyncRawDAL:
564
547
  return await session.execute(stmt, params)
565
548
 
566
549
 
567
- class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOModelT, CUModelT]):
550
+ class AsyncWriteDAL(
551
+ AsyncRawDAL,
552
+ AsyncRawReadDAL,
553
+ AbstractAsyncWriteDAL[AsyncSession, AsyncSQLATableT, DTOModelT, CUModelT, int],
554
+ Generic[AsyncSQLATableT, DTOModelT, CUModelT],
555
+ ):
568
556
  """写入数据访问层基类."""
569
557
 
570
558
  _Table: ClassVar[type[AsyncSQLATableT]] # pyright: ignore[reportGeneralTypeIssues]
@@ -644,7 +632,7 @@ class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOMo
644
632
  _ensure_strict_fields(provided_keys=provided_keys, allowed_names=allowed_names, strict=strict)
645
633
 
646
634
  @classmethod
647
- async def _update_full_by_id_core(
635
+ async def update_full_by_id(
648
636
  cls,
649
637
  session: AsyncSession,
650
638
  entity_id: int,
@@ -678,19 +666,7 @@ class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOMo
678
666
  return entity
679
667
 
680
668
  @classmethod
681
- async def update_full_by_id(
682
- cls,
683
- session: AsyncSession,
684
- entity_id: int,
685
- cu: CUModelT,
686
- *,
687
- need_refresh: bool = False,
688
- strict_missing: bool = True,
689
- ) -> AsyncSQLATableT | None:
690
- return await cls._update_full_by_id_core(session, entity_id, cu, need_refresh=need_refresh, strict_missing=strict_missing)
691
-
692
- @classmethod
693
- async def _update_partial_by_id_core(
669
+ async def update_partial_by_id(
694
670
  cls,
695
671
  session: AsyncSession,
696
672
  entity_id: int,
@@ -757,30 +733,6 @@ class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOMo
757
733
  await session.refresh(entity)
758
734
  return entity
759
735
 
760
- @classmethod
761
- async def update_partial_by_id(
762
- cls,
763
- session: AsyncSession,
764
- entity_id: int,
765
- cu: CUModelT,
766
- *,
767
- need_refresh: bool = False,
768
- fields: set[InstrumentedAttribute[Any]] | set[sa.Column[Any]] | None = None,
769
- none_policy: Literal["ignore", "allow", "forbid"] = "ignore",
770
- none_policy_overrides: dict[InstrumentedAttribute[Any] | sa.Column[Any], Literal["ignore", "allow", "forbid"]] | None = None,
771
- strict: bool = False,
772
- ) -> AsyncSQLATableT | None:
773
- return await cls._update_partial_by_id_core(
774
- session,
775
- entity_id,
776
- cu,
777
- need_refresh=need_refresh,
778
- fields=fields,
779
- none_policy=none_policy,
780
- none_policy_overrides=none_policy_overrides,
781
- strict=strict,
782
- )
783
-
784
736
  @classmethod
785
737
  async def delete_by_id(
786
738
  cls,
@@ -816,11 +768,11 @@ class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOMo
816
768
  yield entity
817
769
 
818
770
  @classmethod
819
- async def _batch_update_by_conditions_core(
771
+ async def batch_update_by_conditions(
820
772
  cls,
821
773
  session: AsyncSession,
822
774
  *,
823
- conditions: list[ColumnExpressionArgument[bool]],
775
+ whereclause: list[ColumnExpressionArgument[bool]],
824
776
  update_data: dict[InstrumentedAttribute[Any], Any] | dict[sa.Column[Any], Any],
825
777
  updater_id: int | None = None,
826
778
  ) -> int:
@@ -845,24 +797,13 @@ class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOMo
845
797
  if hasattr(cls._Table, "update_operator_id") and updater_id is not None:
846
798
  final_update_data["update_operator_id"] = updater_id
847
799
 
848
- stmt = sa.update(cls._Table).where(*conditions).values(**final_update_data)
800
+ stmt = sa.update(cls._Table).where(*whereclause).values(**final_update_data)
849
801
 
850
802
  result = await session.execute(stmt)
851
803
  await session.flush()
852
804
 
853
805
  return result.rowcount # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownVariableType]
854
806
 
855
- @classmethod
856
- async def batch_update_by_conditions(
857
- cls,
858
- session: AsyncSession,
859
- *,
860
- whereclause: list[ColumnExpressionArgument[bool]],
861
- update_data: dict[InstrumentedAttribute[Any], Any] | dict[sa.Column[Any], Any],
862
- updater_id: int | None = None,
863
- ) -> int:
864
- return await cls._batch_update_by_conditions_core(session, conditions=whereclause, update_data=update_data, updater_id=updater_id)
865
-
866
807
  @classmethod
867
808
  async def batch_update_by_ids(
868
809
  cls,
@@ -876,15 +817,15 @@ class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOMo
876
817
  if not filtered_ids:
877
818
  return 0
878
819
  _id_column = cls._Table.id # pyright: ignore[reportAttributeAccessIssue,reportUnknownVariableType, reportUnknownMemberType]
879
- return await cls._batch_update_by_conditions_core(
820
+ return await cls.batch_update_by_conditions(
880
821
  session,
881
- conditions=[_id_column.in_(filtered_ids)], # pyright: ignore[reportUnknownMemberType]
822
+ whereclause=[_id_column.in_(filtered_ids)], # pyright: ignore[reportUnknownMemberType]
882
823
  update_data=update_data,
883
824
  updater_id=updater_id,
884
825
  )
885
826
 
886
827
  @classmethod
887
- async def _update_only_set_with_optimistic_lock_core(
828
+ async def update_only_set_with_optimistic_lock(
888
829
  cls,
889
830
  session: AsyncSession,
890
831
  entity_id: int,
@@ -935,45 +876,13 @@ class AsyncWriteDAL(AsyncRawDAL, AsyncRawReadDAL, Generic[AsyncSQLATableT, DTOMo
935
876
 
936
877
  raise DBRetryableError(f"{OPTIMISTIC_LOCK_ERROR_MSG_TRAIT}-版本号不匹配({entity_id=}, {expected_version=})")
937
878
 
938
- @classmethod
939
- async def update_only_set_with_optimistic_lock(
940
- cls,
941
- session: AsyncSession,
942
- entity_id: int,
943
- cu: CUModelT,
944
- *,
945
- expected_version: int,
946
- need_refresh: bool = False,
947
- version_field: str = "version",
948
- ) -> AsyncSQLATableT | None:
949
- return await cls._update_only_set_with_optimistic_lock_core(
950
- session,
951
- entity_id,
952
- cu,
953
- expected_version=expected_version,
954
- need_refresh=need_refresh,
955
- version_field=version_field,
956
- )
957
-
958
879
 
959
880
  class AsyncXDALOp(AsyncRawReadDAL, AsyncRawDAL):
960
881
  """扩展数据访问操作类."""
961
882
 
962
883
 
963
884
  class AsyncBaseDAL(AsyncReadDAL[AsyncSQLATableT, DTOModelT], AsyncWriteDAL[AsyncSQLATableT, DTOModelT, CUModelT]):
964
- """基础数据访问层.
965
-
966
- .. deprecated:: 0.3.0
967
- V1 DAL 将在 1.0 移除, 请迁移至 ``AsyncBaseDALV2``.
968
- """
969
-
970
- def __init_subclass__(cls, **kwargs: Any) -> None:
971
- super().__init_subclass__(**kwargs)
972
- warnings.warn(
973
- f"{cls.__name__} 继承了 V1 AsyncBaseDAL, 建议迁移至 AsyncBaseDALV2",
974
- DeprecationWarning,
975
- stacklevel=2,
976
- )
885
+ """基础数据访问层."""
977
886
 
978
887
 
979
888
  class ReadOnlyAsyncBaseDAL(AsyncReadDAL[AsyncSQLATableT, ReadOnlyDTOModelT]):
@@ -212,15 +212,51 @@ class StdBaseDTO(BaseDTO[CUModelT]):
212
212
 
213
213
 
214
214
  class SoftDeleteTableMixin:
215
- """软删除表混入类."""
215
+ """软删除表标记混入类 — 不强制任何列名或类型.
216
216
 
217
- is_delete: Mapped[int] = mapped_column(sa.Integer, default=0, comment="逻辑删除")
217
+ 子类必须提供软删除列并覆写 ``is_soft_deleted`` property.
218
+ 快捷方式: 继承 ``FieldIsDeleteSoftDeleteTableMixin`` 获得标准 ``is_delete`` 列.
219
+
220
+ 自定义示例::
221
+
222
+ class MyTable(Base, SoftDeleteTableMixin):
223
+ __soft_delete_column__ = "deleted_at"
224
+
225
+ deleted_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
226
+
227
+ @property
228
+ def is_soft_deleted(self) -> bool:
229
+ return self.deleted_at is not None
218
230
 
219
- def delete(self, is_delete: int = 1) -> None:
220
- self.is_delete = is_delete
231
+ def soft_delete(self) -> None:
232
+ from datetime import datetime, timezone
221
233
 
222
- def undelete(self) -> None:
223
- self.is_delete = 0
234
+ self.deleted_at = datetime.now(timezone.utc)
235
+ """
236
+
237
+ __soft_delete_column__: ClassVar[str] = "is_delete"
238
+
239
+ def soft_delete(self) -> None:
240
+ """标记为软删除."""
241
+ setattr(self, self.__soft_delete_column__, 1)
242
+
243
+ def soft_undelete(self) -> None:
244
+ """恢复软删除."""
245
+ setattr(self, self.__soft_delete_column__, 0)
246
+
247
+ @property
248
+ def is_soft_deleted(self) -> bool:
249
+ """实体是否已被软删除(默认检查 ``is_delete != 0``,子类可覆写)."""
250
+ return bool(getattr(self, self.__soft_delete_column__))
251
+
252
+
253
+ class FieldIsDeleteSoftDeleteTableMixin(SoftDeleteTableMixin):
254
+ """标准软删除混入类 — 提供 ``is_delete: Mapped[int]`` 列.
255
+
256
+ 等价旧版 ``SoftDeleteTableMixin``,仅需 rename.
257
+ """
258
+
259
+ is_delete: Mapped[int] = mapped_column(sa.Integer, default=0, comment="逻辑删除")
224
260
 
225
261
 
226
262
  class ReadOnlyMixin:
@@ -267,6 +303,65 @@ class FieldMixin:
267
303
  return
268
304
 
269
305
 
306
+ # ---------------------------------------------------------------------------
307
+ # Soft-delete hook management API (explicit register/unregister/check)
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def register_soft_delete_hooks() -> None:
312
+ """显式注册软删除 Session 事件监听器(幂等)。
313
+
314
+ 在以下场景需要显式调用:
315
+ - 多进程 worker 子进程(import 可能未触发)
316
+ - 测试中自建 Session、未走应用启动链
317
+ - FastAPI lifespan / Flask app factory 显式初始化
318
+ """
319
+ if not sa_event.contains(SyncSession, "before_flush", __receive_before_flush):
320
+ sa_event.listen(SyncSession, "before_flush", __receive_before_flush, insert=True)
321
+ if not sa_event.contains(SyncSession, "do_orm_execute", __add_filtering_criteria):
322
+ sa_event.listen(SyncSession, "do_orm_execute", __add_filtering_criteria, insert=True)
323
+
324
+
325
+ def unregister_soft_delete_hooks() -> None:
326
+ """注销软删除 Session 事件监听器(幂等)。"""
327
+ if sa_event.contains(SyncSession, "before_flush", __receive_before_flush):
328
+ sa_event.remove(SyncSession, "before_flush", __receive_before_flush)
329
+ if sa_event.contains(SyncSession, "do_orm_execute", __add_filtering_criteria):
330
+ sa_event.remove(SyncSession, "do_orm_execute", __add_filtering_criteria)
331
+
332
+
333
+ def is_soft_delete_hooks_registered() -> bool:
334
+ """检查软删除钩子是否已注册。"""
335
+ return sa_event.contains(SyncSession, "before_flush", __receive_before_flush) and sa_event.contains(
336
+ SyncSession, "do_orm_execute", __add_filtering_criteria
337
+ )
338
+
339
+
340
+ def setup_dal_hooks() -> None:
341
+ """注册所有必要的 Session 事件监听器(幂等)。
342
+
343
+ 在应用生命周期开始时**调用一次**即可,无需关注具体注册了哪些钩子。
344
+ 涵盖:软删除拦截、软删除查询过滤、只读保护。
345
+
346
+ FastAPI 示例::
347
+
348
+ @asynccontextmanager
349
+ async def lifespan(app):
350
+ setup_dal_hooks()
351
+ yield
352
+
353
+ Flask 示例::
354
+
355
+ def create_app():
356
+ app = Flask(__name__)
357
+ setup_dal_hooks()
358
+ return app
359
+ """
360
+ register_soft_delete_hooks()
361
+ if not sa_event.contains(SyncSession, "before_flush", __prevent_readonly_write):
362
+ sa_event.listen(SyncSession, "before_flush", __prevent_readonly_write, insert=True)
363
+
364
+
270
365
  # ---------------------------------------------------------------------------
271
366
  # Session event listeners (registered on SyncSession — works for both sync
272
367
  # and async since AsyncSession delegates to a SyncSession internally)
@@ -277,7 +372,7 @@ class FieldMixin:
277
372
  def __receive_before_flush(session: SyncSession, flush_context: Any, instances: Any) -> None: # noqa: ARG001 # pyright: ignore[reportUnusedFunction, reportUnusedParameter]
278
373
  for instance in session.deleted:
279
374
  if isinstance(instance, SoftDeleteTableMixin):
280
- instance.delete()
375
+ instance.soft_delete()
281
376
  session.add(instance)
282
377
 
283
378
 
@@ -292,7 +387,7 @@ def __add_filtering_criteria(execute_state: ORMExecuteState) -> None: # pyright
292
387
  execute_state.statement = execute_state.statement.options(
293
388
  with_loader_criteria(
294
389
  SoftDeleteTableMixin,
295
- lambda t: t.is_delete == 0,
390
+ lambda cls: getattr(cls, getattr(cls, "__soft_delete_column__", "is_delete"), sa.null()) == 0,
296
391
  include_aliases=True,
297
392
  )
298
393
  )
@@ -352,6 +447,11 @@ __all__ = (
352
447
  "T",
353
448
  "V",
354
449
  "_ensure_strict_fields",
450
+ "FieldIsDeleteSoftDeleteTableMixin",
355
451
  "escape_like",
356
452
  "filtered_in_sql_values",
453
+ "is_soft_delete_hooks_registered",
454
+ "register_soft_delete_hooks",
455
+ "setup_dal_hooks",
456
+ "unregister_soft_delete_hooks",
357
457
  )
@@ -42,7 +42,7 @@ def build_offset_stmt(
42
42
  if order_by is not None:
43
43
  stmt = stmt.order_by(order_by)
44
44
  else:
45
- stmt = stmt.order_by(table.id) # pyright: ignore[reportAttributeAccessIssue]
45
+ stmt = stmt.order_by(table.id)
46
46
  return stmt.offset(p.skip).limit(p.limit)
47
47
 
48
48
 
@@ -55,7 +55,7 @@ def build_cursor_stmt(
55
55
  使用 id > cursor_value 的 keyset 分页方式.
56
56
  """
57
57
  p = pagination or CursorPagination()
58
- id_col = table.id # pyright: ignore[reportAttributeAccessIssue]
58
+ id_col = table.id
59
59
  stmt = sa.select(table).order_by(id_col)
60
60
 
61
61
  if p.cursor is not None: