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.
- {mwgencode-1.5.18/mwgencode.egg-info → mwgencode-1.6.0}/PKG-INFO +1 -1
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gen_code.py +42 -5
- mwgencode-1.6.0/gencode/gencode/sample/migrations/README +61 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/__init__.py +2 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/alembic.ini +50 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/audit.py +53 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/dependency_order.py +239 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/env.py +214 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/ownership.py +47 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/precheck.py +130 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/safety_policy.py +117 -0
- mwgencode-1.6.0/gencode/gencode/sample/migrations/script.py.mako +24 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/gen_code_flask.yaml +5 -2
- {mwgencode-1.5.18 → mwgencode-1.6.0}/manage.py +21 -7
- {mwgencode-1.5.18 → mwgencode-1.6.0/mwgencode.egg-info}/PKG-INFO +1 -1
- {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/SOURCES.txt +10 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/setup.py +1 -1
- {mwgencode-1.5.18 → mwgencode-1.6.0}/CHANGES.txt +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/LICENSE.txt +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/MANIFEST.in +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/README.rst +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/dd_models.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/ext.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/export_class2swgclass.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_bo_models_code.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_state_code.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_swagger_code.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/gen_tests_code.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/.env_example +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/babel.cfg +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/config-sample.ini +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/config.ini +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/create_new_table_run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/dockerignore.dock +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/file_utils.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/gencode.xmi +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/gitignore.git +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/migrate_run.bat +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/migrate_run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/requirements.txt +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/run.sh +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/models_rm.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_dev_data.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_init.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_rm.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/seeds/seed_utils.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/test__init__.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/test_run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/sample/utils.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/Dockerfile.tmp +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/Dockerfile_dg.tmp +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/README.md +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/__init__.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/asgi_run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/config.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/default.conf +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/docker-compose-dev.yaml +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/docker-compose.yaml +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/drone.tmp +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/flask_models.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/flask_models_base.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/gen_code_run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/k8s-tmp.yml +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/sample.mdj +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/setup.tmp +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/supervisord.conf +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swagger_file.yaml +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swg_class.tmp +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swg_ctrl_code.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/swg_package_mng.tmp +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/test_test_base.tmp +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/__init__.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/init_test_data.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/test_base.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/tests/test_classmng.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/uwsgi.ini +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/gencode/template/uwsgi_run.pys +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/import_dd_classes.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/import_swagger2_class.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importmdj/import_uml_models.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/__init__.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_classes.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_sequences.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_states.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/importxmi/import_swagger.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/swg2_class_models.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/uml_class_models.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/upgrade.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/gencode/utils.py +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/help.md +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/dependency_links.txt +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/entry_points.txt +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/requires.txt +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/mwgencode.egg-info/top_level.txt +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/pyproject.toml +0 -0
- {mwgencode-1.5.18 → mwgencode-1.6.0}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
200
|
-
|
|
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
|