@hupan56/wlkj 3.2.0 → 3.3.1
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.
- package/bin/cli.js +117 -0
- package/package.json +2 -2
- package/templates/.qoder/.runtime/hook-errors.log +4 -0
- package/templates/qoder/commands/optional/wl-insight.md +276 -276
- package/templates/qoder/commands/optional/wl-report.md +1 -1
- package/templates/qoder/commands/optional/wl-spec.md +13 -1
- package/templates/qoder/commands/optional/wl-status.md +14 -1
- package/templates/qoder/commands/wl-code.md +111 -4
- package/templates/qoder/commands/wl-commit.md +12 -1
- package/templates/qoder/commands/wl-design.md +69 -5
- package/templates/qoder/commands/wl-init.md +129 -102
- package/templates/qoder/commands/wl-prd.md +162 -6
- package/templates/qoder/commands/wl-search.md +106 -20
- package/templates/qoder/commands/wl-task.md +617 -613
- package/templates/qoder/commands/wl-test.md +20 -1
- package/templates/qoder/contracts/spec.md +4 -0
- package/templates/qoder/hooks/post-tool-use.py +71 -0
- package/templates/qoder/hooks/pre-tool-use.py +136 -0
- package/templates/qoder/hooks/session-start.py +365 -397
- package/templates/qoder/scripts/capability/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/__pycache__/present_html.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/__pycache__/registry.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/__pycache__/registry_mcp.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/adapters/__init__.py +1 -1
- package/templates/qoder/scripts/capability/adapters/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/adapters/__pycache__/cli.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/adapters/__pycache__/mcp.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/adapters/__pycache__/qw.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/adapters/mcp.py +76 -100
- package/templates/qoder/scripts/capability/adapters/qw.py +295 -295
- package/templates/qoder/scripts/capability/caps/__init__.py +5 -5
- package/templates/qoder/scripts/capability/caps/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/context.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/cron.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/identity.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/memory.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/notify.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/present.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/repo.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/sandbox.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/memory.py +1 -1
- package/templates/qoder/scripts/capability/caps/notify.py +64 -0
- package/templates/qoder/scripts/capability/caps/sandbox.py +38 -0
- package/templates/qoder/scripts/capability/present_html.py +68 -0
- package/templates/qoder/scripts/capability/registry.py +27 -25
- package/templates/qoder/scripts/capability/registry_mcp.py +67 -5
- package/templates/qoder/scripts/capability/smoke_test_report.json +34 -20
- package/templates/qoder/scripts/deployment/setup/carriers.py +3 -1
- package/templates/qoder/scripts/deployment/setup/init_doctor.py +10 -3
- package/templates/qoder/scripts/domain/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/domain/integration/__init__.py +0 -0
- package/templates/qoder/scripts/domain/integration/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/domain/integration/__pycache__/return_to_platform.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/domain/integration/return_to_platform.py +395 -0
- package/templates/qoder/scripts/domain/integration/spec_upload.py +208 -0
- package/templates/qoder/scripts/domain/kg/build/kg_build.py +27 -3
- package/templates/qoder/scripts/domain/kg/extract/asset/__init__.py +10 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/asset_tree.py +57 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/discussion_importer.py +62 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/prd_importer.py +146 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/prototype_importer.py +64 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/returns_importer.py +52 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal3.py +104 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal4.py +55 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal5.py +95 -0
- package/templates/qoder/scripts/domain/kg/extract/db/__init__.py +8 -0
- package/templates/qoder/scripts/domain/kg/extract/db/data_profile.py +22 -0
- package/templates/qoder/scripts/domain/kg/extract/db/fk_extractor.py +55 -0
- package/templates/qoder/scripts/domain/kg/extract/db/schema_extractor.py +90 -0
- package/templates/qoder/scripts/domain/kg/extract/extract.py +84 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/__init__.py +9 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/community_summarizer.py +206 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/embed_builder.py +132 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/naming_matcher.py +80 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/promote.py +59 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/recompute.py +93 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/weak_link.py +421 -0
- package/templates/qoder/scripts/domain/kg/extract/java/__init__.py +15 -0
- package/templates/qoder/scripts/domain/kg/extract/java/_parser.py +271 -0
- package/templates/qoder/scripts/domain/kg/extract/java/all.py +145 -0
- package/templates/qoder/scripts/domain/kg/extract/java/build_java_to_pg.py +102 -0
- package/templates/qoder/scripts/domain/kg/extract/java/call_chain.py +49 -0
- package/templates/qoder/scripts/domain/kg/extract/java/class_extractor.py +141 -0
- package/templates/qoder/scripts/domain/kg/extract/java/domain_extractor.py +148 -0
- package/templates/qoder/scripts/domain/kg/extract/java/dubbo_extractor.py +33 -0
- package/templates/qoder/scripts/domain/kg/extract/java/endpoint_extractor.py +36 -0
- package/templates/qoder/scripts/domain/kg/extract/java/javadoc_extractor.py +110 -0
- package/templates/qoder/scripts/domain/kg/extract/java/llm_cn_filler.py +150 -0
- package/templates/qoder/scripts/domain/kg/extract/java/member_extractor.py +157 -0
- package/templates/qoder/scripts/domain/kg/extract/java/mybatisplus_extractor.py +34 -0
- package/templates/qoder/scripts/domain/kg/extract/java/pg_upsert.py +165 -0
- package/templates/qoder/scripts/domain/kg/extract/java/satoken_extractor.py +30 -0
- package/templates/qoder/scripts/domain/kg/extract/java/spring_extractor.py +39 -0
- package/templates/qoder/scripts/domain/kg/extract/java/validation_extractor.py +33 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/__init__.py +9 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/all.py +79 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/mapper_parser.py +99 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/relation_builder.py +69 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/sql_extractor.py +78 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/__init__.py +8 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/prd_chunk_embed.py +105 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/prd_llm_extract.py +153 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/req_anchor.py +120 -0
- package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +4 -2
- package/templates/qoder/scripts/domain/kg/kg.py +42 -5
- package/templates/qoder/scripts/domain/kg/search/_remote.py +187 -0
- package/templates/qoder/scripts/domain/kg/search/context_pack.py +32 -2
- package/templates/qoder/scripts/domain/kg/search/search_index.py +74 -20
- package/templates/qoder/scripts/domain/kg/switch_project.py +158 -0
- package/templates/qoder/scripts/domain/kg/sync_repowiki.py +109 -0
- package/templates/qoder/scripts/domain/task/__pycache__/wlkj_panel.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/domain/task/{zentao_panel.py → wlkj_panel.py} +315 -53
- package/templates/qoder/scripts/engine/poller.py +219 -0
- package/templates/qoder/scripts/foundation/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/foundation/core/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/foundation/core/__pycache__/paths.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/foundation/integrations/active_task.py +2 -1
- package/templates/qoder/scripts/orchestration/wlkj.py +4 -0
- package/templates/qoder/scripts/protocol/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1 -1
- package/templates/qoder/scripts/protocol/transports/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/protocol/transports/__pycache__/base.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/protocol/transports/__pycache__/cli.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/protocol/transports/__pycache__/http.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/protocol/transports/__pycache__/stdio.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/protocol/transports/http.py +7 -1
- package/templates/qoder/scripts/validation/metrics/__pycache__/present_board.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/validation/metrics/present_board.py +180 -0
- package/templates/qoder/settings.json +10 -0
- package/templates/root/AGENTS.md +9 -10
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""方法/字段级提取器: 端点 + 权限 + 校验 + 调用链 + 字段映射。
|
|
3
|
+
|
|
4
|
+
和 class_extractor 互补: class_extractor 处理类级(实体+注入),
|
|
5
|
+
本文件处理类内成员(方法→端点/权限, 字段→校验/列)。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from . import _parser as P
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Sa-Token 权限注解
|
|
12
|
+
_SATOKEN_PERM = {'SaCheckPermission', 'SaCheckRole', 'SaCheckLogin', 'SaMode', 'SaIgnore'}
|
|
13
|
+
# 校验注解
|
|
14
|
+
_VALIDATION = {'NotNull', 'NotBlank', 'NotEmpty', 'Size', 'Min', 'Max',
|
|
15
|
+
'Email', 'Pattern', 'Positive', 'Negative', 'Digits', 'Past', 'Future'}
|
|
16
|
+
# MyBatis-Plus 字段注解
|
|
17
|
+
_MP_FIELD = {'TableId', 'TableField', 'TableLogic'}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract(root, repo_id: str, filepath: str, owner_classes: list):
|
|
21
|
+
"""提取方法/字段级实体 + 边。
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
owner_classes: class_extractor 已提取的类信息 [{name, etype, id, anns}]
|
|
25
|
+
方法级边要锚到所属类上。
|
|
26
|
+
Returns:
|
|
27
|
+
(entities, edges)
|
|
28
|
+
"""
|
|
29
|
+
entities, edges = [], []
|
|
30
|
+
if root is None:
|
|
31
|
+
return entities, edges
|
|
32
|
+
|
|
33
|
+
# 建 name → owner 映射(方法/字段属于哪个类)
|
|
34
|
+
owner_map = {c['name']: c for c in owner_classes}
|
|
35
|
+
|
|
36
|
+
for cls in P.get_class_decls(root):
|
|
37
|
+
cls_name = P.get_class_name(cls)
|
|
38
|
+
owner = owner_map.get(cls_name)
|
|
39
|
+
if not owner:
|
|
40
|
+
continue # 不是后端组件类(如普通 DTO),由 domain_extractor 处理
|
|
41
|
+
owner_id = owner['id']
|
|
42
|
+
|
|
43
|
+
# ===== 方法级: 端点 + 权限 + 调用链 =====
|
|
44
|
+
for m in P.get_methods(cls):
|
|
45
|
+
method_name = m['name']
|
|
46
|
+
m_anns = m['annotations']
|
|
47
|
+
|
|
48
|
+
# 端点: @GetMapping/@PostMapping 等
|
|
49
|
+
endpoint_path = _extract_endpoint_path(m_anns, owner.get('anns_dict', {}))
|
|
50
|
+
if endpoint_path:
|
|
51
|
+
ep_id = 'EP:%s:%s' % (repo_id, endpoint_path)
|
|
52
|
+
entities.append({
|
|
53
|
+
'id': ep_id, 'repo_id': repo_id, 'type': 'ENDPOINT',
|
|
54
|
+
'canonical': endpoint_path, 'cn': '',
|
|
55
|
+
'props': {'method': _extract_http_verb(list(m_anns.keys())),
|
|
56
|
+
'handler': '%s.%s' % (cls_name, method_name),
|
|
57
|
+
'returns': m.get('return_type', '')},
|
|
58
|
+
})
|
|
59
|
+
edges.append({
|
|
60
|
+
'from_id': ep_id, 'to_id': owner_id,
|
|
61
|
+
'edge_type': 'handled_by',
|
|
62
|
+
'from_repo': repo_id, 'to_repo': repo_id,
|
|
63
|
+
'confidence': 1.0, 'source': 'explicit',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# 权限: @SaCheckPermission("asset:list")
|
|
67
|
+
for sa in _SATOKEN_PERM:
|
|
68
|
+
if sa in m_anns:
|
|
69
|
+
perm_code = m_anns[sa].strip('"').strip("'") or sa
|
|
70
|
+
perm_id = 'PERM:%s:%s' % (repo_id, perm_code)
|
|
71
|
+
entities.append({
|
|
72
|
+
'id': perm_id, 'repo_id': repo_id, 'type': 'PERMISSION',
|
|
73
|
+
'canonical': perm_code, 'cn': '',
|
|
74
|
+
'props': {'check_type': sa, 'method': '%s.%s' % (cls_name, method_name)},
|
|
75
|
+
})
|
|
76
|
+
edges.append({
|
|
77
|
+
'from_id': owner_id, 'to_id': perm_id,
|
|
78
|
+
'edge_type': 'guards',
|
|
79
|
+
'from_repo': repo_id, 'to_repo': repo_id,
|
|
80
|
+
'confidence': 1.0, 'source': 'explicit',
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
# 调用链: 方法体内 remoteClientService.getById()
|
|
84
|
+
# 要把 object 名映射回字段类型(字段类型在 owner 的 fields 里)
|
|
85
|
+
field_types = {f['name']: f['type'] for f in P.get_fields(cls)}
|
|
86
|
+
for call in P.get_method_calls(m['body_node']):
|
|
87
|
+
obj_name = call['object']
|
|
88
|
+
method = call['method']
|
|
89
|
+
if not obj_name:
|
|
90
|
+
continue
|
|
91
|
+
dep_type = field_types.get(obj_name) or _guess_type_by_name(obj_name)
|
|
92
|
+
if not dep_type:
|
|
93
|
+
continue
|
|
94
|
+
dep_prefix = _guess_dep_prefix(dep_type)
|
|
95
|
+
edges.append({
|
|
96
|
+
'from_id': owner_id,
|
|
97
|
+
'to_id': '%s:%s:%s' % (dep_prefix, repo_id, dep_type),
|
|
98
|
+
'edge_type': 'calls',
|
|
99
|
+
'from_repo': repo_id, 'to_repo': repo_id,
|
|
100
|
+
'confidence': 1.0, 'source': 'explicit',
|
|
101
|
+
'props': {'method': method, 'callee_field': obj_name},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
return entities, edges
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ===== 端点相关 =====
|
|
108
|
+
_ENDPOINT_ANN_TO_VERB = {
|
|
109
|
+
'GetMapping': 'GET', 'PostMapping': 'POST',
|
|
110
|
+
'PutMapping': 'PUT', 'DeleteMapping': 'DELETE',
|
|
111
|
+
'PatchMapping': 'PATCH', 'RequestMapping': 'ANY',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _extract_endpoint_path(method_anns: dict, class_anns: dict) -> str:
|
|
116
|
+
"""提取端点路径: 类级 @RequestMapping + 方法级 @GetMapping。"""
|
|
117
|
+
base = ''
|
|
118
|
+
if 'RequestMapping' in class_anns:
|
|
119
|
+
base = class_anns['RequestMapping'].strip('"').strip("'")
|
|
120
|
+
for ann, _ in method_anns.items():
|
|
121
|
+
if ann in _ENDPOINT_ANN_TO_VERB:
|
|
122
|
+
path = method_anns[ann].strip('"').strip("'")
|
|
123
|
+
if base and path and not path.startswith('/'):
|
|
124
|
+
path = base.rstrip('/') + '/' + path
|
|
125
|
+
elif base and not path:
|
|
126
|
+
path = base
|
|
127
|
+
return path or '/'
|
|
128
|
+
return ''
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_http_verb(ann_names: list) -> str:
|
|
132
|
+
for ann in ann_names:
|
|
133
|
+
if ann in _ENDPOINT_ANN_TO_VERB:
|
|
134
|
+
return _ENDPOINT_ANN_TO_VERB[ann]
|
|
135
|
+
return 'ANY'
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _guess_type_by_name(field_name: str) -> str:
|
|
139
|
+
"""从字段名猜类型(如 xxxMapper → XxxMapper)。
|
|
140
|
+
字段类型有时在 javadoc/import 里找不到, 用命名兜底。"""
|
|
141
|
+
parts = field_name.split('_')
|
|
142
|
+
guessed = ''.join(p.capitalize() for p in parts if p)
|
|
143
|
+
if 'mapper' in field_name.lower():
|
|
144
|
+
return guessed if guessed.endswith('Mapper') else guessed + 'Mapper'
|
|
145
|
+
if 'service' in field_name.lower():
|
|
146
|
+
return guessed if guessed.endswith('Service') else guessed + 'Service'
|
|
147
|
+
return ''
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _guess_dep_prefix(type_name: str) -> str:
|
|
151
|
+
if type_name.endswith('Mapper'):
|
|
152
|
+
return 'MAP'
|
|
153
|
+
if type_name.endswith('ServiceImpl') or type_name.endswith('Service'):
|
|
154
|
+
return 'SVC'
|
|
155
|
+
if type_name.endswith('Controller'):
|
|
156
|
+
return 'CTRL'
|
|
157
|
+
return 'SVC'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""MyBatis-Plus ORM 提取器(@TableName/@TableId/@TableField/@TableLogic)。
|
|
3
|
+
|
|
4
|
+
职责: 识别 Entity→Table 映射 + 字段→列映射。
|
|
5
|
+
本项目用 MyBatis-Plus 不是 JPA, @TableName("employee_online") 是表名真源。
|
|
6
|
+
实体识别在 class_extractor(Entity 类型) + domain_extractor(字段映射),
|
|
7
|
+
这里按 MyBatis-Plus 关注点导出。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from . import _parser as P
|
|
11
|
+
from . import class_extractor as _ce
|
|
12
|
+
from . import domain_extractor as _de
|
|
13
|
+
|
|
14
|
+
MP_TABLE = {'TableName'}
|
|
15
|
+
MP_ID = {'TableId'}
|
|
16
|
+
MP_FIELD = {'TableField'}
|
|
17
|
+
MP_LOGIC = {'TableLogic'}
|
|
18
|
+
MP_ALL = MP_TABLE | MP_ID | MP_FIELD | MP_LOGIC
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_mybatis_entity(annotations: dict) -> bool:
|
|
22
|
+
"""类是否 @TableName(MyBatis-Plus Entity)。"""
|
|
23
|
+
return 'TableName' in annotations
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_table_name(annotations: dict) -> str:
|
|
27
|
+
"""从 @TableName 注解取表名。"""
|
|
28
|
+
v = annotations.get('TableName', '')
|
|
29
|
+
return v.strip('"').strip("'") if v else ''
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract(root, repo_id: str, filepath: str):
|
|
33
|
+
"""MyBatis-Plus Entity 提取(= domain_extractor, 处理 Entity/字段/列映射)。"""
|
|
34
|
+
return _de.extract(root, repo_id, filepath)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PG 批量 upsert: 把 Java extractor 的 entities/edges 写进 PostgreSQL。
|
|
3
|
+
|
|
4
|
+
独立于 DuckDB 写入路径 —— GOAL 2 直接写 PG(KNOWLEDGE_BACKEND=pg 时)。
|
|
5
|
+
用 executemany + ON CONFLICT 幂等 upsert。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
# 路径自举
|
|
14
|
+
_THIS = os.path.dirname(os.path.abspath(__file__))
|
|
15
|
+
for _i in range(8):
|
|
16
|
+
_p = os.path.dirname(_THIS)
|
|
17
|
+
if os.path.isfile(os.path.join(_p, 'foundation', 'bootstrap.py')):
|
|
18
|
+
sys.path.insert(0, _p)
|
|
19
|
+
break
|
|
20
|
+
_THIS = _p
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_pg_engine():
|
|
24
|
+
"""获取 PG 引擎(优先用工作台的 SessionLocal 引擎)。"""
|
|
25
|
+
# 优先工作台 backend(KNOWLEDGE_BACKEND=pg 时)
|
|
26
|
+
ws_backend = os.path.join(os.getcwd(), 'wlinkj-workspace', 'backend')
|
|
27
|
+
if os.path.isfile(os.path.join(ws_backend, '.env')) or os.path.isfile(os.path.join(ws_backend, '..', '..', '.env')):
|
|
28
|
+
try:
|
|
29
|
+
from dotenv import load_dotenv
|
|
30
|
+
for env_path in [os.path.join(ws_backend, '.env'),
|
|
31
|
+
os.path.join(ws_backend, '..', '..', '.env')]:
|
|
32
|
+
if os.path.isfile(env_path):
|
|
33
|
+
load_dotenv(env_path); break
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
# 用工作台 db 引擎
|
|
37
|
+
sys.path.insert(0, ws_backend)
|
|
38
|
+
try:
|
|
39
|
+
from app.db import engine
|
|
40
|
+
return engine, 'app.db'
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
# 兜底: 直接从环境变量建引擎
|
|
44
|
+
db_url = os.environ.get('DATABASE_URL', '')
|
|
45
|
+
if db_url and 'postgresql' in db_url:
|
|
46
|
+
from sqlalchemy import create_engine
|
|
47
|
+
return create_engine(db_url), 'env'
|
|
48
|
+
return None, None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def upsert_batch(entities: list, edges: list, batch_size: int = 500):
|
|
52
|
+
"""批量 upsert entities + edges 到 PG。
|
|
53
|
+
|
|
54
|
+
entities: [{id, repo_id, type, canonical, cn, props}]
|
|
55
|
+
edges: [{from_id, to_id, edge_type, from_repo, to_repo, confidence, source, props}]
|
|
56
|
+
幂等: ON CONFLICT 覆盖。
|
|
57
|
+
"""
|
|
58
|
+
engine, src = _get_pg_engine()
|
|
59
|
+
if engine is None:
|
|
60
|
+
return {'ok': False, 'error': 'PG 引擎不可用 (检查 DATABASE_URL/工作台路径)'}
|
|
61
|
+
|
|
62
|
+
from sqlalchemy import text
|
|
63
|
+
ent_ok = ent_fail = ed_ok = ed_fail = 0
|
|
64
|
+
t0 = time.time()
|
|
65
|
+
|
|
66
|
+
with engine.begin() as conn:
|
|
67
|
+
# === 先确保所有 repo_id 在 repos 表注册 (entities.repo_id 有FK约束) ===
|
|
68
|
+
repo_ids = set()
|
|
69
|
+
for e in entities:
|
|
70
|
+
if e.get('repo_id'):
|
|
71
|
+
repo_ids.add(e['repo_id'])
|
|
72
|
+
for e in edges:
|
|
73
|
+
if e.get('from_repo'):
|
|
74
|
+
repo_ids.add(e['from_repo'])
|
|
75
|
+
if e.get('to_repo'):
|
|
76
|
+
repo_ids.add(e['to_repo'])
|
|
77
|
+
for rid in repo_ids:
|
|
78
|
+
try:
|
|
79
|
+
conn.execute(text("""
|
|
80
|
+
INSERT INTO repos (id, name) VALUES (:id, :id)
|
|
81
|
+
ON CONFLICT (id) DO NOTHING
|
|
82
|
+
"""), {'id': rid})
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# === entities ===
|
|
87
|
+
for i in range(0, len(entities), batch_size):
|
|
88
|
+
batch = entities[i:i + batch_size]
|
|
89
|
+
for e in batch:
|
|
90
|
+
try:
|
|
91
|
+
props_json = e.get('props') or {}
|
|
92
|
+
if not isinstance(props_json, str):
|
|
93
|
+
props_json = json.dumps(props_json, ensure_ascii=False)
|
|
94
|
+
conn.execute(text("""
|
|
95
|
+
INSERT INTO entities (id, repo_id, type, canonical, cn, props)
|
|
96
|
+
VALUES (:id, :repo_id, :type, :canonical, :cn, CAST(:props AS JSONB))
|
|
97
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
98
|
+
type=EXCLUDED.type, canonical=EXCLUDED.canonical,
|
|
99
|
+
cn=EXCLUDED.cn, props=EXCLUDED.props
|
|
100
|
+
"""), {
|
|
101
|
+
'id': e['id'], 'repo_id': e.get('repo_id', ''),
|
|
102
|
+
'type': e['type'], 'canonical': e.get('canonical', ''),
|
|
103
|
+
'cn': e.get('cn', ''), 'props': props_json,
|
|
104
|
+
})
|
|
105
|
+
ent_ok += 1
|
|
106
|
+
except Exception:
|
|
107
|
+
ent_fail += 1
|
|
108
|
+
|
|
109
|
+
# === edges ===
|
|
110
|
+
for i in range(0, len(edges), batch_size):
|
|
111
|
+
batch = edges[i:i + batch_size]
|
|
112
|
+
for e in batch:
|
|
113
|
+
try:
|
|
114
|
+
props_json = e.get('props') or {}
|
|
115
|
+
if not isinstance(props_json, str):
|
|
116
|
+
props_json = json.dumps(props_json, ensure_ascii=False)
|
|
117
|
+
conn.execute(text("""
|
|
118
|
+
INSERT INTO edges (from_id, to_id, edge_type, from_repo, to_repo,
|
|
119
|
+
confidence, source, expires_at, fingerprint, props)
|
|
120
|
+
VALUES (:from_id, :to_id, :edge_type, :from_repo, :to_repo,
|
|
121
|
+
:confidence, :source,
|
|
122
|
+
CAST(:expires_at AS TIMESTAMPTZ), :fingerprint,
|
|
123
|
+
CAST(:props AS JSONB))
|
|
124
|
+
ON CONFLICT DO NOTHING
|
|
125
|
+
"""), {
|
|
126
|
+
'from_id': e['from_id'], 'to_id': e['to_id'],
|
|
127
|
+
'edge_type': e['edge_type'],
|
|
128
|
+
'from_repo': e.get('from_repo', ''),
|
|
129
|
+
'to_repo': e.get('to_repo', ''),
|
|
130
|
+
'confidence': e.get('confidence', 1.0),
|
|
131
|
+
'source': e.get('source', 'explicit'),
|
|
132
|
+
'expires_at': e.get('expires_at_iso') or e.get('expires_at'),
|
|
133
|
+
'fingerprint': e.get('fingerprint'),
|
|
134
|
+
'props': props_json,
|
|
135
|
+
})
|
|
136
|
+
ed_ok += 1
|
|
137
|
+
except Exception:
|
|
138
|
+
ed_fail += 1
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
'ok': True, 'engine_source': src,
|
|
142
|
+
'entities_ok': ent_ok, 'entities_fail': ent_fail,
|
|
143
|
+
'edges_ok': ed_ok, 'edges_fail': ed_fail,
|
|
144
|
+
'seconds': round(time.time() - t0, 1),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def upsert_freshness(entities: list, fingerprint_map: dict):
|
|
149
|
+
"""更新 knowledge_freshness 表(增量构建依赖)。
|
|
150
|
+
|
|
151
|
+
fingerprint_map: {entity_id: content_hash}(可选, 没传则不写指纹)
|
|
152
|
+
"""
|
|
153
|
+
engine, _ = _get_pg_engine()
|
|
154
|
+
if engine is None:
|
|
155
|
+
return
|
|
156
|
+
from sqlalchemy import text
|
|
157
|
+
with engine.begin() as conn:
|
|
158
|
+
for e in entities:
|
|
159
|
+
fp = fingerprint_map.get(e['id'], '')
|
|
160
|
+
conn.execute(text("""
|
|
161
|
+
INSERT INTO knowledge_freshness (entity_id, source, fingerprint, extracted_at)
|
|
162
|
+
VALUES (:id, 'java_ast', :fp, now())
|
|
163
|
+
ON CONFLICT (entity_id) DO UPDATE SET
|
|
164
|
+
source='java_ast', fingerprint=EXCLUDED.fingerprint, extracted_at=now()
|
|
165
|
+
"""), {'id': e['id'], 'fp': fp})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Sa-Token 权限提取器(@SaCheckPermission/@SaCheckRole/@SaCheckLogin)。
|
|
3
|
+
|
|
4
|
+
职责: 识别 Sa-Token 权限注解 → PERMISSION 实体 + guards 边。
|
|
5
|
+
本项目用 Sa-Token 不是 Spring Security, @SaCheckPermission 是真实权限点来源。
|
|
6
|
+
方法级权限提取在 member_extractor, 这里是类级判定 + 常量定义。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from . import _parser as P
|
|
10
|
+
from . import class_extractor as _ce
|
|
11
|
+
|
|
12
|
+
# Sa-Token 权限注解
|
|
13
|
+
SATOKEN_PERM = {'SaCheckPermission'}
|
|
14
|
+
SATOKEN_ROLE = {'SaCheckRole'}
|
|
15
|
+
SATOKEN_LOGIN = {'SaCheckLogin'}
|
|
16
|
+
SATOKEN_IGNORE = {'SaIgnore'}
|
|
17
|
+
SATOKEN_ALL = SATOKEN_PERM | SATOKEN_ROLE | SATOKEN_LOGIN | SATOKEN_IGNORE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def has_satoken_guard(annotations: dict) -> bool:
|
|
21
|
+
"""类/方法是否有 Sa-Token 权限注解。"""
|
|
22
|
+
return bool(set(annotations.keys()) & SATOKEN_ALL)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract(root, repo_id: str, filepath: str):
|
|
26
|
+
"""Sa-Token 类级提取(转发到 class_extractor, 行为一致)。
|
|
27
|
+
|
|
28
|
+
PERMISSION 实体 + guards 边在 member_extractor 的方法级遍历里产出。
|
|
29
|
+
"""
|
|
30
|
+
return _ce.extract(root, repo_id, filepath)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Spring 注解提取器(@RestController/@Service/@Mapper/@Autowired/@Bean)。
|
|
3
|
+
|
|
4
|
+
职责: 识别 Spring 后端组件类型 + 构造器/Autowired 注入关系。
|
|
5
|
+
本文件按 GOAL 2 目标要求独立拆分, 与 class_extractor.py 共享同一套提取逻辑
|
|
6
|
+
(单一事实源在 class_extractor, 这里导出 Spring 相关部分)。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from . import _parser as P
|
|
10
|
+
from . import class_extractor as _ce
|
|
11
|
+
|
|
12
|
+
# Spring 注解集合(供外部判定用)
|
|
13
|
+
SPRING_SERVICE = {'Service', 'DubboService'}
|
|
14
|
+
SPRING_CONTROLLER = {'RestController', 'Controller'}
|
|
15
|
+
SPRING_MAPPER = {'Mapper', 'Repository'}
|
|
16
|
+
SPRING_CONFIG = {'Configuration', 'Component', 'ConfigurationProperties'}
|
|
17
|
+
SPRING_INJECT = {'Autowired', 'Resource'}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_spring_component(annotations: dict) -> bool:
|
|
21
|
+
"""类是否带 Spring 组件注解。"""
|
|
22
|
+
keys = set(annotations.keys())
|
|
23
|
+
return bool(keys & (SPRING_SERVICE | SPRING_CONTROLLER | SPRING_MAPPER | SPRING_CONFIG))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify(name: str, annotations: dict):
|
|
27
|
+
"""按 Spring 注解 + 命名规则判定实体类型(与 class_extractor._classify 一致)。"""
|
|
28
|
+
return _ce._classify(name, annotations)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# 复用 class_extractor 的完整提取入口(保证行为一致)
|
|
32
|
+
def extract(root, repo_id: str, filepath: str):
|
|
33
|
+
"""提取 Spring 类级实体 + 边(= class_extractor.extract 的 Spring 部分)。
|
|
34
|
+
|
|
35
|
+
实际实现见 class_extractor.extract(一次遍历识别 Spring/Dubbo/Sa-Token/MyBatis-Plus),
|
|
36
|
+
本函数等价于对结果按注解来源过滤 Spring 部分。为保证 zero-behavior-change,
|
|
37
|
+
这里直接转发到 class_extractor.extract。
|
|
38
|
+
"""
|
|
39
|
+
return _ce.extract(root, repo_id, filepath)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""校验规则提取器(@NotNull/@NotBlank/@Size/@Email/@Pattern 等)。
|
|
3
|
+
|
|
4
|
+
职责: 从字段注解提取校验规则 → VALIDATION 实体 + validates 边。
|
|
5
|
+
按 GOAL 2 目标要求独立拆分, 共享 domain_extractor 的字段遍历逻辑
|
|
6
|
+
(字段校验在 domain_extractor 处理 Entity 字段时一并产出)。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from . import _parser as P
|
|
10
|
+
from . import domain_extractor as _de
|
|
11
|
+
|
|
12
|
+
VALIDATION_ANN = {'NotNull', 'NotBlank', 'NotEmpty', 'Size', 'Min', 'Max',
|
|
13
|
+
'Email', 'Pattern', 'Positive', 'Negative', 'Digits',
|
|
14
|
+
'Past', 'Future', 'DecimalMin', 'DecimalMax'}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def has_validation(annotations: dict) -> bool:
|
|
18
|
+
"""字段是否有校验注解。"""
|
|
19
|
+
return bool(set(annotations.keys()) & VALIDATION_ANN)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rule_summary(annotations: dict) -> list:
|
|
23
|
+
"""汇总字段的校验规则 → [{rule, arg}]。"""
|
|
24
|
+
out = []
|
|
25
|
+
for ann, arg in annotations.items():
|
|
26
|
+
if ann in VALIDATION_ANN:
|
|
27
|
+
out.append({'rule': ann, 'arg': arg})
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract(root, repo_id: str, filepath: str):
|
|
32
|
+
"""校验规则提取(转发到 domain_extractor, Entity 字段的校验在那里产出)。"""
|
|
33
|
+
return _de.extract(root, repo_id, filepath)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""MyBatis XML 提取协调器。
|
|
3
|
+
|
|
4
|
+
扫所有 *Mapper.xml → 解析 → 建关系 → 返回 (entities, edges)。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import glob
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
_THIS = os.path.dirname(os.path.abspath(__file__))
|
|
13
|
+
for _i in range(8):
|
|
14
|
+
_p = os.path.dirname(_THIS)
|
|
15
|
+
if os.path.isfile(os.path.join(_p, 'foundation', 'bootstrap.py')):
|
|
16
|
+
sys.path.insert(0, _p); break
|
|
17
|
+
_THIS = _p
|
|
18
|
+
|
|
19
|
+
from domain.kg.extract.mybatis import mapper_parser as mp
|
|
20
|
+
from domain.kg.extract.mybatis import relation_builder as rb
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_file(filepath: str, repo_id: str):
|
|
24
|
+
"""单文件提取 → (entities, edges)。"""
|
|
25
|
+
parsed = mp.parse_mapper_xml(filepath)
|
|
26
|
+
if not parsed.get('namespace_short'):
|
|
27
|
+
return [], []
|
|
28
|
+
return rb.build_relations(parsed, repo_id, filepath)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_repo(repo_dir: str, repo_id: str, logger=print):
|
|
32
|
+
"""扫 repo 下所有 mapper XML → 提取。"""
|
|
33
|
+
t0 = time.time()
|
|
34
|
+
patterns = [
|
|
35
|
+
os.path.join(repo_dir, '**', '*Mapper.xml'),
|
|
36
|
+
os.path.join(repo_dir, '**', '*mapper*.xml'),
|
|
37
|
+
os.path.join(repo_dir, '**', 'mapper', '*.xml'),
|
|
38
|
+
]
|
|
39
|
+
xml_files = set()
|
|
40
|
+
for pat in patterns:
|
|
41
|
+
xml_files.update(glob.glob(pat, recursive=True))
|
|
42
|
+
# 跳过编译产物 target/classes 和测试资源
|
|
43
|
+
xml_files = [f for f in xml_files if '/target/' not in f.replace('\\', '/')
|
|
44
|
+
and '\\target\\' not in f.lower()
|
|
45
|
+
and '/test/' not in f.replace('\\', '/').lower()]
|
|
46
|
+
total = len(xml_files)
|
|
47
|
+
logger(' [mybatis] 扫描 %d 个 mapper XML (repo=%s)' % (total, repo_id))
|
|
48
|
+
|
|
49
|
+
all_entities, all_edges = [], []
|
|
50
|
+
err = 0
|
|
51
|
+
for xf in xml_files:
|
|
52
|
+
try:
|
|
53
|
+
es, eds = extract_file(xf, repo_id)
|
|
54
|
+
all_entities += es
|
|
55
|
+
all_edges += eds
|
|
56
|
+
except Exception as e:
|
|
57
|
+
err += 1
|
|
58
|
+
if err <= 2:
|
|
59
|
+
logger(' [mybatis] 失败 %s: %s' % (xf, str(e)[:50]))
|
|
60
|
+
|
|
61
|
+
dt = time.time() - t0
|
|
62
|
+
tables = set()
|
|
63
|
+
for e in all_entities:
|
|
64
|
+
if e['type'] == 'TABLE':
|
|
65
|
+
tables.add(e['canonical'])
|
|
66
|
+
logger(' [mybatis] 完成: %d 实体 / %d 边 / %d 表 / %d 错 / %.1fs' % (
|
|
67
|
+
len(all_entities), len(all_edges), len(tables), err, dt))
|
|
68
|
+
return all_entities, all_edges
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == '__main__':
|
|
72
|
+
import argparse, json
|
|
73
|
+
ap = argparse.ArgumentParser()
|
|
74
|
+
ap.add_argument('repo_dir')
|
|
75
|
+
ap.add_argument('--repo-id', default='')
|
|
76
|
+
args = ap.parse_args()
|
|
77
|
+
rid = args.repo_id or os.path.basename(args.repo_dir.rstrip('/\\'))
|
|
78
|
+
es, eds = extract_repo(args.repo_dir, rid)
|
|
79
|
+
print(json.dumps({'entities': len(es), 'edges': len(eds)}, ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""mapper XML 解析器: namespace + resultMap + 方法→SQL 映射。
|
|
3
|
+
|
|
4
|
+
输出每个 mapper 文件的结构化信息, 供 relation_builder 建 queries 边 + COLUMN 实体。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from . import sql_extractor as _sq
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _extract_table_names(sql):
|
|
15
|
+
"""从 SQL 提取表名 (转发到 sql_extractor)。"""
|
|
16
|
+
return _sq.extract_table_names(sql)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_mapper_xml(filepath: str) -> dict:
|
|
20
|
+
"""解析一个 mapper XML。
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
{
|
|
24
|
+
'namespace': 'com.fywl.ics.demo.mapper.TestDemoMapper',
|
|
25
|
+
'namespace_short': 'TestDemoMapper',
|
|
26
|
+
'statements': [{'id':'customPageList','type':'select','sql':'SELECT * FROM test_demo...', 'tables':['test_demo']}],
|
|
27
|
+
'result_maps': {'TestDemoResult': [{'property':'id','column':'id'}, ...]},
|
|
28
|
+
'result_types': ['com.fywl.ics.demo.domain.vo.TestDemoVo'],
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
tree = ET.parse(filepath)
|
|
33
|
+
root = tree.getroot()
|
|
34
|
+
except Exception:
|
|
35
|
+
return _empty_result()
|
|
36
|
+
|
|
37
|
+
namespace = root.get('namespace', '') or ''
|
|
38
|
+
namespace_short = namespace.rsplit('.', 1)[-1] if namespace else os.path.basename(filepath).replace('.xml', '')
|
|
39
|
+
|
|
40
|
+
statements = []
|
|
41
|
+
result_maps = {}
|
|
42
|
+
result_types = set()
|
|
43
|
+
|
|
44
|
+
for tag in ('select', 'insert', 'update', 'delete'):
|
|
45
|
+
for stmt in root.findall(tag):
|
|
46
|
+
sid = stmt.get('id', '')
|
|
47
|
+
sql_text = _extract_sql_text(stmt)
|
|
48
|
+
tables = _extract_table_names(sql_text)
|
|
49
|
+
if stmt.get('resultType'):
|
|
50
|
+
result_types.add(stmt.get('resultType'))
|
|
51
|
+
statements.append({
|
|
52
|
+
'id': sid, 'type': tag, 'sql': sql_text, 'tables': tables,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
for rm in root.findall('resultMap'):
|
|
56
|
+
rm_id = rm.get('id', '')
|
|
57
|
+
cols = []
|
|
58
|
+
for child_tag in ('id', 'result'):
|
|
59
|
+
for child in rm.findall(child_tag):
|
|
60
|
+
cols.append({
|
|
61
|
+
'property': child.get('property', ''),
|
|
62
|
+
'column': child.get('column', ''),
|
|
63
|
+
})
|
|
64
|
+
if rm.get('type'):
|
|
65
|
+
result_types.add(rm.get('type'))
|
|
66
|
+
result_maps[rm_id] = cols
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
'namespace': namespace,
|
|
70
|
+
'namespace_short': namespace_short,
|
|
71
|
+
'statements': statements,
|
|
72
|
+
'result_maps': result_maps,
|
|
73
|
+
'result_types': list(result_types),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_sql_text(node) -> str:
|
|
78
|
+
"""提取 statement 节点的完整 SQL 文本(含 <if>/<foreach> 展开)。"""
|
|
79
|
+
parts = []
|
|
80
|
+
if node.text:
|
|
81
|
+
parts.append(node.text)
|
|
82
|
+
for child in node:
|
|
83
|
+
# <if>/<foreach>/<choose>/<where>/<set>/<trim> 等, 取 text + tail
|
|
84
|
+
if child.text:
|
|
85
|
+
parts.append(child.text)
|
|
86
|
+
# 递归子节点 (foreach 里可能有 if)
|
|
87
|
+
for sub in child:
|
|
88
|
+
if sub.text:
|
|
89
|
+
parts.append(sub.text)
|
|
90
|
+
if sub.tail:
|
|
91
|
+
parts.append(sub.tail)
|
|
92
|
+
if child.tail:
|
|
93
|
+
parts.append(child.tail)
|
|
94
|
+
return ' '.join(p.strip() for p in parts if p and p.strip())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _empty_result():
|
|
98
|
+
return {'namespace': '', 'namespace_short': '', 'statements': [],
|
|
99
|
+
'result_maps': {}, 'result_types': []}
|