python-ddd-framework 0.3.2__py3-none-any.whl → 0.4.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.
Files changed (334) hide show
  1. python_ddd_framework/__init__.py +139 -21
  2. python_ddd_framework/application/builder.py +23 -78
  3. python_ddd_framework/application/composition.py +4 -22
  4. python_ddd_framework/application/runtime.py +96 -291
  5. python_ddd_framework/application/state.py +7 -75
  6. python_ddd_framework/application_services/__init__.py +5 -21
  7. python_ddd_framework/{services/application_bindings.py → application_services/bindings.py} +52 -9
  8. python_ddd_framework/application_services/catalog.py +36 -288
  9. python_ddd_framework/application_services/contracts.py +14 -13
  10. python_ddd_framework/application_services/dispatcher.py +19 -269
  11. python_ddd_framework/application_services/invocation.py +126 -7
  12. python_ddd_framework/application_services/module.py +168 -0
  13. python_ddd_framework/application_services/pagination.py +21 -0
  14. python_ddd_framework/auditing/control.py +1 -1
  15. python_ddd_framework/auditing/module.py +15 -0
  16. python_ddd_framework/authorization/__init__.py +2 -0
  17. python_ddd_framework/authorization/contracts.py +7 -2
  18. python_ddd_framework/authorization/definitions.py +5 -0
  19. python_ddd_framework/authorization/module.py +40 -0
  20. python_ddd_framework/authorization/options.py +7 -0
  21. python_ddd_framework/background_execution/child.py +13 -3
  22. python_ddd_framework/background_execution/declarations.py +45 -0
  23. python_ddd_framework/background_execution/lifecycle.py +12 -0
  24. python_ddd_framework/background_execution/module.py +107 -1
  25. python_ddd_framework/background_jobs/catalog.py +59 -42
  26. python_ddd_framework/background_jobs/contracts.py +16 -19
  27. python_ddd_framework/background_jobs/pgqueuer/enqueue.py +4 -0
  28. python_ddd_framework/background_jobs/pgqueuer/module.py +2 -1
  29. python_ddd_framework/background_jobs/pgqueuer/runtime.py +7 -7
  30. python_ddd_framework/background_workers/catalog.py +23 -36
  31. python_ddd_framework/background_workers/contracts.py +3 -0
  32. python_ddd_framework/background_workers/execution.py +1 -1
  33. python_ddd_framework/background_workers/runtime.py +38 -1
  34. python_ddd_framework/caching/__init__.py +7 -6
  35. python_ddd_framework/caching/contracts.py +65 -67
  36. python_ddd_framework/caching/errors.py +0 -7
  37. python_ddd_framework/caching/module.py +18 -0
  38. python_ddd_framework/caching/naming.py +29 -0
  39. python_ddd_framework/caching/options.py +38 -0
  40. python_ddd_framework/caching/unit_of_work.py +46 -0
  41. python_ddd_framework/cli/__init__.py +31 -6
  42. python_ddd_framework/cli/project.py +68 -6
  43. python_ddd_framework/cli/runtime.py +1 -1
  44. python_ddd_framework/developer_kit/generation.py +75 -22
  45. python_ddd_framework/developer_kit/project_metadata.py +2 -4
  46. python_ddd_framework/developer_kit/templates/basic/cookiecutter.json +4 -0
  47. python_ddd_framework/developer_kit/templates/basic/{{cookiecutter.module_name}}/README.md +45 -0
  48. python_ddd_framework/developer_kit/templates/basic/{{cookiecutter.module_name}}/__init__.py.jinja +0 -0
  49. python_ddd_framework/developer_kit/templates/basic/{{cookiecutter.module_name}}/contracts/__init__.py.jinja +0 -0
  50. python_ddd_framework/developer_kit/templates/basic/{{cookiecutter.module_name}}/contracts/conversion_service.py.jinja +7 -0
  51. python_ddd_framework/developer_kit/templates/basic/{{cookiecutter.module_name}}/module.py.jinja +38 -0
  52. python_ddd_framework/developer_kit/templates/basic/{{cookiecutter.module_name}}/services/__init__.py.jinja +0 -0
  53. python_ddd_framework/developer_kit/templates/basic/{{cookiecutter.module_name}}/services/default_conversion_service.py.jinja +10 -0
  54. python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -1
  55. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +41 -49
  56. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/__init__.py.jinja +1 -0
  57. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +1 -0
  58. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +45 -0
  59. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +8 -0
  60. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +1 -0
  61. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +35 -0
  62. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +5 -0
  63. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +15 -0
  64. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/__init__.py.jinja +1 -0
  65. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +28 -0
  66. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +26 -0
  67. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/__init__.py.jinja +1 -0
  68. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +9 -0
  69. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +9 -0
  70. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/__init__.py.jinja +1 -0
  71. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +23 -0
  72. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/__init__.py.jinja +1 -0
  73. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/{integration.py.jinja → hosted_services/order_integration_service.py.jinja} +8 -2
  74. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +16 -0
  75. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/__init__.py.jinja +1 -0
  76. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +16 -0
  77. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/__init__.py.jinja +1 -0
  78. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +17 -0
  79. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +105 -10
  80. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/__init__.py.jinja +1 -0
  81. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/{options.py.jinja → options/order_options.py.jinja} +1 -1
  82. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/__init__.py.jinja +1 -0
  83. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +65 -0
  84. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +35 -0
  85. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +41 -0
  86. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/__init__.py.jinja +1 -0
  87. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +17 -0
  88. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/__init__.py.jinja +1 -0
  89. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +6 -0
  90. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +7 -0
  91. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/__init__.py.jinja +1 -0
  92. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +7 -0
  93. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +39 -2
  94. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/__init__.py.jinja +1 -0
  95. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +13 -0
  96. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +10 -0
  97. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +12 -0
  98. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/__init__.py.jinja +1 -0
  99. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +9 -0
  100. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +12 -0
  101. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/__init__.py.jinja +1 -0
  102. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/{orders.py.jinja → entities/order.py.jinja} +10 -18
  103. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/__init__.py.jinja +1 -0
  104. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +13 -0
  105. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +42 -3
  106. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/__init__.py.jinja +1 -0
  107. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +17 -0
  108. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/__init__.py.jinja +1 -0
  109. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +22 -0
  110. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/__init__.py.jinja +1 -0
  111. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +23 -0
  112. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/__init__.py.jinja +1 -0
  113. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/{settings.py.jinja → settings/approval_settings.py.jinja} +3 -2
  114. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/__init__.py.jinja +1 -0
  115. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +10 -0
  116. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/__init__.py.jinja +1 -0
  117. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +3 -0
  118. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/__init__.py.jinja +1 -0
  119. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +6 -0
  120. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/__init__.py.jinja +1 -0
  121. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +11 -0
  122. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/__init__.py.jinja +1 -0
  123. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +26 -0
  124. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +13 -0
  125. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +40 -1
  126. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/__init__.py.jinja +1 -0
  127. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/{permissions.py.jinja → permissions/order_permission_provider.py.jinja} +6 -2
  128. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +7 -0
  129. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/__init__.py.jinja +1 -0
  130. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +18 -0
  131. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/__init__.py.jinja +1 -0
  132. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +15 -0
  133. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/__init__.py.jinja +1 -0
  134. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +5 -0
  135. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +85 -7
  136. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/__init__.py.jinja +1 -0
  137. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/{files.py.jinja → routers/order_files.py.jinja} +23 -20
  138. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/__init__.py.jinja +1 -0
  139. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +44 -0
  140. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -1
  141. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/__init__.py.jinja +1 -7
  142. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/base.py.jinja +7 -0
  143. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/{orders.py.jinja → order_model.py.jinja} +7 -3
  144. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +42 -5
  145. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +79 -0
  146. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/__init__.py.jinja +1 -0
  147. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +4 -3
  148. python_ddd_framework/developer_kit/templates/project/cookiecutter.json +1 -1
  149. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +16 -3
  150. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +29 -22
  151. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{app.development.yaml.jinja → backend/app.development.yaml.jinja} +2 -0
  152. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{pyproject.toml.jinja → backend/pyproject.toml.jinja} +1 -1
  153. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{src → backend/src}/host/main.py.jinja +2 -0
  154. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{src → backend/src}/host/module.py.jinja +40 -3
  155. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +40 -10
  156. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +134 -23
  157. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/scripts/.gitkeep +0 -0
  158. python_ddd_framework/distributed_lock/contracts.py +4 -1
  159. python_ddd_framework/domain/__init__.py +2 -0
  160. python_ddd_framework/domain/services.py +11 -0
  161. python_ddd_framework/errors/business.py +10 -1
  162. python_ddd_framework/errors/lifecycle.py +3 -31
  163. python_ddd_framework/events/__init__.py +5 -2
  164. python_ddd_framework/events/aggregate.py +3 -3
  165. python_ddd_framework/events/catalog.py +12 -11
  166. python_ddd_framework/events/contracts.py +6 -1
  167. python_ddd_framework/events/contribution.py +1 -52
  168. python_ddd_framework/events/entities.py +23 -0
  169. python_ddd_framework/events/module.py +97 -0
  170. python_ddd_framework/events/runtime.py +80 -29
  171. python_ddd_framework/events/subscription.py +36 -0
  172. python_ddd_framework/events/unit_of_work.py +25 -0
  173. python_ddd_framework/extensions/__init__.py +22 -0
  174. python_ddd_framework/extensions/catalog.py +198 -0
  175. python_ddd_framework/extensions/contracts.py +85 -0
  176. python_ddd_framework/extensions/models.py +142 -0
  177. python_ddd_framework/extensions/module.py +21 -0
  178. python_ddd_framework/fastapi/__init__.py +2 -0
  179. python_ddd_framework/fastapi/action.py +1 -2
  180. python_ddd_framework/fastapi/adapter.py +16 -0
  181. python_ddd_framework/fastapi/application_services.py +43 -25
  182. python_ddd_framework/fastapi/background.py +2 -1
  183. python_ddd_framework/fastapi/contracts.py +26 -8
  184. python_ddd_framework/fastapi/health.py +1 -1
  185. python_ddd_framework/fastapi/module.py +14 -0
  186. python_ddd_framework/fastapi/parameters.py +3 -18
  187. python_ddd_framework/fastapi/realtime/connection.py +10 -7
  188. python_ddd_framework/fastapi/realtime/module.py +3 -0
  189. python_ddd_framework/fastapi/realtime/runtime.py +10 -24
  190. python_ddd_framework/fastapi/request_context.py +2 -1
  191. python_ddd_framework/fastapi/routing.py +2 -1
  192. python_ddd_framework/fastapi/server.py +8 -0
  193. python_ddd_framework/fastapi/settings.py +2 -1
  194. python_ddd_framework/hosted_services/bridge.py +20 -1
  195. python_ddd_framework/hosted_services/catalog.py +6 -16
  196. python_ddd_framework/hosted_services/contracts.py +4 -0
  197. python_ddd_framework/hosted_services/declarations.py +15 -0
  198. python_ddd_framework/hosted_services/module.py +69 -0
  199. python_ddd_framework/hosted_services/runtime.py +30 -1
  200. python_ddd_framework/identity/__init__.py +24 -0
  201. python_ddd_framework/identity/application.py +38 -90
  202. python_ddd_framework/identity/contracts.py +181 -16
  203. python_ddd_framework/identity/http_api.py +79 -2
  204. python_ddd_framework/identity/management.py +166 -0
  205. python_ddd_framework/identity/module.py +12 -4
  206. python_ddd_framework/identity/services.py +29 -16
  207. python_ddd_framework/identity/sqlalchemy/migrations/0003_extra_properties.py +26 -0
  208. python_ddd_framework/identity/sqlalchemy/migrations/0004_concurrency_version.py +29 -0
  209. python_ddd_framework/identity/sqlalchemy/models.py +21 -2
  210. python_ddd_framework/identity/sqlalchemy/module.py +11 -2
  211. python_ddd_framework/identity/sqlalchemy/repository.py +458 -0
  212. python_ddd_framework/identity/sqlalchemy/session_security.py +88 -0
  213. python_ddd_framework/identity/sqlalchemy/stores.py +69 -290
  214. python_ddd_framework/identity/tokens.py +2 -5
  215. python_ddd_framework/{application_services/validation.py → invocation/arguments.py} +8 -6
  216. python_ddd_framework/invocation/contribution.py +61 -0
  217. python_ddd_framework/invocation/dispatcher.py +291 -0
  218. python_ddd_framework/invocation/entries.py +2 -25
  219. python_ddd_framework/invocation/entrypoints.py +126 -0
  220. python_ddd_framework/{application_services → invocation}/execution.py +8 -5
  221. python_ddd_framework/invocation/function_runtime.py +3 -1
  222. python_ddd_framework/invocation/interception.py +113 -42
  223. python_ddd_framework/{application_services → invocation}/interceptors.py +2 -2
  224. python_ddd_framework/invocation/managed_proxy.py +116 -0
  225. python_ddd_framework/invocation/managed_services.py +250 -0
  226. python_ddd_framework/invocation/methods.py +345 -0
  227. python_ddd_framework/invocation/module.py +113 -0
  228. python_ddd_framework/{application_services → invocation}/policies.py +8 -1
  229. python_ddd_framework/invocation/validation.py +25 -0
  230. python_ddd_framework/lifecycle/__init__.py +2 -0
  231. python_ddd_framework/lifecycle/composition.py +5 -2
  232. python_ddd_framework/lifecycle/participants.py +33 -0
  233. python_ddd_framework/messaging/__init__.py +25 -0
  234. python_ddd_framework/messaging/channel.py +205 -0
  235. python_ddd_framework/messaging/contracts.py +69 -0
  236. python_ddd_framework/messaging/module.py +101 -0
  237. python_ddd_framework/messaging/options.py +13 -0
  238. python_ddd_framework/messaging/receipt.py +27 -0
  239. python_ddd_framework/messaging/runtime.py +124 -0
  240. python_ddd_framework/modularity/__init__.py +11 -1
  241. python_ddd_framework/modularity/contracts.py +47 -16
  242. python_ddd_framework/modularity/discovery.py +21 -2
  243. python_ddd_framework/modularity/graph.py +45 -1
  244. python_ddd_framework/modularity/registry.py +83 -3
  245. python_ddd_framework/notifications/__init__.py +2 -0
  246. python_ddd_framework/notifications/catalog.py +3 -2
  247. python_ddd_framework/notifications/declarations.py +18 -0
  248. python_ddd_framework/notifications/module.py +21 -0
  249. python_ddd_framework/observability/logging.py +3 -0
  250. python_ddd_framework/observability/tracing.py +3 -0
  251. python_ddd_framework/options/contribution.py +12 -0
  252. python_ddd_framework/options/registry.py +44 -11
  253. python_ddd_framework/realtime/__init__.py +2 -1
  254. python_ddd_framework/realtime/contracts.py +3 -7
  255. python_ddd_framework/realtime/messages.py +26 -1
  256. python_ddd_framework/redis/cache.py +243 -0
  257. python_ddd_framework/redis/cache_scripts.py +40 -0
  258. python_ddd_framework/redis/distributed_lock.py +10 -2
  259. python_ddd_framework/redis/module.py +13 -4
  260. python_ddd_framework/redis/notifications.py +5 -2
  261. python_ddd_framework/redis/runtime.py +1 -79
  262. python_ddd_framework/seeding/__init__.py +13 -0
  263. python_ddd_framework/seeding/contracts.py +45 -0
  264. python_ddd_framework/seeding/module.py +73 -0
  265. python_ddd_framework/seeding/runtime.py +63 -0
  266. python_ddd_framework/services/arbitration.py +23 -0
  267. python_ddd_framework/services/binding.py +2 -3
  268. python_ddd_framework/services/composition.py +108 -0
  269. python_ddd_framework/services/contribution.py +44 -54
  270. python_ddd_framework/services/convention.py +53 -181
  271. python_ddd_framework/services/fixed_lifetime.py +12 -15
  272. python_ddd_framework/services/framework_provider.py +3 -158
  273. python_ddd_framework/services/native_graph.py +35 -60
  274. python_ddd_framework/services/provider.py +59 -4
  275. python_ddd_framework/services/repository.py +6 -0
  276. python_ddd_framework/services/runtime.py +129 -215
  277. python_ddd_framework/settings/catalog.py +5 -4
  278. python_ddd_framework/settings/declarations.py +18 -0
  279. python_ddd_framework/settings/definitions.py +5 -0
  280. python_ddd_framework/settings/manager.py +1 -1
  281. python_ddd_framework/settings/module.py +50 -1
  282. python_ddd_framework/settings/notifications.py +4 -2
  283. python_ddd_framework/settings/refresh_module.py +9 -2
  284. python_ddd_framework/settings/sqlalchemy/module.py +2 -1
  285. python_ddd_framework/sqlalchemy/__init__.py +12 -0
  286. python_ddd_framework/sqlalchemy/alembic_runtime/env.py +1 -0
  287. python_ddd_framework/sqlalchemy/auditing.py +7 -1
  288. python_ddd_framework/sqlalchemy/extensions.py +186 -0
  289. python_ddd_framework/sqlalchemy/metadata.py +110 -6
  290. python_ddd_framework/sqlalchemy/migration.py +61 -8
  291. python_ddd_framework/sqlalchemy/migration_operations.py +86 -0
  292. python_ddd_framework/sqlalchemy/module.py +14 -11
  293. python_ddd_framework/sqlalchemy/module_migration.py +71 -3
  294. python_ddd_framework/sqlalchemy/repository.py +217 -13
  295. python_ddd_framework/sqlalchemy/session_provider.py +14 -1
  296. python_ddd_framework/testing/runtime.py +5 -4
  297. python_ddd_framework/unit_of_work/__init__.py +2 -1
  298. python_ddd_framework/unit_of_work/contracts.py +6 -7
  299. python_ddd_framework/unit_of_work/errors.py +11 -0
  300. python_ddd_framework/unit_of_work/lifecycle.py +19 -0
  301. python_ddd_framework/unit_of_work/manager.py +86 -32
  302. python_ddd_framework/unit_of_work/module.py +61 -0
  303. python_ddd_framework/unit_of_work/options.py +7 -1
  304. python_ddd_framework/validation/__init__.py +1 -0
  305. python_ddd_framework/validation/models.py +37 -0
  306. {python_ddd_framework-0.3.2.dist-info → python_ddd_framework-0.4.0.dist-info}/METADATA +139 -44
  307. python_ddd_framework-0.4.0.dist-info/RECORD +488 -0
  308. {python_ddd_framework-0.3.2.dist-info → python_ddd_framework-0.4.0.dist-info}/WHEEL +1 -1
  309. python_ddd_framework/application_services/seeding.py +0 -72
  310. python_ddd_framework/caching/catalog.py +0 -73
  311. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +0 -10
  312. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +0 -24
  313. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +0 -83
  314. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +0 -84
  315. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +0 -35
  316. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +0 -14
  317. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +0 -19
  318. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +0 -24
  319. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +0 -36
  320. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/orders.py.jinja +0 -49
  321. python_ddd_framework-0.3.2.dist-info/RECORD +0 -355
  322. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{.dockerignore → backend/.dockerignore} +0 -0
  323. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{.gitignore → backend/.gitignore} +0 -0
  324. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{.python-version → backend/.python-version} +0 -0
  325. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{Dockerfile → backend/Dockerfile} +0 -0
  326. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{compose.dev.yaml.jinja → backend/compose.dev.yaml.jinja} +0 -0
  327. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{compose.production.yaml.jinja → backend/compose.production.yaml.jinja} +0 -0
  328. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{src → backend/src}/host/__init__.py.jinja +0 -0
  329. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{tests → backend/tests}/conftest.py.jinja +0 -0
  330. /python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/{tests → backend/tests}/host/test_http.py.jinja +0 -0
  331. /python_ddd_framework/{application_services → invocation}/signature.py +0 -0
  332. {python_ddd_framework-0.3.2.dist-info → python_ddd_framework-0.4.0.dist-info}/entry_points.txt +0 -0
  333. {python_ddd_framework-0.3.2.dist-info → python_ddd_framework-0.4.0.dist-info}/licenses/LICENSE +0 -0
  334. {python_ddd_framework-0.3.2.dist-info → python_ddd_framework-0.4.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
@@ -23,6 +23,7 @@ context.configure(
23
23
  target_metadata=target_metadata,
24
24
  include_schemas=True,
25
25
  include_name=config.attributes.get("include_name"),
26
+ include_object=config.attributes.get("include_object"),
26
27
  **version_table_options,
27
28
  )
28
29
  with context.begin_transaction():
@@ -39,7 +39,13 @@ def _apply_audit_fields(session: Session, flush_context: object, instances: obje
39
39
  entity.created_at = now
40
40
  entity.created_by = user_id
41
41
  for entity in tuple(session.dirty):
42
- if isinstance(entity, AuditedEntityMixin) and entity not in session.new:
42
+ # merge 或相同值赋值也会标记 dirty;审计不能把无净变化的实体变成一次更新。
43
+ # 保留集合变化,和仓储更新事件使用相同的原生变更判定。
44
+ if (
45
+ isinstance(entity, AuditedEntityMixin)
46
+ and entity not in session.new
47
+ and session.is_modified(entity)
48
+ ):
43
49
  entity.updated_at = now
44
50
  entity.updated_by = user_id
45
51
 
@@ -0,0 +1,186 @@
1
+ """扩展字段的原生列映射、应用私有 mapper 及持久化边界。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Generic, TypeVar, get_args
8
+
9
+ from pydantic import BaseModel
10
+ from pydantic.fields import FieldInfo
11
+ from pydantic_core import to_json
12
+ from sqlalchemy import Column, MetaData, Table, inspect
13
+ from sqlalchemy.orm import registry
14
+ from sqlalchemy.schema import SchemaItem
15
+
16
+ from ..extensions import ExtensionCatalog, ExtensionProperties, ExtensionTarget, ModelExtension
17
+ from ..modularity import ModuleDeclaration, ModuleKey, ModuleRef
18
+
19
+ _T = TypeVar("_T")
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ExtensionColumn:
24
+ """引用契约的原生字段身份;列工厂留在消费者 persistence Module。"""
25
+
26
+ field: FieldInfo
27
+ factory: Callable[[], SchemaItem]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class SqlAlchemyModelExtension(ModelExtension):
32
+ columns: tuple[ExtensionColumn, ...] = ()
33
+
34
+ @property
35
+ def required_module(self) -> ModuleRef:
36
+ return ModuleRef("python_ddd_framework.sqlalchemy.module:SqlAlchemyPersistenceModule")
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class SqlAlchemyExtensionMapping:
41
+ target: ExtensionTarget
42
+ table: Table
43
+ model_base: type[object]
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class SqlAlchemyExtensionMigrations(ModuleDeclaration):
48
+ migrations_package: str
49
+ branch_label: str
50
+ depends_on: tuple[str, ...]
51
+ connection_name: str = "default"
52
+
53
+ @property
54
+ def required_module(self) -> ModuleRef:
55
+ return ModuleRef("python_ddd_framework.sqlalchemy.module:SqlAlchemyPersistenceModule")
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class _ExtensionColumn:
60
+ owner: ModuleKey
61
+ field_name: str
62
+ column: Column[object]
63
+
64
+
65
+ class SqlAlchemyExtensionModel(Generic[_T]):
66
+ """一处有效 row 映射;模型、读写和迁移范围引用相同列对象。"""
67
+
68
+ def __init__(
69
+ self,
70
+ model: type[_T],
71
+ target: ExtensionTarget,
72
+ catalog: ExtensionCatalog,
73
+ columns: tuple[_ExtensionColumn, ...],
74
+ ) -> None:
75
+ self.model = model
76
+ self.target = target
77
+ self.columns = columns
78
+ self._catalog = catalog
79
+
80
+ def read(self, row: _T) -> ExtensionProperties:
81
+ if not isinstance(row, self.model):
82
+ raise TypeError("row belongs to a different Application's mapper")
83
+ stored = getattr(row, "extra_properties") # noqa: B009 - 原生 mapper 的动态属性边界
84
+ if not isinstance(stored, dict):
85
+ raise TypeError("extra_properties must be a JSON object")
86
+ values = dict(stored)
87
+ for mapping in self.columns:
88
+ if mapping.field_name in values:
89
+ raise ValueError("mapped extension field cannot also be stored in JSON")
90
+ values[mapping.field_name] = getattr(row, mapping.column.key)
91
+ return self._catalog.property_model(self.target).model_validate_json(to_json(values))
92
+
93
+ def write(self, row: _T, properties: ExtensionProperties) -> None:
94
+ if not isinstance(row, self.model):
95
+ raise TypeError("row belongs to a different Application's mapper")
96
+ effective = self._catalog.properties(self.target, properties)
97
+ values = effective.model_dump(mode="json")
98
+ for mapping in self.columns:
99
+ setattr(row, mapping.column.key, getattr(effective, mapping.field_name))
100
+ del values[mapping.field_name]
101
+ setattr(row, "extra_properties", values) # noqa: B010 - 原生 mapper 的动态属性边界
102
+
103
+
104
+ def _map_extensions(
105
+ metadata: MetaData,
106
+ mappings: tuple[SqlAlchemyExtensionMapping, ...],
107
+ catalog: ExtensionCatalog,
108
+ ) -> tuple[MetaData, tuple[SqlAlchemyExtensionModel[object], ...]]:
109
+ # 这份 metadata 就是运行时和迁移共同消费的有效模型;不为差异过滤另复制表。
110
+ mapper_registry = registry(metadata=MetaData(naming_convention=metadata.naming_convention))
111
+ native_base = mapper_registry.generate_base()
112
+ for table in metadata.sorted_tables:
113
+ table.to_metadata(mapper_registry.metadata)
114
+ models: list[SqlAlchemyExtensionModel[object]] = []
115
+ for mapping in mappings:
116
+ catalog.property_model(mapping.target)
117
+ if any(model.target is mapping.target for model in models):
118
+ raise ValueError("an extension target has only one ORM mapping")
119
+ if mapping.table.metadata is not metadata or inspect(mapping.model_base, raiseerr=False):
120
+ raise ValueError("extension mapping requires an owned table and an unmapped model base")
121
+ table = mapper_registry.metadata.tables[mapping.table.key]
122
+ if "extra_properties" not in table.c:
123
+ raise ValueError("the base table must own its extra_properties JSON container")
124
+ columns = _columns_for_target(mapping.target, table, catalog)
125
+ model = type(
126
+ mapping.model_base.__name__, (mapping.model_base, native_base), {"__table__": table}
127
+ )
128
+ assert issubclass(model, mapping.model_base)
129
+ models.append(SqlAlchemyExtensionModel(model, mapping.target, catalog, columns))
130
+ return mapper_registry.metadata, tuple(models)
131
+
132
+
133
+ def _columns_for_target(
134
+ target: ExtensionTarget,
135
+ table: Table,
136
+ catalog: ExtensionCatalog,
137
+ ) -> tuple[_ExtensionColumn, ...]:
138
+ columns: list[_ExtensionColumn] = []
139
+ for contribution in catalog.contributions:
140
+ if contribution.declaration.target is not target:
141
+ continue
142
+ declaration = contribution.declaration
143
+ if not isinstance(declaration, SqlAlchemyModelExtension):
144
+ continue
145
+ fields = {id(field): name for name, field in declaration.properties.model_fields.items()}
146
+ mapped: set[str] = set()
147
+ for storage in declaration.columns:
148
+ name = fields.get(id(storage.field))
149
+ if name is None or name in mapped:
150
+ raise ValueError(
151
+ "column mapping must reference a unique field of its property schema"
152
+ )
153
+ mapped.add(name)
154
+ column = _validate_column(storage.factory(), name, storage.field.annotation, table)
155
+ table.append_column(column)
156
+ columns.append(_ExtensionColumn(contribution.owner, name, column))
157
+ return tuple(columns)
158
+
159
+
160
+ def _validate_column(column: object, name: str, annotation: object, table: Table) -> Column[object]:
161
+ if not isinstance(column, Column) or getattr(column, "table", None) is not None:
162
+ raise TypeError("extension column factory must return a fresh unattached Column")
163
+ if (
164
+ column.primary_key
165
+ or column.foreign_keys
166
+ or (column.unique and not column.index)
167
+ or column.constraints
168
+ ):
169
+ raise ValueError("extension mapping only owns standalone columns and indexes")
170
+ column_name = column.name or name
171
+ if not column_name.isidentifier() or column_name in table.c:
172
+ raise ValueError(
173
+ "extension column conflicts with an existing column or has an invalid name"
174
+ )
175
+ if column.key and column.key != column_name:
176
+ raise ValueError("extension column key must match its name")
177
+ candidates = get_args(annotation) or (annotation,)
178
+ if column.type.python_type not in candidates:
179
+ raise TypeError("extension column Python type does not match the property schema")
180
+ if column.nullable and type(None) not in candidates:
181
+ raise ValueError("nullable extension columns require an optional property type")
182
+ if any(isinstance(item, type) and issubclass(item, BaseModel) for item in candidates):
183
+ raise TypeError("nested Pydantic properties use JSON storage")
184
+ column.name = column_name
185
+ column.key = column_name
186
+ return column
@@ -4,18 +4,29 @@ from __future__ import annotations
4
4
 
5
5
  import importlib.util
6
6
  from collections.abc import Callable
7
- from dataclasses import dataclass, field
7
+ from dataclasses import dataclass, field, replace
8
8
  from pathlib import Path
9
- from typing import TypeVar
9
+ from typing import TypeVar, cast
10
10
 
11
11
  from sqlalchemy import Constraint, ForeignKey, MetaData, Table
12
12
 
13
+ from ..extensions import ExtensionCatalog, ExtensionTarget
14
+ from ..extensions.module import ExtensionsModule
13
15
  from ..modularity import AppModule, ModuleDescriptor, ModuleKey, ModuleRegistry
14
16
  from ..modularity.discovery import _import_package_modules
15
17
  from ..options import OptionsSnapshot
16
18
  from .errors import SqlAlchemyMetadataChangedError, SqlAlchemyModelRegistrationError
19
+ from .extensions import (
20
+ SqlAlchemyExtensionMapping,
21
+ SqlAlchemyExtensionMigrations,
22
+ SqlAlchemyExtensionModel,
23
+ SqlAlchemyModelExtension,
24
+ _map_extensions,
25
+ )
17
26
  from .options import _validate_connection_name
18
27
 
28
+ _TRow = TypeVar("_TRow")
29
+
19
30
  _MODEL_REGISTRATIONS_ATTRIBUTE = "__python_ddd_framework_sqlalchemy_model_registrations__"
20
31
  _MIGRATION_REGISTRATIONS_ATTRIBUTE = "__python_ddd_framework_sqlalchemy_migration_registrations__"
21
32
  _TModule = TypeVar("_TModule", bound=type[AppModule])
@@ -27,6 +38,7 @@ class SqlAlchemyModelRegistration:
27
38
  migrations_package: str
28
39
  branch_label: str
29
40
  connection_name: str = "default"
41
+ extension_mappings: tuple[SqlAlchemyExtensionMapping, ...] = ()
30
42
  _declared_fingerprint: tuple[object, ...] = field(init=False, repr=False, compare=False)
31
43
 
32
44
  @classmethod
@@ -38,6 +50,7 @@ class SqlAlchemyModelRegistration:
38
50
  migrations_package: str,
39
51
  branch_label: str,
40
52
  connection_name: str = "default",
53
+ extension_mappings: tuple[SqlAlchemyExtensionMapping, ...] = (),
41
54
  ) -> SqlAlchemyModelRegistration:
42
55
  if not isinstance(models_package, str) or not models_package:
43
56
  raise ValueError("models_package must be a non-empty import path")
@@ -50,7 +63,7 @@ class SqlAlchemyModelRegistration:
50
63
  raise ValueError("models_package must identify a package")
51
64
  # Declarative class 导入会写入原生 MetaData,必须早于构造时的指纹冻结。
52
65
  _import_package_modules(models_package)
53
- return cls(metadata, migrations_package, branch_label, connection_name)
66
+ return cls(metadata, migrations_package, branch_label, connection_name, extension_mappings)
54
67
 
55
68
  def __post_init__(self) -> None:
56
69
  if not isinstance(self.metadata, MetaData):
@@ -123,10 +136,17 @@ class _SqlAlchemyMigrationSource:
123
136
  migrations_package: str
124
137
  branch_label: str
125
138
  migration_path: Path
139
+ extension: SqlAlchemyExtensionMigrations | None = None
126
140
 
127
141
 
128
142
  class SqlAlchemyModelCatalog:
129
- __slots__ = ("_contributions", "_migration_contributions", "_migration_declarations")
143
+ __slots__ = (
144
+ "_contributions",
145
+ "_migration_contributions",
146
+ "_migration_declarations",
147
+ "extension_models",
148
+ "extension_sources",
149
+ )
130
150
 
131
151
  def __init__(
132
152
  self,
@@ -139,6 +159,16 @@ class SqlAlchemyModelCatalog:
139
159
  self._contributions = contributions
140
160
  self._migration_contributions = migration_contributions
141
161
  self._migration_declarations = migration_declarations
162
+ self.extension_models: tuple[SqlAlchemyExtensionModel[object], ...] = ()
163
+ self.extension_sources: tuple[_SqlAlchemyMigrationSource, ...] = ()
164
+
165
+ def extension_model(
166
+ self, target: ExtensionTarget, model_base: type[_TRow]
167
+ ) -> SqlAlchemyExtensionModel[_TRow]:
168
+ for model in self.extension_models:
169
+ if model.target is target and issubclass(model.model, model_base):
170
+ return cast(SqlAlchemyExtensionModel[_TRow], model)
171
+ raise ValueError("extension persistence mapping is not registered")
142
172
 
143
173
  @property
144
174
  def contributions(self) -> tuple[SqlAlchemyModelContribution, ...]:
@@ -152,6 +182,9 @@ class SqlAlchemyModelCatalog:
152
182
  def connection_names(self) -> tuple[str, ...]:
153
183
  names = {item.registration.connection_name for item in self._contributions}
154
184
  names.update(item.registration.connection_name for item in self._migration_contributions)
185
+ names.update(
186
+ item.extension.connection_name for item in self.extension_sources if item.extension
187
+ )
155
188
  return tuple(name for name in ("default", *sorted(names - {"default"})) if name in names)
156
189
 
157
190
  def for_connection(
@@ -196,7 +229,15 @@ class SqlAlchemyModelCatalog:
196
229
  )
197
230
  for item in self.migrations_for_connection(connection_name)
198
231
  )
199
- return (*model_sources, *external_sources)
232
+ return (
233
+ *model_sources,
234
+ *external_sources,
235
+ *(
236
+ item
237
+ for item in self.extension_sources
238
+ if item.extension and item.extension.connection_name == connection_name
239
+ ),
240
+ )
200
241
 
201
242
  def _assert_unchanged(self) -> None:
202
243
  for contribution in self._contributions:
@@ -257,7 +298,70 @@ def _build_model_catalog(
257
298
  builder = _SqlAlchemyModelCatalogBuilder(options)
258
299
  for descriptor in registry.dependency_order:
259
300
  builder.add_module(descriptor)
260
- return builder.build()
301
+ catalog = builder.build()
302
+ effective: list[SqlAlchemyModelContribution] = []
303
+ extension_models: list[SqlAlchemyExtensionModel[object]] = []
304
+ for contribution in catalog.contributions:
305
+ registration = contribution.registration
306
+ if registration.extension_mappings:
307
+ metadata, models = _map_extensions(
308
+ registration.metadata,
309
+ registration.extension_mappings,
310
+ registry.instance(ExtensionsModule).catalog,
311
+ )
312
+ extension_models.extend(models)
313
+ registration = replace(registration, metadata=metadata)
314
+ effective.append(replace(contribution, registration=registration))
315
+ catalog._contributions = tuple(effective)
316
+ catalog.extension_models = tuple(extension_models)
317
+ sources: list[_SqlAlchemyMigrationSource] = []
318
+ for owner, declaration in registry.declarations(SqlAlchemyExtensionMigrations):
319
+ _validate_connection_name(declaration.connection_name)
320
+ if not declaration.depends_on or not all(
321
+ isinstance(item, str) and item.strip() == item and item
322
+ for item in declaration.depends_on
323
+ ):
324
+ raise ValueError("extension migrations require explicit base revision dependencies")
325
+ builder._claim_branch(declaration.branch_label, owner.key)
326
+ sources.append(
327
+ _SqlAlchemyMigrationSource(
328
+ owner.key,
329
+ declaration.migrations_package,
330
+ declaration.branch_label,
331
+ _migration_package_path(declaration.migrations_package, owner=owner.key),
332
+ declaration,
333
+ )
334
+ )
335
+ catalog.extension_sources = tuple(sources)
336
+ _validate_extension_models(catalog, registry.instance(ExtensionsModule).catalog)
337
+ return catalog
338
+
339
+
340
+ def _validate_extension_models(
341
+ catalog: SqlAlchemyModelCatalog, extensions: ExtensionCatalog
342
+ ) -> None:
343
+ if len({model.target for model in catalog.extension_models}) != len(catalog.extension_models):
344
+ raise ValueError("an extension target has only one ORM mapping")
345
+ for item in extensions.contributions:
346
+ if isinstance(item.declaration, SqlAlchemyModelExtension) and not any(
347
+ model.target is item.declaration.target for model in catalog.extension_models
348
+ ):
349
+ raise ValueError("SQL extension requires exactly one registered ORM target")
350
+ for model in catalog.extension_models:
351
+ for column in model.columns:
352
+ source = next(
353
+ (item for item in catalog.extension_sources if item.owner == column.owner), None
354
+ )
355
+ if source is None:
356
+ raise ValueError("mapped extension columns require an owning migration declaration")
357
+ base = next(
358
+ item
359
+ for item in catalog.contributions
360
+ if column.column.table in item.registration.metadata.tables.values()
361
+ )
362
+ assert source.extension is not None
363
+ if source.extension.connection_name != base.registration.connection_name:
364
+ raise ValueError("extension migration and base table must share a connection")
261
365
 
262
366
 
263
367
  class _SqlAlchemyModelCatalogBuilder:
@@ -28,7 +28,7 @@ from .errors import (
28
28
  SqlAlchemyMigrationError,
29
29
  SqlAlchemyModelRegistrationError,
30
30
  )
31
- from .metadata import SqlAlchemyModelCatalog, _build_model_catalog
31
+ from .metadata import SqlAlchemyModelCatalog, _SqlAlchemyMigrationSource
32
32
  from .migration_options import SqlAlchemyMigrationOptions
33
33
  from .module_migration import _ModuleMigration
34
34
  from .options import ConnectionStringsOptions
@@ -51,7 +51,9 @@ class SqlAlchemyMigrator:
51
51
  "SqlAlchemyMigrator requires composed Application, "
52
52
  f"got {self._application.state.value}"
53
53
  )
54
- models = _build_model_catalog(self._application.modules, self._application.options)
54
+ from .module import SqlAlchemyPersistenceModule
55
+
56
+ models = self._application.modules.instance(SqlAlchemyPersistenceModule).catalog
55
57
  models._assert_unchanged()
56
58
  return models
57
59
 
@@ -88,8 +90,10 @@ class SqlAlchemyMigrator:
88
90
  ) -> tuple[str, ...]:
89
91
  models = self.model_catalog
90
92
  owner = ModuleKey(module.__module__, module.__qualname__) if module is not None else None
91
- if module_operation is _ModuleMigration.revision and not any(
92
- item.owner == owner for item in models.contributions
93
+ if (
94
+ module_operation is _ModuleMigration.revision
95
+ and not any(item.owner == owner for item in models.contributions)
96
+ and not any(item.owner == owner for item in models.extension_sources)
93
97
  ):
94
98
  raise SqlAlchemyModelRegistrationError(
95
99
  owner=str(owner), reason="revision requires a Module with registered ORM models"
@@ -111,9 +115,11 @@ class SqlAlchemyMigrator:
111
115
  completed.append(connection_name)
112
116
  continue
113
117
  config = _alembic_config(models, connection_name, self._application.options)
114
- execution_options = self._application.options.get(BackgroundExecutionOptions)
118
+ # 未选择后台能力就没有执行资源;不能为了迁移强制引入整个后台模块。
115
119
  config.attributes["background_execution_disabled"] = (
116
- execution_options.profile is BackgroundExecutionProfile.DISABLED
120
+ BackgroundExecutionOptions not in self._application.options.values
121
+ or self._application.options.get(BackgroundExecutionOptions).profile
122
+ is BackgroundExecutionProfile.DISABLED
117
123
  )
118
124
  _validate_revision_graph(
119
125
  models,
@@ -323,8 +329,11 @@ def _validate_revision_graph(
323
329
  ) -> None:
324
330
  migration_sources = models._migration_sources_for_connection(connection_name)
325
331
  # walk_revisions 强制 Alembic 在任何 engine 创建前加载并校验组合 graph/depends_on。
326
- revisions = tuple(ScriptDirectory.from_config(config).walk_revisions())
332
+ script = ScriptDirectory.from_config(config)
333
+ revisions = tuple(script.walk_revisions())
327
334
  for source in migration_sources:
335
+ if source.extension is not None:
336
+ _validate_extension_prerequisites(models, connection_name, source, script)
328
337
  owned_revisions = tuple(
329
338
  revision
330
339
  for revision in revisions
@@ -352,6 +361,50 @@ def _validate_revision_graph(
352
361
  f"{actual_labels!r}, expected {(source.branch_label,)!r}"
353
362
  ),
354
363
  )
364
+ if source.extension is not None and not (allow_empty and source.owner == owner):
365
+ # 声明约束当前分支的每个 head;历史 revision 保留当时的 depends_on。
366
+ # revision 命令允许在已应用新前置后,为所选 owner 追加满足新声明的 revision。
367
+ for head in script.get_revisions(f"{source.branch_label}@heads"):
368
+ ancestry = {
369
+ item.revision
370
+ for item in script.iterate_revisions(head.revision, "base", implicit_base=True)
371
+ }
372
+ if not set(source.extension.depends_on) <= ancestry:
373
+ raise ValueError("extension revision is missing required Alembic depends_on")
374
+
375
+
376
+ def _validate_extension_prerequisites(
377
+ models: SqlAlchemyModelCatalog,
378
+ connection_name: str,
379
+ source: _SqlAlchemyMigrationSource,
380
+ script: ScriptDirectory,
381
+ ) -> None:
382
+ assert source.extension is not None
383
+ prerequisites = tuple(script.get_revision(item) for item in source.extension.depends_on)
384
+ if any(
385
+ item is None or item.revision != requested
386
+ for item, requested in zip(prerequisites, source.extension.depends_on, strict=True)
387
+ ):
388
+ raise ValueError("extension depends_on must name exact existing base revisions")
389
+ # 必须覆盖被扩展表的真实基础迁移 owner;运行时 Module 图不参与判断。
390
+ base_paths = {
391
+ contribution.migration_path
392
+ for contribution in models.for_connection(connection_name)
393
+ if any(
394
+ column.owner == source.owner
395
+ and column.column.table in contribution.registration.metadata.tables.values()
396
+ for model in models.extension_models
397
+ for column in model.columns
398
+ )
399
+ }
400
+ ancestor_paths = {
401
+ Path(item.path).resolve().parent
402
+ for item in script.iterate_revisions(
403
+ source.extension.depends_on, "base", implicit_base=True
404
+ )
405
+ }
406
+ if not base_paths <= ancestor_paths:
407
+ raise ValueError("extension depends_on does not cover its base table migrations")
355
408
 
356
409
 
357
410
  def _upgrade_heads(connection: Connection, config: Config, connection_name: str) -> None:
@@ -369,7 +422,7 @@ def _check_current_heads(connection: Connection, config: Config, connection_name
369
422
  ).get_current_heads()
370
423
  )
371
424
  )
372
- expected = tuple(sorted(script.get_heads()))
425
+ expected = tuple(sorted(script.get_heads(consider_depends_on=True)))
373
426
  if current != expected:
374
427
  raise MigrationHeadsMismatchError(
375
428
  connection_name=connection_name,
@@ -0,0 +1,86 @@
1
+ """从正式扩展 metadata 推导 Alembic 操作的列/索引所有权。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ from alembic.operations import ops
9
+ from sqlalchemy import Column
10
+
11
+
12
+ class _MigrationOperations:
13
+ def __init__(self, columns: Iterable[Column[Any]], *, extension: bool) -> None:
14
+ columns = tuple(columns)
15
+ self._columns = {
16
+ (column.table.schema, column.table.name, column.name)
17
+ for column in columns
18
+ if column.table is not None
19
+ }
20
+ self._indexes = {
21
+ (column.table.schema, column.table.name, index.name)
22
+ for column in columns
23
+ if column.table is not None
24
+ for index in column.table.indexes
25
+ if index.columns
26
+ and all(
27
+ (column.table.schema, column.table.name, item.name) in self._columns
28
+ for item in index.columns
29
+ )
30
+ }
31
+ self._extension = extension
32
+
33
+ def column(self, schema: str | None, table: str, name: str | None) -> bool:
34
+ selected = (schema, table, name) in self._columns
35
+ return selected if self._extension else not selected
36
+
37
+ def index(self, schema: str | None, table: str, name: str | None) -> bool:
38
+ selected = (schema, table, name) in self._indexes
39
+ return selected if self._extension else not selected
40
+
41
+ def restrict(self, container: ops.OpContainer) -> None:
42
+ retained: list[ops.MigrateOperation] = []
43
+ for operation in container.ops:
44
+ if isinstance(operation, ops.OpContainer):
45
+ self.restrict(operation)
46
+ if operation.ops:
47
+ retained.append(operation)
48
+ else:
49
+ selected = self._operation(operation)
50
+ if selected is not None:
51
+ retained.append(selected)
52
+ container.ops = retained
53
+
54
+ def _operation(self, operation: ops.MigrateOperation) -> ops.MigrateOperation | None:
55
+ if isinstance(operation, (ops.CreateTableOp, ops.DropTableOp)):
56
+ if self._extension:
57
+ raise ValueError("extension revisions cannot create or remove a base table")
58
+ return self._base_table(operation)
59
+ if isinstance(operation, (ops.AddColumnOp, ops.AlterColumnOp, ops.DropColumnOp)):
60
+ name = (
61
+ operation.column.name
62
+ if isinstance(operation, ops.AddColumnOp)
63
+ else operation.column_name
64
+ )
65
+ return operation if self.column(operation.schema, operation.table_name, name) else None
66
+ if isinstance(operation, (ops.CreateIndexOp, ops.DropIndexOp)):
67
+ return (
68
+ operation
69
+ if operation.table_name is not None
70
+ and self.index(operation.schema, operation.table_name, operation.index_name)
71
+ else None
72
+ )
73
+ # 表注释等 table comparator 不经过 include_object;扩展只有列和索引的权利。
74
+ return None if self._extension else operation
75
+
76
+ def _base_table(self, operation: ops.CreateTableOp | ops.DropTableOp) -> ops.MigrateOperation:
77
+ created = operation.reverse() if isinstance(operation, ops.DropTableOp) else operation
78
+ # 原生 CreateTableOp 包含内联列,不会逐列执行 include_object。只重写 operation,
79
+ # 不复制/改写作为迁移权威的 Table 或 MetaData。
80
+ created.columns = [
81
+ item
82
+ for item in created.columns
83
+ if not isinstance(item, Column)
84
+ or self.column(created.schema, created.table_name, item.name)
85
+ ]
86
+ return created.reverse() if isinstance(operation, ops.DropTableOp) else created
@@ -4,9 +4,11 @@ from __future__ import annotations
4
4
 
5
5
  from dishka import Provider, Scope
6
6
 
7
+ from ..events.module import LocalEventsModule
8
+ from ..extensions.module import ExtensionsModule
7
9
  from ..lifecycle import ConfigureContext, InitializeContext
8
- from ..modularity import AppModule, ModuleRegistry
9
- from ..options import OptionsSnapshot
10
+ from ..modularity import AppModule
11
+ from ..services.composition import ServiceBindingContext, ServiceComposition
10
12
  from ..unit_of_work.contracts import _UnitOfWorkFactory
11
13
  from .metadata import SqlAlchemyModelCatalog, _build_model_catalog
12
14
  from .migration_options import SqlAlchemyMigrationOptions
@@ -20,6 +22,9 @@ from .unit_of_work import _SqlAlchemyUnitOfWorkFactory
20
22
 
21
23
 
22
24
  class SqlAlchemyPersistenceModule(AppModule):
25
+ dependencies = (LocalEventsModule, ExtensionsModule)
26
+ catalog: SqlAlchemyModelCatalog
27
+
23
28
  def configure(self, context: ConfigureContext) -> None:
24
29
  context.configure(
25
30
  ConnectionStringsOptions,
@@ -28,11 +33,7 @@ class SqlAlchemyPersistenceModule(AppModule):
28
33
  )
29
34
  context.configure(SqlAlchemyMigrationOptions, section="sqlalchemy_migrations")
30
35
  provider = Provider()
31
- provider.provide(
32
- _model_catalog,
33
- provides=SqlAlchemyModelCatalog,
34
- scope=Scope.APP,
35
- )
36
+ context.services.add_composition(ServiceComposition(bind=self._bind))
36
37
  provider.provide(
37
38
  _engine_registry,
38
39
  provides=_SqlAlchemyEngineRegistry,
@@ -42,10 +43,12 @@ class SqlAlchemyPersistenceModule(AppModule):
42
43
  provider.provide(SqlAlchemySessionProvider, scope=Scope.APP)
43
44
  context.services.contribute(provider, reason="SQLAlchemy PostgreSQL persistence boundary")
44
45
 
46
+ def _bind(self, context: ServiceBindingContext) -> None:
47
+ self.catalog = _build_model_catalog(context.modules, context.options)
48
+ provider = Provider()
49
+ provider.provide(lambda: self.catalog, provides=SqlAlchemyModelCatalog, scope=Scope.APP)
50
+ context.add_provider(provider)
51
+
45
52
  async def initialize(self, context: InitializeContext) -> None:
46
53
  engines = await context.container.get(_SqlAlchemyEngineRegistry)
47
54
  await engines.ping_all()
48
-
49
-
50
- def _model_catalog(registry: ModuleRegistry, options: OptionsSnapshot) -> SqlAlchemyModelCatalog:
51
- return _build_model_catalog(registry, options)