mwgencode 1.5.18__tar.gz → 1.6.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 (106) hide show
  1. {mwgencode-1.5.18/mwgencode.egg-info → mwgencode-1.6.0}/PKG-INFO +1 -1
  2. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gen_code.py +42 -5
  3. mwgencode-1.6.0/gencode/gencode/sample/migrations/README +61 -0
  4. mwgencode-1.6.0/gencode/gencode/sample/migrations/__init__.py +2 -0
  5. mwgencode-1.6.0/gencode/gencode/sample/migrations/alembic.ini +50 -0
  6. mwgencode-1.6.0/gencode/gencode/sample/migrations/audit.py +53 -0
  7. mwgencode-1.6.0/gencode/gencode/sample/migrations/dependency_order.py +239 -0
  8. mwgencode-1.6.0/gencode/gencode/sample/migrations/env.py +214 -0
  9. mwgencode-1.6.0/gencode/gencode/sample/migrations/ownership.py +47 -0
  10. mwgencode-1.6.0/gencode/gencode/sample/migrations/precheck.py +130 -0
  11. mwgencode-1.6.0/gencode/gencode/sample/migrations/safety_policy.py +117 -0
  12. mwgencode-1.6.0/gencode/gencode/sample/migrations/script.py.mako +24 -0
  13. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/gen_code_flask.yaml +5 -2
  14. {mwgencode-1.5.18 → mwgencode-1.6.0}/manage.py +21 -7
  15. {mwgencode-1.5.18 → mwgencode-1.6.0/mwgencode.egg-info}/PKG-INFO +1 -1
  16. {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/SOURCES.txt +10 -0
  17. {mwgencode-1.5.18 → mwgencode-1.6.0}/setup.py +1 -1
  18. {mwgencode-1.5.18 → mwgencode-1.6.0}/CHANGES.txt +0 -0
  19. {mwgencode-1.5.18 → mwgencode-1.6.0}/LICENSE.txt +0 -0
  20. {mwgencode-1.5.18 → mwgencode-1.6.0}/MANIFEST.in +0 -0
  21. {mwgencode-1.5.18 → mwgencode-1.6.0}/README.rst +0 -0
  22. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/__init__.py +0 -0
  23. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/dd_models.py +0 -0
  24. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/ext.py +0 -0
  25. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/__init__.py +0 -0
  26. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/export_class2swgclass.py +0 -0
  27. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_bo_models_code.py +0 -0
  28. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_state_code.py +0 -0
  29. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_swagger_code.py +0 -0
  30. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_tests_code.py +0 -0
  31. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/.env_example +0 -0
  32. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/__init__.py +0 -0
  33. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/babel.cfg +0 -0
  34. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/config-sample.ini +0 -0
  35. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/config.ini +0 -0
  36. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/create_new_table_run.pys +0 -0
  37. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/dockerignore.dock +0 -0
  38. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/file_utils.pys +0 -0
  39. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/gencode.xmi +0 -0
  40. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/gitignore.git +0 -0
  41. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/migrate_run.bat +0 -0
  42. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/migrate_run.pys +0 -0
  43. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/requirements.txt +0 -0
  44. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/run.sh +0 -0
  45. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/__init__.py +0 -0
  46. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/models_rm.pys +0 -0
  47. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_dev_data.pys +0 -0
  48. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_init.pys +0 -0
  49. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_rm.pys +0 -0
  50. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_run.pys +0 -0
  51. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_utils.pys +0 -0
  52. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/test__init__.pys +0 -0
  53. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/test_run.pys +0 -0
  54. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/utils.pys +0 -0
  55. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/Dockerfile.tmp +0 -0
  56. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/Dockerfile_dg.tmp +0 -0
  57. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/README.md +0 -0
  58. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/__init__.py +0 -0
  59. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/__init__.pys +0 -0
  60. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/asgi_run.pys +0 -0
  61. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/config.pys +0 -0
  62. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/default.conf +0 -0
  63. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/docker-compose-dev.yaml +0 -0
  64. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/docker-compose.yaml +0 -0
  65. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/drone.tmp +0 -0
  66. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/flask_models.pys +0 -0
  67. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/flask_models_base.pys +0 -0
  68. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/gen_code_run.pys +0 -0
  69. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/k8s-tmp.yml +0 -0
  70. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/run.pys +0 -0
  71. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/sample.mdj +0 -0
  72. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/setup.tmp +0 -0
  73. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/supervisord.conf +0 -0
  74. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swagger_file.yaml +0 -0
  75. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swg_class.tmp +0 -0
  76. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swg_ctrl_code.pys +0 -0
  77. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swg_package_mng.tmp +0 -0
  78. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/test_test_base.tmp +0 -0
  79. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/__init__.py +0 -0
  80. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/__init__.pys +0 -0
  81. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/init_test_data.pys +0 -0
  82. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/run.pys +0 -0
  83. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/test_base.pys +0 -0
  84. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/test_classmng.pys +0 -0
  85. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/uwsgi.ini +0 -0
  86. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/uwsgi_run.pys +0 -0
  87. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/__init__.py +0 -0
  88. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/import_dd_classes.py +0 -0
  89. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/import_swagger2_class.py +0 -0
  90. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/import_uml_models.py +0 -0
  91. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/__init__.py +0 -0
  92. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_classes.py +0 -0
  93. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_sequences.py +0 -0
  94. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_states.py +0 -0
  95. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_swagger.py +0 -0
  96. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/swg2_class_models.py +0 -0
  97. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/uml_class_models.py +0 -0
  98. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/upgrade.py +0 -0
  99. {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/utils.py +0 -0
  100. {mwgencode-1.5.18 → mwgencode-1.6.0}/help.md +0 -0
  101. {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/dependency_links.txt +0 -0
  102. {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/entry_points.txt +0 -0
  103. {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/requires.txt +0 -0
  104. {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/top_level.txt +0 -0
  105. {mwgencode-1.5.18 → mwgencode-1.6.0}/pyproject.toml +0 -0
  106. {mwgencode-1.5.18 → mwgencode-1.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: mwgencode
3
- Version: 1.5.18
3
+ Version: 1.6.0
4
4
  Summary: 根据starUML文档产生flask专案的代码
5
5
  Home-page: https://bitbucket.org/maxwin-inc/gencode/src/
6
6
  Author: cxhjet
@@ -20,7 +20,7 @@ class GenCode():
20
20
  self.modelfile = os.path.abspath(modelfile)
21
21
  self.rootpath = os.path.abspath(rootpath)
22
22
 
23
- def model(self, outfile='models_base.py', type='flask',appdir='app',exists2cover=True):
23
+ def model(self, writer='self', outfile='models_base.py', type='flask',appdir='app',exists2cover=True):
24
24
  '''
25
25
  产生基本资料的base类别的代码
26
26
  :param outfile: bomodel的文件名
@@ -32,6 +32,10 @@ class GenCode():
32
32
  outfile_name = os.path.join(self.rootpath,appdir, outfile)
33
33
  else:
34
34
  outfile_name = os.path.join(self.rootpath, outfile)
35
+ # ai 不变更models_base.py,如果outfile_name 已存在,则不变更
36
+ if writer == 'ai' and os.path.exists(outfile_name):
37
+ logging.info('提示:AI 写代码,不变更models_base.py')
38
+ return
35
39
  gen_code = Gen_bo_models(self.modelfile,
36
40
  outfile_name,
37
41
  type=type)
@@ -128,7 +132,7 @@ class GenProject_Sample(GenProject_base):
128
132
  def __init__(self,modelfile,rootpath):
129
133
  super().__init__(modelfile,rootpath)
130
134
 
131
- def gen_code(self,include_gencoderun,project_name=None):
135
+ def gen_code(self,include_gencoderun,project_name=None,writer='self'):
132
136
  p_name = project_name or os.path.split(self.rootpath)[-1]
133
137
  if not os.path.exists(self.modelfile):
134
138
  if not os.path.exists('docs'):
@@ -148,7 +152,7 @@ class GenProject_Sample(GenProject_base):
148
152
  if not os.path.exists(gen_code_yaml):
149
153
  template = self.env.get_template(r'gen_code_flask.yaml')
150
154
  saveUTF8File(gen_code_yaml,
151
- [template.render(pro_name=p_name,pro_type='flask')])
155
+ [template.render(pro_name=p_name,pro_type='flask',writer=writer)])
152
156
 
153
157
  class GenProject_Aiohttp(GenProject_base):
154
158
  def __init__(self,modelfile,rootpath):
@@ -168,11 +172,13 @@ class GenProject_Flask(GenProject_base):
168
172
  super().__init__(modelfile,rootpath)
169
173
 
170
174
  def gen_code(self,
175
+ writer:str = 'self',
171
176
  include_swagger:bool=True,
172
177
  include_model:bool=True,
173
178
  include_auth:bool = True,
174
179
  include_test:bool = False,
175
180
  include_seeds:bool = False,
181
+ include_migrations:bool = False,
176
182
  need_upgrade:bool = False,
177
183
  use_uwsgi:bool = True,
178
184
  plugins:list = None):
@@ -196,8 +202,16 @@ class GenProject_Flask(GenProject_base):
196
202
  # outfile为’‘时,采用默认的路径
197
203
  gen = GenSwaggerCodeFromUml(self.rootpath, self.modelfile, type='flask')
198
204
  if include_swagger:
199
- gen.gen_swagger_code(outfile='')
200
- gen.gen_swagger_ctr_code()
205
+ # 如果是ai writer,在swaggers目录存在时就不产生swagger file,
206
+ write_swagger = True
207
+ if writer == 'ai':
208
+ if os.path.exists(os.path.join(self.rootpath,'swagger')):
209
+ logging.info('提示:AI 写代码,不变更swagger file')
210
+ write_swagger = False
211
+
212
+ if write_swagger:
213
+ gen.gen_swagger_code(outfile='')
214
+ gen.gen_swagger_ctr_code()
201
215
 
202
216
  else:
203
217
  logging.info('提示:不产生swagger file')
@@ -326,6 +340,29 @@ class GenProject_Flask(GenProject_base):
326
340
  self.save_as(os.path.join(self.sample_path,'seeds', 'seed_utils.pys'),
327
341
  os.path.join(os.path.realpath(self.rootpath),'seeds', 'seed_utils.py'))
328
342
 
343
+ if include_migrations:
344
+ self.save_as(os.path.join(self.sample_path,'migrations', '__init__.py'),
345
+ os.path.join(os.path.realpath(self.rootpath),'migrations', '__init__.py'))
346
+ self.save_as(os.path.join(self.sample_path,'migrations', 'alembic.ini'),
347
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'alembic.ini'))
348
+ self.save_as(os.path.join(self.sample_path,'migrations', 'audit.py'),
349
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'audit.py'))
350
+ self.save_as(os.path.join(self.sample_path,'migrations', 'dependency_order.py'),
351
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'dependency_order.py'))
352
+ self.save_as(os.path.join(self.sample_path,'migrations', 'env.py'),
353
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'env.py'))
354
+ self.save_as(os.path.join(self.sample_path,'migrations', 'ownership.py'),
355
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'ownership.py'))
356
+ self.save_as(os.path.join(self.sample_path,'migrations', 'precheck.py'),
357
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'precheck.py'))
358
+ self.save_as(os.path.join(self.sample_path,'migrations', 'README'),
359
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'README'))
360
+ self.save_as(os.path.join(self.sample_path,'migrations', 'safety_policy.py'),
361
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'safety_policy.py'))
362
+ self.save_as(os.path.join(self.sample_path,'migrations', 'script.py.mako'),
363
+ os.path.join(os.path.realpath(self.rootpath),'migrations', 'script.py.mako'))
364
+
365
+
329
366
  if include_test:
330
367
  gen_test_code = GenTestsCodeFromUml(self.rootpath, self.modelfile, type='flask')
331
368
  gen_test_code.gen_tests_codes()
@@ -0,0 +1,61 @@
1
+ Flask 单数据库迁移配置说明。
2
+
3
+ 共享数据库安全策略:
4
+ - 在 `migrations/ownership.py` 中配置本服务拥有的表/字段范围。
5
+ - 可选环境变量:`MIGRATION_OWNED_TABLES=table_a,table_b`。
6
+ - 自动差异仅比较“模型定义 + owned 范围”内对象。
7
+ - 所有 `drop_*` 破坏性操作都会被阻断,不会自动执行删除。
8
+
9
+ 迁移阶段说明:
10
+ - `expand`:新增表/字段,执行低风险结构修改。
11
+ - `contract`:执行高风险结构修改(例如收紧 nullable、缩短长度)。
12
+
13
+ 推荐执行顺序(生产):
14
+ - `flask schema-plan --phase expand`
15
+ - `flask schema-upgrade --phase expand`
16
+ - 发布应用
17
+ - `flask schema-verify --phase contract`
18
+ - `flask schema-upgrade --phase contract`
19
+
20
+ 兼容别名(仍可用,但建议迁移到新命名):
21
+ - `db-safe-plan`
22
+ - `db-safe-verify`
23
+ - `db-safe-migrate`
24
+ - `db-safe-upgrade`
25
+
26
+ “同步版本”通常有两种场景,你可以这样做。
27
+
28
+ ```powershell
29
+ # 1) 设置 CLI 入口
30
+ $env:FLASK_APP = "manage:app"
31
+ ```
32
+
33
+ 1. **首次接管已有数据库(库里有业务表,但没有 alembic_version)**
34
+ 用这条即可,它会自动做 `stamp head` 再升级:
35
+ ```powershell
36
+ flask schema-upgrade --phase expand
37
+ ```
38
+
39
+ 2. **你改了 models,想生成“同步变更”的新版本文件**
40
+ 先确保数据库在最新,再生成迁移版本:
41
+ ```powershell
42
+ flask schema-migrate --phase expand -m "sync models"
43
+ ```
44
+
45
+ 3. **执行并同步到数据库**
46
+ ```powershell
47
+ flask schema-upgrade --phase expand
48
+ ```
49
+
50
+ 4. **如果有高风险收敛变更(如 nullable 收紧、长度缩短)**
51
+ ```powershell
52
+ flask schema-verify --phase contract
53
+ flask schema-upgrade --phase contract
54
+ ```
55
+
56
+ 自动生成迁移时的建表顺序规则:
57
+ - 新增表会按外键依赖自动重排,尽量保证被依赖表先创建。
58
+ - 循环依赖用强连通分量(SCC)识别:只把「同一 SCC 内部」的外键延后为 `op.create_foreign_key(...)`;指向 SCC 外部的外键仍内联并参与排序(避免误拆非环依赖)。
59
+ - 单表自引用也属于环,会把该自引用外键单独延后。
60
+ - 如果新增表之间存在循环外键,成环的 FK 会改写为后续 `op.create_foreign_key(...)`,先建表再补约束。
61
+ - 这项改写只影响 autogenerate 生成出的脚本结构,不改变后续 `drop_*` 安全过滤和阶段过滤行为。
@@ -0,0 +1,50 @@
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # template used to generate migration files
5
+ # file_template = %%(rev)s_%%(slug)s
6
+
7
+ # set to 'true' to run the environment during
8
+ # the 'revision' command, regardless of autogenerate
9
+ # revision_environment = false
10
+
11
+
12
+ # Logging configuration
13
+ [loggers]
14
+ keys = root,sqlalchemy,alembic,flask_migrate
15
+
16
+ [handlers]
17
+ keys = console
18
+
19
+ [formatters]
20
+ keys = generic
21
+
22
+ [logger_root]
23
+ level = WARN
24
+ handlers = console
25
+ qualname =
26
+
27
+ [logger_sqlalchemy]
28
+ level = WARN
29
+ handlers =
30
+ qualname = sqlalchemy.engine
31
+
32
+ [logger_alembic]
33
+ level = INFO
34
+ handlers =
35
+ qualname = alembic
36
+
37
+ [logger_flask_migrate]
38
+ level = INFO
39
+ handlers =
40
+ qualname = flask_migrate
41
+
42
+ [handler_console]
43
+ class = StreamHandler
44
+ args = (sys.stderr,)
45
+ level = NOTSET
46
+ formatter = generic
47
+
48
+ [formatter_generic]
49
+ format = %(levelname)-5.5s [%(name)s] %(message)s
50
+ datefmt = %H:%M:%S
@@ -0,0 +1,53 @@
1
+ """
2
+ Schema migration audit helpers.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from datetime import datetime, timezone
7
+
8
+ from sqlalchemy import Column, DateTime, Integer, MetaData, String, Table, Text, text
9
+
10
+ AUDIT_TABLE = "schema_migration_audit"
11
+
12
+
13
+ def ensure_audit_table(engine) -> None:
14
+ metadata = MetaData()
15
+ Table(
16
+ AUDIT_TABLE,
17
+ metadata,
18
+ Column("id", Integer, primary_key=True, autoincrement=True),
19
+ Column("revision", String(64), nullable=False),
20
+ Column("phase", String(20), nullable=False),
21
+ Column("status", String(20), nullable=False),
22
+ Column("payload_json", Text, nullable=True),
23
+ Column("created_at", DateTime, nullable=False),
24
+ )
25
+ metadata.create_all(engine, checkfirst=True)
26
+
27
+
28
+ def build_audit_insert_sql(revision: str, phase: str, status: str, payload_json: str) -> str:
29
+ rev = _escape_sql(revision)
30
+ phase_val = _escape_sql(phase)
31
+ status_val = _escape_sql(status)
32
+ payload = _escape_sql(payload_json)
33
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
34
+ return (
35
+ f"INSERT INTO {AUDIT_TABLE} (revision, phase, status, payload_json, created_at) "
36
+ f"VALUES ('{rev}', '{phase_val}', '{status_val}', '{payload}', '{now}')"
37
+ )
38
+
39
+
40
+ def record_migration_audit(engine, revision: str, phase: str, status: str, payload_json: str) -> None:
41
+ ensure_audit_table(engine)
42
+ sql = build_audit_insert_sql(
43
+ revision=revision,
44
+ phase=phase,
45
+ status=status,
46
+ payload_json=payload_json,
47
+ )
48
+ with engine.begin() as conn:
49
+ conn.execute(text(sql))
50
+
51
+
52
+ def _escape_sql(value: str | None) -> str:
53
+ return (value or "").replace("'", "''")
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ from alembic.operations import ops
4
+ import sqlalchemy as sa
5
+
6
+
7
+ def _table_key(schema, table_name):
8
+ return (schema, table_name)
9
+
10
+
11
+ def _iter_foreign_key_constraints(create_op):
12
+ for item in create_op.columns:
13
+ if isinstance(item, sa.ForeignKeyConstraint):
14
+ yield item
15
+
16
+
17
+ def _target_table_key(constraint):
18
+ target_fullname = constraint.elements[0].target_fullname
19
+ parts = target_fullname.split(".")
20
+ if len(parts) < 2:
21
+ return (None, target_fullname)
22
+ if len(parts) == 2:
23
+ return (None, parts[0])
24
+ return (".".join(parts[:-2]), parts[-2])
25
+
26
+
27
+ def _fk_signature(constraint):
28
+ source_columns = tuple(column.name for column in constraint.columns)
29
+ target_columns = tuple(element.target_fullname for element in constraint.elements)
30
+ return (
31
+ constraint.name,
32
+ source_columns,
33
+ target_columns,
34
+ )
35
+
36
+
37
+ def _clone_create_table_op(original_op, deferred_fk_signatures):
38
+ items = []
39
+ for item in original_op.columns:
40
+ if isinstance(item, sa.ForeignKeyConstraint):
41
+ if _fk_signature(item) in deferred_fk_signatures:
42
+ continue
43
+ items.append(item)
44
+ return ops.CreateTableOp(
45
+ original_op.table_name,
46
+ items,
47
+ schema=original_op.schema,
48
+ if_not_exists=original_op.if_not_exists,
49
+ _namespace_metadata=original_op._namespace_metadata,
50
+ _constraints_included=original_op._constraints_included,
51
+ comment=original_op.comment,
52
+ info=dict(original_op.info),
53
+ prefixes=list(original_op.prefixes) if original_op.prefixes else None,
54
+ **dict(original_op.kw),
55
+ )
56
+
57
+
58
+ def _build_dependency_graph(create_ops):
59
+ metadata = sa.MetaData()
60
+ create_ops_by_key = {
61
+ _table_key(create_op.schema, create_op.table_name): create_op
62
+ for create_op in create_ops
63
+ }
64
+ new_table_keys = set(create_ops_by_key)
65
+ dependencies = {key: set() for key in new_table_keys}
66
+ constraints_by_source = {key: [] for key in new_table_keys}
67
+
68
+ for source_key, create_op in create_ops_by_key.items():
69
+ table = create_op.to_table().to_metadata(metadata)
70
+ for constraint in table.foreign_key_constraints:
71
+ constraints_by_source[source_key].append(constraint)
72
+ target_key = _target_table_key(constraint)
73
+ if target_key in new_table_keys:
74
+ dependencies[source_key].add(target_key)
75
+
76
+ return create_ops_by_key, dependencies, constraints_by_source
77
+
78
+
79
+ def _compute_strongly_connected_components(graph):
80
+ index = 0
81
+ indices = {}
82
+ lowlinks = {}
83
+ stack = []
84
+ on_stack = set()
85
+ components = []
86
+
87
+ def strongconnect(node):
88
+ nonlocal index
89
+ indices[node] = index
90
+ lowlinks[node] = index
91
+ index += 1
92
+ stack.append(node)
93
+ on_stack.add(node)
94
+
95
+ for neighbor in graph[node]:
96
+ if neighbor not in indices:
97
+ strongconnect(neighbor)
98
+ lowlinks[node] = min(lowlinks[node], lowlinks[neighbor])
99
+ elif neighbor in on_stack:
100
+ lowlinks[node] = min(lowlinks[node], indices[neighbor])
101
+
102
+ if lowlinks[node] == indices[node]:
103
+ component = []
104
+ while True:
105
+ member = stack.pop()
106
+ on_stack.remove(member)
107
+ component.append(member)
108
+ if member == node:
109
+ break
110
+ components.append(component)
111
+
112
+ for node in graph:
113
+ if node not in indices:
114
+ strongconnect(node)
115
+
116
+ return components
117
+
118
+
119
+ def _find_deferred_fk_signatures(dependencies, constraints_by_source):
120
+ components = _compute_strongly_connected_components(dependencies)
121
+ component_by_key = {}
122
+ cyclic_component_ids = set()
123
+
124
+ for index, component in enumerate(components):
125
+ for key in component:
126
+ component_by_key[key] = index
127
+ if len(component) > 1:
128
+ cyclic_component_ids.add(index)
129
+ continue
130
+ only_key = component[0]
131
+ if only_key in dependencies[only_key]:
132
+ cyclic_component_ids.add(index)
133
+
134
+ deferred_fk_signatures = set()
135
+ for source_key, constraints in constraints_by_source.items():
136
+ for constraint in constraints:
137
+ target_key = _target_table_key(constraint)
138
+ if target_key not in component_by_key:
139
+ continue
140
+ if component_by_key[source_key] != component_by_key[target_key]:
141
+ continue
142
+ if component_by_key[source_key] not in cyclic_component_ids:
143
+ continue
144
+ deferred_fk_signatures.add(_fk_signature(constraint))
145
+
146
+ return deferred_fk_signatures
147
+
148
+
149
+ def _toposort_table_keys(original_keys, deferred_fk_signatures, constraints_by_source):
150
+ """
151
+ Topological sort of table keys, excluding deferred (cyclic) FKs.
152
+
153
+ The dependency direction is: source_key depends ON target_key
154
+ So when we create tables, we should create target_key BEFORE source_key.
155
+ This means target_key has lower indegree (fewer tables it depends on).
156
+ """
157
+ # Build adjacency: dependencies[source] = {tables that source depends ON}
158
+ dependencies = {key: set() for key in original_keys}
159
+
160
+ for source_key in original_keys:
161
+ for constraint in constraints_by_source[source_key]:
162
+ if _fk_signature(constraint) in deferred_fk_signatures:
163
+ continue
164
+ target_key = _target_table_key(constraint)
165
+ if target_key not in dependencies:
166
+ continue # External table, skip
167
+ dependencies[source_key].add(target_key)
168
+
169
+ # indegree[key] = number of tables that depend ON this key (should be created after this key)
170
+ # We want to process tables with indegree=0 first (no one depends on them to be created first)
171
+ # Actually for creation order: we need to create tables that ARE depended upon first
172
+ # So indegree here means "how many tables this table depends on"
173
+ indegree = {key: len(deps) for key, deps in dependencies.items()}
174
+
175
+ # dependents[target] = {tables that depend ON target}
176
+ dependents = {key: set() for key in original_keys}
177
+ for source_key, target_keys in dependencies.items():
178
+ for target_key in target_keys:
179
+ dependents[target_key].add(source_key)
180
+
181
+ # Start with tables that have no dependencies (can be created first)
182
+ ready = [key for key in original_keys if indegree[key] == 0]
183
+ ordered = []
184
+ visited = set()
185
+
186
+ while ready:
187
+ key = ready.pop(0)
188
+ ordered.append(key)
189
+ visited.add(key)
190
+
191
+ # For each table that depends on this key, reduce its indegree
192
+ for dependent_key in dependents[key]:
193
+ indegree[dependent_key] -= 1
194
+ if indegree[dependent_key] == 0 and dependent_key not in visited:
195
+ ready.append(dependent_key)
196
+
197
+ # If we couldn't order all, return original order (shouldn't happen with proper SCC handling)
198
+ if len(ordered) != len(original_keys):
199
+ return list(original_keys)
200
+ return ordered
201
+
202
+
203
+ def rewrite_create_table_dependency_order(container):
204
+ if container is None or not hasattr(container, "ops"):
205
+ return
206
+
207
+ create_ops = [op for op in container.ops if isinstance(op, ops.CreateTableOp)]
208
+ other_ops = [op for op in container.ops if not isinstance(op, ops.CreateTableOp)]
209
+ # 单表自引用外键也需要拆成后置 CreateForeignKeyOp,不能因「少于两张新表」而跳过
210
+ if not create_ops:
211
+ return
212
+
213
+ create_ops_by_key, dependencies, constraints_by_source = _build_dependency_graph(create_ops)
214
+ original_keys = [
215
+ _table_key(create_op.schema, create_op.table_name)
216
+ for create_op in create_ops
217
+ ]
218
+ deferred_fk_signatures = _find_deferred_fk_signatures(
219
+ dependencies,
220
+ constraints_by_source,
221
+ )
222
+ ordered_keys = _toposort_table_keys(
223
+ original_keys=original_keys,
224
+ deferred_fk_signatures=deferred_fk_signatures,
225
+ constraints_by_source=constraints_by_source,
226
+ )
227
+
228
+ rewritten_ops = [
229
+ _clone_create_table_op(create_ops_by_key[key], deferred_fk_signatures)
230
+ for key in ordered_keys
231
+ ]
232
+ deferred_fk_ops = [
233
+ ops.CreateForeignKeyOp.from_constraint(constraint)
234
+ for source_key in original_keys
235
+ for constraint in constraints_by_source[source_key]
236
+ if _fk_signature(constraint) in deferred_fk_signatures
237
+ ]
238
+
239
+ container.ops = rewritten_ops + deferred_fk_ops + other_ops