lush-sqlalchemyx 0.3.2__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 (28) hide show
  1. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/PKG-INFO +1 -1
  2. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/pyproject.toml +1 -1
  3. lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/__init__.py +19 -0
  4. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/__init__.py +10 -0
  5. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_async.py +11 -3
  6. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_common.py +108 -8
  7. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_sync.py +11 -3
  8. lush_sqlalchemyx-0.3.2/src/lush_sqlalchemyx/shortcuts/__init__.py +0 -0
  9. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/README.md +0 -0
  10. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/_compat.py +0 -0
  11. {lush_sqlalchemyx-0.3.2/src/lush_sqlalchemyx → lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/base}/__init__.py +0 -0
  12. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_pagination.py +0 -0
  13. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/base/dal/_repository.py +0 -0
  14. {lush_sqlalchemyx-0.3.2/src/lush_sqlalchemyx/base → lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/integrations}/__init__.py +0 -0
  15. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/fastapi/__init__.py +0 -0
  16. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/fastapi/depends/__init__.py +0 -0
  17. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/flask/__init__.py +0 -0
  18. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/integrations/flask/ext.py +0 -0
  19. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/__init__.py +0 -0
  20. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/__init__.py +0 -0
  21. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/manager.py +0 -0
  22. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/mapper.py +0 -0
  23. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/sync_manager.py +0 -0
  24. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/mgrs/mysql/sync_mapper.py +0 -0
  25. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/py.typed +0 -0
  26. {lush_sqlalchemyx-0.3.2 → lush_sqlalchemyx-0.4.0}/src/lush_sqlalchemyx/same_impl_just_warn_wrapper.py +0 -0
  27. {lush_sqlalchemyx-0.3.2/src/lush_sqlalchemyx/integrations → lush_sqlalchemyx-0.4.0/src/lush_sqlalchemyx/shortcuts}/__init__.py +0 -0
  28. {lush_sqlalchemyx-0.3.2 → 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.2
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.2"
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"
@@ -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
+ )
@@ -43,6 +43,7 @@ from ._common import (
43
43
  CUModelT,
44
44
  DBRetryableError,
45
45
  DTOModelT,
46
+ FieldIsDeleteSoftDeleteTableMixin,
46
47
  FieldMixin,
47
48
  ReadOnlyMixin,
48
49
  RetryConfig,
@@ -52,6 +53,10 @@ from ._common import (
52
53
  StdBaseDTO,
53
54
  escape_like,
54
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,
55
60
  )
56
61
 
57
62
  # Event listener references — accessed by tests via getattr(module, name).
@@ -102,10 +107,15 @@ __all__ = (
102
107
  "RetryConfig",
103
108
  "SQLATableT",
104
109
  "SoftDeleteTableMixin",
110
+ "FieldIsDeleteSoftDeleteTableMixin",
105
111
  "StdBaseCU",
106
112
  "StdBaseDTO",
107
113
  "escape_like",
108
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",
109
119
  # async
110
120
  "AsyncBaseDAL",
111
121
  "AsyncRawDAL",
@@ -31,6 +31,7 @@ from ._common import (
31
31
  CUModelT,
32
32
  DBRetryableError,
33
33
  DTOModelT,
34
+ FieldIsDeleteSoftDeleteTableMixin,
34
35
  ReadOnlyMixin,
35
36
  RetryConfig,
36
37
  SoftDeleteTableMixin,
@@ -142,7 +143,7 @@ class ReadOnlyBasicAsyncBaseTable(AsyncSqlATableBase, ReadOnlyMixin):
142
143
  __abstract__ = True
143
144
 
144
145
 
145
- class StdAsyncBaseTable(BasicAsyncBaseTable, SoftDeleteTableMixin):
146
+ class StdAsyncBaseTable(BasicAsyncBaseTable, FieldIsDeleteSoftDeleteTableMixin):
146
147
  """标准异步表类: 包含 id/时间戳/操作人/软删除等标准字段.
147
148
 
148
149
  .. deprecated::
@@ -333,7 +334,10 @@ class AsyncReadDAL(
333
334
  session: AsyncSession,
334
335
  entity_id: int,
335
336
  ) -> AsyncSQLATableT | None:
336
- 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
337
341
 
338
342
  @classmethod
339
343
  async def batch_get_field__entity(
@@ -407,6 +411,8 @@ class AsyncReadDAL(
407
411
  ) -> DTOModelT | None:
408
412
  entity = await session.get(cls._Table, entity_id)
409
413
  if entity:
414
+ if isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted:
415
+ return None
410
416
  if need_refresh:
411
417
  await session.refresh(entity)
412
418
  return cls._DTO.model_validate(entity)
@@ -428,7 +434,9 @@ class AsyncReadDAL(
428
434
  @classmethod
429
435
  async def exists(cls, session: AsyncSession, entity_id: int) -> bool:
430
436
  entity = await session.get(cls._Table, entity_id)
431
- return entity is not None
437
+ if entity is None:
438
+ return False
439
+ return not (isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted)
432
440
 
433
441
  @classmethod
434
442
  async def get_by_id_for_update(
@@ -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
  )
@@ -28,6 +28,7 @@ from ._common import (
28
28
  CUModelT,
29
29
  DBRetryableError,
30
30
  DTOModelT,
31
+ FieldIsDeleteSoftDeleteTableMixin,
31
32
  ReadOnlyMixin,
32
33
  RetryConfig,
33
34
  SoftDeleteTableMixin,
@@ -136,7 +137,7 @@ class ReadOnlySyncBaseTable(SyncSqlATableBase, ReadOnlyMixin):
136
137
  __abstract__ = True
137
138
 
138
139
 
139
- class StdSyncBaseTable(BasicSyncBaseTable, SoftDeleteTableMixin):
140
+ class StdSyncBaseTable(BasicSyncBaseTable, FieldIsDeleteSoftDeleteTableMixin):
140
141
  """标准同步表类: 包含 id/时间戳/操作人/软删除等标准字段.
141
142
 
142
143
  .. deprecated::
@@ -326,7 +327,10 @@ class SyncReadDAL(
326
327
  session: Session,
327
328
  entity_id: int,
328
329
  ) -> SyncSQLATableT | None:
329
- return session.get(cls._Table, entity_id)
330
+ entity = session.get(cls._Table, entity_id)
331
+ if entity is not None and isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted:
332
+ return None
333
+ return entity
330
334
 
331
335
  @classmethod
332
336
  def batch_get_field__entity(
@@ -398,6 +402,8 @@ class SyncReadDAL(
398
402
  ) -> DTOModelT | None:
399
403
  entity = session.get(cls._Table, entity_id)
400
404
  if entity:
405
+ if isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted:
406
+ return None
401
407
  if need_refresh:
402
408
  session.refresh(entity)
403
409
  return cls._DTO.model_validate(entity)
@@ -419,7 +425,9 @@ class SyncReadDAL(
419
425
  @classmethod
420
426
  def exists(cls, session: Session, entity_id: int) -> bool:
421
427
  entity = session.get(cls._Table, entity_id)
422
- return entity is not None
428
+ if entity is None:
429
+ return False
430
+ return not (isinstance(entity, SoftDeleteTableMixin) and entity.is_soft_deleted)
423
431
 
424
432
  @classmethod
425
433
  def get_by_id_for_update(